diff --git a/AGENTS.md b/AGENTS.md
index 697b8e7..b1742ec 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -69,7 +69,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| 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.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, classic or in the accent colour), Futuristic (`FlowView`, animated energy flow) |
+| 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.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, classic or in the accent colour), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures); the drawn styles share `ClusterValues` |
| Consumption / Charging | period totals, daily log, charging sessions and curve |
| 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 |
diff --git a/README.md b/README.md
index 291826d..ca6b1c2 100644
--- a/README.md
+++ b/README.md
@@ -79,7 +79,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| Tab | What it shows |
|---|---|
-| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (classic turquoise or in your accent colour) and Futuristic |
+| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (classic turquoise or in your accent colour), Futuristic, Sci-fi, Cartoon and Blueprint |
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
diff --git a/SPECS.md b/SPECS.md
index a65574f..103f524 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -131,6 +131,25 @@ 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.24.0 (branch `styles-retro-future`): three more Overview styles, each one
+ Canvas view fed the shared `ClusterValues` (now with the tyre pressures, formatted in the
+ chosen unit). **Sci-fi** (`SciFiView`): a starship console — rounded elbow frames in orange
+ and lilac, pill labels, condensed capitals; velocity large, power as blocks (regeneration
+ blue), rows for reserves, range, efficiency, since start, trip and climate, the reserves as a
+ stack of blocks, gears as blocks in the frame; below 20 % the frames turn red ("red alert").
+ **Cartoon** (`CartoonView`): comic panels with ink outlines and hard shadows on night blue; a
+ battery with a face (happy, unsure, worried and sweating), a speedometer whose needle springs
+ into place (animated only until it settles, only on screen), the speed in a speech bubble,
+ the gear on a sticker, a "zap-o-meter" with a sound word (PSST, PURR, VROOM, ZOOM!,
+ RECHARGE!), stats on pills. **Blueprint** (`BlueprintView`): a top-view drawing of the car on a
+ gridded navy sheet — the battery hatched as far as it is charged, leader lines to each tyre's
+ pressure, power as a force arrow (dashed and reversed while regenerating), range and trip as
+ dimension lines, the cabin setpoint as a note, and a title block with speed, power, gear,
+ usage, odometer and time. Palettes `scifi_*`, `cartoon_*`, `blueprint_*` in colors.xml.
+ `MainActivity` now maps each style to its pane in a table (`stylePanes`); the new panes are
+ built in code (`drawnPane`). Checked on the emulator with sample values in a throwaway build
+ (`screenshots/nilsmg4-2.24.0-scifi.png`, `-cartoon.png`, `-blueprint.png`) and without data
+ through the real setting; no crash. Not tried on the car.
- 2026-10-24 — App 2.23.1 (branch `styles-retro-future`): "Retro (accent colour)", the Retro
style in the chosen paint colour — display in `evAccent`, labels in `evAccentLight`;
regeneration stays green, a low battery turns amber (not red, so it never looks like Dynamic
diff --git a/app/build.gradle b/app/build.gradle
index 55868d9..4bacad9 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
- versionCode 116
- versionName "2.23.1"
+ versionCode 117
+ versionName "2.24.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/BlueprintView.java b/app/src/main/java/com/evsuite/abrp/BlueprintView.java
new file mode 100644
index 0000000..0816839
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/BlueprintView.java
@@ -0,0 +1,283 @@
+package com.evsuite.abrp;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.DashPathEffect;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.View;
+
+import java.util.Locale;
+
+/**
+ * The Blueprint Overview style: a technical drawing of the car from above, white lines on a navy
+ * sheet with a grid.
+ *
+ * The battery pack between the axles is hatched as far as it is charged. Each tyre has a leader
+ * line to its pressure. Power is a force arrow at the front — out of the car while drawing,
+ * into it (dashed) while regenerating — its length scaled to the power. Dimension lines give the
+ * range and the trip, the cabin is annotated with the climate setpoint, and a title block in the
+ * corner holds speed, power, gear, usage, odometer and time. Unread values show "—", no hatch and
+ * no arrow. Redrawn only when a reading arrives.
+ */
+public final class BlueprintView extends View {
+
+ /** The MG4's length over its width, 4.287 m by 1.836 m. */
+ private static final float ASPECT = 4.287f / 1.836f;
+
+ private final Paint sheet = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint grid = new Paint();
+ private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint faint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint dashed = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path path = new Path();
+ private final RectF rect = new RectF();
+ private final int ink;
+ private ClusterValues v = new ClusterValues();
+
+ public BlueprintView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ float d = context.getResources().getDisplayMetrics().density;
+ ink = context.getColor(R.color.blueprint_line);
+ sheet.setColor(context.getColor(R.color.blueprint_sheet));
+ grid.setStyle(Paint.Style.STROKE);
+ line.setStyle(Paint.Style.STROKE);
+ line.setColor(ink);
+ line.setStrokeJoin(Paint.Join.ROUND);
+ line.setStrokeCap(Paint.Cap.ROUND);
+ faint.setStyle(Paint.Style.STROKE);
+ faint.setColor(AccentColor.withAlpha(ink, 0x99));
+ dashed.setStyle(Paint.Style.STROKE);
+ dashed.setColor(AccentColor.withAlpha(ink, 0xB0));
+ dashed.setPathEffect(new DashPathEffect(new float[] { 8f * d, 6f * d }, 0f));
+ text.setColor(ink);
+ text.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL));
+ text.setFontFeatureSettings("tnum");
+ }
+
+ void setValues(ClusterValues values) {
+ v = values == null ? new ClusterValues() : values;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float w = getWidth(), h = getHeight();
+ if (w <= 0 || h <= 0) return;
+ float d = getResources().getDisplayMetrics().density;
+
+ // The sheet and its grid: fine every 20 dp, bolder every 100 dp; a border inset.
+ rect.set(0, 0, w, h);
+ c.drawRect(rect, sheet);
+ for (float step : new float[] { 20f * d, 100f * d }) {
+ grid.setColor(AccentColor.withAlpha(ink, step > 50f * d ? 0x22 : 0x10));
+ grid.setStrokeWidth(Math.max(1f, d * 0.75f));
+ for (float x = 0; x <= w; x += step) c.drawLine(x, 0, x, h, grid);
+ for (float y = 0; y <= h; y += step) c.drawLine(0, y, w, y, grid);
+ }
+ line.setStrokeWidth(2f * d);
+ c.drawRect(8f * d, 8f * d, w - 8f * d, h - 8f * d, line);
+
+ // The car, front to the right, its size from the space left of the title block.
+ // Centred, with its power arrow, in the space left of the title block.
+ float carH = Math.min(h * 0.40f, w * 0.44f / ASPECT), carW = carH * ASPECT;
+ float l = w * 0.04f + (w * 0.60f - carW - w * 0.12f) / 2f, t = (h - carH) / 2f, r = l + carW, b = t + carH;
+ drawCar(c, l, t, r, b, d);
+ drawTyres(c, l, t, r, b, d);
+ drawPower(c, r, (t + b) / 2f, w * 0.10f, d);
+ drawDimensions(c, l, t, r, b, d);
+ drawTitleBlock(c, w * 0.66f, h * 0.10f, w - 22f * d, h - 22f * d, d);
+ }
+
+ private void drawCar(Canvas c, float l, float t, float r, float b, float d) {
+ float len = r - l, wid = b - t;
+ // Body: rounded, fuller at the front.
+ path.reset();
+ path.moveTo(l + len * 0.10f, t);
+ path.lineTo(r - len * 0.14f, t);
+ path.cubicTo(r - len * 0.02f, t, r, t + wid * 0.12f, r, t + wid * 0.30f);
+ path.lineTo(r, b - wid * 0.30f);
+ path.cubicTo(r, b - wid * 0.12f, r - len * 0.02f, b, r - len * 0.14f, b);
+ path.lineTo(l + len * 0.10f, b);
+ path.cubicTo(l + len * 0.02f, b, l, b - wid * 0.14f, l, b - wid * 0.32f);
+ path.lineTo(l, t + wid * 0.32f);
+ path.cubicTo(l, t + wid * 0.14f, l + len * 0.02f, t, l + len * 0.10f, t);
+ line.setStrokeWidth(2.5f * d);
+ c.drawPath(path, line);
+ // Glass: windscreen, roof and rear window, dashed.
+ rect.set(l + len * 0.24f, t + wid * 0.12f, l + len * 0.74f, b - wid * 0.12f);
+ c.drawRoundRect(rect, wid * 0.12f, wid * 0.12f, dashed);
+ c.drawLine(l + len * 0.62f, t + wid * 0.13f, l + len * 0.62f, b - wid * 0.13f, dashed);
+ c.drawLine(l + len * 0.33f, t + wid * 0.13f, l + len * 0.33f, b - wid * 0.13f, dashed);
+ // Mirrors.
+ float mx = l + len * 0.66f;
+ line.setStrokeWidth(2f * d);
+ c.drawLine(mx, t, mx - len * 0.03f, t - wid * 0.09f, line);
+ c.drawLine(mx - len * 0.03f, t - wid * 0.09f, mx - len * 0.07f, t - wid * 0.02f, line);
+ c.drawLine(mx, b, mx - len * 0.03f, b + wid * 0.09f, line);
+ c.drawLine(mx - len * 0.03f, b + wid * 0.09f, mx - len * 0.07f, b + wid * 0.02f, line);
+
+ // The battery between the axles, hatched as far as it is charged (from the rear).
+ float pl = l + len * 0.25f, pr = l + len * 0.73f, pt = t + wid * 0.24f, pb = b - wid * 0.24f;
+ faint.setStrokeWidth(1.5f * d);
+ c.drawRect(pl, pt, pr, pb, faint);
+ if (v.socPercent != null) {
+ float fx = pl + (pr - pl) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
+ c.save();
+ c.clipRect(pl, pt, fx, pb);
+ float step = 9f * d;
+ for (float x = pl - (pb - pt); x < fx; x += step) c.drawLine(x, pb, x + (pb - pt), pt, faint);
+ c.restore();
+ line.setStrokeWidth(2f * d);
+ c.drawLine(fx, pt - 6f * d, fx, pb + 6f * d, line);
+ }
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setTextSize(14f * d);
+ String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent);
+ float labelY = (pt + pb) / 2f + 5f * d;
+ rect.set((pl + pr) / 2f - 70f * d, labelY - 18f * d, (pl + pr) / 2f + 70f * d, labelY + 8f * d);
+ c.drawRect(rect, sheet);
+ c.drawText("BATTERY " + soc, (pl + pr) / 2f, labelY, text);
+
+ // The cabin's climate setpoint, as a note.
+ String climate = v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius);
+ text.setTextSize(12f * d);
+ c.drawText("CABIN SET " + climate, l + len * 0.49f, t + wid * 0.19f, text);
+ }
+
+ private void drawTyres(Canvas c, float l, float t, float r, float b, float d) {
+ float len = r - l, wid = b - t, tw = len * 0.13f, th = wid * 0.13f;
+ float rear = l + len * 0.20f, front = l + len * 0.80f;
+ // Driving to the right, the car's left side is at the top.
+ Object[][] tyres = {
+ { front, t, v.tyreFl, true, "FL" }, { front, b, v.tyreFr, false, "FR" },
+ { rear, t, v.tyreRl, true, "RL" }, { rear, b, v.tyreRr, false, "RR" },
+ };
+ line.setStrokeWidth(2f * d);
+ text.setTextSize(15f * d);
+ for (Object[] tyre : tyres) {
+ float x = (Float) tyre[0], y = (Float) tyre[1];
+ boolean top = (Boolean) tyre[3];
+ rect.set(x - tw / 2f, y - th / 2f, x + tw / 2f, y + th / 2f);
+ c.drawRect(rect, sheet);
+ c.drawRoundRect(rect, 4f * d, 4f * d, line);
+ // A leader line out to the pressure.
+ float ly = top ? y - th / 2f - wid * 0.24f : y + th / 2f + wid * 0.24f;
+ float lx = x + len * 0.06f;
+ faint.setStrokeWidth(1.5f * d);
+ c.drawLine(x, top ? y - th / 2f : y + th / 2f, lx, ly, faint);
+ c.drawLine(lx, ly, lx + 110f * d, ly, faint);
+ c.drawCircle(x, top ? y - th / 2f : y + th / 2f, 3f * d, line);
+ text.setTextAlign(Paint.Align.LEFT);
+ c.drawText(tyre[4] + " " + tyre[2], lx + 4f * d, ly - 6f * d, text);
+ }
+ }
+
+ /** Power as a force arrow at the front; length scaled to 150 kW; regeneration points back. */
+ private void drawPower(Canvas c, float x, float y, float maxLen, float d) {
+ text.setTextAlign(Paint.Align.LEFT);
+ text.setTextSize(15f * d);
+ if (v.powerKw == null || Math.abs(v.powerKw) < 0.1f) {
+ c.drawText(v.powerKw == null ? "P = —" : "P = 0 kW", x + 16f * d, y + 5f * d, text);
+ return;
+ }
+ float kw = v.powerKw;
+ float len = Math.max(24f * d, maxLen * Math.min(150f, Math.abs(kw)) / 150f);
+ float x0 = x + 10f * d, x1 = x0 + len;
+ Paint p = kw >= 0f ? line : dashed;
+ p.setStrokeWidth(2.5f * d);
+ c.drawLine(x0, y, x1, y, p);
+ // The head: at the far end drawing, at the car regenerating.
+ float hx = kw >= 0f ? x1 : x0, dir = kw >= 0f ? 1f : -1f, hs = 10f * d;
+ line.setStrokeWidth(2.5f * d);
+ c.drawLine(hx, y, hx - dir * hs, y - hs * 0.6f, line);
+ c.drawLine(hx, y, hx - dir * hs, y + hs * 0.6f, line);
+ c.drawText(String.format(Locale.US, "P = %.1f kW", kw), x0, y - 10f * d, text);
+ if (kw < 0f) c.drawText("(REGEN)", x0, y + 22f * d, text);
+ }
+
+ /** Dimension lines: the range under the car, the trip above it. */
+ private void drawDimensions(Canvas c, float l, float t, float r, float b, float d) {
+ float wid = b - t;
+ String range = v.rangeKm == null ? "RANGE —" : String.format(Locale.US, "RANGE %.0f km", v.rangeKm);
+ dimension(c, l, r, b + wid * 0.50f, range, d);
+ String trip = v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm);
+ if (v.usageTrip != null) trip += String.format(Locale.US, " @ %.1f kWh/100 km", v.usageTrip);
+ dimension(c, l, r, t - wid * 0.50f, trip, d);
+ }
+
+ private void dimension(Canvas c, float x0, float x1, float y, String label, float d) {
+ faint.setStrokeWidth(1.5f * d);
+ c.drawLine(x0, y - 10f * d, x0, y + 10f * d, faint);
+ c.drawLine(x1, y - 10f * d, x1, y + 10f * d, faint);
+ c.drawLine(x0, y, x1, y, faint);
+ float hs = 9f * d;
+ c.drawLine(x0, y, x0 + hs, y - hs * 0.45f, faint);
+ c.drawLine(x0, y, x0 + hs, y + hs * 0.45f, faint);
+ c.drawLine(x1, y, x1 - hs, y - hs * 0.45f, faint);
+ c.drawLine(x1, y, x1 - hs, y + hs * 0.45f, faint);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setTextSize(15f * d);
+ float tw = text.measureText(label);
+ float mid = (x0 + x1) / 2f;
+ rect.set(mid - tw / 2f - 8f * d, y - 12f * d, mid + tw / 2f + 8f * d, y + 12f * d);
+ c.drawRect(rect, sheet);
+ c.drawText(label, mid, y + 5f * d, text);
+ }
+
+ /** The drawing's title block: rows of fields, the speed large. */
+ private void drawTitleBlock(Canvas c, float l, float t, float r, float b, float d) {
+ line.setStrokeWidth(2f * d);
+ rect.set(l, t, r, b);
+ c.drawRect(rect, sheet);
+ c.drawRect(rect, line);
+ // Eight rows: the speed takes two, the last holds scale and time.
+ float rowH = (b - t) / 8f;
+ String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh);
+ String[][] rows = {
+ { "DRAWING", "NILS' MG4 · TOP VIEW" },
+ { "SPEED", null },
+ { "POWER", v.powerKw == null ? "—" : String.format(Locale.US, "%.1f kW", v.powerKw) },
+ { "GEAR", Gear.label(v.gear).isEmpty() ? "—" : Gear.label(v.gear) },
+ { "USAGE 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min) },
+ { "ODOMETER", v.odometerKm == null ? "—" : String.format(Locale.US, "%,.0f km", v.odometerKm) },
+ };
+ float split = l + (r - l) * 0.34f;
+ float y = t;
+ for (int i = 0; i < rows.length; i++) {
+ float rh = i == 1 ? rowH * 2f : rowH;
+ if (i > 0) c.drawLine(l, y, r, y, faint);
+ text.setTextAlign(Paint.Align.LEFT);
+ text.setTextSize(11f * d);
+ c.drawText(rows[i][0], l + 10f * d, y + 18f * d, text);
+ if (i == 1) {
+ text.setTextSize(rh * 0.62f);
+ text.setTextAlign(Paint.Align.RIGHT);
+ c.drawText(speed, r - (r - l) * 0.22f, y + rh * 0.80f, text);
+ text.setTextSize(14f * d);
+ text.setTextAlign(Paint.Align.LEFT);
+ c.drawText("km/h", r - (r - l) * 0.20f, y + rh * 0.80f, text);
+ } else {
+ text.setTextSize(Math.min(rh * 0.42f, 18f * d));
+ c.drawText(rows[i][1], i == 0 ? l + 10f * d : split + 12f * d, y + rh * 0.72f, text);
+ }
+ y += rh;
+ }
+ // The last row: scale and time, split in two.
+ c.drawLine(l, y, r, y, faint);
+ c.drawLine(split, y, split, b, faint);
+ text.setTextSize(11f * d);
+ text.setTextAlign(Paint.Align.LEFT);
+ c.drawText("SCALE 1:20", l + 10f * d, y + rowH * 0.62f, text);
+ c.drawText("TIME", split + 10f * d, y + 18f * d, text);
+ text.setTextSize(Math.min(rowH * 0.5f, 22f * d));
+ text.setTextAlign(Paint.Align.RIGHT);
+ c.drawText(v.clock.isEmpty() ? "—" : v.clock, r - 12f * d, y + rowH * 0.72f, text);
+ c.drawLine(split, t + rowH * 3f, split, t + rowH * 7f, faint);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/CartoonView.java b/app/src/main/java/com/evsuite/abrp/CartoonView.java
new file mode 100644
index 0000000..7dfe6a6
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/CartoonView.java
@@ -0,0 +1,353 @@
+package com.evsuite.abrp;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.os.SystemClock;
+import android.util.AttributeSet;
+import android.view.View;
+
+import java.util.Locale;
+
+/**
+ * The Cartoon Overview style: comic panels in flat colours with thick ink outlines and hard
+ * shadows, on a night-blue page (no white page at night).
+ *
+ * Left, a battery with a face: happy above 50 %, unsure from 20 %, worried and sweating below.
+ * Centre, a chunky speedometer whose needle springs and wobbles into place, the speed in a
+ * speech bubble and the gear on a sticker. Right, the "zap-o-meter" for power with a sound word
+ * in a burst — PURR, VROOM, ZOOM! or RECHARGE! while regenerating. Below, stats on pills.
+ * Unread values show "—", a face with no expression and no needle.
+ *
+ * The needle animates (about 30 frames a second) only until it settles, and only on screen.
+ */
+public final class CartoonView extends View {
+
+ private static final float START_DEG = 150f, SWEEP_DEG = 240f, MAX_KMH = 180f;
+ private static final long FRAME_MS = 33;
+
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint ink = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint textInk = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path path = new Path();
+ private final RectF rect = new RectF();
+ private final int night, yellow, cyan, pink, cream, inkColour, green, orange, red;
+ private ClusterValues v = new ClusterValues();
+ /** The needle in km/h and its speed, for the spring. */
+ private float needle = Float.NaN, needleVelocity;
+ private long lastFrame;
+
+ public CartoonView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ night = context.getColor(R.color.cartoon_night);
+ yellow = context.getColor(R.color.cartoon_yellow);
+ cyan = context.getColor(R.color.cartoon_cyan);
+ pink = context.getColor(R.color.cartoon_pink);
+ cream = context.getColor(R.color.cartoon_cream);
+ inkColour = context.getColor(R.color.cartoon_ink);
+ green = 0xFF5BD35B;
+ orange = 0xFFFF9F1C;
+ red = 0xFFE63946;
+ ink.setStyle(Paint.Style.STROKE);
+ ink.setStrokeJoin(Paint.Join.ROUND);
+ ink.setStrokeCap(Paint.Cap.ROUND);
+ ink.setColor(inkColour);
+ Typeface black = Typeface.create("sans-serif-black", Typeface.NORMAL);
+ text.setTypeface(black);
+ text.setFontFeatureSettings("tnum");
+ textInk.setTypeface(black);
+ textInk.setFontFeatureSettings("tnum");
+ textInk.setStyle(Paint.Style.STROKE);
+ textInk.setStrokeJoin(Paint.Join.ROUND);
+ textInk.setColor(inkColour);
+ }
+
+ void setValues(ClusterValues values) {
+ v = values == null ? new ClusterValues() : values;
+ if (v.speedKmh != null && Float.isNaN(needle)) needle = v.speedKmh;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float w = getWidth(), h = getHeight();
+ if (w <= 0 || h <= 0) return;
+ float d = getResources().getDisplayMetrics().density;
+ rect.set(0, 0, w, h);
+ fill.setColor(night);
+ c.drawRoundRect(rect, 24f * d, 24f * d, fill);
+
+ boolean moving = stepNeedle();
+
+ drawBattery(c, w * 0.02f, h * 0.05f, w * 0.27f, h * 0.76f, d);
+ drawSpeedometer(c, w * 0.50f, h * 0.43f, h * 0.38f, d);
+ drawZap(c, w * 0.73f, h * 0.05f, w * 0.98f, h * 0.76f, d);
+ drawPills(c, w, h, d);
+
+ if (moving && isShown()) postInvalidateDelayed(FRAME_MS);
+ else lastFrame = 0;
+ }
+
+ /** A spring towards the speed: overshoots a little and wobbles to rest. True while moving. */
+ private boolean stepNeedle() {
+ if (v.speedKmh == null || Float.isNaN(needle)) return false;
+ long now = SystemClock.uptimeMillis();
+ float dt = lastFrame == 0 ? FRAME_MS / 1000f : Math.min(100, now - lastFrame) / 1000f;
+ lastFrame = now;
+ float target = Math.max(0f, Math.min(MAX_KMH, v.speedKmh));
+ needleVelocity += (target - needle) * 60f * dt;
+ needleVelocity *= (float) Math.pow(0.02, dt);
+ needle += needleVelocity * dt;
+ if (Math.abs(target - needle) < 0.2f && Math.abs(needleVelocity) < 0.5f) {
+ needle = target;
+ needleVelocity = 0f;
+ return false;
+ }
+ return true;
+ }
+
+ // ---------- Panels ----------
+
+ /** A comic panel: a hard shadow, the colour, a thick outline. */
+ private void panel(Canvas c, float l, float t, float r, float b, int colour, float d) {
+ float off = 6f * d, radius = 18f * d;
+ fill.setColor(inkColour);
+ rect.set(l + off, t + off, r + off, b + off);
+ c.drawRoundRect(rect, radius, radius, fill);
+ fill.setColor(colour);
+ rect.set(l, t, r, b);
+ c.drawRoundRect(rect, radius, radius, fill);
+ ink.setStrokeWidth(4f * d);
+ c.drawRoundRect(rect, radius, radius, ink);
+ }
+
+ private void drawBattery(Canvas c, float l, float t, float r, float b, float d) {
+ panel(c, l, t, r, b, yellow, d);
+ Float soc = v.socPercent;
+ // The body: a battery standing up, filled to the charge.
+ float bw = (r - l) * 0.46f, bl = (l + r) / 2f - bw / 2f, bt = t + (b - t) * 0.14f, bb = t + (b - t) * 0.66f;
+ fill.setColor(inkColour);
+ rect.set(bl + bw * 0.32f, bt - 12f * d, bl + bw * 0.68f, bt + 2f * d);
+ c.drawRoundRect(rect, 4f * d, 4f * d, fill);
+ fill.setColor(cream);
+ rect.set(bl, bt, bl + bw, bb);
+ c.drawRoundRect(rect, 16f * d, 16f * d, fill);
+ if (soc != null) {
+ float level = bb - (bb - bt) * Math.max(0f, Math.min(100f, soc)) / 100f;
+ fill.setColor(soc < 20f ? red : soc < 50f ? orange : green);
+ c.save();
+ c.clipRect(bl, level, bl + bw, bb);
+ c.drawRoundRect(rect, 16f * d, 16f * d, fill);
+ c.restore();
+ }
+ ink.setStrokeWidth(4f * d);
+ c.drawRoundRect(rect, 16f * d, 16f * d, ink);
+
+ // The face.
+ float fx = bl + bw / 2f, fy = bt + (bb - bt) * 0.42f, eye = bw * 0.09f;
+ fill.setColor(inkColour);
+ c.drawOval(fx - bw * 0.22f - eye, fy - eye * 1.4f, fx - bw * 0.22f + eye, fy + eye * 1.4f, fill);
+ c.drawOval(fx + bw * 0.22f - eye, fy - eye * 1.4f, fx + bw * 0.22f + eye, fy + eye * 1.4f, fill);
+ fill.setColor(0xFFFFFFFF);
+ c.drawCircle(fx - bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill);
+ c.drawCircle(fx + bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill);
+ float my = fy + bw * 0.30f, mw = bw * 0.26f;
+ ink.setStrokeWidth(4f * d);
+ path.reset();
+ path.moveTo(fx - mw, my);
+ if (soc == null) path.lineTo(fx + mw, my);
+ else if (soc >= 50f) path.quadTo(fx, my + mw * 0.9f, fx + mw, my); // smile
+ else if (soc >= 20f) path.quadTo(fx, my + mw * 0.15f, fx + mw, my); // unsure
+ else path.quadTo(fx, my - mw * 0.8f, fx + mw, my); // worried
+ c.drawPath(path, ink);
+ if (soc != null && soc < 20f) {
+ // A drop of sweat.
+ float sx = bl + bw + 10f * d, sy = bt + 14f * d;
+ path.reset();
+ path.moveTo(sx, sy);
+ path.quadTo(sx + 12f * d, sy + 18f * d, sx, sy + 24f * d);
+ path.quadTo(sx - 12f * d, sy + 18f * d, sx, sy);
+ fill.setColor(0xFF7FD3FF);
+ c.drawPath(path, fill);
+ ink.setStrokeWidth(2.5f * d);
+ c.drawPath(path, ink);
+ }
+
+ outlined(c, soc == null ? "—" : String.format(Locale.US, "%.0f%%", soc), (l + r) / 2f,
+ t + (b - t) * 0.84f, (b - t) * 0.15f, 0xFFFFFFFF, d);
+ outlined(c, v.rangeKm == null ? "— km to go" : String.format(Locale.US, "%.0f km to go!", v.rangeKm),
+ (l + r) / 2f, t + (b - t) * 0.95f, (b - t) * 0.065f, 0xFFFFFFFF, d);
+ }
+
+ private void drawSpeedometer(Canvas c, float cx, float cy, float r, float d) {
+ // Shadow, face, coloured zones, outline.
+ float off = 6f * d;
+ fill.setColor(inkColour);
+ c.drawCircle(cx + off, cy + off, r, fill);
+ fill.setColor(cream);
+ c.drawCircle(cx, cy, r, fill);
+ float zr = r * 0.80f;
+ rect.set(cx - zr, cy - zr, cx + zr, cy + zr);
+ ink.setStrokeCap(Paint.Cap.BUTT);
+ int[] zones = { green, orange, red };
+ for (int i = 0; i < 3; i++) {
+ ink.setColor(zones[i]);
+ ink.setStrokeWidth(r * 0.14f);
+ c.drawArc(rect, START_DEG + i * SWEEP_DEG / 3f, SWEEP_DEG / 3f, false, ink);
+ }
+ ink.setColor(inkColour);
+ ink.setStrokeCap(Paint.Cap.ROUND);
+ ink.setStrokeWidth(5f * d);
+ c.drawCircle(cx, cy, r, ink);
+ // Chunky ticks and numbers every 30 km/h.
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setColor(inkColour);
+ text.setTextSize(r * 0.12f);
+ for (int kmh = 0; kmh <= MAX_KMH; kmh += 30) {
+ double a = Math.toRadians(START_DEG + SWEEP_DEG * kmh / MAX_KMH);
+ float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
+ ink.setStrokeWidth(5f * d);
+ c.drawLine(cx + r * 0.88f * cos, cy + r * 0.88f * sin, cx + r * 0.72f * cos, cy + r * 0.72f * sin, ink);
+ c.drawText(String.valueOf(kmh), cx + r * 0.56f * cos, cy + r * 0.56f * sin + text.getTextSize() * 0.36f, text);
+ }
+ // The needle, springy.
+ if (v.speedKmh != null && !Float.isNaN(needle)) {
+ double a = Math.toRadians(START_DEG + SWEEP_DEG * Math.max(-4f, Math.min(MAX_KMH + 4f, needle)) / MAX_KMH);
+ float nx = cx + r * 0.78f * (float) Math.cos(a), ny = cy + r * 0.78f * (float) Math.sin(a);
+ ink.setStrokeWidth(12f * d);
+ c.drawLine(cx, cy, nx, ny, ink);
+ fill.setColor(red);
+ ink.setStrokeWidth(7f * d);
+ ink.setColor(red);
+ c.drawLine(cx, cy, nx, ny, ink);
+ ink.setColor(inkColour);
+ }
+ fill.setColor(pink);
+ c.drawCircle(cx, cy, r * 0.09f, fill);
+ ink.setStrokeWidth(4f * d);
+ c.drawCircle(cx, cy, r * 0.09f, ink);
+
+ // The speed in a speech bubble below the hub.
+ String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh);
+ text.setTextSize(r * 0.30f);
+ float tw = Math.max(text.measureText(speed), r * 0.4f);
+ float bt = cy + r * 0.22f, bb = cy + r * 0.62f;
+ rect.set(cx - tw / 2f - r * 0.12f, bt, cx + tw / 2f + r * 0.12f, bb);
+ fill.setColor(0xFFFFFFFF);
+ c.drawRoundRect(rect, 14f * d, 14f * d, fill);
+ ink.setStrokeWidth(4f * d);
+ c.drawRoundRect(rect, 14f * d, 14f * d, ink);
+ text.setColor(inkColour);
+ c.drawText(speed, cx, bb - (bb - bt) * 0.22f, text);
+ text.setTextSize(r * 0.09f);
+ c.drawText("km/h", cx, cy + r * 0.76f, text);
+
+ // The gear on a round sticker at the top right of the dial.
+ String gear = Gear.label(v.gear);
+ if (!gear.isEmpty()) {
+ float gx = cx + r * 0.80f, gy = cy - r * 0.78f, gr = r * 0.20f;
+ fill.setColor(inkColour);
+ c.drawCircle(gx + 4f * d, gy + 4f * d, gr, fill);
+ fill.setColor(pink);
+ c.drawCircle(gx, gy, gr, fill);
+ c.drawCircle(gx, gy, gr, ink);
+ outlined(c, gear, gx, gy + gr * 0.40f, gr * 1.1f, 0xFFFFFFFF, d);
+ }
+ }
+
+ private void drawZap(Canvas c, float l, float t, float r, float b, float d) {
+ panel(c, l, t, r, b, cyan, d);
+ Float kw = v.powerKw;
+ outlined(c, "ZAP-O-METER", (l + r) / 2f, t + (b - t) * 0.13f, (b - t) * 0.07f, yellow, d);
+
+ // The sound word in a burst.
+ String word = kw == null ? "…" : kw < -5f ? "RECHARGE!" : kw < 0.5f ? "PSST" : kw < 10f ? "PURR"
+ : kw < 40f ? "VROOM" : "ZOOM!";
+ float bx = (l + r) / 2f, by = t + (b - t) * 0.40f, br = Math.min(r - l, b - t) * 0.30f;
+ path.reset();
+ for (int i = 0; i < 24; i++) {
+ double a = Math.PI * 2 * i / 24;
+ float rr = i % 2 == 0 ? br : br * 0.74f;
+ float x = bx + rr * 1.25f * (float) Math.cos(a), y = by + rr * 0.85f * (float) Math.sin(a);
+ if (i == 0) path.moveTo(x, y); else path.lineTo(x, y);
+ }
+ path.close();
+ fill.setColor(kw != null && kw < -5f ? green : pink);
+ c.drawPath(path, fill);
+ ink.setStrokeWidth(4f * d);
+ c.drawPath(path, ink);
+ text.setTextSize(br * 0.42f);
+ float size = br * 0.42f * Math.min(1f, (br * 2.1f) / Math.max(1f, text.measureText(word)));
+ c.save();
+ c.rotate(-8f, bx, by);
+ outlined(c, word, bx, by + size * 0.36f, size, yellow, d);
+ c.restore();
+
+ // The tube: regeneration fills left of the middle mark in green, power right of it.
+ float tl = l + (r - l) * 0.10f, tr = r - (r - l) * 0.10f, tt = t + (b - t) * 0.70f, tb = t + (b - t) * 0.80f;
+ float zero = tl + (tr - tl) * 0.25f;
+ rect.set(tl, tt, tr, tb);
+ fill.setColor(cream);
+ c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill);
+ if (kw != null && Math.abs(kw) >= 0.1f) {
+ float x = kw >= 0f ? zero + (tr - zero) * Math.min(150f, kw) / 150f : zero - (zero - tl) * Math.min(50f, -kw) / 50f;
+ c.save();
+ c.clipRect(Math.min(zero, x), tt, Math.max(zero, x), tb);
+ fill.setColor(kw >= 0f ? orange : green);
+ c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill);
+ c.restore();
+ }
+ ink.setStrokeWidth(4f * d);
+ c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, ink);
+ ink.setStrokeWidth(3f * d);
+ c.drawLine(zero, tt - 6f * d, zero, tb + 6f * d, ink);
+ outlined(c, kw == null ? "— kW" : String.format(Locale.US, "%.1f kW", kw), (l + r) / 2f,
+ t + (b - t) * 0.94f, (b - t) * 0.10f, 0xFFFFFFFF, d);
+ }
+
+ private void drawPills(Canvas c, float w, float h, float d) {
+ String[] items = {
+ v.usage5Min == null ? "— kWh/100" : String.format(Locale.US, "%.1f kWh/100", v.usage5Min),
+ v.tripKm == null ? "Trip —" : String.format(Locale.US, "Trip %.1f km", v.tripKm),
+ v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius),
+ v.odometerKm == null ? "— km" : String.format(Locale.US, "%,.0f km", v.odometerKm),
+ v.clock.isEmpty() ? "—" : v.clock,
+ };
+ int[] colours = { pink, yellow, cyan, yellow, pink };
+ float t = h * 0.84f, b = h * 0.97f, gap = 16f * d, x = w * 0.02f;
+ float pw = (w * 0.96f - gap * (items.length - 1)) / items.length;
+ for (int i = 0; i < items.length; i++) {
+ float off = 4f * d;
+ fill.setColor(inkColour);
+ rect.set(x + off, t + off, x + pw + off, b + off);
+ c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
+ fill.setColor(colours[i]);
+ rect.set(x, t, x + pw, b);
+ c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
+ ink.setStrokeWidth(3.5f * d);
+ c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, ink);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setTextSize((b - t) * 0.46f);
+ text.setColor(inkColour);
+ c.drawText(items[i], x + pw / 2f, b - (b - t) * 0.30f, text);
+ x += pw + gap;
+ }
+ }
+
+ /** Comic lettering: the text in ink, thick, then the colour on top. */
+ private void outlined(Canvas c, String s, float x, float baseline, float size, int colour, float d) {
+ textInk.setTextSize(size);
+ textInk.setTextAlign(Paint.Align.CENTER);
+ textInk.setStrokeWidth(Math.max(3f * d, size * 0.12f));
+ c.drawText(s, x, baseline, textInk);
+ text.setTextSize(size);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setColor(colour);
+ c.drawText(s, x, baseline, text);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/ClusterValues.java b/app/src/main/java/com/evsuite/abrp/ClusterValues.java
index d5a8a1c..e71c0ea 100644
--- a/app/src/main/java/com/evsuite/abrp/ClusterValues.java
+++ b/app/src/main/java/com/evsuite/abrp/ClusterValues.java
@@ -1,8 +1,8 @@
package com.evsuite.abrp;
/**
- * One reading for the drawn Overview styles (Retro, Futuristic): what they show, already in
- * display units. Every field is null when unread, and the styles show that as dashes, never 0.
+ * One reading for the drawn Overview styles (Retro, Futuristic, Sci-fi, Cartoon, Blueprint):
+ * what they show, already in display units. Every field is null when unread, and the styles show that as dashes, never 0.
*/
final class ClusterValues {
Float socPercent;
@@ -19,4 +19,6 @@ final class ClusterValues {
Float odometerKm;
/** "HH:mm", never null. */
String clock = "";
+ /** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */
+ String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
}
diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index 871b480..2b9c92b 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -69,6 +69,14 @@ public class MainActivity extends AppCompatActivity {
private View gaugesPane;
private View cockpitPane;
private View retroPane, futuristicPane;
+ /** The drawn styles' views, each fed the same {@link ClusterValues} per reading. */
+ private RetroView retroView;
+ private FlowView flowView;
+ private SciFiView sciFiView;
+ private CartoonView cartoonView;
+ private BlueprintView blueprintView;
+ /** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */
+ private View[] stylePanes;
private View consumptionPane;
private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */
@@ -249,7 +257,16 @@ public class MainActivity extends AppCompatActivity {
cockpitPane = inflatePane(R.layout.pane_cockpit);
retroPane = inflatePane(R.layout.pane_retro);
futuristicPane = inflatePane(R.layout.pane_futuristic);
- // The Overview page holds both styles; Settings → Overview style picks the one shown.
+ retroView = retroPane.findViewById(R.id.retro_view);
+ flowView = futuristicPane.findViewById(R.id.flow_view);
+ sciFiView = new SciFiView(this, null);
+ cartoonView = new CartoonView(this, null);
+ blueprintView = new BlueprintView(this, null);
+ View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
+ blueprintPane = drawnPane(blueprintView);
+ stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, retroPane,
+ futuristicPane, sciFiPane, cartoonPane, blueprintPane };
+ // The Overview page holds every style; 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,
@@ -259,6 +276,9 @@ public class MainActivity extends AppCompatActivity {
overviewPage.addView(cockpitPane);
overviewPage.addView(retroPane);
overviewPage.addView(futuristicPane);
+ overviewPage.addView(sciFiPane);
+ overviewPage.addView(cartoonPane);
+ overviewPage.addView(blueprintPane);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
settingsPane = inflatePane(R.layout.pane_settings);
@@ -1442,7 +1462,8 @@ public class MainActivity extends AppCompatActivity {
private Double dashTripKm;
/** The stored values of the Overview styles, in the order of the drop-down. */
- private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC" };
+ private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC",
+ "SCIFI", "CARTOON", "BLUEPRINT" };
/** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */
private int overviewStyle() {
@@ -1454,20 +1475,18 @@ public class MainActivity extends AppCompatActivity {
private void applyOverviewStyle() {
int style = overviewStyle();
- vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE);
- gaugesPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE);
- cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE);
+ for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style] ? View.VISIBLE : View.GONE);
// Retro in either palette: one pane, the view told which.
- retroPane.setVisibility(style == 3 || style == 4 ? View.VISIBLE : View.GONE);
- ((RetroView) retroPane.findViewById(R.id.retro_view)).setAccentPalette(style == 4);
- futuristicPane.setVisibility(style == 5 ? View.VISIBLE : View.GONE);
+ retroView.setAccentPalette("RETRO_ACCENT".equals(OVERVIEW_STYLES[style]));
}
- /** Tiles, Gauges, Cockpit, Retro (classic or accent colour) or Futuristic; all are fed on every reading. */
+ /** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
private void bindOverviewStyle() {
String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges),
getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro),
- getString(R.string.overview_style_retro_accent), getString(R.string.overview_style_futuristic) };
+ getString(R.string.overview_style_retro_accent), getString(R.string.overview_style_futuristic),
+ getString(R.string.overview_style_scifi), getString(R.string.overview_style_cartoon),
+ getString(R.string.overview_style_blueprint) };
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(),
position -> {
prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply();
@@ -1496,9 +1515,17 @@ public class MainActivity extends AppCompatActivity {
private final java.text.SimpleDateFormat clockFormat =
new java.text.SimpleDateFormat("HH:mm", java.util.Locale.US);
+ /** A drawn style's view in a pane with the Overview's margins. */
+ private View drawnPane(View view) {
+ android.widget.FrameLayout pane = new android.widget.FrameLayout(this);
+ float dp = getResources().getDisplayMetrics().density;
+ pane.setPadding(Math.round(24 * dp), Math.round(12 * dp), Math.round(24 * dp), Math.round(12 * dp));
+ pane.addView(view, new android.widget.FrameLayout.LayoutParams(
+ android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.MATCH_PARENT));
+ return pane;
+ }
+
private void showDrawnStyles(EnergySnapshot s) {
- RetroView retro = retroPane.findViewById(R.id.retro_view);
- FlowView flow = futuristicPane.findViewById(R.id.flow_view);
ClusterValues v = new ClusterValues();
v.socPercent = s.getSocPercent();
v.rangeKm = s.getRangeKm();
@@ -1511,8 +1538,16 @@ public class MainActivity extends AppCompatActivity {
v.climateCelsius = s.getClimate().getDriverTargetCelsius();
v.odometerKm = s.getOdometerKm();
v.clock = clockFormat.format(new java.util.Date());
- retro.setValues(v);
- flow.setValues(v);
+ PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null));
+ v.tyreFl = unit.format(s.getTirePressures().getFrontLeftKpa());
+ v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa());
+ v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
+ v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
+ retroView.setValues(v);
+ flowView.setValues(v);
+ sciFiView.setValues(v);
+ cartoonView.setValues(v);
+ blueprintView.setValues(v);
}
// ---------- Cockpit (Overview style) ----------
diff --git a/app/src/main/java/com/evsuite/abrp/SciFiView.java b/app/src/main/java/com/evsuite/abrp/SciFiView.java
new file mode 100644
index 0000000..1591331
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/SciFiView.java
@@ -0,0 +1,194 @@
+package com.evsuite.abrp;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.util.AttributeSet;
+import android.view.View;
+
+import java.util.Locale;
+
+/**
+ * The Sci-fi Overview style: a starship console. Rounded "elbow" frames in orange, lilac and
+ * blue, pill-shaped labels, condensed capitals and blocky bar graphs on black.
+ *
+ * Left: velocity (the speed) and the power transfer as a bar of blocks, regeneration in blue to
+ * the left of zero. Middle: labelled rows — reserves, range, efficiency, trip, climate. Right:
+ * the reserves as a stack of blocks. The gears are blocks in the left frame, the odometer and
+ * clock run along the bottom frame. Below 20 % charge the frames turn red ("red alert").
+ * Unread values show "—" and light no blocks. Redrawn only when a reading arrives.
+ */
+public final class SciFiView extends View {
+
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path path = new Path();
+ private final RectF rect = new RectF();
+ private final int orange, peach, lilac, blue, alert, off;
+ private ClusterValues v = new ClusterValues();
+
+ public SciFiView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ orange = context.getColor(R.color.scifi_orange);
+ peach = context.getColor(R.color.scifi_peach);
+ lilac = context.getColor(R.color.scifi_lilac);
+ blue = context.getColor(R.color.scifi_blue);
+ alert = context.getColor(R.color.scifi_alert);
+ off = context.getColor(R.color.scifi_off);
+ text.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD));
+ text.setFontFeatureSettings("tnum");
+ }
+
+ void setValues(ClusterValues values) {
+ v = values == null ? new ClusterValues() : values;
+ invalidate();
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float w = getWidth(), h = getHeight();
+ if (w <= 0 || h <= 0) return;
+ float d = getResources().getDisplayMetrics().density;
+ boolean redAlert = v.socPercent != null && v.socPercent < 20f;
+ int frame = redAlert ? alert : orange, frame2 = redAlert ? alert : lilac;
+
+ // Frames: an elbow at the top left and one at the bottom left.
+ float side = w * 0.085f, bar = h * 0.055f, gap = 6f * d;
+ float split = h * 0.50f;
+ // Each elbow is only its corner; blocks fill the side between them.
+ float corner = bar + side * 0.9f + 12f * d;
+ elbow(c, 0, 0, w * 0.60f, corner, side, bar, true, frame);
+ elbow(c, 0, h - corner, w * 0.70f, h, side, bar, false, frame2);
+ // The top bar's end: the title, then a pill.
+ label(c, redAlert ? "RED ALERT · LOW RESERVES" : "PROPULSION STATUS", w * 0.61f + 10f * d,
+ bar * 0.86f, bar * 1.05f, redAlert ? alert : orange, Paint.Align.LEFT);
+ pill(c, w * 0.94f, 0, w, bar, frame2);
+ // The bottom bar's end: odometer and time.
+ String odo = v.odometerKm == null ? "—" : String.format(Locale.US, "%06.0f", v.odometerKm);
+ label(c, "ODO " + odo + " · " + (v.clock.isEmpty() ? "—" : v.clock),
+ w - 4f * d, h - bar * 0.14f, bar * 0.95f, peach, Paint.Align.RIGHT);
+
+ // The gears as blocks down the lower elbow.
+ String[] gears = { "P", "R", "N", "D" };
+ float gy = split + gap / 2f, gh = (h - corner - gap - gy - 3 * gap) / 4f;
+ for (int i = 0; i < 4; i++) {
+ boolean on = v.gear != null && v.gear == i + 1;
+ float top = gy + i * (gh + gap);
+ fill.setColor(on ? peach : off);
+ c.drawRect(0, top, side, top + gh, fill);
+ label(c, gears[i], side - 8f * d, top + gh - 6f * d, gh * 0.7f, on ? 0xFF000000 : lilac, Paint.Align.RIGHT);
+ }
+ // Upper elbow: three numbered blocks, the console's decoration.
+ float by = corner + gap, bh = (split - gap / 2f - by - 2 * gap) / 3f;
+ int[] colours = { lilac, blue, peach };
+ String[] codes = { "01-4471", "02-0815", "03-2026" };
+ for (int i = 0; i < 3; i++) {
+ float top = by + i * (bh + gap);
+ fill.setColor(redAlert ? alert : colours[i]);
+ c.drawRect(0, top, side, top + bh, fill);
+ label(c, codes[i], side - 8f * d, top + bh - 6f * d, Math.min(bh * 0.4f, 16f * d), 0xFF000000, Paint.Align.RIGHT);
+ }
+
+ float x0 = side + w * 0.035f;
+ // Velocity, large.
+ label(c, "VELOCITY · KM/H", x0, h * 0.17f, h * 0.045f, lilac, Paint.Align.LEFT);
+ label(c, v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), x0 - 4f * d,
+ h * 0.45f, h * 0.30f, orange, Paint.Align.LEFT);
+
+ // Power transfer: blocks from −50 to 150 kW in 10 kW steps.
+ label(c, "POWER TRANSFER · KW", x0, h * 0.62f, h * 0.045f, lilac, Paint.Align.LEFT);
+ String kw = v.powerKw == null ? "—" : String.format(Locale.US, "%.1f", v.powerKw);
+ label(c, kw, w * 0.42f, h * 0.62f, h * 0.06f, v.powerKw != null && v.powerKw < 0f ? blue : peach, Paint.Align.RIGHT);
+ int blocks = 20, zero = 5;
+ float bx0 = x0, bx1 = w * 0.42f, pitch = (bx1 - bx0) / blocks;
+ int litTo = v.powerKw == null ? zero : zero + Math.round(Math.max(-50f, Math.min(150f, v.powerKw)) / 10f);
+ for (int i = 0; i < blocks; i++) {
+ boolean on = v.powerKw != null && (litTo > zero ? i >= zero && i < litTo : i >= litTo && i < zero);
+ fill.setColor(on ? (i < zero ? blue : i >= 15 ? alert : orange) : off);
+ float x = bx0 + i * pitch;
+ rect.set(x, h * 0.65f, x + pitch - 4f * d, h * 0.74f);
+ c.drawRoundRect(rect, i == 0 || i == blocks - 1 ? 10f * d : 0f, 10f * d, fill);
+ }
+ label(c, "−50", bx0, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT);
+ label(c, "0", bx0 + zero * pitch, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT);
+ label(c, "150", bx1, h * 0.79f, h * 0.035f, lilac, Paint.Align.RIGHT);
+
+ // Middle: labelled rows.
+ String[][] rows = {
+ { "RESERVES", v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent) },
+ { "RANGE", v.rangeKm == null ? "—" : String.format(Locale.US, "%.0f KM", v.rangeKm) },
+ { "EFFICIENCY 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f", v.usage5Min) },
+ { "SINCE START", v.usageTrip == null ? "—" : String.format(Locale.US, "%.1f", v.usageTrip) },
+ { "TRIP", v.tripKm == null ? "—" : String.format(Locale.US, "%.1f KM", v.tripKm) },
+ { "CLIMATE", v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius) },
+ };
+ float rx = w * 0.47f, rw = w * 0.15f, ry = h * 0.11f, rh = h * 0.085f, rg = h * 0.035f;
+ for (int i = 0; i < rows.length; i++) {
+ float top = ry + i * (rh + rg);
+ int colour = redAlert && i == 0 ? alert : i % 2 == 0 ? lilac : blue;
+ pill(c, rx, top, rx + rw, top + rh, colour);
+ label(c, rows[i][0], rx + rw - 14f * d, top + rh * 0.72f, rh * 0.48f, 0xFF000000, Paint.Align.RIGHT);
+ label(c, rows[i][1], rx + rw + 16f * d, top + rh * 0.82f, rh * 0.85f, i == 0 && redAlert ? alert : peach, Paint.Align.LEFT);
+ }
+
+ // Right: the reserves as ten blocks, filled from the bottom.
+ float sx0 = w * 0.84f, sx1 = w * 0.93f, sTop = h * 0.11f, sBottom = h * 0.80f;
+ label(c, "RESERVES", (sx0 + sx1) / 2f, sTop - 8f * d, h * 0.04f, lilac, Paint.Align.CENTER);
+ int on = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 10f);
+ float sh = (sBottom - sTop) / 10f;
+ for (int i = 0; i < 10; i++) {
+ float bottom = sBottom - i * sh;
+ fill.setColor(i < on ? (redAlert ? alert : i % 2 == 0 ? blue : lilac) : off);
+ rect.set(sx0, bottom - sh + 3f * d, sx1, bottom);
+ c.drawRoundRect(rect, i == 9 || i == 0 ? sh * 0.4f : 0f, sh * 0.4f, fill);
+ }
+ }
+
+ /**
+ * A frame: a vertical bar down the left joined by a rounded outer corner to a horizontal
+ * bar along the top (or the bottom), with a smaller rounded inner corner.
+ */
+ private void elbow(Canvas c, float l, float t, float r, float b, float side, float bar, boolean top, int colour) {
+ float outer = side * 0.9f, inner = bar * 1.2f;
+ path.reset();
+ if (top) {
+ path.moveTo(l, b);
+ path.lineTo(l, t + outer);
+ path.quadTo(l, t, l + outer, t);
+ path.lineTo(r, t);
+ path.lineTo(r, t + bar);
+ path.lineTo(l + side + inner, t + bar);
+ path.quadTo(l + side, t + bar, l + side, t + bar + inner);
+ path.lineTo(l + side, b);
+ } else {
+ path.moveTo(l, t);
+ path.lineTo(l, b - outer);
+ path.quadTo(l, b, l + outer, b);
+ path.lineTo(r, b);
+ path.lineTo(r, b - bar);
+ path.lineTo(l + side + inner, b - bar);
+ path.quadTo(l + side, b - bar, l + side, b - bar - inner);
+ path.lineTo(l + side, t);
+ }
+ path.close();
+ fill.setColor(colour);
+ c.drawPath(path, fill);
+ }
+
+ private void pill(Canvas c, float l, float t, float r, float b, int colour) {
+ fill.setColor(colour);
+ rect.set(l, t, r, b);
+ c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
+ }
+
+ private void label(Canvas c, String s, float x, float baseline, float size, int colour, Paint.Align align) {
+ text.setTextSize(size);
+ text.setColor(colour);
+ text.setTextAlign(align);
+ c.drawText(s, x, baseline, text);
+ }
+}
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 5b2e4b6..d0b5072 100644
--- a/app/src/main/res/values/colors.xml
+++ b/app/src/main/res/values/colors.xml
@@ -28,4 +28,21 @@
#FF5C4D
#060B0A
#1E2B28
+
+ #FF9966
+ #FFCC99
+ #CC99CC
+ #9999FF
+ #FF4D4D
+ #2A2530
+
+ #1C1F3B
+ #FFD23F
+ #3BCEAC
+ #EE4266
+ #FFF5D6
+ #111111
+
+ #0A2240
+ #E3EEFF
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2323bca..e55052a 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -131,6 +131,9 @@
Retro
Futuristic
Retro (accent colour)
+ Sci-fi
+ Cartoon
+ Blueprint
BATTERY
USAGE · 5 MIN
kWh/100 km
diff --git a/screenshots/nilsmg4-2.24.0-blueprint.png b/screenshots/nilsmg4-2.24.0-blueprint.png
new file mode 100644
index 0000000..c741c2c
Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-blueprint.png differ
diff --git a/screenshots/nilsmg4-2.24.0-cartoon.png b/screenshots/nilsmg4-2.24.0-cartoon.png
new file mode 100644
index 0000000..d891de4
Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-cartoon.png differ
diff --git a/screenshots/nilsmg4-2.24.0-scifi.png b/screenshots/nilsmg4-2.24.0-scifi.png
new file mode 100644
index 0000000..5434fe4
Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-scifi.png differ