diff --git a/AGENTS.md b/AGENTS.md
index 117329f..3c98264 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph; the drawn styles share `ClusterValues`. A button in the top-right corner hides the app's navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`) |
+| 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph, Mascot (`MascotView`, an anime co-pilot whose face follows `MascotMood`); the drawn styles share `ClusterValues`. A button in the top-right corner hides the app's navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`) |
| 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 f849228..29bf3f3 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, Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes and Chronograph. The small button in the top-right corner hides the app's navigation for a full-width Overview; tap it again to bring it back |
+| **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, Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes, Chronograph and Mascot (an anime co-pilot who reacts to the drive). The small button in the top-right corner hides the app's navigation for a full-width Overview; tap it again to bring it back |
| **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 33d993d..0f9bf0c 100644
--- a/SPECS.md
+++ b/SPECS.md
@@ -131,6 +131,20 @@ 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.28.0 (branch `styles-retro-future`): Overview style **Mascot**
+ (`MascotView` on `ClusterCanvasView`): an anime co-pilot, head and shoulders, drawn on a
+ 100 × 100 grid — an adult woman, fully dressed, with a headset and a racing jacket in the
+ accent colour (so the style takes the accent). Her face follows `MascotMood`, chosen from the
+ reading in this order: waiting (nothing read), reverse (glances back), worried under 20 %
+ (sweat drop), asleep in P (drifting Zs), cheering while regenerating more than 5 kW
+ (sparkles), determined over 60 kW, enjoying 100 km/h and more (wind lines), else content. A
+ new mood shows after it lasts 1.5 s (`MascotMood.Holder`), so values hovering at a threshold
+ do not make her flicker; R and "waiting" show at once. Unread values are never a reason for a
+ mood. She says a line per mood in a speech bubble (`mascot_*` string arrays, one picked every
+ 30 s, shrunk to fit). Beside her: speed, the gear in a ring, battery bar with percent and
+ range, power, 5-minute usage, trip, and the clock. She blinks and the Zs, sparkles and wind
+ move with the 0.2 s readings; no animation loop of its own. Emulator: all eight moods checked
+ with sample values in a throwaway build, and "waiting" with no data. Not tried on the car.
- 2026-10-24 — App 2.27.3 (branch `styles-retro-future`): first step to the cruise control (ACC)
set speed. The head unit's `CarAdvancedAssistedDrivingManager` names a vendor float
`AAD_EPTCCTRGTSPD` = `0x2160A15B`, by its name the powertrain's cruise control target speed;
diff --git a/app/build.gradle b/app/build.gradle
index e229fd2..4d2e6cb 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
- versionCode 124
- versionName "2.27.3"
+ versionCode 125
+ versionName "2.28.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/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java
index 3e1ec78..f2bbe06 100644
--- a/app/src/main/java/com/evsuite/abrp/MainActivity.java
+++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java
@@ -285,12 +285,13 @@ public class MainActivity extends AppCompatActivity {
blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView);
terminalView = new TerminalView(this);
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
- new AviationView(this), new NixieView(this), new ChronographView(this));
+ new AviationView(this), new NixieView(this), new ChronographView(this), new MascotView(this));
View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane,
- canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3], canvasPanes[4] };
+ canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3], canvasPanes[4],
+ canvasPanes[5] };
// 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(
@@ -1558,7 +1559,7 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_blueprint), getString(R.string.overview_style_mg),
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
getString(R.string.overview_style_aviation), getString(R.string.overview_style_nixie),
- getString(R.string.overview_style_chronograph) };
+ getString(R.string.overview_style_chronograph), getString(R.string.overview_style_mascot) };
}
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
diff --git a/app/src/main/java/com/evsuite/abrp/MascotMood.java b/app/src/main/java/com/evsuite/abrp/MascotMood.java
new file mode 100644
index 0000000..04b71dd
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/MascotMood.java
@@ -0,0 +1,70 @@
+package com.evsuite.abrp;
+
+/**
+ * What the Mascot Overview style's co-pilot feels, from one reading ({@link #of}), and a
+ * {@link Holder} that keeps her from flickering between moods when a value hovers at a
+ * threshold. Pure Java, unit-tested.
+ */
+enum MascotMood {
+ /** Nothing read yet (the emulator, or the car's services not up). */
+ WAITING,
+ /** Reversing: looks back, careful. */
+ REVERSE,
+ /** Battery under {@link #LOW_SOC} %: worried, a sweat drop. */
+ WORRIED,
+ /** In P: asleep. */
+ SLEEPING,
+ /** Regenerating more than {@link #REGEN_KW} kW: cheering. */
+ CHEERING,
+ /** Drawing more than {@link #BOOST_KW} kW: determined. */
+ DETERMINED,
+ /** {@link #FAST_KMH} km/h and more: enjoying the speed, wind in her hair. */
+ FAST,
+ /** Anything else: content. */
+ CONTENT;
+
+ static final float LOW_SOC = 20f, REGEN_KW = 5f, BOOST_KW = 60f, FAST_KMH = 100f;
+ /** How long a new mood must last before she shows it. */
+ static final long HOLD_MS = 1500;
+
+ /** The mood for one reading; unread values never count as a reason. */
+ static MascotMood of(ClusterValues v) {
+ if (v == null || (v.gear == null && v.speedKmh == null && v.socPercent == null && v.powerKw == null)) {
+ return WAITING;
+ }
+ if (v.gear != null && v.gear == 2) return REVERSE;
+ if (v.socPercent != null && v.socPercent < LOW_SOC) return WORRIED;
+ if (v.gear != null && v.gear == 1) return SLEEPING;
+ if (v.powerKw != null && v.powerKw < -REGEN_KW) return CHEERING;
+ if (v.powerKw != null && v.powerKw > BOOST_KW) return DETERMINED;
+ if (v.speedKmh != null && v.speedKmh >= FAST_KMH) return FAST;
+ return CONTENT;
+ }
+
+ /** Moods that show at once: shifting into R and losing the data are not to be waited out. */
+ boolean immediate() {
+ return this == REVERSE || this == WAITING;
+ }
+
+ /** The shown mood: a new one takes over after it has lasted {@link #HOLD_MS}. */
+ static final class Holder {
+ private MascotMood shown, candidate;
+ private long candidateSince;
+
+ MascotMood update(MascotMood now, long nowMs) {
+ if (shown == null || now == shown || now.immediate()) {
+ shown = now;
+ candidate = null;
+ return shown;
+ }
+ if (now != candidate) {
+ candidate = now;
+ candidateSince = nowMs;
+ } else if (nowMs - candidateSince >= HOLD_MS) {
+ shown = now;
+ candidate = null;
+ }
+ return shown;
+ }
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/MascotView.java b/app/src/main/java/com/evsuite/abrp/MascotView.java
new file mode 100644
index 0000000..1642324
--- /dev/null
+++ b/app/src/main/java/com/evsuite/abrp/MascotView.java
@@ -0,0 +1,511 @@
+package com.evsuite.abrp;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.os.SystemClock;
+
+/**
+ * The Mascot Overview style: an anime co-pilot beside the values, who reacts to the drive.
+ *
+ * On the left, head and shoulders: a young woman with dark plum hair, a headset with a boom mic
+ * and a racing jacket in the accent colour with an octagon badge. Her face follows
+ * {@link MascotMood} (held for a moment so it does not flicker): content, enjoying the speed
+ * with wind lines, cheering with sparkles while regenerating, determined under hard
+ * acceleration, worried with a sweat drop on a low battery, asleep with drifting Zs in P,
+ * glancing back in R, and patient while nothing is read. She blinks now and then and says a
+ * line for her mood in a speech bubble.
+ *
+ * On the right: speed large, the gear in a ring, battery as a bar with percent and range, then
+ * power, 5-minute usage and the trip; the clock top right, left of the full-screen button.
+ * Unread values show "—".
+ *
+ * Drawn on a 100 × 100 grid scaled to the pane. Nothing animates on its own: blinking, the Zs,
+ * sparkles and wind move because readings redraw the view every 0.2 s.
+ */
+final class MascotView extends ClusterCanvasView {
+
+ private static final int[] LINES = {
+ R.array.mascot_waiting, R.array.mascot_reverse, R.array.mascot_worried, R.array.mascot_sleeping,
+ R.array.mascot_cheering, R.array.mascot_determined, R.array.mascot_fast, R.array.mascot_content };
+
+ private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+ private final Path p = new Path();
+ private final RectF r = new RectF();
+ private final MascotMood.Holder holder = new MascotMood.Holder();
+ private final String[][] lines = new String[LINES.length][];
+ private final int accent, accentDark, accentLight, regen, warn, white, grey, track;
+ private final int hair = 0xFF2E2440, hairLight = 0xFF5A4880, skin = 0xFFFBE2D0, skinShade = 0xFFE9BFA9,
+ iris = 0xFF2F8F9D, pupil = 0xFF123238, lash = 0xFF221A2E, lips = 0xFFC2606A, mouth = 0xFF8E3A48,
+ shirt = 0xFFF4F4F6, headset = 0xFF2A2D33, cup = 0xFF3C414A;
+
+ MascotView(Context context) {
+ super(context);
+ accent = AccentColor.resolve(context, R.attr.evAccent);
+ accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
+ accentDark = Color.rgb(Math.round(Color.red(accent) * 0.6f), Math.round(Color.green(accent) * 0.6f),
+ Math.round(Color.blue(accent) * 0.6f));
+ regen = context.getColor(R.color.ev_ok);
+ warn = context.getColor(R.color.ev_error);
+ white = context.getColor(R.color.ev_text_primary);
+ grey = context.getColor(R.color.ev_text_secondary);
+ track = context.getColor(R.color.ev_outline);
+ for (int i = 0; i < LINES.length; i++) lines[i] = context.getResources().getStringArray(LINES[i]);
+ stroke.setStyle(Paint.Style.STROKE);
+ stroke.setStrokeCap(Paint.Cap.ROUND);
+ stroke.setStrokeJoin(Paint.Join.ROUND);
+ text.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
+ text.setFontFeatureSettings("tnum");
+ }
+
+ @Override
+ protected void onDraw(Canvas c) {
+ super.onDraw(c);
+ float w = getWidth(), h = getHeight();
+ if (w <= 0 || h <= 0) return;
+ long t = SystemClock.uptimeMillis();
+ MascotMood mood = holder.update(MascotMood.of(v), t);
+
+ // A soft halo behind her.
+ float k = h * 0.98f / 100f, ox = w * 0.02f, oy = h - 100f * k;
+ fill.setColor(AccentColor.withAlpha(accent, 0x30));
+ c.drawCircle(ox + 50f * k, oy + 46f * k, 46f * k, fill);
+
+ c.save();
+ c.translate(ox, oy);
+ c.scale(k, k);
+ drawCharacter(c, mood, t);
+ c.restore();
+
+ float x0 = ox + 78f * k;
+ drawBubble(c, mood, t, x0, h, ox + 70f * k, oy + 46f * k, w);
+ drawValues(c, x0, ox + 93f * k, w, h);
+ }
+
+ // ---------- The co-pilot, on the 100 × 100 grid ----------
+
+ private void drawCharacter(Canvas c, MascotMood mood, long t) {
+ float tilt = mood == MascotMood.SLEEPING ? 8f : mood == MascotMood.REVERSE ? -6f
+ : mood == MascotMood.CHEERING ? -4f : 0f;
+
+ // Hair behind the head, falling behind the shoulders.
+ c.save();
+ c.rotate(tilt, 50f, 62f);
+ fill.setColor(hair);
+ c.drawOval(oval(27f, 9f, 73f, 57f), fill);
+ p.reset();
+ p.moveTo(28.5f, 28f);
+ p.cubicTo(26f, 46f, 25f, 64f, 22f, 84f);
+ p.lineTo(78f, 84f);
+ p.cubicTo(75f, 64f, 74f, 46f, 71.5f, 28f);
+ p.close();
+ c.drawPath(p, fill);
+ c.restore();
+
+ // Neck, jacket, shirt collar, badge.
+ fill.setColor(skin);
+ c.drawRect(44.5f, 48f, 55.5f, 70f, fill);
+ fill.setColor(skinShade);
+ c.drawOval(oval(44f, 50f, 56f, 57f), fill);
+ p.reset();
+ p.moveTo(10f, 100f);
+ p.lineTo(14f, 80f);
+ p.cubicTo(16f, 71f, 26f, 68f, 40f, 66f);
+ p.lineTo(60f, 66f);
+ p.cubicTo(74f, 68f, 84f, 71f, 86f, 80f);
+ p.lineTo(90f, 100f);
+ p.close();
+ fill.setColor(accent);
+ c.drawPath(p, fill);
+ triangle(c, 40.5f, 66f, 59.5f, 66f, 50f, 85f, shirt);
+ triangle(c, 45f, 66f, 55f, 66f, 50f, 74f, skin);
+ stroke.setColor(accentDark);
+ stroke.setStrokeWidth(2.2f);
+ c.drawLine(40.5f, 66.5f, 49f, 90f, stroke);
+ c.drawLine(59.5f, 66.5f, 51f, 90f, stroke);
+ stroke.setColor(shirt);
+ stroke.setStrokeWidth(1.6f);
+ c.drawLine(16f, 79f, 38f, 70f, stroke);
+ c.drawLine(84f, 79f, 62f, 70f, stroke);
+ badge(c, 68f, 86f, 4f);
+
+ // The head.
+ c.save();
+ c.rotate(tilt, 50f, 62f);
+ p.reset();
+ p.moveTo(35f, 28f);
+ p.cubicTo(35f, 44f, 42f, 55f, 50f, 57f);
+ p.cubicTo(58f, 55f, 65f, 44f, 65f, 28f);
+ p.cubicTo(65f, 14f, 35f, 14f, 35f, 28f);
+ p.close();
+ fill.setColor(skin);
+ c.drawPath(p, fill);
+
+ boolean blink = t % 4200 < 260;
+ boolean blush = mood == MascotMood.CHEERING || mood == MascotMood.FAST || mood == MascotMood.SLEEPING;
+ if (blush) {
+ fill.setColor(0x55FF7A8A);
+ c.drawOval(oval(37.5f, 43.2f, 43.5f, 46f), fill);
+ c.drawOval(oval(56.5f, 43.2f, 62.5f, 46f), fill);
+ }
+ eye(c, 43f, 38f, mood, blink, true);
+ eye(c, 57f, 38f, mood, blink, false);
+ stroke.setColor(skinShade);
+ stroke.setStrokeWidth(0.8f);
+ c.drawLine(50f, 43.5f, 49.3f, 45.2f, stroke);
+ mouth(c, mood);
+
+ drawFrontHair(c);
+ brows(c, mood);
+ drawHeadset(c);
+ if (mood == MascotMood.WORRIED) sweatDrop(c);
+ c.restore();
+
+ if (mood == MascotMood.SLEEPING) sleepZs(c, t);
+ if (mood == MascotMood.CHEERING) sparkles(c, t);
+ if (mood == MascotMood.FAST) wind(c, t);
+ }
+
+ private void drawFrontHair(Canvas c) {
+ fill.setColor(hair);
+ p.reset();
+ p.moveTo(33f, 36f);
+ p.lineTo(33.5f, 24f);
+ p.cubicTo(36f, 10f, 64f, 10f, 66.5f, 24f);
+ p.lineTo(67f, 36f);
+ p.lineTo(64f, 28f);
+ p.lineTo(61.5f, 33f);
+ p.lineTo(58.5f, 26.5f);
+ p.lineTo(55f, 32f);
+ p.lineTo(51.5f, 25.5f);
+ p.lineTo(48f, 31.5f);
+ p.lineTo(44.5f, 26f);
+ p.lineTo(41.5f, 32.5f);
+ p.lineTo(38.5f, 27f);
+ p.lineTo(36f, 34f);
+ p.close();
+ c.drawPath(p, fill);
+ // Side locks framing the face, the right one mirrored.
+ p.reset();
+ p.moveTo(33.5f, 26f);
+ p.cubicTo(31f, 38f, 31f, 50f, 33f, 61f);
+ p.lineTo(36.2f, 52f);
+ p.cubicTo(35.5f, 44f, 36f, 36f, 37.5f, 30f);
+ p.close();
+ c.drawPath(p, fill);
+ c.save();
+ c.scale(-1f, 1f, 50f, 0f);
+ c.drawPath(p, fill);
+ c.restore();
+ stroke.setColor(hairLight);
+ stroke.setStrokeWidth(1.3f);
+ c.drawArc(oval(38f, 13.5f, 62f, 30f), 205f, 55f, false, stroke);
+ }
+
+ private void drawHeadset(Canvas c) {
+ stroke.setColor(headset);
+ stroke.setStrokeWidth(2.2f);
+ c.drawArc(oval(31f, 9.5f, 69f, 48f), 180f, 180f, false, stroke);
+ fill.setColor(cup);
+ c.drawRoundRect(oval(28.5f, 29f, 36f, 42f), 2.5f, 2.5f, fill);
+ c.drawRoundRect(oval(64f, 29f, 71.5f, 42f), 2.5f, 2.5f, fill);
+ fill.setColor(accent);
+ c.drawRect(29.5f, 33.5f, 30.6f, 37.5f, fill);
+ c.drawRect(69.4f, 33.5f, 70.5f, 37.5f, fill);
+ stroke.setStrokeWidth(1f);
+ p.reset();
+ p.moveTo(67.5f, 41f);
+ p.quadTo(66f, 50f, 58.5f, 50.5f);
+ c.drawPath(p, stroke);
+ fill.setColor(headset);
+ c.drawCircle(57.6f, 50.5f, 1.3f, fill);
+ }
+
+ private void eye(Canvas c, float cx, float cy, MascotMood mood, boolean blink, boolean left) {
+ stroke.setColor(lash);
+ if (mood == MascotMood.SLEEPING || (blink && mood != MascotMood.CHEERING)) {
+ // Closed, calm: a lid line curving down.
+ stroke.setStrokeWidth(1.2f);
+ c.drawArc(oval(cx - 4f, cy - 3.2f, cx + 4f, cy + 1.6f), 20f, 140f, false, stroke);
+ return;
+ }
+ if (mood == MascotMood.CHEERING) {
+ // Happy, squeezed shut: ^ ^.
+ stroke.setStrokeWidth(1.4f);
+ c.drawArc(oval(cx - 4f, cy - 1.5f, cx + 4f, cy + 4.5f), 200f, 140f, false, stroke);
+ return;
+ }
+ float look = mood == MascotMood.REVERSE ? -1.6f : 0f;
+ float irisW = mood == MascotMood.WORRIED ? 1.7f : 2.3f;
+ fill.setColor(Color.WHITE);
+ oval(cx - 4f, cy - 2.6f, cx + 4f, cy + 2.8f);
+ c.drawOval(r, fill);
+ c.save();
+ p.reset();
+ p.addOval(r, Path.Direction.CW);
+ c.clipPath(p);
+ fill.setColor(iris);
+ c.drawOval(oval(cx - irisW + look, cy - 2.8f, cx + irisW + look, cy + 2.8f), fill);
+ fill.setColor(pupil);
+ c.drawOval(oval(cx - irisW * 0.5f + look, cy - 1.5f, cx + irisW * 0.5f + look, cy + 1.6f), fill);
+ fill.setColor(Color.WHITE);
+ c.drawCircle(cx - 0.8f + look, cy - 1.2f, 0.75f, fill);
+ c.drawCircle(cx + 0.8f + look, cy + 1.2f, 0.35f, fill);
+ if (mood == MascotMood.DETERMINED) {
+ // Narrowed: the lid comes down over the top of the eye.
+ fill.setColor(skin);
+ c.drawRect(cx - 4.5f, cy - 3.2f, cx + 4.5f, cy - 1.0f, fill);
+ }
+ c.restore();
+ stroke.setStrokeWidth(1.25f);
+ if (mood == MascotMood.DETERMINED) {
+ c.drawLine(cx - 4.3f, cy - 0.9f, cx + 4.3f, cy - 1.1f, stroke);
+ } else {
+ c.drawArc(oval(cx - 4.4f, cy - 3f, cx + 4.4f, cy + 3.4f), 195f, 150f, false, stroke);
+ }
+ // The flick at the outer corner.
+ float outer = left ? cx - 4.2f : cx + 4.2f, dir = left ? -1f : 1f;
+ c.drawLine(outer, cy - 0.6f, outer + dir * 1.3f, cy - 1.6f, stroke);
+ }
+
+ private void brows(Canvas c, MascotMood mood) {
+ stroke.setColor(hair);
+ stroke.setStrokeWidth(1.1f);
+ switch (mood) {
+ case WORRIED:
+ c.drawLine(39f, 31.5f, 46.5f, 29.3f, stroke);
+ c.drawLine(53.5f, 29.3f, 61f, 31.5f, stroke);
+ break;
+ case DETERMINED:
+ c.drawLine(39f, 29.6f, 46.8f, 32f, stroke);
+ c.drawLine(53.2f, 32f, 61f, 29.6f, stroke);
+ break;
+ default:
+ float lift = mood == MascotMood.CHEERING || mood == MascotMood.FAST ? -0.8f : 0f;
+ c.drawArc(oval(39f, 30f + lift, 47f, 33.5f + lift), 200f, 120f, false, stroke);
+ c.drawArc(oval(53f, 30f + lift, 61f, 33.5f + lift), 220f, 120f, false, stroke);
+ }
+ }
+
+ private void mouth(Canvas c, MascotMood mood) {
+ stroke.setColor(lips);
+ stroke.setStrokeWidth(1f);
+ fill.setColor(mouth);
+ switch (mood) {
+ case CHEERING:
+ case FAST:
+ p.reset();
+ p.moveTo(45.5f, 48.8f);
+ p.quadTo(50f, 56f, 54.5f, 48.8f);
+ p.close();
+ c.drawPath(p, fill);
+ fill.setColor(0xFFE88A95);
+ c.drawOval(oval(47.8f, 51.2f, 52.2f, 52.8f), fill);
+ break;
+ case SLEEPING:
+ case REVERSE:
+ c.drawOval(oval(48.8f, 49.3f, 51.2f, 51.6f), fill);
+ break;
+ case WORRIED:
+ p.reset();
+ p.moveTo(46.5f, 51f);
+ p.quadTo(47.8f, 49.6f, 49.2f, 51f);
+ p.quadTo(50.6f, 52.4f, 52f, 51f);
+ p.quadTo(53.2f, 49.8f, 54f, 50.8f);
+ c.drawPath(p, stroke);
+ break;
+ case DETERMINED:
+ p.reset();
+ p.moveTo(46f, 50.6f);
+ p.quadTo(51f, 52.6f, 55f, 49f);
+ c.drawPath(p, stroke);
+ break;
+ default:
+ c.drawArc(oval(46f, 46.5f, 54f, 51.2f), 25f, 130f, false, stroke);
+ }
+ }
+
+ private void sweatDrop(Canvas c) {
+ fill.setColor(0xFF8FD3FF);
+ p.reset();
+ p.moveTo(63f, 21f);
+ p.quadTo(66.5f, 26.5f, 63f, 28f);
+ p.quadTo(59.5f, 26.5f, 63f, 21f);
+ c.drawPath(p, fill);
+ }
+
+ /** Three Zs drifting up and to the left, over three seconds. */
+ private void sleepZs(Canvas c, long t) {
+ float phase = (t % 3000) / 3000f;
+ text.setTextAlign(Paint.Align.CENTER);
+ float[][] zs = { { 24f, 22f, 5f }, { 17f, 14f, 6.5f }, { 9f, 5.5f, 8f } };
+ for (float[] z : zs) {
+ text.setTextSize(z[2]);
+ text.setColor(AccentColor.withAlpha(accentLight, Math.round(255 * (1f - phase * 0.5f))));
+ c.drawText("Z", z[0] - phase * 2f, z[1] + z[2] - phase * 3f, text);
+ }
+ }
+
+ /** Four-pointed sparkles around her head, twinkling. */
+ private void sparkles(Canvas c, long t) {
+ float[][] at = { { 22f, 20f }, { 78f, 14f }, { 18f, 46f }, { 84f, 40f }, { 50f, 3f } };
+ for (int i = 0; i < at.length; i++) {
+ float s = 2.2f + 1.6f * (float) Math.abs(Math.sin(t / 260.0 + i * 1.3));
+ fill.setColor(i % 2 == 0 ? regen : Color.WHITE);
+ star(c, at[i][0], at[i][1], s);
+ }
+ }
+
+ /** Streaks rushing past on her left. */
+ private void wind(Canvas c, long t) {
+ stroke.setStrokeWidth(0.9f);
+ stroke.setColor(0x88FFFFFF);
+ for (int i = 0; i < 4; i++) {
+ float x = 26f - ((t / 6f + i * 37f) % 24f);
+ float y = 16f + i * 10f;
+ c.drawLine(x - 9f, y, x, y, stroke);
+ }
+ }
+
+ private void star(Canvas c, float x, float y, float s) {
+ p.reset();
+ p.moveTo(x, y - s);
+ p.quadTo(x, y, x + s, y);
+ p.quadTo(x, y, x, y + s);
+ p.quadTo(x, y, x - s, y);
+ p.quadTo(x, y, x, y - s);
+ p.close();
+ c.drawPath(p, fill);
+ }
+
+ /** A small silver octagon on the jacket. */
+ private void badge(Canvas c, float x, float y, float s) {
+ p.reset();
+ for (int i = 0; i < 8; i++) {
+ double a = Math.toRadians(22.5 + i * 45);
+ float px = x + s * (float) Math.cos(a), py = y + s * (float) Math.sin(a);
+ if (i == 0) p.moveTo(px, py);
+ else p.lineTo(px, py);
+ }
+ p.close();
+ fill.setColor(0xFFD7DBE0);
+ c.drawPath(p, fill);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setTextSize(3.4f);
+ text.setColor(accentDark);
+ c.drawText("MG", x, y + 1.2f, text);
+ }
+
+ private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int colour) {
+ p.reset();
+ p.moveTo(x1, y1);
+ p.lineTo(x2, y2);
+ p.lineTo(x3, y3);
+ p.close();
+ fill.setColor(colour);
+ c.drawPath(p, fill);
+ }
+
+ private RectF oval(float left, float top, float right, float bottom) {
+ r.set(left, top, right, bottom);
+ return r;
+ }
+
+ // ---------- Speech bubble and values, in pixels ----------
+
+ private void drawBubble(Canvas c, MascotMood mood, long t, float x0, float h, float tipX, float tipY, float w) {
+ String[] options = lines[mood.ordinal()];
+ String line = options.length == 0 ? "" : options[(int) ((t / 30000 + mood.ordinal()) % options.length)];
+ text.setTextAlign(Paint.Align.LEFT);
+ float pad = h * 0.045f, room = w - 140f * dp - x0 - 2f * pad;
+ // Shrink a long line to fit the room left of the clock.
+ text.setTextSize(h * 0.07f);
+ float width = text.measureText(line);
+ if (width > room && room > 0f) {
+ text.setTextSize(h * 0.07f * room / width);
+ width = room;
+ }
+ float right = x0 + width + 2f * pad;
+ float top = h * 0.05f, bottom = top + h * 0.07f + 2f * pad;
+ fill.setColor(0xFFF7F7FA);
+ c.drawRoundRect(oval(x0, top, right, bottom), h * 0.05f, h * 0.05f, fill);
+ triangle(c, x0 + h * 0.03f, bottom - 2f, x0 + h * 0.11f, bottom - 2f, tipX, tipY, 0xFFF7F7FA);
+ text.setColor(0xFF1C1A24);
+ c.drawText(line, x0 + pad, bottom - pad - h * 0.012f, text);
+ }
+
+ /** Speed and gear from x0; the battery and the bottom row from xLow, clear of her shoulder. */
+ private void drawValues(Canvas c, float x0, float xLow, float w, float h) {
+ float right = w - 16f * dp;
+ // The clock, left of the full-screen button.
+ text.setTextAlign(Paint.Align.RIGHT);
+ text.setTextSize(20f * dp);
+ text.setColor(grey);
+ c.drawText(v.clock.isEmpty() ? "—" : v.clock, w - 72f * dp, 30f * dp, text);
+
+ // Speed, large, and the gear in a ring at the right.
+ text.setTextAlign(Paint.Align.LEFT);
+ text.setTextSize(h * 0.34f);
+ text.setColor(white);
+ String speed = fmt(v.speedKmh, "%.0f", "—");
+ float baseline = h * 0.64f;
+ c.drawText(speed, x0, baseline, text);
+ float after = x0 + text.measureText(speed) + 14f * dp;
+ text.setTextSize(h * 0.065f);
+ text.setColor(grey);
+ c.drawText("km/h", after, baseline, text);
+
+ float gr = h * 0.11f, gx = right - gr, gy = h * 0.50f;
+ stroke.setColor(accent);
+ stroke.setStrokeWidth(4f * dp);
+ c.drawCircle(gx, gy, gr, stroke);
+ String gear = Gear.label(v.gear);
+ text.setTextAlign(Paint.Align.CENTER);
+ text.setTextSize(gr * 1.1f);
+ text.setColor(white);
+ c.drawText(gear.isEmpty() ? " " : gear, gx, gy + gr * 0.38f, text);
+
+ // Battery: percent and range over a bar.
+ float barY = h * 0.77f, barH = 12f * dp;
+ boolean low = v.socPercent != null && v.socPercent < MascotMood.LOW_SOC;
+ text.setTextSize(h * 0.055f);
+ text.setTextAlign(Paint.Align.LEFT);
+ text.setColor(low ? warn : white);
+ c.drawText(fmt(v.socPercent, "%.0f %%", "— %"), xLow, barY - 10f * dp, text);
+ text.setTextAlign(Paint.Align.RIGHT);
+ text.setColor(white);
+ c.drawText(fmt(v.rangeKm, "%.0f km", "— km"), right, barY - 10f * dp, text);
+ fill.setColor(track);
+ c.drawRoundRect(oval(xLow, barY, right, barY + barH), barH / 2f, barH / 2f, fill);
+ if (v.socPercent != null) {
+ float soc = Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
+ fill.setColor(low ? warn : accent);
+ c.drawRoundRect(oval(xLow, barY, xLow + Math.max(barH, (right - xLow) * soc), barY + barH), barH / 2f, barH / 2f, fill);
+ }
+
+ // Power, usage, trip.
+ float col = (right - xLow) / 3f, labelY = h * 0.885f, valueY = h * 0.965f;
+ boolean regenerating = v.powerKw != null && v.powerKw < 0f;
+ cell(c, xLow, labelY, valueY, "POWER", fmt(v.powerKw, "%.1f kW", "— kW"), regenerating ? regen : white);
+ cell(c, xLow + col, labelY, valueY, "USAGE · 5 MIN", fmt(v.usage5Min, "%.1f kWh/100 km", "—"), white);
+ cell(c, xLow + 2f * col, labelY, valueY, "TRIP", fmt(v.tripKm, "%.1f km", "— km"), white);
+ }
+
+ private void cell(Canvas c, float x, float labelY, float valueY, String label, String value, int colour) {
+ text.setTextAlign(Paint.Align.LEFT);
+ text.setTextSize(13f * dp);
+ text.setLetterSpacing(0.12f);
+ text.setColor(grey);
+ c.drawText(label, x, labelY, text);
+ text.setLetterSpacing(0f);
+ text.setTextSize(getHeight() * 0.055f);
+ text.setColor(colour);
+ c.drawText(value, x, valueY, text);
+ }
+}
diff --git a/app/src/main/java/com/evsuite/abrp/OverviewStyle.java b/app/src/main/java/com/evsuite/abrp/OverviewStyle.java
index 6a64491..5e98be8 100644
--- a/app/src/main/java/com/evsuite/abrp/OverviewStyle.java
+++ b/app/src/main/java/com/evsuite/abrp/OverviewStyle.java
@@ -27,7 +27,8 @@ enum OverviewStyle {
TERMINAL(true, true),
AVIATION(false, false),
NIXIE(false, false),
- CHRONOGRAPH(true, false);
+ CHRONOGRAPH(true, false),
+ MASCOT(true, false);
static final String PREF_KEY = "overview_style";
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 8b33004..9675b64 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -139,6 +139,41 @@
Aviation
Nixie tubes
Chronograph
+ Mascot
+
+
+ - Waiting for the car…
+ - Hello! Is anyone there?
+
+
+ - Careful behind us…
+ - I\'ll watch the back!
+
+
+ - Battery\'s getting low…
+ - Maybe a charger soon?
+
+
+ - Zz… wake me when we drive.
+ - Parked. Nap time.
+
+
+ - Free energy, yay!
+ - Charging on the go!
+
+
+ - Full power!
+ - Let\'s go!
+
+
+ - Wheee, motorway!
+ - I love this speed!
+
+
+ - Nice and easy.
+ - Enjoying the drive.
+ - Smooth driving!
+
BATTERY
USAGE · 5 MIN
kWh/100 km
diff --git a/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java b/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java
new file mode 100644
index 0000000..cbbd773
--- /dev/null
+++ b/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java
@@ -0,0 +1,63 @@
+package com.evsuite.abrp;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+
+/** The Mascot style's moods — pure JVM. */
+public class MascotMoodTest {
+
+ private static ClusterValues values(Integer gear, Float speed, Float soc, Float power) {
+ ClusterValues v = new ClusterValues();
+ v.gear = gear;
+ v.speedKmh = speed;
+ v.socPercent = soc;
+ v.powerKw = power;
+ return v;
+ }
+
+ @Test
+ public void nothingReadIsWaiting() {
+ assertEquals(MascotMood.WAITING, MascotMood.of(null));
+ assertEquals(MascotMood.WAITING, MascotMood.of(new ClusterValues()));
+ }
+
+ @Test
+ public void moodsByPriority() {
+ assertEquals(MascotMood.REVERSE, MascotMood.of(values(2, 3f, 10f, 5f)));
+ assertEquals(MascotMood.WORRIED, MascotMood.of(values(4, 50f, 15f, 10f)));
+ assertEquals(MascotMood.WORRIED, MascotMood.of(values(1, 0f, 15f, 0f)));
+ assertEquals(MascotMood.SLEEPING, MascotMood.of(values(1, 0f, 80f, 0f)));
+ assertEquals(MascotMood.CHEERING, MascotMood.of(values(4, 120f, 80f, -20f)));
+ assertEquals(MascotMood.DETERMINED, MascotMood.of(values(4, 60f, 80f, 90f)));
+ assertEquals(MascotMood.FAST, MascotMood.of(values(4, 110f, 80f, 25f)));
+ assertEquals(MascotMood.CONTENT, MascotMood.of(values(4, 50f, 80f, 10f)));
+ assertEquals(MascotMood.CONTENT, MascotMood.of(values(3, 0f, 80f, 0f)));
+ }
+
+ @Test
+ public void unreadValuesAreNoReason() {
+ // Speed unread, battery unread: in D she is content, not worried or fast.
+ assertEquals(MascotMood.CONTENT, MascotMood.of(values(4, null, null, 10f)));
+ assertEquals(MascotMood.CONTENT, MascotMood.of(values(null, 50f, null, null)));
+ }
+
+ @Test
+ public void holderWaitsOutShortChanges() {
+ MascotMood.Holder holder = new MascotMood.Holder();
+ assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CONTENT, 0));
+ assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 100));
+ assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CONTENT, 300));
+ assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 400));
+ assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 1800));
+ assertEquals(MascotMood.CHEERING, holder.update(MascotMood.CHEERING, 1900));
+ }
+
+ @Test
+ public void reverseAndWaitingShowAtOnce() {
+ MascotMood.Holder holder = new MascotMood.Holder();
+ holder.update(MascotMood.SLEEPING, 0);
+ assertEquals(MascotMood.REVERSE, holder.update(MascotMood.REVERSE, 10));
+ assertEquals(MascotMood.WAITING, holder.update(MascotMood.WAITING, 20));
+ }
+}
diff --git a/app/src/test/java/com/evsuite/abrp/OverviewStyleTest.java b/app/src/test/java/com/evsuite/abrp/OverviewStyleTest.java
index f612fd8..d1f05aa 100644
--- a/app/src/test/java/com/evsuite/abrp/OverviewStyleTest.java
+++ b/app/src/test/java/com/evsuite/abrp/OverviewStyleTest.java
@@ -36,6 +36,7 @@ public class OverviewStyleTest {
assertTrue(OverviewStyle.TERMINAL.hasOriginal);
assertFalse(OverviewStyle.CARTOON.usesAccent);
assertFalse(OverviewStyle.NIXIE.usesAccent);
+ assertTrue(OverviewStyle.MASCOT.usesAccent);
assertEquals("original_colours_RETRO", OverviewStyle.RETRO.originalKey());
}
}