From 7df1930edd84e2b567d90ad7c4b34a9798136365 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sat, 24 Oct 2026 23:17:21 +0200 Subject: [PATCH] App 2.29.0: a chibi Mascot, chosen in Settings beside the tyre unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChibiFigure draws a small, full-figure co-pilot in a hoodie whose whole body acts the mood (waving, arms up and hopping, fist, hands on cheeks, nose bubble, pointing back, leaning into the wind). Settings → Mascot (Classic / Chibi, MascotCharacter) shows only for the Mascot style; the chibi has lines of her own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 4 +- README.md | 2 +- SPECS.md | 10 + app/build.gradle | 4 +- .../java/com/evsuite/abrp/ChibiFigure.java | 397 ++++++++++++++++++ .../java/com/evsuite/abrp/MainActivity.java | 21 +- .../com/evsuite/abrp/MascotCharacter.java | 22 + .../java/com/evsuite/abrp/MascotView.java | 37 +- .../main/res/layout-w1000dp/pane_settings.xml | 8 +- app/src/main/res/layout/pane_settings.xml | 7 + app/src/main/res/values/strings.xml | 37 ++ .../java/com/evsuite/abrp/MascotMoodTest.java | 8 + 12 files changed, 542 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/ChibiFigure.java create mode 100644 app/src/main/java/com/evsuite/abrp/MascotCharacter.java diff --git a/AGENTS.md b/AGENTS.md index 3c98264..53ae597 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,11 @@ 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, 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`) | +| 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`; classic or `ChibiFigure`, `MascotCharacter`); 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 | -| Settings | full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro and Terminal add "Original" for their own palette, `hasOriginal`), tyre unit, battery, charging price, version, update check, install from USB, About | +| Settings | full screen, voice key, Overview style with the accent colour beside it, the Mascot character (only for that style) (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro and Terminal add "Original" for their own palette, `hasOriginal`), tyre unit, battery, charging price, version, update check, install from USB, About | | Logs | update log (every install step, with the platform's words), upload log | Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, diff --git a/README.md b/README.md index 29bf3f3..343c13b 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, 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 | +| **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, classic or chibi). 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 0f9bf0c..045fa8a 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,16 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-24 — App 2.29.0 (branch `styles-retro-future`): a second Mascot, the **chibi** + (`ChibiFigure`): small and full-figure, a big round head with a coral bob and a star clip, + a hoodie in the accent colour, dark outlines like a sticker; an adult character in chibi + proportions, clothed. Her whole body acts the mood: waves when content, both arms up and + hopping with star eyes when cheering, fist at her chest when determined, hands on her cheeks + with a tear when worried, a nose bubble asleep in P, glancing and pointing back in R, leaning + into the wind when fast, "?" while waiting. Her own lines (`chibi_*`). Settings → Mascot + (Classic / Chibi, `MascotCharacter`, pref `mascot_character`) sits beside Tyre pressure and + shows only while the Mascot style is chosen. Emulator: all moods checked with sample values in + a throwaway build, and the Settings drop-down. Not tried on the car. - 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 diff --git a/app/build.gradle b/app/build.gradle index 4d2e6cb..e057c14 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 125 - versionName "2.28.0" + versionCode 126 + versionName "2.29.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/ChibiFigure.java b/app/src/main/java/com/evsuite/abrp/ChibiFigure.java new file mode 100644 index 0000000..a6224d7 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChibiFigure.java @@ -0,0 +1,397 @@ +package com.evsuite.abrp; + +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; + +/** + * The chibi co-pilot for the Mascot Overview style ({@link MascotCharacter#CHIBI}): the small, + * full-figure version — a big round head with a coral bob and a star clip, a hoodie in the + * accent colour, short legs, white trainers — drawn with dark outlines like a sticker. + * + * Her whole body acts the mood: waves while content, throws both arms up and hops while + * cheering (star eyes), pumps a fist when determined, holds her cheeks with a tear when worried, + * sleeps with a nose bubble in P, glances and points back in R, leans into the wind when + * fast, and wonders "?" while waiting. + * + * Draws on the same 100 × 100 grid as {@link MascotView}'s classic figure; it holds no state. + */ +final class ChibiFigure { + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path p = new Path(); + private final Path q = new Path(); + private final RectF r = new RectF(); + private final int accent, accentLight, regen; + private final int ink = 0xFF3A2A33, hair = 0xFFF0855C, hairLight = 0xFFFFB08A, skin = 0xFFFFE6D6, + iris = 0xFF6E45B8, irisLight = 0xFFA27BE6, blush = 0x66FF7A8A, trousers = 0xFF2D3142, + shoe = 0xFFF6F6F8, mouth = 0xFF8E3A48, tongue = 0xFFE88A95; + + ChibiFigure(int accent, int accentLight, int regen) { + this.accent = accent; + this.accentLight = accentLight; + this.regen = regen; + line.setStyle(Paint.Style.STROKE); + line.setStrokeCap(Paint.Cap.ROUND); + line.setStrokeJoin(Paint.Join.ROUND); + line.setColor(ink); + text.setTextAlign(Paint.Align.CENTER); + text.setFakeBoldText(true); + } + + /** Where the speech bubble's tail points, on the grid. */ + static final float TIP_X = 70f, TIP_Y = 34f; + + void draw(Canvas c, MascotMood mood, long t, boolean blink) { + // Smaller and further left than the grid, so her big head stays clear of the speed. + c.save(); + c.translate(-6f, 0f); + c.scale(0.92f, 0.92f, 50f, 100f); + // A hop while cheering. + float hop = mood == MascotMood.CHEERING ? -3.5f * (float) Math.abs(Math.sin(t / 180.0)) : 0f; + fill.setColor(0x40000000); + float shadow = 18f + hop * 0.8f; + c.drawOval(oval(50f - shadow, 96.5f, 50f + shadow, 100f), fill); + + c.save(); + c.translate(0f, hop); + float lean = mood == MascotMood.FAST ? 5f : mood == MascotMood.SLEEPING ? -6f : 0f; + c.rotate(lean, 50f, 96f); + + drawLegs(c); + drawBody(c); + // Hands on her cheeks and the fist at her chest are in front of the head. + boolean inFront = mood == MascotMood.WORRIED || mood == MascotMood.DETERMINED; + if (!inFront) drawArms(c, mood, t); + drawHead(c, mood, t, blink); + if (inFront) drawArms(c, mood, t); + c.restore(); + + switch (mood) { + case SLEEPING: zs(c, t); break; + case CHEERING: sparkles(c, t); break; + case FAST: wind(c, t); break; + case WAITING: question(c, t); break; + default: break; + } + c.restore(); + } + + private void drawLegs(Canvas c) { + fill.setColor(trousers); + line.setStrokeWidth(1.2f); + for (float x : new float[] { 42f, 52f }) { + oval(x, 84f, x + 6f, 95f); + c.drawRect(r, fill); + c.drawRect(r, line); + } + fill.setColor(shoe); + for (float x : new float[] { 39.5f, 50.5f }) { + oval(x, 92.5f, x + 10f, 98.5f); + c.drawOval(r, fill); + c.drawOval(r, line); + } + } + + private void drawBody(Canvas c) { + p.reset(); + p.moveTo(35.5f, 88f); + p.lineTo(38f, 67f); + p.quadTo(50f, 60f, 62f, 67f); + p.lineTo(64.5f, 88f); + p.quadTo(50f, 91f, 35.5f, 88f); + p.close(); + fill.setColor(accent); + c.drawPath(p, fill); + line.setStrokeWidth(1.2f); + c.drawPath(p, line); + // The pocket and the drawstrings. + line.setStrokeWidth(0.9f); + p.reset(); + p.moveTo(42.5f, 88.5f); + p.lineTo(43.5f, 79.5f); + p.quadTo(50f, 78f, 56.5f, 79.5f); + p.lineTo(57.5f, 88.5f); + c.drawPath(p, line); + line.setColor(Color.WHITE); + line.setStrokeWidth(1f); + c.drawLine(46.5f, 66f, 46f, 74f, line); + c.drawLine(53.5f, 66f, 54f, 74f, line); + line.setColor(ink); + } + + /** Two arms from the shoulders to hands placed by the mood. */ + private void drawArms(Canvas c, MascotMood mood, long t) { + float wave = (float) Math.sin(t / 220.0) * 3f; + float lx, ly, rx, ry; + switch (mood) { + case CONTENT: lx = 33f; ly = 82f; rx = 82f + wave * 0.5f; ry = 64f + wave; break; + case CHEERING: lx = 16f; ly = 56f; rx = 84f; ry = 56f; break; + case DETERMINED: lx = 33f; ly = 82f; rx = 57f; ry = 72f; break; + case WORRIED: lx = 34f; ly = 55f; rx = 66f; ry = 55f; break; + // Pointing back. + case REVERSE: lx = 20f; ly = 66f; rx = 67f; ry = 82f; break; + case FAST: lx = 30f; ly = 76f; rx = 70f; ry = 76f; break; + default: lx = 33f; ly = 82f; rx = 67f; ry = 82f; + } + arm(c, 39.5f, 69f, lx, ly); + arm(c, 60.5f, 69f, rx, ry); + } + + private void arm(Canvas c, float sx, float sy, float hx, float hy) { + line.setStrokeWidth(7.4f); + c.drawLine(sx, sy, hx, hy, line); + line.setColor(accent); + line.setStrokeWidth(5f); + c.drawLine(sx, sy, hx, hy, line); + line.setColor(ink); + fill.setColor(skin); + line.setStrokeWidth(1.1f); + c.drawCircle(hx, hy, 3.2f, fill); + c.drawCircle(hx, hy, 3.2f, line); + } + + private void drawHead(Canvas c, MascotMood mood, long t, boolean blink) { + float tilt = mood == MascotMood.REVERSE ? -10f : mood == MascotMood.SLEEPING ? 10f + : mood == MascotMood.WAITING ? 6f : 0f; + c.save(); + c.rotate(tilt, 50f, 62f); + // The bob behind the face. + fill.setColor(hair); + line.setStrokeWidth(1.3f); + p.reset(); + p.addOval(oval(20f, 5f, 80f, 62f), Path.Direction.CW); + q.reset(); + q.addRoundRect(oval(19.5f, 30f, 80.5f, 64f), 7f, 7f, Path.Direction.CW); + p.op(q, Path.Op.UNION); + c.drawPath(p, fill); + c.drawPath(p, line); + // The face. + fill.setColor(skin); + oval(25f, 15f, 75f, 65f); + c.drawOval(r, fill); + c.drawOval(r, line); + // Bangs. + p.reset(); + p.moveTo(22.5f, 40f); + p.cubicTo(19f, 7f, 81f, 7f, 77.5f, 40f); + p.lineTo(73f, 27f); + p.lineTo(66f, 34f); + p.lineTo(60f, 23f); + p.lineTo(52f, 32f); + p.lineTo(45f, 22f); + p.lineTo(38f, 32f); + p.lineTo(32f, 25f); + p.lineTo(27f, 35f); + p.close(); + fill.setColor(hair); + c.drawPath(p, fill); + c.drawPath(p, line); + line.setColor(hairLight); + line.setStrokeWidth(1.6f); + c.drawArc(oval(30f, 10f, 56f, 30f), 200f, 60f, false, line); + line.setColor(ink); + // The star clip. + fill.setColor(accentLight); + star(c, 70f, 19f, 4.5f); + line.setStrokeWidth(0.8f); + c.drawPath(p, line); + + if (mood == MascotMood.CHEERING || mood == MascotMood.FAST || mood == MascotMood.CONTENT + || mood == MascotMood.SLEEPING || mood == MascotMood.WORRIED) { + fill.setColor(blush); + c.drawOval(oval(29.5f, 50f, 37f, 54f), fill); + c.drawOval(oval(63f, 50f, 70.5f, 54f), fill); + } + eye(c, 39f, 44f, mood, blink, t); + eye(c, 61f, 44f, mood, blink, t); + brows(c, mood); + mouth(c, mood, t); + if (mood == MascotMood.WORRIED) { + fill.setColor(0xFF8FD3FF); + drop(c, 72f, 30f, 3f); + drop(c, 34.5f, 52f, 1.8f); + } + c.restore(); + } + + private void eye(Canvas c, float cx, float cy, MascotMood mood, boolean blink, long t) { + line.setColor(ink); + line.setStrokeWidth(1.5f); + if (mood == MascotMood.SLEEPING || (blink && mood != MascotMood.CHEERING && mood != MascotMood.FAST)) { + c.drawArc(oval(cx - 4.5f, cy - 3f, cx + 4.5f, cy + 2f), 20f, 140f, false, line); + return; + } + if (mood == MascotMood.FAST) { + // ^ ^ + c.drawArc(oval(cx - 4.5f, cy - 2f, cx + 4.5f, cy + 5f), 200f, 140f, false, line); + return; + } + float look = mood == MascotMood.REVERSE ? -2f : 0f; + oval(cx - 4.6f, cy - 5.6f, cx + 4.6f, cy + 5.6f); + if (mood == MascotMood.REVERSE) { + fill.setColor(Color.WHITE); + c.drawOval(r, fill); + fill.setColor(iris); + c.drawOval(oval(cx - 3f + look, cy - 4f, cx + 3f + look, cy + 4.5f), fill); + } else { + fill.setColor(iris); + c.drawOval(r, fill); + fill.setColor(irisLight); + c.drawOval(oval(cx - 3.6f, cy + 0.5f, cx + 3.6f, cy + 5f), fill); + } + if (mood == MascotMood.CHEERING) { + fill.setColor(Color.WHITE); + star(c, cx, cy - 0.5f, 3.6f); + } else { + fill.setColor(Color.WHITE); + c.drawCircle(cx - 1.6f + look, cy - 2.4f, 2f, fill); + c.drawCircle(cx + 1.8f + look, cy + 2.4f, 0.9f, fill); + } + if (mood == MascotMood.DETERMINED) { + // A flat lid over the top: focused. + fill.setColor(skin); + c.drawRect(cx - 5.2f, cy - 6.4f, cx + 5.2f, cy - 2f, fill); + line.setStrokeWidth(1.6f); + c.drawLine(cx - 5f, cy - 2f, cx + 5f, cy - 2f, line); + return; + } + line.setStrokeWidth(1.6f); + c.drawArc(oval(cx - 5f, cy - 5.9f, cx + 5f, cy + 5.6f), 200f, 140f, false, line); + } + + private void brows(Canvas c, MascotMood mood) { + line.setColor(ink); + line.setStrokeWidth(1.4f); + if (mood == MascotMood.DETERMINED) { + c.drawLine(34f, 33.5f, 43.5f, 36.5f, line); + c.drawLine(56.5f, 36.5f, 66f, 33.5f, line); + } else if (mood == MascotMood.WORRIED) { + c.drawLine(34f, 36f, 43f, 33f, line); + c.drawLine(57f, 33f, 66f, 36f, line); + } + } + + private void mouth(Canvas c, MascotMood mood, long t) { + line.setColor(ink); + line.setStrokeWidth(1.2f); + switch (mood) { + case CHEERING: + case FAST: + p.reset(); + p.moveTo(45f, 53.5f); + p.quadTo(50f, 61.5f, 55f, 53.5f); + p.close(); + fill.setColor(mouth); + c.drawPath(p, fill); + fill.setColor(tongue); + c.drawOval(oval(47.5f, 56.4f, 52.5f, 58.4f), fill); + c.drawPath(p, line); + break; + case DETERMINED: + fill.setColor(Color.WHITE); + oval(44.5f, 53f, 55.5f, 57f); + c.drawRoundRect(r, 1.5f, 1.5f, fill); + c.drawRoundRect(r, 1.5f, 1.5f, line); + c.drawLine(44.8f, 55f, 55.2f, 55f, line); + break; + case WORRIED: + p.reset(); + p.moveTo(45.5f, 56f); + p.quadTo(47f, 54.3f, 48.5f, 56f); + p.quadTo(50f, 57.7f, 51.5f, 56f); + p.quadTo(53f, 54.3f, 54.5f, 56f); + c.drawPath(p, line); + break; + case SLEEPING: { + fill.setColor(mouth); + c.drawOval(oval(48.6f, 54.5f, 51.4f, 57f), fill); + // A nose bubble that swells and pops. + float grow = (t % 2400) / 2400f; + fill.setColor(0x7090D8FF); + c.drawCircle(53.5f, 51f, 1f + grow * 4f, fill); + break; + } + case REVERSE: + fill.setColor(mouth); + c.drawOval(oval(48.6f, 54f, 51.4f, 57f), fill); + break; + case WAITING: + c.drawLine(47.5f, 55.5f, 52.5f, 55.5f, line); + break; + default: + // A cat smile: w. + p.reset(); + p.moveTo(45.5f, 54f); + p.quadTo(47.75f, 57.5f, 50f, 54.5f); + p.quadTo(52.25f, 57.5f, 54.5f, 54f); + c.drawPath(p, line); + } + } + + private void zs(Canvas c, long t) { + float phase = (t % 3000) / 3000f; + float[][] zs = { { 20f, 24f, 6f }, { 13f, 14f, 7.5f }, { 5f, 4f, 9f } }; + 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); + } + } + + private void sparkles(Canvas c, long t) { + float[][] at = { { 14f, 30f }, { 86f, 26f }, { 12f, 62f }, { 88f, 60f }, { 50f, 1.5f } }; + for (int i = 0; i < at.length; i++) { + float s = 2.4f + 1.8f * (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); + } + } + + private void wind(Canvas c, long t) { + line.setStrokeWidth(1f); + line.setColor(0x88FFFFFF); + for (int i = 0; i < 5; i++) { + float x = 18f - ((t / 6f + i * 37f) % 20f); + float y = 20f + i * 14f; + c.drawLine(x - 10f, y, x, y, line); + } + line.setColor(ink); + } + + private void question(Canvas c, long t) { + text.setTextSize(14f); + text.setColor(accentLight); + float bob = (float) Math.sin(t / 400.0) * 1.2f; + c.drawText("?", 16f, 18f + bob, text); + } + + private void drop(Canvas c, float x, float y, float s) { + p.reset(); + p.moveTo(x, y - s * 1.6f); + p.quadTo(x + s * 1.2f, y + s * 0.2f, x, y + s); + p.quadTo(x - s * 1.2f, y + s * 0.2f, x, y - s * 1.6f); + c.drawPath(p, fill); + } + + /** A four-pointed sparkle; leaves its outline in {@link #p}. */ + 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); + } + + private RectF oval(float left, float top, float right, float bottom) { + r.set(left, top, right, bottom); + return r; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index f2bbe06..e266ae0 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -69,6 +69,7 @@ public class MainActivity extends AppCompatActivity { /** The styles built on {@link ClusterCanvasView}, fed together. */ private final java.util.List canvasStyles = new java.util.ArrayList<>(); private TerminalView terminalView; + private MascotView mascotView; /** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */ private View[] stylePanes; private View consumptionPane; @@ -284,8 +285,10 @@ public class MainActivity extends AppCompatActivity { View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView); terminalView = new TerminalView(this); + mascotView = new MascotView(this); + mascotView.setCharacter(MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null))); java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView, - new AviationView(this), new NixieView(this), new ChronographView(this), new MascotView(this)); + new AviationView(this), new NixieView(this), new ChronographView(this), mascotView); 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, @@ -1120,6 +1123,7 @@ public class MainActivity extends AppCompatActivity { voiceKeySwitch.setOnCheckedChangeListener((btn, checked) -> prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply()); bindTireUnit(); + bindMascotCharacter(); bindOverviewStyle(); bindAccentColor(); fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch); @@ -1260,6 +1264,17 @@ public class MainActivity extends AppCompatActivity { prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply()); } + /** Who sits in the Mascot style; the drop-down shows only while that style is chosen. */ + private void bindMascotCharacter() { + MascotCharacter[] characters = MascotCharacter.values(); + String[] labels = { getString(R.string.mascot_character_classic), getString(R.string.mascot_character_chibi) }; + int current = MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null)).ordinal(); + bindChoices(settingsPane.findViewById(R.id.mascot_choices), labels, current, position -> { + prefs.edit().putString(MascotCharacter.PREF_KEY, characters[position].name()).apply(); + mascotView.setCharacter(characters[position]); + }); + } + /** The service reads these settings on every tick, so a change applies without a restart. */ private void bindCadenceControls() { UploadSettings current = UploadSettings.from(prefs); @@ -1548,6 +1563,10 @@ public class MainActivity extends AppCompatActivity { TextView note = settingsPane.findViewById(R.id.accent_note); note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE); note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()])); + // The wide layout keeps the Mascot field's place so the page does not jump. + View mascot = settingsPane.findViewById(R.id.mascot_field); + boolean wide = getResources().getConfiguration().screenWidthDp >= 1000; + mascot.setVisibility(style == OverviewStyle.MASCOT ? View.VISIBLE : wide ? View.INVISIBLE : View.GONE); } /** The drop-down's labels, by {@link OverviewStyle} ordinal. */ diff --git a/app/src/main/java/com/evsuite/abrp/MascotCharacter.java b/app/src/main/java/com/evsuite/abrp/MascotCharacter.java new file mode 100644 index 0000000..d69bafc --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MascotCharacter.java @@ -0,0 +1,22 @@ +package com.evsuite.abrp; + +/** + * Who sits in the Mascot Overview style, in the order of the Settings drop-down: the classic + * co-pilot (head and shoulders, headset, racing jacket) or the chibi (small, full figure, big + * head, hoodie, bubbly). Each has her own drawing and lines; both follow {@link MascotMood}. + * Pure Java, unit-tested. + */ +enum MascotCharacter { + CLASSIC, + CHIBI; + + static final String PREF_KEY = "mascot_character"; + + /** The stored character; anything unknown is the classic one. */ + static MascotCharacter fromPref(String stored) { + for (MascotCharacter character : values()) { + if (character.name().equals(stored)) return character; + } + return CLASSIC; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MascotView.java b/app/src/main/java/com/evsuite/abrp/MascotView.java index 1642324..289d46e 100644 --- a/app/src/main/java/com/evsuite/abrp/MascotView.java +++ b/app/src/main/java/com/evsuite/abrp/MascotView.java @@ -24,14 +24,20 @@ import android.os.SystemClock; * power, 5-minute usage and the trip; the clock top right, left of the full-screen button. * Unread values show "—". * + * Two characters ({@link MascotCharacter}): this classic co-pilot, or the chibi drawn by + * {@link ChibiFigure}, each with her own lines. + * * 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 }; + /** Her lines per {@link MascotCharacter}, then per {@link MascotMood}, in their orders. */ + 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 }, + { R.array.chibi_waiting, R.array.chibi_reverse, R.array.chibi_worried, R.array.chibi_sleeping, + R.array.chibi_cheering, R.array.chibi_determined, R.array.chibi_fast, R.array.chibi_content } }; private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG); @@ -39,7 +45,9 @@ final class MascotView extends ClusterCanvasView { 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 String[][][] lines = new String[LINES.length][][]; + private final ChibiFigure chibi; + private MascotCharacter character = MascotCharacter.CLASSIC; 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, @@ -56,7 +64,11 @@ final class MascotView extends ClusterCanvasView { 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]); + for (int i = 0; i < LINES.length; i++) { + lines[i] = new String[LINES[i].length][]; + for (int j = 0; j < LINES[i].length; j++) lines[i][j] = context.getResources().getStringArray(LINES[i][j]); + } + chibi = new ChibiFigure(accent, accentLight, regen); stroke.setStyle(Paint.Style.STROKE); stroke.setStrokeCap(Paint.Cap.ROUND); stroke.setStrokeJoin(Paint.Join.ROUND); @@ -64,6 +76,12 @@ final class MascotView extends ClusterCanvasView { text.setFontFeatureSettings("tnum"); } + /** Who sits beside the values (Settings → Mascot, shown for this style only). */ + void setCharacter(MascotCharacter character) { + this.character = character == null ? MascotCharacter.CLASSIC : character; + invalidate(); + } + @Override protected void onDraw(Canvas c) { super.onDraw(c); @@ -80,11 +98,14 @@ final class MascotView extends ClusterCanvasView { c.save(); c.translate(ox, oy); c.scale(k, k); - drawCharacter(c, mood, t); + boolean chibiShown = character == MascotCharacter.CHIBI; + if (chibiShown) chibi.draw(c, mood, t, t % 4200 < 260); + else drawCharacter(c, mood, t); c.restore(); float x0 = ox + 78f * k; - drawBubble(c, mood, t, x0, h, ox + 70f * k, oy + 46f * k, w); + float tipX = chibiShown ? ChibiFigure.TIP_X : 70f, tipY = chibiShown ? ChibiFigure.TIP_Y : 46f; + drawBubble(c, mood, t, x0, h, ox + tipX * k, oy + tipY * k, w); drawValues(c, x0, ox + 93f * k, w, h); } @@ -420,7 +441,7 @@ final class MascotView extends ClusterCanvasView { // ---------- 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[] options = lines[character.ordinal()][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; diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index ac0111b..62b585c 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -33,7 +33,13 @@ - + + + + + + + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index 5c8f743..c596939 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -21,6 +21,13 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9675b64..c511c81 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,43 @@ Nixie tubes Chronograph Mascot + Mascot + Classic + Chibi + + + Hellooo? Car? + Is the car awake yet? + + + Beep beep, backing up! + Eyes on the back! + + + The battery\'s hungry! + Snack time for the car? + + + Zzz… five more minutes… + Napping in P… + + + Free energy! Yay! + Regen power! + + + Zoom zoom! + Full throttle! + + + Weeee, so fast! + Wind in my hair! + + + La la la, driving~ + Comfy cruising! + Vroom vroom! + Waiting for the car… diff --git a/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java b/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java index cbbd773..a9b2787 100644 --- a/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java +++ b/app/src/test/java/com/evsuite/abrp/MascotMoodTest.java @@ -60,4 +60,12 @@ public class MascotMoodTest { assertEquals(MascotMood.REVERSE, holder.update(MascotMood.REVERSE, 10)); assertEquals(MascotMood.WAITING, holder.update(MascotMood.WAITING, 20)); } + + @Test + public void characterFromPref() { + assertEquals(MascotCharacter.CHIBI, MascotCharacter.fromPref("CHIBI")); + assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref("CLASSIC")); + assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref(null)); + assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref("NOPE")); + } }