App 2.22.0: Overview style Cockpit, segmented rings and a power trace

A third Overview style beside Tiles and Dashboard: rings for battery,
power (zero at the top, regeneration anticlockwise in green) with the
speed and gear inside, and 5-minute usage with a tick for the average
since start; below, the power of the last two minutes, the clock,
climate and odometer. Unread values light nothing and show "—"; the
trace leaves gaps (PowerTrace, unit-tested). Emulator only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-24 16:48:49 +02:00
co-authored by Claude Opus 5.5
parent 234a7e1b60
commit cd0dfcd612
11 changed files with 634 additions and 13 deletions
+2 -2
View File
@@ -68,11 +68,11 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents | | Tab | Contents |
|---|---| |---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread | | Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread. Three styles (Settings → Overview style): Tiles, Dashboard (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace) |
| Consumption / Charging | period totals, daily log, charging sessions and curve | | Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency | | Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | | Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | | Settings | full screen, voice key, tyre unit, accent colour, Overview style, battery, charging price, version, update check, install from USB, About |
| Logs | update log (every install step, with the platform's words), upload log | | Logs | update log (every install step, with the platform's words), upload log |
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
+11
View File
@@ -131,6 +131,17 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-24 — App 2.22.0 (branch `dashboard-cockpit`, from `dashboard-gauges`): a third Overview
style, "Cockpit", designed without a reference: three segmented rings (`RingView`, Canvas) —
battery (0…100 %, range inside, below 20 % in the warning colour), power with the speed and the
gear inside (zero at the top: drawing power clockwise to 150 kW in the accent colour,
regeneration anticlockwise to −50 kW in green) and 5-minute usage (0…30 kWh/100 km, a white tick
for the average since start). Segments brighten towards the leading one. Below, the power of the
last two minutes as a trace (`TraceView`; `PowerTrace`, one slot per second, unit-tested; gaps
for unread or missed readings, never zeros), the clock, climate setpoint and odometer. Settings
→ Overview style: Tiles / Dashboard / Cockpit. Checked on the emulator with sample values in a
throwaway build (`screenshots/nilsmg4-2.22.0-cockpit.png`) and without data through the real
setting (nothing lit, "—", the page fits 1778×640). Not tried on the car.
- 2026-10-24 — App 2.21.0 (branch `dashboard-gauges`): Overview style "Dashboard", an instrument - 2026-10-24 — App 2.21.0 (branch `dashboard-gauges`): Overview style "Dashboard", an instrument
cluster after a reference picture of the owner's: power (−50…150 kW), speed (0…180 km/h, with cluster after a reference picture of the owner's: power (−50…150 kW), speed (0…180 km/h, with
TOTAL odometer and TRIP km since start) and battery (E…F, below 20 % in the warning colour, TOTAL odometer and TRIP km since start) and battery (E…F, below 20 % in the warning colour,
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 110 versionCode 111
versionName "2.21.0" versionName "2.22.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -67,6 +67,7 @@ public class MainActivity extends AppCompatActivity {
private View logPane; private View logPane;
private View vehiclePane; private View vehiclePane;
private View dashboardPane; private View dashboardPane;
private View cockpitPane;
private View consumptionPane; private View consumptionPane;
private View chargingPane; private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */ /** Display, battery, charging price and Wi-Fi. */
@@ -244,6 +245,7 @@ public class MainActivity extends AppCompatActivity {
serverPane = inflatePane(R.layout.pane_server); serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle); vehiclePane = inflatePane(R.layout.pane_vehicle);
dashboardPane = inflatePane(R.layout.pane_dashboard); dashboardPane = inflatePane(R.layout.pane_dashboard);
cockpitPane = inflatePane(R.layout.pane_cockpit);
// The Overview page holds both styles; Settings → Overview style picks the one shown. // The Overview page holds both styles; Settings → Overview style picks the one shown.
android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
@@ -251,6 +253,7 @@ public class MainActivity extends AppCompatActivity {
android.view.ViewGroup.LayoutParams.MATCH_PARENT)); android.view.ViewGroup.LayoutParams.MATCH_PARENT));
overviewPage.addView(vehiclePane); overviewPage.addView(vehiclePane);
overviewPage.addView(dashboardPane); overviewPage.addView(dashboardPane);
overviewPage.addView(cockpitPane);
consumptionPane = inflatePane(R.layout.pane_consumption); consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging); chargingPane = inflatePane(R.layout.pane_charging);
settingsPane = inflatePane(R.layout.pane_settings); settingsPane = inflatePane(R.layout.pane_settings);
@@ -261,6 +264,7 @@ public class MainActivity extends AppCompatActivity {
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME); UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
bindVehiclePane(); bindVehiclePane();
bindDashboard(); bindDashboard();
bindCockpit();
bindConsumptionPane(); bindConsumptionPane();
bindChargingPane(); bindChargingPane();
bindServerPane(); bindServerPane();
@@ -1307,6 +1311,8 @@ public class MainActivity extends AppCompatActivity {
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote, showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
average, getString(R.string.usage_last_5_min), R.string.range_waiting); average, getString(R.string.usage_last_5_min), R.string.range_waiting);
dashTripKm = trip == null ? null : trip.distanceKm; dashTripKm = trip == null ? null : trip.distanceKm;
cockpitTrip = trip;
cockpitAverage = average;
if (dashUsage != null) dashUsage.setText(average == null ? "—" if (dashUsage != null) dashUsage.setText(average == null ? "—"
: String.format(java.util.Locale.US, "%.1f kWh/100 km", average.kwhPer100Km)); : String.format(java.util.Locale.US, "%.1f kWh/100 km", average.kwhPer100Km));
} }
@@ -1427,22 +1433,30 @@ public class MainActivity extends AppCompatActivity {
/** Kilometres since start, from the range estimator; null until it has a distance. */ /** Kilometres since start, from the range estimator; null until it has a distance. */
private Double dashTripKm; private Double dashTripKm;
private boolean dashboardStyle() { /** The stored values of the Overview styles, in the order of the drop-down. */
return "DASHBOARD".equals(prefs.getString(PREF_OVERVIEW_STYLE, "TILES")); private static final String[] OVERVIEW_STYLES = { "TILES", "DASHBOARD", "COCKPIT" };
/** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */
private int overviewStyle() {
String style = prefs.getString(PREF_OVERVIEW_STYLE, "TILES");
for (int i = 0; i < OVERVIEW_STYLES.length; i++) if (OVERVIEW_STYLES[i].equals(style)) return i;
return 0;
} }
private void applyOverviewStyle() { private void applyOverviewStyle() {
boolean dash = dashboardStyle(); int style = overviewStyle();
dashboardPane.setVisibility(dash ? View.VISIBLE : View.GONE); vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE);
vehiclePane.setVisibility(dash ? View.GONE : View.VISIBLE); dashboardPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE);
cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE);
} }
/** Tiles (the cards) or Dashboard (gauges); both are fed on every reading. */ /** Tiles (the cards), Dashboard (gauges) or Cockpit (rings); all are fed on every reading. */
private void bindOverviewStyle() { private void bindOverviewStyle() {
String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_dashboard) }; String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_dashboard),
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, dashboardStyle() ? 1 : 0, getString(R.string.overview_style_cockpit) };
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(),
position -> { position -> {
prefs.edit().putString(PREF_OVERVIEW_STYLE, position == 1 ? "DASHBOARD" : "TILES").apply(); prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply();
applyOverviewStyle(); applyOverviewStyle();
}); });
} }
@@ -1461,9 +1475,69 @@ public class MainActivity extends AppCompatActivity {
dashBattery.configure(0f, 100f, 25f, 5, getString(R.string.dash_unit_battery)); dashBattery.configure(0f, 100f, 25f, 5, getString(R.string.dash_unit_battery));
dashBattery.setEndLabels("E", "F"); dashBattery.setEndLabels("E", "F");
dashBattery.setWarnBelow(20f); dashBattery.setWarnBelow(20f);
}
// ---------- Cockpit (Overview style) ----------
/** Power over the last two minutes, a slot per second. */
private final PowerTrace cockpitTrace = new PowerTrace(120_000, 120);
private RingView cockpitBattery, cockpitPower, cockpitUsage;
private TraceView cockpitTraceView;
private TextView cockpitClimate, cockpitOdometer;
/** From the range estimator: since start and the last 5 minutes; null until known. */
private RangeEstimator.Estimate cockpitTrip, cockpitAverage;
private void bindCockpit() {
cockpitBattery = cockpitPane.findViewById(R.id.cockpit_battery);
cockpitPower = cockpitPane.findViewById(R.id.cockpit_power);
cockpitUsage = cockpitPane.findViewById(R.id.cockpit_usage);
cockpitTraceView = cockpitPane.findViewById(R.id.cockpit_trace);
cockpitClimate = cockpitPane.findViewById(R.id.cockpit_climate);
cockpitOdometer = cockpitPane.findViewById(R.id.cockpit_odometer);
cockpitBattery.configure(0f, 100f, 40);
cockpitBattery.setWarnBelow(20f);
cockpitBattery.setBigScale(0.36f);
cockpitBattery.setScaleLabels("0", null, "100");
cockpitPower.setCentred(-50f, 150f, 60);
cockpitPower.setTopPill(true);
cockpitPower.setBigScale(0.44f);
cockpitPower.setScaleLabels("−50", "0", "150 kW");
cockpitUsage.configure(0f, 30f, 40);
cockpitUsage.setBigScale(0.36f);
cockpitUsage.setScaleLabels("0", null, "30");
applyOverviewStyle(); applyOverviewStyle();
} }
/** The rings and the strip; every value null-safe ("—", nothing lit, gaps in the trace). */
private void showCockpit(EnergySnapshot s) {
if (cockpitPower == null) return;
java.util.Locale us = java.util.Locale.US;
Float soc = s.getSocPercent();
cockpitBattery.setValue(soc, getString(R.string.cockpit_battery),
soc == null ? null : String.format(us, "%.0f", soc), "%",
fmt(s.getRangeKm(), "%.0f km"), null);
Float power = s.getBatteryPowerKw();
Float speed = s.getSpeedKmh();
Boolean parked = s.getParked();
cockpitPower.setValue(power, parked == null ? "—" : parked ? "P" : "D",
speed == null ? null : String.format(us, "%.0f", speed), getString(R.string.dash_unit_kmh),
power == null ? "—" : String.format(us, "%.1f kW", power), null);
cockpitTrace.add(android.os.SystemClock.elapsedRealtime(), power);
cockpitTraceView.setValues(cockpitTrace.snapshot());
RangeEstimator.Estimate now = cockpitAverage, trip = cockpitTrip;
cockpitUsage.setValue(now == null ? null : (float) now.kwhPer100Km, getString(R.string.cockpit_usage),
now == null ? null : String.format(us, "%.1f", now.kwhPer100Km), getString(R.string.cockpit_usage_unit),
trip == null ? getString(R.string.cockpit_usage_trip, "—")
: getString(R.string.cockpit_usage_trip, String.format(us, "%.1f", trip.kwhPer100Km)),
trip == null ? null : String.format(us, "%.1f km", trip.distanceKm));
cockpitUsage.setMarker(trip == null ? null : (float) trip.kwhPer100Km);
cockpitClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
cockpitOdometer.setText(fmt(s.getOdometerKm(), "%,.0f km"));
}
/** The gauges and the strip; every value null-safe ("—", no needle). */ /** The gauges and the strip; every value null-safe ("—", no needle). */
private void showDashboard(EnergySnapshot s) { private void showDashboard(EnergySnapshot s) {
if (dashPower == null) return; if (dashPower == null) return;
@@ -1504,6 +1578,7 @@ public class MainActivity extends AppCompatActivity {
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa())); vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
showDashboard(s); showDashboard(s);
showCockpit(s);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// Leave the last good reading on screen. // Leave the last good reading on screen.
} }
@@ -0,0 +1,63 @@
package com.evsuite.abrp;
/**
* The last few minutes of battery power for the Cockpit's trace, by monotonic time. Pure Java so
* it can be tested off the car.
*
* Each reading lands in a fixed slot of the window ({@code windowMs / slots} wide); a slot keeps
* the last reading that fell into it. A slot no reading reached, or one whose reading was
* unread, is NaN — the trace leaves a gap there instead of drawing a zero (an unread value is
* not 0 kW).
*/
final class PowerTrace {
private final long windowMs;
private final float[] values;
private final long slotMs;
/** The slot index (time / slotMs) of the newest reading; Long.MIN_VALUE before the first. */
private long newest = Long.MIN_VALUE;
PowerTrace(long windowMs, int slots) {
if (windowMs <= 0 || slots < 2) throw new IllegalArgumentException();
this.windowMs = windowMs;
this.values = new float[slots];
this.slotMs = Math.max(1, windowMs / slots);
java.util.Arrays.fill(values, Float.NaN);
}
long windowMs() {
return windowMs;
}
/** A reading at {@code monotonicMs}; null or not finite for one that could not be read. */
void add(long monotonicMs, Float kw) {
long slot = monotonicMs / slotMs;
if (newest != Long.MIN_VALUE && slot < newest) return; // the clock never runs back
if (newest != Long.MIN_VALUE) {
// Slots skipped since the newest reading had no reading: gaps.
long gap = Math.min(values.length, slot - newest);
for (long s = newest + 1; s < newest + 1 + gap; s++) values[index(s)] = Float.NaN;
}
newest = slot;
values[index(slot)] = kw == null || !Float.isFinite(kw) ? Float.NaN : kw;
}
/**
* Oldest first, as many values as there are slots; NaN where there is nothing. Before the
* first reading every value is NaN.
*/
float[] snapshot() {
float[] out = new float[values.length];
if (newest == Long.MIN_VALUE) {
java.util.Arrays.fill(out, Float.NaN);
return out;
}
long first = newest - values.length + 1;
for (int i = 0; i < out.length; i++) out[i] = values[index(first + i)];
return out;
}
private int index(long slot) {
return (int) Math.floorMod(slot, (long) values.length);
}
}
@@ -0,0 +1,276 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
/**
* The Cockpit's instrument: a ring of segments open at the bottom, lit up to the value, brighter
* towards the leading segment, with text inside — a small label on top, the value large, its
* unit, and up to two caption lines.
*
* Two kinds of scale: from {@code min} at the lower left round to {@code max} at the lower right,
* or centred ({@link #setCentred}): zero at the top, positive values clockwise in the accent
* colour, negative ones (regeneration) anticlockwise in green. An optional marker shows a second
* value on the same scale (an average). Null draws no lit segments and the text "—": an unread
* value is never shown as zero. Canvas only, no gradient drawables (Android 9 crashes on those
* with theme colours).
*/
public final class RingView extends View {
private static final float START_DEG = 135f;
private static final float SWEEP_DEG = 270f;
private static final float TOP_DEG = 270f;
private final Paint track = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint lit = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint halo = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hairline = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint markerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint topPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint pill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint big = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint unitPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint caption = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint scale = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF oval = new RectF();
private final RectF pillRect = new RectF();
private final int accent, regen, warn;
private float min = 0f, max = 100f;
private boolean centred;
private int segments = 48;
private Float warnBelow;
private String minLabel, maxLabel, zeroLabel;
private float bigScale = 0.40f;
private boolean topPill;
private String top, valueText = "—", unit = "", caption1, caption2;
private Float target, marker;
private float shown = Float.NaN;
public RingView(Context context, AttributeSet attrs) {
super(context, attrs);
accent = AccentColor.resolve(context, R.attr.evAccent);
int light = AccentColor.resolve(context, R.attr.evAccentLight);
regen = context.getColor(R.color.ev_ok);
warn = context.getColor(R.color.ev_error);
track.setStyle(Paint.Style.STROKE);
track.setColor(AccentColor.withAlpha(accent, 0x26));
lit.setStyle(Paint.Style.STROKE);
halo.setStyle(Paint.Style.STROKE);
hairline.setStyle(Paint.Style.STROKE);
hairline.setColor(context.getColor(R.color.ev_outline));
markerPaint.setColor(context.getColor(R.color.ev_text_primary));
markerPaint.setStrokeCap(Paint.Cap.ROUND);
topPaint.setColor(light);
topPaint.setTextAlign(Paint.Align.CENTER);
topPaint.setFakeBoldText(true);
pill.setStyle(Paint.Style.STROKE);
pill.setColor(AccentColor.withAlpha(accent, 0x99));
big.setColor(context.getColor(R.color.ev_text_primary));
big.setTextAlign(Paint.Align.CENTER);
big.setFontFeatureSettings("tnum");
big.setTypeface(android.graphics.Typeface.create("sans-serif-light", android.graphics.Typeface.NORMAL));
unitPaint.setColor(light);
unitPaint.setTextAlign(Paint.Align.CENTER);
unitPaint.setLetterSpacing(0.12f);
caption.setColor(context.getColor(R.color.ev_text_secondary));
caption.setTextAlign(Paint.Align.CENTER);
caption.setFontFeatureSettings("tnum");
scale.setColor(context.getColor(R.color.ev_text_secondary));
scale.setTextAlign(Paint.Align.CENTER);
scale.setFontFeatureSettings("tnum");
}
/** A scale from min (lower left) to max (lower right), in this many segments. */
void configure(float min, float max, int segments) {
this.min = min;
this.max = max;
this.segments = Math.max(4, segments);
this.centred = false;
invalidate();
}
/** A scale with zero at the top: max clockwise, min (negative) anticlockwise. */
void setCentred(float min, float max, int segments) {
configure(min, max, segments);
this.centred = true;
}
/** Labels at the two ends of the scale, and at the top (centred scales). */
void setScaleLabels(String minLabel, String zeroLabel, String maxLabel) {
this.minLabel = minLabel;
this.zeroLabel = zeroLabel;
this.maxLabel = maxLabel;
invalidate();
}
/** Lit segments in the warning colour while the value is below this (a low battery). */
void setWarnBelow(Float value) {
warnBelow = value;
invalidate();
}
/** The size of the value text, as a fraction of the radius. */
void setBigScale(float fraction) {
bigScale = fraction;
invalidate();
}
/** The label on top in an outlined pill (the gear) instead of as a plain title. */
void setTopPill(boolean pill) {
topPill = pill;
invalidate();
}
/**
* The value (null when unread) and the texts: the label on top (null for none), the value's
* digits ("—" when null), its unit and two caption lines (null for none).
*/
void setValue(Float value, String top, String valueText, String unit, String caption1, String caption2) {
target = value == null || !Float.isFinite(value) ? null : value;
this.top = top;
this.valueText = valueText == null ? "—" : valueText;
this.unit = unit == null ? "" : unit;
this.caption1 = caption1;
this.caption2 = caption2;
if (target != null && Float.isNaN(shown)) shown = target;
invalidate();
}
/** A second value on the same scale, as a tick on the ring; null for none. */
void setMarker(Float value) {
marker = value == null || !Float.isFinite(value) ? null : value;
invalidate();
}
/** The angle of a value; on a centred scale each side has half the sweep. */
private float angleOf(float v) {
v = Math.max(min, Math.min(max, v));
if (!centred) return START_DEG + SWEEP_DEG * (v - min) / (max - min);
float half = SWEEP_DEG / 2f;
if (v >= 0f) return TOP_DEG + (max > 0f ? half * v / max : 0f);
return TOP_DEG - (min < 0f ? half * v / min : 0f);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
// The ring is open at the bottom, so the circle may hang below the view by the part of
// the gap that holds only the end labels.
float r = Math.min(w / 2f, h / 1.86f) * 0.96f;
if (r <= 0f) return;
float cx = w / 2f, cy = h / 2f + r * 0.06f;
float thick = r * 0.13f;
float rr = r - thick / 2f;
oval.set(cx - rr, cy - rr, cx + rr, cy + rr);
track.setStrokeWidth(thick);
lit.setStrokeWidth(thick);
halo.setStrokeWidth(thick * 1.7f);
// The value, eased towards its target.
boolean animating = false;
if (target != null) {
shown += (target - shown) * 0.25f;
if (Math.abs(target - shown) < (max - min) * 0.001f) shown = target;
animating = shown != target;
}
float seg = SWEEP_DEG / segments;
float gap = Math.min(seg * 0.32f, 2.2f);
float zeroAngle = centred ? TOP_DEG : START_DEG;
float valueAngle = target == null ? zeroAngle : angleOf(shown);
float from = Math.min(zeroAngle, valueAngle), to = Math.max(zeroAngle, valueAngle);
boolean negative = valueAngle < zeroAngle;
int colour = negative ? regen
: warnBelow != null && target != null && shown < warnBelow ? warn : accent;
float span = Math.max(seg, to - from);
for (int i = 0; i < segments; i++) {
float a0 = START_DEG + i * seg;
float mid = a0 + seg / 2f;
boolean on = target != null && mid >= from && mid <= to;
if (!on) {
c.drawArc(oval, a0 + gap / 2f, seg - gap, false, track);
continue;
}
// Brighter away from zero, towards the leading segment.
float f = Math.max(0f, Math.min(1f, (negative ? to - mid : mid - from) / span));
int alpha = (int) (0x55 + (0xFF - 0x55) * f);
lit.setColor(AccentColor.withAlpha(colour, alpha));
c.drawArc(oval, a0 + gap / 2f, seg - gap, false, lit);
}
if (target != null && to - from >= seg * 0.5f) {
// A soft halo on the leading segment.
float lead = negative ? from : to;
halo.setColor(AccentColor.withAlpha(colour, 0x33));
c.drawArc(oval, lead - (negative ? 0f : seg), seg, false, halo);
}
// A fine inner ring, and the scale's zero at the top.
hairline.setStrokeWidth(Math.max(1f, r * 0.006f));
float ir = r - thick - r * 0.05f;
oval.set(cx - ir, cy - ir, cx + ir, cy + ir);
c.drawArc(oval, START_DEG, SWEEP_DEG, false, hairline);
if (centred) {
markerPaint.setStrokeWidth(r * 0.012f);
c.drawLine(cx, cy - ir, cx, cy - ir + r * 0.05f, markerPaint);
}
if (marker != null) {
double a = Math.toRadians(angleOf(marker));
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
markerPaint.setStrokeWidth(r * 0.022f);
float o = r + r * 0.015f, in = r - thick - r * 0.03f;
c.drawLine(cx + o * cos, cy + o * sin, cx + in * cos, cy + in * sin, markerPaint);
}
// End labels, tucked into the gap under the ends of the ring.
scale.setTextSize(r * 0.075f);
drawScaleLabel(c, cx, cy, rr, START_DEG - 9f, minLabel, negative && target != null ? regen : 0);
drawScaleLabel(c, cx, cy, rr, START_DEG + SWEEP_DEG + 9f, maxLabel, 0);
if (centred && zeroLabel != null) {
c.drawText(zeroLabel, cx, cy - ir + r * 0.05f + scale.getTextSize() * 1.15f, scale);
}
// Inside: the label on top in a pill, the value, the unit, the captions.
if (top != null) {
topPaint.setTextSize(r * (topPill ? 0.11f : 0.075f));
topPaint.setLetterSpacing(topPill ? 0f : 0.16f);
float ty = cy - r * 0.40f;
if (topPill) {
float tw = Math.max(topPaint.measureText(top), topPaint.getTextSize() * 0.7f);
float ph = topPaint.getTextSize() * 1.45f;
pillRect.set(cx - tw / 2f - ph * 0.45f, ty - ph * 0.72f, cx + tw / 2f + ph * 0.45f, ty + ph * 0.28f);
pill.setStrokeWidth(Math.max(1.5f, r * 0.008f));
c.drawRoundRect(pillRect, ph / 2f, ph / 2f, pill);
}
c.drawText(top, cx, ty, topPaint);
}
big.setTextSize(r * bigScale);
c.drawText(valueText, cx, cy + big.getTextSize() * 0.30f, big);
unitPaint.setTextSize(r * 0.085f);
c.drawText(unit, cx, cy + big.getTextSize() * 0.30f + r * 0.15f, unitPaint);
caption.setTextSize(r * 0.085f);
float y = cy + big.getTextSize() * 0.30f + r * 0.34f;
if (caption1 != null) c.drawText(caption1, cx, y, caption);
if (caption2 != null) c.drawText(caption2, cx, y + caption.getTextSize() * 1.3f, caption);
if (animating) postInvalidateOnAnimation();
}
private void drawScaleLabel(Canvas c, float cx, float cy, float radius, float deg, String label, int colour) {
if (label == null) return;
double a = Math.toRadians(deg);
int old = scale.getColor();
if (colour != 0) scale.setColor(colour);
c.drawText(label, cx + radius * (float) Math.cos(a),
cy + radius * (float) Math.sin(a) + scale.getTextSize() * 0.36f, scale);
scale.setColor(old);
}
}
@@ -0,0 +1,106 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
import java.util.Locale;
/**
* The Cockpit's strip: battery power over the last minutes ({@link PowerTrace}), newest on the
* right. Drawing power above the zero line in the accent colour, regeneration below it in green,
* each as a line over a faint fill. Gaps (unread or missed readings) stay gaps. The scale grows
* in steps of 10 kW to fit what is shown, at least ±10 kW.
*/
public final class TraceView extends View {
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint zero = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint label = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path linePath = new Path();
private final Path fillPath = new Path();
private final int accent, regen;
private float[] values = new float[0];
public TraceView(Context context, AttributeSet attrs) {
super(context, attrs);
accent = AccentColor.resolve(context, R.attr.evAccent);
regen = context.getColor(R.color.ev_ok);
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
fill.setStyle(Paint.Style.FILL);
zero.setStyle(Paint.Style.STROKE);
zero.setColor(context.getColor(R.color.ev_outline));
label.setColor(context.getColor(R.color.ev_text_secondary));
label.setFontFeatureSettings("tnum");
}
/** The values oldest first, NaN for gaps. */
void setValues(float[] values) {
this.values = values == null ? new float[0] : values;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
float density = getResources().getDisplayMetrics().density;
label.setTextSize(15f * density);
float left = label.measureText("−150 kW") + 12f * density, right = w, top = 6f * density, bottom = h - 6f * density;
if (right - left <= 0f || bottom - top <= 0f || values.length < 2) return;
float hi = 0f, lo = 0f;
for (float v : values) {
if (Float.isNaN(v)) continue;
hi = Math.max(hi, v);
lo = Math.min(lo, v);
}
hi = Math.max(10f, (float) Math.ceil(hi / 10f) * 10f);
lo = Math.min(-10f, (float) Math.floor(lo / 10f) * 10f);
float zy = top + (bottom - top) * hi / (hi - lo);
zero.setStrokeWidth(Math.max(1f, density));
c.drawLine(left, zy, right, zy, zero);
label.setTextAlign(Paint.Align.LEFT);
c.drawText(String.format(Locale.US, "%.0f kW", hi), 0f, top + label.getTextSize(), label);
c.drawText(String.format(Locale.US, "%.0f kW", lo).replace('-', '−'), 0f, bottom, label);
float step = (right - left) / (values.length - 1);
line.setStrokeWidth(2.5f * density);
// Draw every run of readings twice, clipped to each side of the zero line.
for (int side = 0; side < 2; side++) {
int colour = side == 0 ? accent : regen;
line.setColor(colour);
fill.setColor(AccentColor.withAlpha(colour, 0x38));
c.save();
if (side == 0) c.clipRect(left, top - density * 3f, right, zy);
else c.clipRect(left, zy, right, bottom + density * 3f);
int i = 0;
while (i < values.length) {
if (Float.isNaN(values[i])) { i++; continue; }
int start = i;
linePath.reset();
fillPath.reset();
fillPath.moveTo(left + start * step, zy);
for (; i < values.length && !Float.isNaN(values[i]); i++) {
float x = left + i * step;
float y = top + (bottom - top) * (hi - values[i]) / (hi - lo);
if (i == start) linePath.moveTo(x, y); else linePath.lineTo(x, y);
fillPath.lineTo(x, y);
}
fillPath.lineTo(left + (i - 1) * step, zy);
fillPath.close();
c.drawPath(fillPath, fill);
if (i - start == 1) c.drawCircle(left + start * step, top + (bottom - top) * (hi - values[start]) / (hi - lo), line.getStrokeWidth(), fill);
c.drawPath(linePath, line);
}
c.restore();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Overview as a cockpit (Settings → Overview style → Cockpit): segmented rings for battery,
power with the speed inside, and efficiency; below them the power of the last two minutes,
the clock, climate and odometer. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:baselineAligned="false">
<com.evsuite.abrp.RingView android:id="@+id/cockpit_battery" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginTop="44dp" android:layout_marginBottom="20dp" />
<com.evsuite.abrp.RingView android:id="@+id/cockpit_power" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1.3" android:layout_marginStart="12dp" android:layout_marginEnd="12dp" />
<com.evsuite.abrp.RingView android:id="@+id/cockpit_usage" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginTop="44dp" android:layout_marginBottom="20dp" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="128dp" android:layout_marginTop="10dp" android:orientation="horizontal" android:gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="28dp" android:paddingTop="10dp" android:paddingBottom="10dp" android:background="@drawable/bg_glass" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/cockpit_trace_title" android:textColor="?attr/evAccentLight" android:textSize="14sp" android:letterSpacing="0.16" />
<com.evsuite.abrp.TraceView android:id="@+id/cockpit_trace" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_marginTop="2dp" />
</LinearLayout>
<View android:layout_width="1dp" android:layout_height="match_parent" android:layout_marginStart="28dp" android:layout_marginEnd="28dp" android:background="@color/ev_outline" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="center_horizontal">
<TextClock android:layout_width="wrap_content" android:layout_height="wrap_content" android:format24Hour="HH:mm" android:format12Hour="HH:mm" android:textColor="@color/ev_text_primary" android:textSize="44sp" android:fontFamily="sans-serif-light" android:fontFeatureSettings="tnum" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
<TextView android:id="@+id/cockpit_climate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/no_value" android:textColor="?attr/evAccentLight" android:textSize="18sp" android:fontFeatureSettings="tnum" />
<TextView android:id="@+id/cockpit_odometer" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="18dp" android:text="@string/no_value" android:textColor="@color/ev_text_secondary" android:textSize="18sp" android:fontFeatureSettings="tnum" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
+6
View File
@@ -127,6 +127,12 @@
<string name="dash_unit_battery">SOC</string> <string name="dash_unit_battery">SOC</string>
<string name="dash_total">TOTAL %1$s</string> <string name="dash_total">TOTAL %1$s</string>
<string name="dash_trip">TRIP %1$s</string> <string name="dash_trip">TRIP %1$s</string>
<string name="overview_style_cockpit">Cockpit</string>
<string name="cockpit_battery">BATTERY</string>
<string name="cockpit_usage">USAGE · 5 MIN</string>
<string name="cockpit_usage_unit">kWh/100 km</string>
<string name="cockpit_usage_trip">since start %1$s</string>
<string name="cockpit_trace_title">POWER · LAST 2 MIN</string>
<string name="label_voice_key">Double-press the voice key to open this app</string> <string name="label_voice_key">Double-press the voice key to open this app</string>
<string name="hint_voice_key">Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses.</string> <string name="hint_voice_key">Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses.</string>
<string name="label_abrp_upload_enabled">Send live data</string> <string name="label_abrp_upload_enabled">Send live data</string>
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
/** The Cockpit's power trace buffer — pure JVM. */
public class PowerTraceTest {
private static final float NaN = Float.NaN;
@Test
public void emptyIsAllGaps() {
float[] v = new PowerTrace(4000, 4).snapshot();
assertEquals(4, v.length);
for (float f : v) assertTrue(Float.isNaN(f));
}
@Test
public void newestIsLastAndOlderSlide() {
PowerTrace t = new PowerTrace(4000, 4); // 1 s slots
t.add(10_000, 5f);
t.add(11_000, 10f);
t.add(12_000, -3f);
assertArrayEquals(new float[] { NaN, 5f, 10f, -3f }, t.snapshot(), 0f);
t.add(13_000, 1f);
t.add(14_000, 2f);
assertArrayEquals(new float[] { 10f, -3f, 1f, 2f }, t.snapshot(), 0f);
}
@Test
public void unreadAndMissedSlotsAreGapsNotZero() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(11_000, null);
t.add(13_000, 7f); // nothing at 12 s
assertArrayEquals(new float[] { 5f, NaN, NaN, 7f }, t.snapshot(), 0f);
}
@Test
public void aLongPauseClearsEverything() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(11_000, 6f);
t.add(60_000, 8f);
assertArrayEquals(new float[] { NaN, NaN, NaN, 8f }, t.snapshot(), 0f);
}
@Test
public void laterReadingInTheSameSlotWinsAndTheClockNeverRunsBack() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(10_500, 6f);
t.add(9_000, 99f);
assertArrayEquals(new float[] { NaN, NaN, NaN, 6f }, t.snapshot(), 0f);
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB