From 57cabfa0e1497eb91044dbadf6a6ea69a75b7038 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 24 Oct 2026 23:07:50 +0200 Subject: [PATCH] App 2.28.0: Overview style Mascot, an anime co-pilot who reacts to the drive MascotView draws her head and shoulders beside speed, gear, battery, power, usage and trip. MascotMood picks her expression from the reading (waiting, reverse, worried, asleep, cheering, determined, fast, content) and holds a new mood for 1.5 s so values at a threshold do not make her flicker. A speech bubble carries a line per mood. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 2 +- README.md | 2 +- SPECS.md | 14 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/MainActivity.java | 7 +- .../java/com/evsuite/abrp/MascotMood.java | 70 +++ .../java/com/evsuite/abrp/MascotView.java | 511 ++++++++++++++++++ .../java/com/evsuite/abrp/OverviewStyle.java | 3 +- app/src/main/res/values/strings.xml | 35 ++ .../java/com/evsuite/abrp/MascotMoodTest.java | 63 +++ .../com/evsuite/abrp/OverviewStyleTest.java | 1 + 11 files changed, 704 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/MascotMood.java create mode 100644 app/src/main/java/com/evsuite/abrp/MascotView.java create mode 100644 app/src/test/java/com/evsuite/abrp/MascotMoodTest.java 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()); } }