diff --git a/SPECS.md b/SPECS.md
index 5d534c0..89c8a8e 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -131,6 +131,16 @@ 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
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).
+- 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
+ TOTAL odometer and TRIP km since start) and battery (E…F, below 20 % in the warning colour,
+ range underneath) as gauges (`GaugeView`, Canvas, accent-coloured arcs, white needles that ease
+ to a new value), and a strip of gear (P/D), climate setpoint, clock, 5-minute usage and a
+ battery bar (`BatteryBarView`). Unread values draw no needle and "—". Settings → Display →
+ "Overview style: Tiles / Dashboard"; Tiles stays the default; both are fed from the same
+ reading. Checked on the emulator with sample values in a throwaway build
+ (`screenshots/nilsmg4-2.21.0-dashboard.png`) and without data through the real setting; the
+ Overview and Settings fit 1778×640. Not tried on the car.
- 2026-10-23 — App 2.20.0: the Overview's Vehicle info card shows the lease mileage: "Lease
allowance today" (40,000 km over four years from 2023-05-01, accruing evenly per day, in
`LeaseAllowance`, unit-tested) and "Versus allowance", the odometer against it ("… km over" /
diff --git a/app/build.gradle b/app/build.gradle
index df5f0ec..8e9e4f1 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
- versionCode 109
- versionName "2.20.0"
+ versionCode 110
+ versionName "2.21.0"
// 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.
diff --git a/app/src/main/java/com/evsuite/abrp/BatteryBarView.java b/app/src/main/java/com/evsuite/abrp/BatteryBarView.java
new file mode 100644
index 0000000..fe088da
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/BatteryBarView.java
@@ -0,0 +1,55 @@
+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 dashboard strip's battery: an outline with a terminal and vertical cells filled to the
+ * state of charge, in the accent colour. Null draws the empty outline, never an empty battery.
+ */
+public final class BatteryBarView extends View {
+
+ private static final int CELLS = 20;
+ private final Paint outline = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint cell = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final RectF rect = new RectF();
+ private Float soc;
+
+ public BatteryBarView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ int accent = AccentColor.resolve(context, R.attr.evAccent);
+ outline.setStyle(Paint.Style.STROKE);
+ outline.setColor(accent);
+ cell.setColor(accent);
+ }
+
+ void setSoc(Float percent) {
+ soc = percent == null || !Float.isFinite(percent) ? null : Math.max(0f, Math.min(100f, percent));
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float h = getHeight(), w = getWidth();
+ float stroke = Math.max(2f, h * 0.06f);
+ outline.setStrokeWidth(stroke);
+ float tip = h * 0.12f;
+ rect.set(stroke / 2f, stroke / 2f, w - tip - stroke / 2f, h - stroke / 2f);
+ c.drawRoundRect(rect, h * 0.12f, h * 0.12f, outline);
+ c.drawRect(w - tip, h * 0.32f, w, h * 0.68f, cell);
+ if (soc == null) return;
+ float pad = stroke * 1.6f;
+ float left = rect.left + pad, right = rect.right - pad, top = rect.top + pad, bottom = rect.bottom - pad;
+ float pitch = (right - left) / CELLS;
+ int filled = Math.round(soc / 100f * CELLS);
+ for (int i = 0; i < filled; i++) {
+ float x = left + i * pitch;
+ c.drawRect(x, top, x + pitch * 0.6f, bottom, cell);
+ }
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/GaugeView.java b/app/src/main/java/com/evsuite/abrp/GaugeView.java
new file mode 100644
index 0000000..c1401b3
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/GaugeView.java
@@ -0,0 +1,195 @@
+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;
+
+import java.util.Locale;
+
+/**
+ * A round instrument for the dashboard Overview: a 240° scale open at the bottom, major and minor
+ * ticks, numbers, a needle, and up to three lines of text in the middle — the unit, the value as
+ * digits, and a caption.
+ *
+ * Drawn on a Canvas with colours resolved from the accent theme, so it needs no gradient
+ * drawables (which crash Android 9 with theme colours). A null value draws no needle and a dash:
+ * an unread value is never shown as zero. The needle eases towards a new value instead of
+ * jumping, and stops redrawing once it is there.
+ */
+public final class GaugeView extends View {
+
+ private static final float START_DEG = 150f;
+ private static final float SWEEP_DEG = 240f;
+
+ private final Paint arc = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint tick = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint warnTick = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint number = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint unitPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint digits = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint caption = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint needle = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint hub = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final RectF oval = new RectF();
+
+ private float min = 0f, max = 100f, major = 20f;
+ private int minorPerMajor = 4;
+ private boolean numbers = true;
+ private String lowLabel, highLabel;
+ private Float warnBelow;
+ private String unit = "", valueText = "—", captionText;
+ private Float target;
+ private float shown = Float.NaN;
+
+ public GaugeView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ int accent = AccentColor.resolve(context, R.attr.evAccent);
+ int light = AccentColor.resolve(context, R.attr.evAccentLight);
+ arc.setStyle(Paint.Style.STROKE);
+ arc.setStrokeCap(Paint.Cap.ROUND);
+ arc.setColor(accent);
+ glow.setStyle(Paint.Style.STROKE);
+ glow.setStrokeCap(Paint.Cap.ROUND);
+ glow.setColor(AccentColor.withAlpha(accent, 0x40));
+ tick.setColor(accent);
+ tick.setStrokeCap(Paint.Cap.ROUND);
+ warnTick.setColor(0xFFFFB4AB);
+ warnTick.setStrokeCap(Paint.Cap.ROUND);
+ number.setColor(0xFFF4F3EF);
+ number.setTextAlign(Paint.Align.CENTER);
+ unitPaint.setColor(0xFFBDC4C6);
+ unitPaint.setTextAlign(Paint.Align.CENTER);
+ digits.setColor(light);
+ digits.setTextAlign(Paint.Align.CENTER);
+ digits.setFontFeatureSettings("tnum");
+ caption.setColor(0xFFBDC4C6);
+ caption.setTextAlign(Paint.Align.CENTER);
+ caption.setFontFeatureSettings("tnum");
+ needle.setColor(0xFFF4F3EF);
+ needle.setStrokeCap(Paint.Cap.ROUND);
+ hub.setColor(accent);
+ }
+
+ /** The scale: range, spacing of the numbered ticks, and minor ticks between two of them. */
+ void configure(float min, float max, float major, int minorPerMajor, String unit) {
+ this.min = min;
+ this.max = max;
+ this.major = major;
+ this.minorPerMajor = minorPerMajor;
+ this.unit = unit;
+ invalidate();
+ }
+
+ /** Letters at the two ends (E and F) instead of numbers along the scale. */
+ void setEndLabels(String low, String high) {
+ lowLabel = low;
+ highLabel = high;
+ numbers = false;
+ invalidate();
+ }
+
+ /** Ticks below this value drawn in the warning colour (a low battery). */
+ void setWarnBelow(Float value) {
+ warnBelow = value;
+ invalidate();
+ }
+
+ /**
+ * The needle's value, null when unread, and its digits ("—" then) and caption. Drawing is
+ * left to the next frame, and only the needle animates.
+ */
+ void setValue(Float value, String valueText, String captionText) {
+ target = value == null || !Float.isFinite(value) ? null : value;
+ this.valueText = valueText == null ? "—" : valueText;
+ this.captionText = captionText;
+ if (target != null && Float.isNaN(shown)) shown = target;
+ invalidate();
+ }
+
+ private float angleOf(float v) {
+ float f = (Math.max(min, Math.min(max, v)) - min) / (max - min);
+ return START_DEG + SWEEP_DEG * f;
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float w = getWidth(), h = getHeight();
+ // Below the centre: the unit, the digits and two caption lines, down to 0.9 r.
+ float r = Math.min(w / 2f, h / 1.92f) * 0.95f;
+ if (r <= 0f) return;
+ float cx = w / 2f, cy = (h - 1.9f * r) / 2f + r;
+
+ float stroke = r * 0.035f;
+ arc.setStrokeWidth(stroke);
+ glow.setStrokeWidth(stroke * 2.5f);
+ oval.set(cx - r, cy - r, cx + r, cy + r);
+ c.drawArc(oval, START_DEG, SWEEP_DEG, false, glow);
+ c.drawArc(oval, START_DEG, SWEEP_DEG, false, arc);
+
+ // Ticks: every minor step; the numbered ones longer and thicker.
+ number.setTextSize(r * 0.105f);
+ float minor = major / Math.max(1, minorPerMajor);
+ int steps = Math.round((max - min) / minor);
+ for (int i = 0; i <= steps; i++) {
+ float v = min + i * minor;
+ boolean isMajor = i % Math.max(1, minorPerMajor) == 0;
+ double a = Math.toRadians(angleOf(v));
+ float outer = r * 0.90f, inner = isMajor ? r * 0.76f : r * 0.83f;
+ Paint p = warnBelow != null && v < warnBelow ? warnTick : tick;
+ p.setStrokeWidth(isMajor ? r * 0.022f : r * 0.012f);
+ float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
+ c.drawLine(cx + outer * cos, cy + outer * sin, cx + inner * cos, cy + inner * sin, p);
+ if (isMajor && numbers) {
+ float lr = r * 0.62f;
+ c.drawText(String.format(Locale.US, "%.0f", v),
+ cx + lr * cos, cy + lr * sin + number.getTextSize() * 0.36f, number);
+ }
+ }
+ if (!numbers) {
+ drawEndLabel(c, cx, cy, r, min, lowLabel, warnBelow != null ? warnTick.getColor() : number.getColor());
+ drawEndLabel(c, cx, cy, r, max, highLabel, number.getColor());
+ }
+
+ // Under the hub, clear of the needle's sweep: unit, digits, caption.
+ unitPaint.setTextSize(r * 0.095f);
+ c.drawText(unit, cx, cy + r * 0.25f, unitPaint);
+ digits.setTextSize(r * 0.22f);
+ c.drawText(valueText, cx, cy + r * 0.50f, digits);
+ if (captionText != null) {
+ caption.setTextSize(r * 0.09f);
+ float y = cy + r * 0.68f;
+ for (String line : captionText.split("\n")) {
+ c.drawText(line, cx, y, caption);
+ y += caption.getTextSize() * 1.25f;
+ }
+ }
+
+ if (target != null) {
+ shown += (target - shown) * 0.25f;
+ if (Math.abs(target - shown) < (max - min) * 0.001f) shown = target;
+ double a = Math.toRadians(angleOf(shown));
+ needle.setStrokeWidth(r * 0.03f);
+ c.drawLine(cx, cy, cx + r * 0.80f * (float) Math.cos(a), cy + r * 0.80f * (float) Math.sin(a), needle);
+ c.drawCircle(cx, cy, r * 0.065f, hub);
+ if (shown != target) postInvalidateOnAnimation();
+ } else {
+ c.drawCircle(cx, cy, r * 0.065f, hub);
+ }
+ }
+
+ private void drawEndLabel(Canvas c, float cx, float cy, float r, float v, String label, int color) {
+ if (label == null) return;
+ double a = Math.toRadians(angleOf(v));
+ float lr = r * 0.62f;
+ int old = number.getColor();
+ number.setColor(color);
+ number.setTextSize(r * 0.16f);
+ c.drawText(label, cx + lr * (float) Math.cos(a), cy + lr * (float) Math.sin(a) + number.getTextSize() * 0.36f, number);
+ number.setColor(old);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index 0ba1795..3e8cf85 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -66,6 +66,7 @@ public class MainActivity extends AppCompatActivity {
private View serverPane;
private View logPane;
private View vehiclePane;
+ private View dashboardPane;
private View consumptionPane;
private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */
@@ -242,15 +243,24 @@ public class MainActivity extends AppCompatActivity {
servicePane = inflatePane(R.layout.pane_service);
serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle);
+ dashboardPane = inflatePane(R.layout.pane_dashboard);
+ // The Overview page holds both styles; Settings → Overview style picks the one shown.
+ android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
+ overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT,
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT));
+ overviewPage.addView(vehiclePane);
+ overviewPage.addView(dashboardPane);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
settingsPane = inflatePane(R.layout.pane_settings);
logPane = inflatePane(R.layout.pane_log);
- panes = new View[] { vehiclePane, consumptionPane, chargingPane, servicePane, serverPane,
+ panes = new View[] { overviewPage, consumptionPane, chargingPane, servicePane, serverPane,
settingsPane, logPane };
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
bindVehiclePane();
+ bindDashboard();
bindConsumptionPane();
bindChargingPane();
bindServerPane();
@@ -1053,6 +1063,7 @@ public class MainActivity extends AppCompatActivity {
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
bindTireUnit();
+ bindOverviewStyle();
bindAccentColor();
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
fullScreenSwitch.setChecked(UploadSettings.fullScreen(prefs));
@@ -1292,9 +1303,12 @@ public class MainActivity extends AppCompatActivity {
showEstimate(vehRangeTrip, vehRangeTripNote, vehUsageTrip, vehUsageTripNote, trip,
trip == null ? "" : getString(R.string.usage_over_distance, trip.distanceKm),
R.string.range_trip_waiting);
+ RangeEstimator.Estimate average = estimator.average(remaining, monotonicMs);
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
- estimator.average(remaining, monotonicMs), 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;
+ if (dashUsage != null) dashUsage.setText(average == null ? "—"
+ : String.format(java.util.Locale.US, "%.1f kWh/100 km", average.kwhPer100Km));
}
private void showEstimate(TextView range, TextView rangeNote, TextView usage, TextView usageNote,
@@ -1404,6 +1418,73 @@ public class MainActivity extends AppCompatActivity {
}
}
+ // ---------- Dashboard (Overview style) ----------
+
+ static final String PREF_OVERVIEW_STYLE = "overview_style";
+ private GaugeView dashPower, dashSpeed, dashBattery;
+ private BatteryBarView dashBatteryBar;
+ private TextView dashGear, dashClimate, dashUsage, dashBatteryText;
+ /** Kilometres since start, from the range estimator; null until it has a distance. */
+ private Double dashTripKm;
+
+ private boolean dashboardStyle() {
+ return "DASHBOARD".equals(prefs.getString(PREF_OVERVIEW_STYLE, "TILES"));
+ }
+
+ private void applyOverviewStyle() {
+ boolean dash = dashboardStyle();
+ dashboardPane.setVisibility(dash ? View.VISIBLE : View.GONE);
+ vehiclePane.setVisibility(dash ? View.GONE : View.VISIBLE);
+ }
+
+ /** Tiles (the cards) or Dashboard (gauges); both are fed on every reading. */
+ private void bindOverviewStyle() {
+ 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,
+ position -> {
+ prefs.edit().putString(PREF_OVERVIEW_STYLE, position == 1 ? "DASHBOARD" : "TILES").apply();
+ applyOverviewStyle();
+ });
+ }
+
+ private void bindDashboard() {
+ dashPower = dashboardPane.findViewById(R.id.dash_power);
+ dashSpeed = dashboardPane.findViewById(R.id.dash_speed);
+ dashBattery = dashboardPane.findViewById(R.id.dash_battery);
+ dashBatteryBar = dashboardPane.findViewById(R.id.dash_battery_bar);
+ dashGear = dashboardPane.findViewById(R.id.dash_gear);
+ dashClimate = dashboardPane.findViewById(R.id.dash_climate);
+ dashUsage = dashboardPane.findViewById(R.id.dash_usage);
+ dashBatteryText = dashboardPane.findViewById(R.id.dash_battery_text);
+ dashPower.configure(-50f, 150f, 50f, 5, getString(R.string.dash_unit_kw));
+ dashSpeed.configure(0f, 180f, 20f, 4, getString(R.string.dash_unit_kmh));
+ dashBattery.configure(0f, 100f, 25f, 5, getString(R.string.dash_unit_battery));
+ dashBattery.setEndLabels("E", "F");
+ dashBattery.setWarnBelow(20f);
+ applyOverviewStyle();
+ }
+
+ /** The gauges and the strip; every value null-safe ("—", no needle). */
+ private void showDashboard(EnergySnapshot s) {
+ if (dashPower == null) return;
+ Float power = s.getBatteryPowerKw();
+ dashPower.setValue(power, power == null ? null : String.format(java.util.Locale.US, "%.1f", power), null);
+ Float speed = s.getSpeedKmh();
+ String total = getString(R.string.dash_total, fmt(s.getOdometerKm(), "%,.0f km"));
+ String trip = getString(R.string.dash_trip, dashTripKm == null ? "—"
+ : String.format(java.util.Locale.US, "%.1f km", dashTripKm));
+ dashSpeed.setValue(speed, speed == null ? null : String.format(java.util.Locale.US, "%.0f", speed),
+ total + "\n" + trip);
+ Float soc = s.getSocPercent();
+ dashBattery.setValue(soc, soc == null ? null : String.format(java.util.Locale.US, "%.0f %%", soc),
+ fmt(s.getRangeKm(), "%.0f km"));
+ dashBatteryBar.setSoc(soc);
+ dashBatteryText.setText(fmt(soc, "%.0f %%"));
+ Boolean parked = s.getParked();
+ dashGear.setText(parked == null ? "—" : parked ? "P" : "D");
+ dashClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
+ }
+
private void showVehicle(EnergySnapshot s) {
try {
vehSoc.setText(fmt(s.getSocPercent(), "%.0f %%"));
@@ -1422,6 +1503,7 @@ public class MainActivity extends AppCompatActivity {
vehTireFr.setText(unit.format(s.getTirePressures().getFrontRightKpa()));
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
+ showDashboard(s);
} catch (Throwable ignored) {
// Leave the last good reading on screen.
}
diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml
index f964486..50d6221 100644
--- a/app/src/main/res/layout-w1000dp/pane_settings.xml
+++ b/app/src/main/res/layout-w1000dp/pane_settings.xml
@@ -24,6 +24,10 @@
+
+
+
+
diff --git a/app/src/main/res/layout/pane_dashboard.xml b/app/src/main/res/layout/pane_dashboard.xml
new file mode 100644
index 0000000..5e97a21
--- /dev/null
+++ b/app/src/main/res/layout/pane_dashboard.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml
index 437f3e5..d78862d 100644
--- a/app/src/main/res/layout/pane_settings.xml
+++ b/app/src/main/res/layout/pane_settings.xml
@@ -15,6 +15,10 @@
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 95d428f..033972d 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -119,6 +119,14 @@
Full screen
Hides the car\'s top and bottom bars while this app is open. Swipe in from the top or bottom edge to show them briefly, e.g. for the climate controls.
+ Overview style
+ Tiles
+ Dashboard
+ kW
+ km/h
+ SOC
+ TOTAL %1$s
+ TRIP %1$s
Double-press the voice key to open this app
Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses.
Send live data
diff --git a/screenshots/nilsmg4-2.21.0-dashboard.png b/screenshots/nilsmg4-2.21.0-dashboard.png
new file mode 100644
index 0000000..1c0ff8f
Binary files /dev/null and b/screenshots/nilsmg4-2.21.0-dashboard.png differ