Author SHA1 Message Date
nilsandClaude Opus 5.5 7d2c1e8bb4 App 2.38.0: Overview styles Visor and Empire
Visor, after Halo's helmet display: the battery as the shield bar, the
range as the ammunition counter, P R N D as grenade slots, the speed in
the reticle with power round it, and the power trail as blips on a
motion tracker that sweeps while moving. Original blue or the accent.

Empire, after Age of Empires: a wooden resource bar, an isometric world
that moves with the speed, the car as the selected unit, P R N D as
commands, a diamond minimap, and ages by odometer (EmpireAge, tested).
The app takes the player's blue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 21:00:50 +01:00
nilsandClaude Opus 5.5 96b9659bdd App 2.37.0: Overview styles Time circuits and Quest
Time circuits, after Back to the Future: three LED rows (the car as the
destination, the present time, the last departure from P), the speed in
red digits and a flux capacitor that flows with the power; at 88 km/h
it all flashes. Letters on fourteen segments (Segments14, tested).
Quest, after World of Warcraft's interface: player frame, minimap, gold
speed, cast bar, quest tracker, chat, action bar and the odometer as
experience. Both in their own colours, the app with them. In THEMES.md
with screenshots. Emulator only; not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 18:54:48 +01:00
nilsandClaude Opus 5.5 cba46504a6 App 2.36.0: Overview style Grid, after the Tron films
Neon lines on black over a perspective grid floor that moves with the
speed; the battery as a light wall, power on a disc whose inner ring
turns with it, and the last two minutes of power as a light-cycle trail
of right angles (the Cockpit's PowerTrace, now in ClusterValues). Cyan,
or the accent colour; in cyan the app turns cyan too. In THEMES.md with
two screenshots. Emulator only; not tried on the car.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-26 18:33:53 +01:00
nilsandClaude Opus 5.5 1e0fd28eb2 SPECS: server 2.6.2 running; the car updated itself to 2.35.3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:53:44 +01:00
nilsandClaude Opus 5.5 784066ba1e k8s: pin server 2.6.2
git.stinnesbeck.com/nils/mg4-server:2.6.2 at
sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77
(update logging, Go 1.27.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:42:58 +01:00
nilsandClaude Opus 5.5 acb7e1969e Server 2.6.2: log update checks and downloads; Go 1.27.3
The update relay logs every check (car, number of releases, newest
pre-release) and every download (car, file, size, then bytes and time,
or that it was cut short); never the forge's address or token, tested.
Go 1.27.3 for the build image and toolchain: govulncheck had found
GO-2026-6809 in crypto/x509 under 1.26.9; source and binary now clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:40:23 +01:00
nilsandClaude Opus 5.5 76a6213126 SPECS: InfluxDB manifests apply as unchanged (confirmed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:33:44 +01:00
nilsandClaude Opus 5.5 d865906859 k8s: write out the defaulted fields so apply reports InfluxDB unchanged
The NetworkPolicy port gets protocol: TCP, the StatefulSet's volume
claim template apiVersion, kind and volumeMode: Filesystem. Kubernetes
filled these in itself, so every apply -k sent a patch and printed
"configured" although kubectl diff was empty. Validated offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
2026-10-25 22:32:57 +01:00
37 changed files with 2544 additions and 33 deletions
+3 -3
View File
@@ -18,7 +18,7 @@ MG4 head unit (Android 9, AAOS, MT2712)
- **app/** — Android app (Java, minSdk 27, runs on API 28). Reads the car through the
`EVHardware/` library, shows it (Overview, Consumption, Charging), sends live data to the
server and to ABRP, and updates itself.
- **server/** — Go 1.26 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
- **server/** — Go 1.27 API at `https://car.k8s.stinnesbeck.com`: stores telemetry, consumption
and charging in InfluxDB, pairs cars by QR code, carries settings typed on a phone, relays
app releases from the private forge. It does **not** talk to ABRP. See `server/README.md`.
- **k8s/** — plain Kubernetes manifests (InfluxDB, server, Grafana with three provisioned
@@ -75,11 +75,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`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph; the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie; Retro and Terminal with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| 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`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
| 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 | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), 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 and charging price (one card on wide screens), version, update check, install from USB, About |
| Settings | drive mode (Eco, Normal, Sport, Snow, Custom; the lit one is what the car reports; changes only in P at a standstill, `DriveModeWrite`; "Set at start" sets the last confirmed choice again when the service starts with the head unit), full screen, voice key, Overview style with the accent colour beside it (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro, Terminal, Grid and Visor add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
+3 -3
View File
@@ -39,8 +39,8 @@ APIs.
## Screenshots
**Overview styles:** the Overview comes in twelve looks, from an 80s fluorescent display to a
starship console and a watch face. [THEMES.md](THEMES.md) shows each one, with and without the
**Overview styles:** the Overview comes in seventeen looks, from an 80s fluorescent display to a
starship console, a watch face, a neon grid, a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
navigation, and what inspired it.
<p align="center">
@@ -88,7 +88,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, lease allowance, 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, MG, Minimal, Terminal, Nixie tubes and Chronograph ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, and Retro and Terminal with "Original") colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, lease allowance, 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, MG, Minimal, Terminal, Nixie tubes, Chronograph, Grid, Time circuits, Quest, Visor and Empire ([THEMES.md](THEMES.md) shows them all). A style with its own colours (Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, and Retro, Terminal, Grid and Visor with "Original") colours the whole app to match, e.g. turquoise for Retro. The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
| **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 |
+65 -2
View File
@@ -39,8 +39,8 @@ car ──live telemetry───▶ car.k8s.stinnesbeck.com (Go)
### Status
Current: app 2.35.3, server 2.6.1 (pinned by digest in `k8s/server.yaml`; applied and running
on the cluster since 2026-10-25).
Current: app 2.35.3 (installed on the car over the air), server 2.6.2 (pinned by digest in
`k8s/server.yaml`; applied and running since 2026-10-25).
- [x] On the car: QR pairing, values on the Overview, voice-key double press, full screen
without black bands.
@@ -138,6 +138,69 @@ 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-26 — App 2.38.0: Overview styles **Visor** (after Halo's helmet display, `VisorView`)
and **Empire** (after Age of Empires' interface, `EmpireView`). Visor: shield bar = battery (24
segments, red and blinking "BATTERY LOW" below 20 %), ammunition counter = range (number and
rounds, one per 10 km), P R N D as grenade slots, a reticle with the speed and power round it
(green and anticlockwise in regen), a motion tracker whose blips are the power trail (rim 60 kW,
40 kW regen) and whose sweep turns only while moving (~30 fps, on screen only), the clock and an
objective panel; original blue or the accent colour (`hasOriginal`). Empire: a wooden resource
bar (battery, range, power, 5-min usage, the age, the clock), an isometric world whose ground
and trees move with the speed (6 m tiles, ~30 fps only while moving), the car as the selected
unit with the battery as its health bar, the speed large over the world, a carved panel with
P R N D as commands, the unit's portrait and stats, and a diamond minimap with the odometer and
the way to the next age; ages by odometer in `EmpireAge` (pure, tested: Dark, Feudal from
10,000 km, Castle 30,000, Imperial 60,000); its own palette, the app in the player's blue.
`stylePanes` is now built from the canvas list. THEMES.md with two screenshots each. Emulator:
sample values, 15 % at a standstill, with and without the navigation, and no data (dashes); no
crash. Not tried on the car.
- 2026-10-26 — App 2.37.0: Overview styles **Time circuits** (after Back to the Future,
`TimeCircuitsView`) and **Quest** (after World of Warcraft's interface, `QuestView`); both
with their own palette, the app in red and gold with them. Time circuits: three LED rows in a
metal panel with embossed tape — red "DESTINATION" (gear as DRV/REV/NTL/PRK, charge, range,
power/regen lamps, usage), green "PRESENT TIME" (date and time from the head unit's clock),
amber "LAST TIME DEPARTED" (when the car last left P, tracked from the readings, dashes until
seen); the speed in red digits, a flux capacitor whose light runs inwards with the power and
outwards in regen; at 88 km/h the speed burns white, the tape reads "88 KM/H — HOLD ON" and the
capacitor flashes. Letters on fourteen segments (`Segments14`, pure, tested). Quest: player
frame (gear as level, battery as health, power as energy), minimap with the clock, gold speed,
a cast bar (Accelerating, Regenerating, Coasting, Resting in P), a road-trip quest tracker,
system chat lines, P R N D action buttons, the odometer as experience (a level per 1000 km), a
"Low battery!" raid warning below 20 %. Names avoid the trademarks; drawings and fonts are the
app's own. THEMES.md with two screenshots each. Emulator: sample values (also 92 km/h and
15 %), with and without the navigation, and no data (dashes); no crash. Not tried on the car.
- 2026-10-26 — App 2.36.0: Overview style **Grid**, after the Tron films (`GridView`): neon lines
on black, a perspective grid floor that runs towards the driver with the speed (4 m tiles,
~30 fps only while moving and on screen), the speed large and thin, the battery as a
20-segment light wall standing on the floor (orange below 20 %, "LOW ENERGY"), power on a disc
(zero at the top, clockwise drawing, anticlockwise regeneration; an inner ring that turns with
the power), the last two minutes of power as a light-cycle trail of right angles
(`ClusterValues.powerTrail`, the Cockpit's `PowerTrace`; gaps stay gaps), P R N D tiles, the
clock, and five read-outs in cut-corner boxes. Glow from layered strokes (no blur filters).
Original cyan, or the accent colour (`hasOriginal`; low battery then white); in Original the
app turns cyan (`ThemeOverlay.EV.Style.Grid`). Named "Grid", not after the films. THEMES.md has
it with two screenshots. Emulator: sample values (full screen, with navigation, accent, 15 %),
the floor moving between frames, and no data (dashes, nothing lit, still floor); no crash.
Tests and lint. Not tried on the car.
- 2026-10-25 — Server 2.6.2: **the update relay logs what it hands out**, per car (installation
ID, or "shared token"): `update check` with the number of releases and the newest
pre-release, `update download started` with file and size, then `update handed out` with
bytes and time, or `update download cut short`; refusals by the forge name the car too. No
forge address or token in the log (tested: `TestUpdatesAreLogged`). Go 1.27.3 (build image
and `go.mod` toolchain), up from 1.26.9: `govulncheck` had found GO-2026-6809 (compromised
intermediate certificates, `crypto/x509`, reached through the server's TLS); now no
vulnerabilities, in the source and in the built binary. go vet, tests, podman build. Pushed
as `mg4-server:2.6.2` (`sha256:e4de1e75…`, checked in the registry) and pinned in
`k8s/server.yaml`. Applied by the owner, together with the real `FORGE_TOKEN`: the car then
updated itself to 2.35.3 over the air (owner, 2026-10-25).
- 2026-10-25 — `k8s/influxdb.yaml`: the values Kubernetes fills in itself are written out — the
NetworkPolicy port's `protocol: TCP`, and `apiVersion`, `kind` and `volumeMode: Filesystem` on
the StatefulSet's volume claim template. Without them every `kubectl apply -k k8s/` reported
both as "configured" while `kubectl diff -k k8s/` was empty (owner). Applied by the owner:
both now "unchanged". Validated offline (kustomize + kubeconform). Also found: the in-app update
check failed with an IOException because the cluster's `FORGE_TOKEN` was still the template
placeholder — the forge refuses it and the server answers 502; `apply -k` does not include
the Secrets, so a new token needs `kubectl apply -f k8s/secrets/secrets.yaml` and a restart.
- 2026-10-25 — App 2.35.3: a car build with the English EVHardware (comments, log lines, the
"Layer" names); no change in behaviour. Emulator: starts, no crash, the log reads
"Layer1: …" to "Layer5: …". Not tried on the car.
+76 -3
View File
@@ -1,6 +1,6 @@
# Overview styles
The Overview can look twelve ways: pick one under **Settings → Overview style**. Every style shows
The Overview can look seventeen ways: pick one under **Settings → Overview style**. Every style shows
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
shows "—" for anything the car did not answer, never a made-up zero.
@@ -10,8 +10,8 @@ emulator at the car's resolution with **sample values** (87 km/h in D, 76 %, 18.
emulator has no car; on the car the values are live.
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
Accent colour; shown here in Dynamic Red). Retro and Terminal can also show their original
colours, and Sci-fi, Cartoon and Nixie tubes always do; while one of those is shown, the whole app
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid and Visor can also show their original
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire always do; while one of those is shown, the whole app
(navigation, buttons, switches) takes on its colours too.
| Style | Look | Colours |
@@ -28,6 +28,11 @@ colours, and Sci-fi, Cartoon and Nixie tubes always do; while one of those is sh
| [Terminal](#terminal) | green-screen shell | original or accent |
| [Nixie tubes](#nixie-tubes) | glowing glass tubes | own |
| [Chronograph](#chronograph) | a watch face | accent |
| [Grid](#grid) | neon lines on a glowing grid floor | original or accent |
| [Time circuits](#time-circuits) | a time machine's LED displays | own |
| [Quest](#quest) | a fantasy game's interface | own |
| [Visor](#visor) | a soldier's helmet display | original or accent |
| [Empire](#empire) | a strategy game's interface | own |
## Tiles
@@ -143,3 +148,71 @@ odometer.
![Chronograph with the navigation](screenshots/themes/chronograph-panel.png)
![Chronograph, full screen](screenshots/themes/chronograph.png)
## Grid
After the neon world of the Tron films: glowing lines on black and a perspective grid floor that
runs towards you as fast as you drive. The speed sits large in the middle; the battery is a
segmented light wall standing on the floor, turning orange when it runs low; power lights up a
disc whose inner ring turns with it, anticlockwise for regeneration; and the last two minutes of
power run across the floor as a light-cycle trail that only turns at right angles. In its
original cyan (shown, and the app turns cyan with it) or in the accent colour — red gives the
films' other side.
![Grid with the navigation](screenshots/themes/grid-panel.png)
![Grid, full screen](screenshots/themes/grid.png)
## Time circuits
After the time machine in Back to the Future: three rows of LED read-outs in a metal panel,
labelled with embossed tape. The red row, "DESTINATION", shows where you can still get to — the
gear as a word (DRV, REV, NTL, PRK), the charge, the range and the usage, with lamps for power and
regeneration; the green row is the present date and time, as in the film; the amber row, "LAST
TIME DEPARTED", is when you last shifted out of P. Beside them the speed in large red digits and
the flux capacitor, whose light runs to its centre with the power and outwards while
regenerating. At 88 km/h the speed burns white and the flux capacitor flashes. The app turns red
with it.
![Time circuits with the navigation](screenshots/themes/time-circuits-panel.png)
![Time circuits, full screen](screenshots/themes/time-circuits.png)
## Quest
After the interface of a fantasy online game like World of Warcraft, with the car as the player
character: a portrait with the gear as its level, the battery as the health bar and power as the
energy bar; a minimap with the clock; the speed in large gold numbers over a cast bar that reads
"Accelerating", "Regenerating", "Coasting" or, in P, "Resting"; a road-trip quest whose objectives
are the trip, the usage and the climate; a chat window with system lines; P R N D as action
buttons, and the odometer as the experience bar, a level per 1000 km. Below 20 % a raid warning
flashes "Low battery!". The app turns gold with it.
![Quest with the navigation](screenshots/themes/quest-panel.png)
![Quest, full screen](screenshots/themes/quest.png)
## Visor
After the helmet display of a science-fiction shooter like Halo: thin light-blue lines and
slanted frames on a dark visor. The shield bar on top is the battery, turning red and blinking
"BATTERY LOW" below 20 %; the ammunition counter is the range, as a number and as rounds, one per
10 km, with P R N D as the grenade slots under it; the reticle in the middle holds the speed and
fills with power round it, green and the other way while regenerating; the motion tracker shows
the last two minutes of power as blips, red while drawing and green while regenerating, and
sweeps while you drive. The objective lists trip, usage, climate and odometer. In its original
blue (shown, and the app turns blue with it) or in the accent colour.
![Visor with the navigation](screenshots/themes/visor-panel.png)
![Visor, full screen](screenshots/themes/visor.png)
## Empire
After the interface of a real-time strategy game like Age of Empires: a wooden resource bar on
top with the battery, range, power and usage, the clock, and the age you have reached by
odometer — Dark Age, then Feudal from 10,000 km, Castle from 30,000 and Imperial from 60,000. In
the isometric world below, the car is the selected unit on a road, its health bar the battery,
while the ground and trees move past as fast as you drive and the speed stands large over the
land. The carved panel at the bottom has P R N D as command buttons, the unit's portrait and
stats, and a diamond minimap with the odometer and the way to the next age. Below 20 % it warns
"Your battery is running low!". The app turns the player's blue with it.
![Empire with the navigation](screenshots/themes/empire-panel.png)
![Empire, full screen](screenshots/themes/empire.png)
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4"
minSdk 27
targetSdk 36
versionCode 138
versionName "2.35.3"
versionCode 141
versionName "2.38.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.
@@ -21,4 +21,6 @@ final class ClusterValues {
String clock = "";
/** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
/** Battery power over the last two minutes, oldest first, NaN where unread; null before any. */
float[] powerTrail;
}
@@ -0,0 +1,49 @@
package com.evsuite.abrp;
/**
* The Empire style's ages, by odometer: the Dark Age below 10,000 km, the Feudal Age below
* 30,000, the Castle Age below 60,000 and the Imperial Age from there. Pure Java, unit-tested.
*/
final class EmpireAge {
private static final String[] NAMES = { "Dark Age", "Feudal Age", "Castle Age", "Imperial Age" };
/** Where each age after the first begins, in km. */
private static final float[] STARTS = { 10_000f, 30_000f, 60_000f };
private EmpireAge() {}
/** 0 to 3, or -1 when the odometer is unread. */
static int index(Float km) {
if (km == null || Float.isNaN(km)) return -1;
int age = 0;
while (age < STARTS.length && km >= STARTS[age]) age++;
return age;
}
/** The age's name, "—" when unread. */
static String name(Float km) {
int age = index(km);
return age < 0 ? "—" : NAMES[age];
}
/** The next age's name, or null in the last age and when unread. */
static String next(Float km) {
int age = index(km);
return age < 0 || age >= STARTS.length ? null : NAMES[age + 1];
}
/** Kilometres left to the next age, or null in the last age and when unread. */
static Float toNext(Float km) {
int age = index(km);
return age < 0 || age >= STARTS.length ? null : STARTS[age] - km;
}
/** How far through the current age, 0..1; 1 in the last age, 0 when unread. */
static float progress(Float km) {
int age = index(km);
if (age < 0) return 0f;
if (age >= STARTS.length) return 1f;
float from = age == 0 ? 0f : STARTS[age - 1];
return Math.max(0f, Math.min(1f, (km - from) / (STARTS[age] - from)));
}
}
@@ -0,0 +1,454 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Empire Overview style, after the interface of a real-time strategy game like Age of
* Empires: a wooden resource bar on top, an isometric world in the middle and a carved panel
* at the bottom with the commands, the selected unit and a diamond-shaped minimap.
*
* The resource bar holds the battery, the range, the power and the usage over 5 minutes, the
* age (by odometer, {@link EmpireAge}) and the clock. In the world the car is the selected unit
* on a road, with the battery as its health bar; the ground moves past with the speed, which
* stands large over the world. Below 20 % a warning blinks there. At the bottom: P R N D as
* command buttons, the unit's portrait and stats (trip, usage since start, climate), and the
* minimap with the odometer and the way to the next age. Unread values show "—" and fill
* nothing; the world stands still when the speed is unread.
*
* The world animates (about 30 frames a second) only while the car moves and the view is on
* screen.
*/
final class EmpireView extends ClusterCanvasView {
private static final int WOOD = 0xFF3B2414, WOOD_LIGHT = 0xFF5A3A1E, WOOD_DARK = 0xFF24150B,
TRIM = 0xFFC8A05A, PARCHMENT = 0xFFF2E2B8, TEAM = 0xFF3D6BFF, HEALTH = 0xFF3CC23C,
RED = 0xFFE03A2A, GRASS = 0xFF4E7A2E, GRASS_ALT = 0xFF557F33, ROAD = 0xFF8A6A3C, REGEN = 0xFF6FE07A;
/** Metres per tile, as the ground moves past. */
private static final float TILE_M = 6f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
/** Tiles travelled: the whole part picks the trees, the fraction moves the ground. */
private double travelled;
private long lastFrame;
EmpireView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
advance(moving);
float top = 46f * u, bottom = h - 186f * u;
drawWorld(c, w, top, bottom, u);
drawSpeed(c, w, top, u);
drawResources(c, w, top, u);
drawPanel(c, w, h, bottom, u);
keepAnimating(moving, 33);
}
private void advance(boolean moving) {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (moving) travelled = (travelled + v.speedKmh / 3.6f * dt / TILE_M) % 1_000_000d;
}
// ---------- Shared ----------
private void say(Canvas c, String s, float x, float y, float size, int colour, Typeface face, Paint.Align a, float u) {
text.setTypeface(face);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(0xCC000000);
c.drawText(s, x + Math.max(1f, size * 0.05f), y + Math.max(1f, size * 0.05f), text);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** Wood with planks and a gold trim. */
private void wood(Canvas c, float l, float t, float rr, float b, float u) {
fill.setColor(WOOD);
c.drawRect(l, t, rr, b, fill);
line.setColor(WOOD_DARK);
line.setStrokeWidth(2f * u);
for (float y = t + 22f * u; y < b; y += 22f * u) c.drawLine(l, y, rr, y, line);
line.setColor(WOOD_LIGHT);
line.setStrokeWidth(1f * u);
for (float y = t + 23.5f * u; y < b; y += 22f * u) c.drawLine(l, y, rr, y, line);
}
/** A recessed frame with a gold edge. */
private void inset(Canvas c, float l, float t, float rr, float b, int bg, float u) {
r.set(l, t, rr, b);
fill.setColor(bg);
c.drawRect(r, fill);
line.setColor(WOOD_DARK);
line.setStrokeWidth(4f * u);
c.drawRect(r, line);
line.setColor(TRIM);
line.setStrokeWidth(1.5f * u);
c.drawRect(r, line);
}
private void bar(Canvas c, float l, float t, float rr, float b, float frac, int colour, float u) {
fill.setColor(0xFF140C06);
c.drawRect(l, t, rr, b, fill);
if (frac > 0f) {
fill.setColor(colour);
c.drawRect(l, t, l + (rr - l) * Math.min(1f, frac), b, fill);
}
line.setColor(0xFF000000);
line.setStrokeWidth(1.5f * u);
c.drawRect(l, t, rr, b, line);
}
// ---------- World ----------
/** Isometric grass with a road through the middle; the car sits on it, the ground moves. */
private void drawWorld(Canvas c, float w, float top, float bottom, float u) {
c.save();
c.clipRect(0, top, w, bottom);
float tw = 112f * u, th = 56f * u;
float ox = w * 0.36f, oy = top + (bottom - top) * 0.62f;
long whole = (long) Math.floor(travelled);
float phase = (float) (travelled - whole);
// Moving forward along the road (up and to the right) moves the ground down and left.
float sx = -phase * tw / 2f, sy = phase * th / 2f;
int n = (int) Math.ceil((w / tw + (bottom - top) / th)) + 2;
for (int i = -n; i <= n; i++) {
for (int j = -n; j <= n; j++) {
float cx = ox + (i + j) * tw / 2f + sx, cy = oy + (i - j) * th / 2f + sy;
if (cx < -tw || cx > w + tw || cy < top - th || cy > bottom + th) continue;
long cell = j + whole;
boolean road = i == 0;
int colour = road ? ROAD : (hash(i, cell) % 3 == 0 ? GRASS_ALT : GRASS);
diamond(c, cx, cy, tw / 2f, th / 2f, colour);
if (road && (cell & 1) == 0) {
// Wheel ruts.
line.setColor(0x40000000);
line.setStrokeWidth(2f * u);
c.drawLine(cx - tw * 0.18f, cy + th * 0.02f, cx + tw * 0.08f, cy - th * 0.11f, line);
}
}
}
// Trees, drawn back to front so the nearer ones overlap.
for (int i = n; i >= -n; i--) {
for (int j = -n; j <= n; j++) {
if (Math.abs(i) < 2) continue;
long cell = j + whole;
if (hash(i, cell) % 7 != 0) continue;
float cx = ox + (i + j) * tw / 2f + sx, cy = oy + (i - j) * th / 2f + sy;
if (cx < -tw || cx > w + tw || cy < top - th || cy > bottom + 2 * th) continue;
tree(c, cx, cy, u);
}
}
drawUnit(c, ox, oy, u);
c.restore();
}
private static int hash(long i, long j) {
long x = i * 73_856_093L ^ j * 19_349_663L;
x ^= x >>> 13;
x *= 0x5bd1e995L;
x ^= x >>> 15;
return (int) ((x & 0x7fffffffL) % 1_000_003L);
}
private void diamond(Canvas c, float cx, float cy, float hw, float hh, int colour) {
path.reset();
path.moveTo(cx, cy - hh);
path.lineTo(cx + hw, cy);
path.lineTo(cx, cy + hh);
path.lineTo(cx - hw, cy);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(0x18000000);
line.setStrokeWidth(1f);
c.drawPath(path, line);
}
private void tree(Canvas c, float x, float y, float u) {
fill.setColor(0x40000000);
c.drawOval(x - 20f * u, y - 6f * u, x + 20f * u, y + 6f * u, fill);
fill.setColor(0xFF5A3A1E);
c.drawRect(x - 3f * u, y - 14f * u, x + 3f * u, y, fill);
for (int k = 0; k < 3; k++) {
float base = y - 10f * u - k * 16f * u, half = (22f - k * 5f) * u;
path.reset();
path.moveTo(x - half, base);
path.lineTo(x, base - 28f * u);
path.lineTo(x + half, base);
path.close();
fill.setColor(k == 1 ? 0xFF2F5A23 : 0xFF274D1D);
c.drawPath(path, fill);
}
}
/** The car as the selected unit: a selection ring, the car along the road, its health bar. */
private void drawUnit(Canvas c, float x, float y, float u) {
line.setColor(0xFFFFFFFF);
line.setStrokeWidth(2f * u);
c.drawOval(x - 62f * u, y - 24f * u, x + 62f * u, y + 24f * u, line);
c.save();
c.translate(x, y - 6f * u);
c.rotate(-26.565f);
carProfile(c, 0f, 0f, 54f * u, TEAM);
c.restore();
float frac = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, x - 40f * u, y - 92f * u, x + 40f * u, y - 84f * u, frac, low ? RED : HEALTH, u);
}
/** A car in profile, facing right, in the player's colour. */
private void carProfile(Canvas c, float cx, float cy, float s, int colour) {
path.reset();
path.moveTo(cx - s, cy + s * 0.15f);
path.lineTo(cx - s * 0.92f, cy - s * 0.15f);
path.quadTo(cx - s * 0.55f, cy - s * 0.22f, cx - s * 0.32f, cy - s * 0.5f);
path.lineTo(cx + s * 0.28f, cy - s * 0.52f);
path.quadTo(cx + s * 0.6f, cy - s * 0.24f, cx + s * 0.95f, cy - s * 0.12f);
path.lineTo(cx + s, cy + s * 0.15f);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
line.setColor(0xFF0A1440);
line.setStrokeWidth(s * 0.04f);
c.drawPath(path, line);
// Windows.
path.reset();
path.moveTo(cx - s * 0.26f, cy - s * 0.42f);
path.lineTo(cx + s * 0.24f, cy - s * 0.43f);
path.lineTo(cx + s * 0.44f, cy - s * 0.2f);
path.lineTo(cx - s * 0.45f, cy - s * 0.2f);
path.close();
fill.setColor(0xFFBFD8FF);
c.drawPath(path, fill);
fill.setColor(0xFF15100A);
c.drawCircle(cx - s * 0.55f, cy + s * 0.17f, s * 0.21f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.17f, s * 0.21f, fill);
fill.setColor(0xFFB0B0B0);
c.drawCircle(cx - s * 0.55f, cy + s * 0.17f, s * 0.1f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.17f, s * 0.1f, fill);
}
// ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float top, float u) {
float x = w * 0.66f;
say(c, fmt(v.speedKmh, "%.0f", "—"), x, top + 150f * u, 130f * u, PARCHMENT, serif, Paint.Align.CENTER, u);
say(c, "km/h", x, top + 184f * u, 26f * u, PARCHMENT, serif, Paint.Align.CENTER, u);
boolean low = v.socPercent != null && v.socPercent < 20f;
if (low && (SystemClock.uptimeMillis() / 600) % 2 == 0) {
say(c, "Your battery is running low!", x, top + 236f * u, 28f * u, 0xFFFFD040, serif,
Paint.Align.CENTER, u);
}
}
// ---------- Resource bar ----------
private void drawResources(Canvas c, float w, float top, float u) {
wood(c, 0, 0, w, top, u);
line.setColor(TRIM);
line.setStrokeWidth(2f * u);
c.drawLine(0, top - 1f * u, w, top - 1f * u, line);
float y = top / 2f, x = 22f * u, slot = 170f * u;
boolean low = v.socPercent != null && v.socPercent < 20f;
icon(c, 0, x, y, u);
say(c, fmt(v.socPercent, "%.0f %%", "—"), x + 30f * u, y + 9f * u, 26f * u, low ? RED : PARCHMENT, sans,
Paint.Align.LEFT, u);
x += slot;
icon(c, 1, x, y, u);
say(c, fmt(v.rangeKm, "%.0f km", "—"), x + 30f * u, y + 9f * u, 26f * u, PARCHMENT, sans, Paint.Align.LEFT, u);
x += slot;
icon(c, 2, x, y, u);
boolean regen = v.powerKw != null && v.powerKw < -0.05f;
String power = v.powerKw == null ? "—" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
say(c, power, x + 30f * u, y + 9f * u, 26f * u, regen ? REGEN : PARCHMENT, sans, Paint.Align.LEFT, u);
x += slot;
icon(c, 3, x, y, u);
say(c, fmt(v.usage5Min, "%.1f kWh/100", "—"), x + 30f * u, y + 9f * u, 26f * u, PARCHMENT, sans,
Paint.Align.LEFT, u);
say(c, v.clock.isEmpty() ? "—" : v.clock, w - 22f * u, y + 9f * u, 26f * u, PARCHMENT, sans,
Paint.Align.RIGHT, u);
say(c, EmpireAge.name(v.odometerKm), w - 130f * u, y + 9f * u, 26f * u, 0xFFFFD873, serif,
Paint.Align.RIGHT, u);
}
/** Small resource pictures: 0 battery, 1 flag, 2 bolt, 3 leaf. */
private void icon(Canvas c, int kind, float x, float y, float u) {
float s = 11f * u;
fill.setColor(0xFF000000);
r.set(x - s - 2f * u, y - s - 2f * u, x + s + 2f * u, y + s + 2f * u);
c.drawRect(r, fill);
line.setColor(TRIM);
line.setStrokeWidth(1.2f * u);
c.drawRect(r, line);
path.reset();
switch (kind) {
case 0:
fill.setColor(HEALTH);
c.drawRect(x - s * 0.5f, y - s * 0.6f, x + s * 0.5f, y + s * 0.85f, fill);
c.drawRect(x - s * 0.22f, y - s * 0.85f, x + s * 0.22f, y - s * 0.6f, fill);
break;
case 1:
fill.setColor(TEAM);
path.moveTo(x - s * 0.5f, y - s * 0.8f);
path.lineTo(x + s * 0.7f, y - s * 0.45f);
path.lineTo(x - s * 0.5f, y - s * 0.1f);
path.close();
c.drawPath(path, fill);
line.setColor(PARCHMENT);
line.setStrokeWidth(1.5f * u);
c.drawLine(x - s * 0.5f, y - s * 0.85f, x - s * 0.5f, y + s * 0.85f, line);
break;
case 2:
fill.setColor(0xFFFFD040);
path.moveTo(x + s * 0.2f, y - s * 0.9f);
path.lineTo(x - s * 0.55f, y + s * 0.1f);
path.lineTo(x, y + s * 0.1f);
path.lineTo(x - s * 0.2f, y + s * 0.9f);
path.lineTo(x + s * 0.55f, y - s * 0.15f);
path.lineTo(x, y - s * 0.15f);
path.close();
c.drawPath(path, fill);
break;
default:
fill.setColor(REGEN);
path.moveTo(x - s * 0.7f, y + s * 0.7f);
path.quadTo(x - s * 0.9f, y - s * 0.9f, x + s * 0.7f, y - s * 0.7f);
path.quadTo(x + s * 0.9f, y + s * 0.8f, x - s * 0.7f, y + s * 0.7f);
c.drawPath(path, fill);
}
}
// ---------- Bottom panel ----------
private void drawPanel(Canvas c, float w, float h, float bottom, float u) {
wood(c, 0, bottom, w, h, u);
line.setColor(TRIM);
line.setStrokeWidth(3f * u);
c.drawLine(0, bottom + 1.5f * u, w, bottom + 1.5f * u, line);
drawCommands(c, bottom, u);
float mapW = 250f * u;
drawUnitInfo(c, 224f * u, bottom, w - mapW - 40f * u, h, u);
drawMinimap(c, w - mapW - 20f * u, bottom, w - 20f * u, h, u);
}
/** P R N D as command buttons, two by two. */
private void drawCommands(Canvas c, float bottom, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float s = 74f * u, gap = 8f * u, l = 30f * u, t = bottom + 16f * u;
for (int i = 0; i < gears.length; i++) {
float x = l + (i % 2) * (s + gap), y = t + (i / 2) * (s + gap);
boolean on = gears[i].equals(now);
inset(c, x, y, x + s, y + s, on ? 0xFF22346E : 0xFF1A120A, u);
if (on) {
line.setColor(0xFFFFD873);
line.setStrokeWidth(3f * u);
c.drawRect(x + 3f * u, y + 3f * u, x + s - 3f * u, y + s - 3f * u, line);
}
say(c, gears[i], x + s / 2f, y + s * 0.68f, 40f * u, on ? 0xFFFFFFFF : 0xFF9A8A6A, serif,
Paint.Align.CENTER, u);
}
}
/** The selected unit: portrait, health, name and stats. */
private void drawUnitInfo(Canvas c, float l, float t, float rr, float b, float u) {
inset(c, l, t + 16f * u, rr, b - 14f * u, 0xFF1A120A, u);
float ps = 96f * u, px = l + 16f * u, py = t + 28f * u;
inset(c, px, py, px + ps, py + ps, 0xFF2C3E1E, u);
carProfile(c, px + ps / 2f, py + ps * 0.6f, ps * 0.38f, TEAM);
float frac = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, px, py + ps + 8f * u, px + ps, py + ps + 22f * u, frac, low ? RED : HEALTH, u);
say(c, fmt(v.socPercent, "%.0f / 100", "—"), px + ps / 2f, py + ps + 40f * u, 16f * u, PARCHMENT, sans,
Paint.Align.CENTER, u);
float x = px + ps + 24f * u;
say(c, "MG4", x, py + 26f * u, 28f * u, 0xFFFFD873, serif, Paint.Align.LEFT, u);
say(c, "Electric Cart", x + 80f * u, py + 26f * u, 20f * u, PARCHMENT, serif, Paint.Align.LEFT, u);
String[][] stats = {
{ "Range", fmt(v.rangeKm, "%.0f km", "—") },
{ "Trip", fmt(v.tripKm, "%.1f km", "—") },
{ "Since start", fmt(v.usageTrip, "%.1f kWh/100 km", "—") },
{ "Climate", fmt(v.climateCelsius, "%.0f °C", "—") },
};
float colW = (rr - x - 16f * u) / 2f;
for (int i = 0; i < stats.length; i++) {
float sx = x + (i % 2) * colW, sy = py + (66f + (i / 2) * 50f) * u;
fill.setColor(TRIM);
path.reset();
path.moveTo(sx + 6f * u, sy - 20f * u);
path.lineTo(sx + 12f * u, sy - 14f * u);
path.lineTo(sx + 6f * u, sy - 8f * u);
path.lineTo(sx, sy - 14f * u);
path.close();
c.drawPath(path, fill);
say(c, stats[i][0], sx + 20f * u, sy - 7f * u, 17f * u, 0xFFBFAE88, sans, Paint.Align.LEFT, u);
say(c, stats[i][1], sx + 20f * u, sy + 18f * u, 23f * u, PARCHMENT, sans, Paint.Align.LEFT, u);
}
}
/** The diamond minimap: the player's land round the car, the road, the odometer under it. */
private void drawMinimap(Canvas c, float l, float t, float rr, float b, float u) {
float cx = (l + rr) / 2f, cy = t + 60f * u, hw = (rr - l) / 2f, hh = 44f * u;
path.reset();
path.moveTo(cx, cy - hh);
path.lineTo(cx + hw, cy);
path.lineTo(cx, cy + hh);
path.lineTo(cx - hw, cy);
path.close();
fill.setColor(0xFF000000);
c.drawPath(path, fill);
c.save();
c.clipPath(path);
fill.setColor(0xFF35561F);
c.drawOval(cx - hw * 0.7f, cy - hh * 0.65f, cx + hw * 0.5f, cy + hh * 0.75f, fill);
fill.setColor(AccentColor.withAlpha(TEAM, 0x70));
c.drawOval(cx - hw * 0.32f, cy - hh * 0.3f, cx + hw * 0.2f, cy + hh * 0.34f, fill);
line.setColor(ROAD);
line.setStrokeWidth(3f * u);
c.drawLine(cx - hw, cy + hh, cx + hw, cy - hh, line);
c.restore();
line.setColor(TRIM);
line.setStrokeWidth(2f * u);
c.drawPath(path, line);
fill.setColor(0xFFFFFFFF);
c.drawCircle(cx, cy, 5f * u, fill);
fill.setColor(TEAM);
c.drawCircle(cx, cy, 3.5f * u, fill);
say(c, fmt(v.odometerKm, "%,.0f km", "—"), cx, cy + hh + 24f * u, 20f * u, PARCHMENT, sans,
Paint.Align.CENTER, u);
String next = EmpireAge.next(v.odometerKm);
Float toNext = EmpireAge.toNext(v.odometerKm);
String towards = next == null || toNext == null ? EmpireAge.name(v.odometerKm)
: String.format(Locale.US, "%s in %,.0f km", next, toNext);
bar(c, l + 12f * u, b - 28f * u, rr - 12f * u, b - 14f * u, EmpireAge.progress(v.odometerKm), 0xFFFFD873, u);
say(c, towards, cx, b - 34f * u, 15f * u, 0xFFBFAE88, sans, Paint.Align.CENTER, u);
}
}
@@ -0,0 +1,411 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
/**
* The Grid Overview style, after the neon world of the Tron films: glowing lines on black, a
* perspective grid floor that runs towards you with the speed, and shapes with cut corners.
*
* Centre: the speed, large and thin. Left: the battery as a segmented light wall standing on
* the floor, with the charge and range beside it. Right: power on a disc — the ring lights from
* the top, clockwise while drawing and anticlockwise while regenerating, and its inner ring
* turns with the power. On the floor, the last two minutes of power as a light-cycle trail,
* which only ever turns at right angles. P R N D as tiles on top, the clock top right, and a
* row of read-outs along the bottom. Unread values show "—", light nothing and leave gaps in
* the trail; the floor stands still when the speed is unread.
*
* Two palettes: the original cyan, or the chosen accent colour ({@link #setAccentPalette}). A
* low battery is orange in the original (the films' other side) and white in the accent one,
* so it never looks like the accent itself. The floor and the disc animate (about 30 frames a
* second) only while the car moves and the view is on screen.
*/
final class GridView extends ClusterCanvasView {
/** Battery segments in the light wall, disc segments, metres per grid tile. */
private static final int WALL = 20, DISC = 60;
private static final float TILE_M = 4f;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint sky = new Paint();
private final Path path = new Path();
private android.graphics.DashPathEffect dash;
private float dashFor;
private final RectF r = new RectF();
private final Typeface thin = Typeface.create("sans-serif-thin", Typeface.NORMAL);
private final Typeface label = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final int accent, accentLight;
private int main, light, dim, alert;
/** The floor's position within one tile (0..1) and the disc's inner ring angle. */
private float floorPhase, discTurn;
private long lastFrame;
private int skyFor = -1;
GridView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.SQUARE);
line.setStrokeJoin(Paint.Join.MITER);
setAccentPalette(false);
}
/** The chosen accent colour instead of the original cyan. */
void setAccentPalette(boolean useAccent) {
main = useAccent ? accent : 0xFF18E6FF;
light = useAccent ? accentLight : 0xFFC8FCFF;
dim = AccentColor.withAlpha(main, 0x40);
alert = useAccent ? 0xFFFFFFFF : 0xFFFF8A1F;
skyFor = -1;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF000000);
float horizon = h * 0.55f;
drawSky(c, w, horizon);
drawFloor(c, w, h, horizon, u);
drawGears(c, w, u);
drawClock(c, w, u);
drawSpeed(c, w, horizon, u);
drawWall(c, horizon, u);
drawDisc(c, w, u);
drawTrail(c, w, h, u);
drawReadouts(c, w, h, u);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
keepAnimating(moving, 33);
}
/** Moves the floor by the distance driven since the last frame, and turns the disc. */
private void advance() {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.speedKmh != null && v.speedKmh > 0.5f) {
floorPhase = (floorPhase + v.speedKmh / 3.6f * dt / TILE_M) % 1f;
}
if (v.powerKw != null) discTurn = (discTurn + v.powerKw * 3f * dt) % 360f;
}
// ---------- Floor ----------
private void drawSky(Canvas c, float w, float horizon) {
if (skyFor != (int) horizon) {
sky.setShader(new LinearGradient(0, horizon - horizon * 0.45f, 0, horizon,
0x00000000, AccentColor.withAlpha(main, 0x30), Shader.TileMode.CLAMP));
skyFor = (int) horizon;
}
c.drawRect(0, horizon - horizon * 0.45f, w, horizon, sky);
}
/** Lines converging on the horizon, and cross lines that run towards the viewer. */
private void drawFloor(Canvas c, float w, float h, float horizon, float u) {
float vx = w / 2f, depth = h - horizon;
line.setStrokeWidth(1.2f * u);
for (int i = -14; i <= 14; i++) {
float bottomX = vx + i * w * 0.09f;
line.setColor(AccentColor.withAlpha(main, 0x38));
c.drawLine(vx + (bottomX - vx) * 0.02f, horizon, bottomX, h, line);
}
for (int k = 0; k < 24; k++) {
float d = k + 1f - floorPhase;
if (d <= 0.05f) continue;
float y = horizon + depth / d;
if (y > h || y < horizon + 1f) continue;
float near = (y - horizon) / depth;
line.setColor(AccentColor.withAlpha(main, (int) (0x10 + 0x50 * near)));
c.drawLine(0, y, w, y, line);
}
glowLine(c, 0, horizon, w, horizon, main, 1.5f * u);
}
// ---------- Top ----------
private void drawGears(Canvas c, float w, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float tw = 58f * u, th = 40f * u, gap = 12f * u;
float x = w / 2f - (gears.length * tw + (gears.length - 1) * gap) / 2f, top = 22f * u;
text.setTypeface(label);
text.setTextSize(24f * u);
text.setTextAlign(Paint.Align.CENTER);
for (String g : gears) {
boolean on = g.equals(now);
chamfer(x, top, x + tw, top + th, 9f * u);
if (on) {
fill.setColor(main);
c.drawPath(path, fill);
glow(c, path, main, 1.5f * u);
} else {
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
text.setColor(on ? 0xFF000000 : dim);
c.drawText(g, x + tw / 2f, top + th * 0.72f, text);
x += tw + gap;
}
}
private void drawClock(Canvas c, float w, float u) {
text.setTypeface(thin);
text.setTextSize(44f * u);
text.setTextAlign(Paint.Align.RIGHT);
glowText(c, v.clock.isEmpty() ? "—" : v.clock, w - 28f * u, 62f * u, light, u);
}
// ---------- Speed ----------
private void drawSpeed(Canvas c, float w, float horizon, float u) {
text.setTypeface(thin);
text.setTextSize(210f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, horizon - 62f * u, light, u);
caption(c, "KM/H", w / 2f, horizon - 22f * u, u, Paint.Align.CENTER);
}
// ---------- Battery wall ----------
private void drawWall(Canvas c, float horizon, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
int colour = low ? alert : main;
float left = 34f * u, right = left + 52f * u, top = 96f * u, bottom = horizon + 70f * u;
float seg = (bottom - top) / WALL;
int lit = v.socPercent == null ? 0 : Math.round(v.socPercent / 100f * WALL);
for (int i = 0; i < WALL; i++) {
float b = bottom - i * seg, t = b - seg + 3f * u;
r.set(left, t, right, b);
if (i < lit) {
fill.setColor(i == lit - 1 ? light : colour);
c.drawRect(r, fill);
} else {
line.setColor(dim);
line.setStrokeWidth(1f * u);
c.drawRect(r, line);
}
}
chamfer(left - 8f * u, top - 8f * u, right + 8f * u, bottom + 8f * u, 10f * u);
glow(c, path, colour, 1.5f * u);
float x = right + 30f * u;
caption(c, low ? "LOW ENERGY" : "ENERGY", x, top + 18f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(96f * u);
text.setTextAlign(Paint.Align.LEFT);
String soc = fmt(v.socPercent, "%.0f", "—");
glowText(c, soc, x, top + 116f * u, low ? alert : light, u);
float socWidth = text.measureText(soc);
text.setTextSize(36f * u);
glowText(c, "%", x + socWidth + 8f * u, top + 116f * u, colour, u);
caption(c, "RANGE", x, top + 168f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(52f * u);
glowText(c, fmt(v.rangeKm, "%.0f km", "—"), x, top + 222f * u, light, u);
}
// ---------- Power disc ----------
/** Zero at the top, −50 kW a quarter turn anticlockwise, 150 kW three quarters clockwise. */
private void drawDisc(Canvas c, float w, float u) {
float cx = w - 170f * u, cy = 238f * u, outer = 132f * u, ring = 22f * u;
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawCircle(cx, cy, outer + 10f * u, line);
float sweepPerKw = 270f / 200f;
float power = v.powerKw == null ? 0f : Math.max(-50f, Math.min(150f, v.powerKw));
float segDeg = 270f / DISC;
for (int i = 0; i < DISC; i++) {
// Segment i covers −50 + i·(200/DISC) … one step more, drawn from −135° (top-left).
float kwFrom = -50f + i * 200f / DISC, kwTo = kwFrom + 200f / DISC;
boolean on = v.powerKw != null
&& (power >= 0 ? kwFrom >= 0f && kwFrom < power : kwTo <= 0f && kwTo > power);
float start = -90f + kwFrom * sweepPerKw;
r.set(cx - outer + ring / 2f, cy - outer + ring / 2f, cx + outer - ring / 2f, cy + outer - ring / 2f);
line.setStrokeWidth(ring);
line.setStrokeCap(Paint.Cap.BUTT);
line.setColor(on ? (power < 0 ? light : main) : AccentColor.withAlpha(main, 0x22));
c.drawArc(r, start + 0.6f, segDeg - 1.2f, false, line);
}
line.setStrokeCap(Paint.Cap.SQUARE);
// The zero mark, and an inner ring of dashes that turns with the power.
glowLine(c, cx, cy - outer - 16f * u, cx, cy - outer + ring + 6f * u, light, 2f * u);
float inner = outer - ring - 22f * u;
line.setStrokeWidth(3f * u);
line.setColor(main);
r.set(cx - inner, cy - inner, cx + inner, cy + inner);
for (int k = 0; k < 8; k++) c.drawArc(r, discTurn + k * 45f, 26f, false, line);
line.setColor(dim);
line.setStrokeWidth(1f * u);
c.drawCircle(cx, cy, inner - 12f * u, line);
text.setTypeface(thin);
text.setTextSize(64f * u);
text.setTextAlign(Paint.Align.CENTER);
glowText(c, fmt(v.powerKw, "%.1f", "—"), cx, cy + 18f * u, light, u);
caption(c, v.powerKw != null && v.powerKw < 0f ? "KW · REGEN" : "KW", cx, cy + 50f * u, u,
Paint.Align.CENTER);
caption(c, "POWER", cx, cy - 46f * u, u, Paint.Align.CENTER);
}
// ---------- Light trail ----------
/** Power over the last two minutes as a trail of right angles; NaN breaks it. */
private void drawTrail(Canvas c, float w, float h, float u) {
float[] trail = v.powerTrail;
float left = 300f * u, right = w - 360f * u, top = h * 0.66f, bottom = h * 0.84f;
if (right - left < 200f * u) return;
float max = 10f, min = -10f;
if (trail != null) {
for (float p : trail) {
if (Float.isNaN(p)) continue;
max = Math.max(max, (float) Math.ceil(p / 10f) * 10f);
min = Math.min(min, (float) Math.floor(p / 10f) * 10f);
}
}
float zero = bottom - (0f - min) / (max - min) * (bottom - top);
line.setColor(dim);
line.setStrokeWidth(1f * u);
if (dash == null || dashFor != u) {
dash = new android.graphics.DashPathEffect(new float[] { 6f * u, 8f * u }, 0);
dashFor = u;
}
line.setPathEffect(dash);
c.drawLine(left, zero, right, zero, line);
line.setPathEffect(null);
caption(c, "POWER · LAST 2 MIN", left, top - 10f * u, u, Paint.Align.LEFT);
if (trail == null || trail.length < 2) return;
float step = (right - left) / (trail.length - 1);
path.reset();
boolean open = false;
float lastX = 0, lastY = 0;
for (int i = 0; i < trail.length; i++) {
float p = trail[i];
if (Float.isNaN(p)) {
open = false;
continue;
}
float x = left + i * step, y = bottom - (p - min) / (max - min) * (bottom - top);
if (!open) {
path.moveTo(x, y);
open = true;
} else {
path.lineTo(x, lastY); // along, then a right angle
path.lineTo(x, y);
}
lastX = x;
lastY = y;
}
glow(c, path, main, 2.5f * u);
if (open) {
fill.setColor(light);
c.drawCircle(lastX, lastY, 5f * u, fill);
fill.setColor(AccentColor.withAlpha(main, 0x50));
c.drawCircle(lastX, lastY, 12f * u, fill);
}
}
// ---------- Read-outs ----------
private void drawReadouts(Canvas c, float w, float h, float u) {
String[][] cells = {
{ "USAGE · 5 MIN", fmt(v.usage5Min, "%.1f", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "—") },
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
float margin = 24f * u, gap = 14f * u, top = h - 70f * u, bottom = h - 14f * u;
float cw = (w - 2 * margin - (cells.length - 1) * gap) / cells.length;
for (int i = 0; i < cells.length; i++) {
float l = margin + i * (cw + gap);
chamfer(l, top, l + cw, bottom, 10f * u);
fill.setColor(0xE6000000);
c.drawPath(path, fill);
line.setColor(dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
caption(c, cells[i][0], l + 16f * u, top + 20f * u, u, Paint.Align.LEFT);
text.setTypeface(thin);
text.setTextSize(30f * u);
text.setTextAlign(Paint.Align.RIGHT);
glowText(c, cells[i][1], l + cw - 16f * u, bottom - 10f * u, light, u);
}
}
// ---------- Light ----------
/** A rectangle with its top-left and bottom-right corners cut, into [path]. */
private void chamfer(float l, float t, float rr, float b, float cut) {
path.reset();
path.moveTo(l + cut, t);
path.lineTo(rr, t);
path.lineTo(rr, b - cut);
path.lineTo(rr - cut, b);
path.lineTo(l, b);
path.lineTo(l, t + cut);
path.close();
}
/** A line that glows: two wide faint strokes under a sharp one. */
private void glow(Canvas c, Path p, int colour, float width) {
line.setColor(AccentColor.withAlpha(colour, 0x18));
line.setStrokeWidth(width * 6f);
c.drawPath(p, line);
line.setColor(AccentColor.withAlpha(colour, 0x40));
line.setStrokeWidth(width * 3f);
c.drawPath(p, line);
line.setColor(colour);
line.setStrokeWidth(width);
c.drawPath(p, line);
}
private void glowLine(Canvas c, float x1, float y1, float x2, float y2, int colour, float width) {
path.reset();
path.moveTo(x1, y1);
path.lineTo(x2, y2);
glow(c, path, colour, width);
}
/** Text with a halo: a wide faint outline under the letters. */
private void glowText(Canvas c, String s, float x, float y, int colour, float u) {
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(7f * u);
text.setColor(AccentColor.withAlpha(colour, 0x30));
c.drawText(s, x, y, text);
text.setStyle(Paint.Style.FILL);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** A small label in spaced capitals. */
private void caption(Canvas c, String s, float x, float y, float u, Paint.Align align) {
text.setTypeface(label);
text.setTextSize(15f * u);
text.setLetterSpacing(0.2f);
text.setTextAlign(align);
text.setStyle(Paint.Style.FILL);
text.setColor(main);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
}
@@ -68,6 +68,8 @@ public class MainActivity extends AppCompatActivity {
/** The styles built on {@link ClusterCanvasView}, fed together. */
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
private TerminalView terminalView;
private GridView gridView;
private VisorView visorView;
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
private View[] stylePanes;
private View consumptionPane;
@@ -342,13 +344,19 @@ public class MainActivity extends AppCompatActivity {
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
mgPane = drawnPane(octagonView);
terminalView = new TerminalView(this);
gridView = new GridView(this);
visorView = new VisorView(this);
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
new NixieView(this), new ChronographView(this));
new NixieView(this), new ChronographView(this), gridView, new TimeCircuitsView(this),
new QuestView(this), visorView, new EmpireView(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, mgPane,
canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3] };
View[] fixedPanes = { vehiclePane, gaugesPane, cockpitPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, mgPane };
// By OverviewStyle ordinal: the fixed styles, then the canvas styles in their order.
stylePanes = new View[fixedPanes.length + canvasPanes.length];
System.arraycopy(fixedPanes, 0, stylePanes, 0, fixedPanes.length);
System.arraycopy(canvasPanes, 0, stylePanes, fixedPanes.length, canvasPanes.length);
// 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(
@@ -1713,9 +1721,11 @@ public class MainActivity extends AppCompatActivity {
private void applyOverviewStyle() {
OverviewStyle style = overviewStyle();
for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style.ordinal()] ? View.VISIBLE : View.GONE);
// Retro and Terminal: their original palette or the accent colour, as chosen for each.
// Retro, Terminal and Grid: their original palette or the accent colour, as chosen for each.
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
gridView.setAccentPalette(!originalColours(OverviewStyle.GRID));
visorView.setAccentPalette(!originalColours(OverviewStyle.VISOR));
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
if (settingsPane != null) bindAccentColor();
}
@@ -1746,7 +1756,9 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_mg),
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
getString(R.string.overview_style_nixie),
getString(R.string.overview_style_chronograph) };
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire) };
}
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
@@ -1812,6 +1824,7 @@ public class MainActivity extends AppCompatActivity {
v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa());
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
v.powerTrail = cockpitTrace.snapshot();
retroView.setValues(v);
flowView.setValues(v);
sciFiView.setValues(v);
@@ -8,7 +8,7 @@ package com.evsuite.abrp;
* colour for it. Styles with only their own fixed palette (the starship console, the cartoon,
* the flight display, the nixie tubes) ignore it and Settings greys it out.
* - {@link #hasOriginal}: has an original palette of its own as well (Retro's turquoise
* display, the terminal's green phosphor); Settings then offers "Original colours" above the
* display, the terminal's green phosphor, the Grid's neon cyan, the Visor's blue); Settings then offers "Original colours" above the
* accent colours, remembered per style under {@link #originalKey()}.
*
* Pure Java, unit-tested.
@@ -25,7 +25,12 @@ enum OverviewStyle {
MINIMAL(true, false),
TERMINAL(true, true),
NIXIE(false, false),
CHRONOGRAPH(true, false);
CHRONOGRAPH(true, false),
GRID(true, true),
TIME_CIRCUITS(false, false),
QUEST(false, false),
VISOR(true, true),
EMPIRE(false, false);
static final String PREF_KEY = "overview_style";
@@ -0,0 +1,430 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Quest Overview style, after the interface of a fantasy online game like World of Warcraft:
* gold-trimmed frames over a dusk landscape, the car as the player character.
*
* Top left, the player frame: a round portrait, the gear in the level badge, the battery as the
* health bar and power as the energy bar. Top right, the minimap with the clock. In the middle
* the speed in large gold numbers, and under it the cast bar — "Accelerating" while drawing
* power, "Regenerating" while recovering it, "Coasting" in between and "Resting" in P. Right,
* the quest tracker: a road trip whose objectives are the trip, the usage and the climate.
* Bottom left, a chat window with system lines; bottom, P R N D as action buttons and the
* odometer as the experience bar (a level per 1000 km). Below 20 % a raid warning flashes.
* Unread values show "—" and fill nothing. Drawn per reading; only the raid warning blinks, by
* the readings that redraw it.
*/
final class QuestView extends ClusterCanvasView {
private static final int GOLD = 0xFFC9A24A, GOLD_DARK = 0xFF7A5C24, YELLOW = 0xFFFFD100,
PANEL = 0xE01A130D, HEALTH = 0xFF26B33A, ENERGY = 0xFFF2D21B, REGEN = 0xFF3FBF6F,
XP = 0xFF8E44C9, RED = 0xFFFF3B30, SYSTEM = 0xFFFFE96A;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint skyPaint = new Paint();
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface serif = Typeface.create(Typeface.SERIF, Typeface.BOLD);
private final Typeface sans = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private int skyFor = -1;
QuestView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
drawLandscape(c, w, h);
drawPlayer(c, u);
drawMinimap(c, w, u);
drawSpeed(c, w, u);
drawCastBar(c, w, u);
drawQuests(c, w, u);
drawChat(c, u);
drawActionBar(c, w, h, u);
drawXp(c, w, h, u);
}
// ---------- World ----------
private void drawLandscape(Canvas c, float w, float h) {
if (skyFor != (int) h) {
skyPaint.setShader(new LinearGradient(0, 0, 0, h, new int[] { 0xFF0B1424, 0xFF1C2A3A, 0xFF2A2418 },
new float[] { 0f, 0.55f, 1f }, Shader.TileMode.CLAMP));
skyFor = (int) h;
}
c.drawRect(0, 0, w, h, skyPaint);
// Two ranges of hills, the near one darker.
hills(c, w, h, h * 0.62f, 0xFF16202A, 0.9f);
hills(c, w, h, h * 0.72f, 0xFF10171D, 1.6f);
}
private void hills(Canvas c, float w, float h, float base, int colour, float wave) {
path.reset();
path.moveTo(0, h);
for (int i = 0; i <= 24; i++) {
float x = w * i / 24f;
float y = base - (float) (Math.sin(i * wave) * 0.5 + Math.sin(i * wave * 0.37 + 1) * 0.5) * h * 0.06f;
path.lineTo(x, y);
}
path.lineTo(w, h);
path.close();
fill.setColor(colour);
c.drawPath(path, fill);
}
// ---------- Frames ----------
/** A dark panel with a gold rim. */
private void panel(Canvas c, float l, float t, float rr, float b, float u) {
r.set(l, t, rr, b);
fill.setColor(PANEL);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
line.setColor(GOLD_DARK);
line.setStrokeWidth(4f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
line.setColor(GOLD);
line.setStrokeWidth(1.5f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
}
/** Text with a dark outline, as game interfaces write over the world. */
private void outlined(Canvas c, String s, float x, float y, float size, int colour, Typeface face,
Paint.Align align, float u) {
text.setTypeface(face);
text.setTextSize(size);
text.setTextAlign(align);
text.setStyle(Paint.Style.STROKE);
text.setStrokeWidth(Math.max(2f * u, size * 0.08f));
text.setStrokeJoin(Paint.Join.ROUND);
text.setColor(0xFF000000);
c.drawText(s, x, y, text);
text.setStyle(Paint.Style.FILL);
text.setColor(colour);
c.drawText(s, x, y, text);
}
/** A bar with a dark track, a fill of [frac] and centred text. */
private void bar(Canvas c, float l, float t, float rr, float b, float frac, int colour, String label, float u) {
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
if (frac > 0f) {
r.set(l, t, l + (rr - l) * Math.min(1f, frac), b);
fill.setColor(colour);
c.drawRect(r, fill);
fill.setColor(0x33FFFFFF);
c.drawRect(r.left, t, r.right, t + (b - t) * 0.35f, fill);
}
line.setColor(GOLD_DARK);
line.setStrokeWidth(2f * u);
c.drawRect(l, t, rr, b, line);
outlined(c, label, (l + rr) / 2f, b - (b - t) * 0.22f, (b - t) * 0.72f, 0xFFFFFFFF, sans,
Paint.Align.CENTER, u);
}
// ---------- Player ----------
private void drawPlayer(Canvas c, float u) {
float px = 86f * u, py = 86f * u, pr = 60f * u;
// Name plate and bars, behind the portrait.
float l = px + 20f * u, rr = px + 380f * u;
panel(c, l, py - 56f * u, rr, py + 46f * u, u);
outlined(c, "MG4", l + 60f * u, py - 28f * u, 24f * u, YELLOW, serif, Paint.Align.LEFT, u);
float soc = v.socPercent == null ? 0f : v.socPercent / 100f;
boolean low = v.socPercent != null && v.socPercent < 20f;
bar(c, l + 56f * u, py - 18f * u, rr - 12f * u, py + 10f * u, soc, low ? RED : HEALTH,
fmt(v.socPercent, "%.0f %%", "—") + " · " + fmt(v.rangeKm, "%.0f km", "—"), u);
boolean regen = v.powerKw != null && v.powerKw < -0.5f;
float energy = v.powerKw == null ? 0f : Math.abs(v.powerKw) / 150f;
bar(c, l + 56f * u, py + 14f * u, rr - 12f * u, py + 36f * u, energy, regen ? REGEN : ENERGY,
v.powerKw == null ? "—" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW", Math.abs(v.powerKw)), u);
// The portrait: a gold ring round the car's name.
fill.setColor(0xFF140F0A);
c.drawCircle(px, py, pr, fill);
line.setColor(GOLD_DARK);
line.setStrokeWidth(10f * u);
c.drawCircle(px, py, pr, line);
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
c.drawCircle(px, py, pr + 3f * u, line);
drawCarGlyph(c, px, py + 6f * u, pr * 0.62f, u);
// The level badge: the gear.
float bx = px - pr * 0.72f, by = py + pr * 0.72f, br = 20f * u;
fill.setColor(0xFF140F0A);
c.drawCircle(bx, by, br, fill);
line.setColor(GOLD);
line.setStrokeWidth(3f * u);
c.drawCircle(bx, by, br, line);
String gear = Gear.label(v.gear);
outlined(c, gear.isEmpty() ? "?" : gear, bx, by + 8f * u, 22f * u, YELLOW, serif, Paint.Align.CENTER, u);
}
/** A small car in profile for the portrait. */
private void drawCarGlyph(Canvas c, float cx, float cy, float s, float u) {
path.reset();
path.moveTo(cx - s, cy + s * 0.15f);
path.lineTo(cx - s * 0.92f, cy - s * 0.12f);
path.quadTo(cx - s * 0.55f, cy - s * 0.2f, cx - s * 0.35f, cy - s * 0.48f);
path.lineTo(cx + s * 0.3f, cy - s * 0.5f);
path.quadTo(cx + s * 0.62f, cy - s * 0.22f, cx + s * 0.95f, cy - s * 0.1f);
path.lineTo(cx + s, cy + s * 0.15f);
path.close();
fill.setColor(GOLD);
c.drawPath(path, fill);
fill.setColor(0xFF140F0A);
c.drawCircle(cx - s * 0.55f, cy + s * 0.18f, s * 0.2f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.18f, s * 0.2f, fill);
fill.setColor(GOLD);
c.drawCircle(cx - s * 0.55f, cy + s * 0.18f, s * 0.11f, fill);
c.drawCircle(cx + s * 0.55f, cy + s * 0.18f, s * 0.11f, fill);
}
// ---------- Minimap ----------
private void drawMinimap(Canvas c, float w, float u) {
float cx = w - 120f * u, cy = 128f * u, mr = 84f * u;
outlined(c, "The Open Road", cx, 30f * u, 20f * u, YELLOW, serif, Paint.Align.CENTER, u);
fill.setColor(0xFF1E3524);
c.drawCircle(cx, cy, mr, fill);
// Roads and a river, clipped to the map.
c.save();
path.reset();
path.addCircle(cx, cy, mr, Path.Direction.CW);
c.clipPath(path);
line.setColor(0xFF8A7650);
line.setStrokeWidth(5f * u);
c.drawLine(cx, cy + mr, cx, cy - mr, line);
c.drawLine(cx - mr, cy - mr * 0.35f, cx + mr, cy + mr * 0.1f, line);
line.setColor(0xFF2E5C8A);
line.setStrokeWidth(7f * u);
path.reset();
path.moveTo(cx - mr, cy + mr * 0.5f);
path.quadTo(cx - mr * 0.2f, cy + mr * 0.2f, cx + mr, cy + mr * 0.7f);
c.drawPath(path, line);
c.restore();
// The player arrow.
path.reset();
path.moveTo(cx, cy - 13f * u);
path.lineTo(cx + 9f * u, cy + 10f * u);
path.lineTo(cx, cy + 5f * u);
path.lineTo(cx - 9f * u, cy + 10f * u);
path.close();
fill.setColor(YELLOW);
c.drawPath(path, fill);
line.setColor(0xFF000000);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
// The stone rim.
line.setColor(0xFF4A4036);
line.setStrokeWidth(12f * u);
c.drawCircle(cx, cy, mr + 5f * u, line);
line.setColor(GOLD);
line.setStrokeWidth(2f * u);
c.drawCircle(cx, cy, mr + 11f * u, line);
c.drawCircle(cx, cy, mr - 1f * u, line);
// The clock under it.
panel(c, cx - 50f * u, cy + mr + 16f * u, cx + 50f * u, cy + mr + 48f * u, u);
outlined(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + mr + 40f * u, 22f * u, 0xFFFFFFFF, sans,
Paint.Align.CENTER, u);
}
// ---------- Speed and cast bar ----------
private void drawSpeed(Canvas c, float w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
if (low && (SystemClock.uptimeMillis() / 600) % 2 == 0) {
outlined(c, "Low battery!", w / 2f, 64f * u, 34f * u, RED, serif, Paint.Align.CENTER, u);
}
outlined(c, fmt(v.speedKmh, "%.0f", "—"), w / 2f, 236f * u, 150f * u, YELLOW, serif, Paint.Align.CENTER, u);
outlined(c, "km/h", w / 2f, 270f * u, 24f * u, GOLD, serif, Paint.Align.CENTER, u);
}
private void drawCastBar(Canvas c, float w, float u) {
boolean parked = v.gear != null && v.gear == 1;
Float p = v.powerKw;
String spell;
int colour;
float frac;
if (parked) {
spell = "Resting";
colour = 0xFF5B7BD9;
frac = 1f;
} else if (p == null) {
spell = "—";
colour = ENERGY;
frac = 0f;
} else if (p > 0.5f) {
spell = "Accelerating";
colour = 0xFFFFB300;
frac = p / 150f;
} else if (p < -0.5f) {
spell = "Regenerating";
colour = REGEN;
frac = -p / 50f;
} else {
spell = "Coasting";
colour = 0xFF8F8F8F;
frac = 0.02f;
}
float bw = Math.min(520f * u, w * 0.36f), l = (w - bw) / 2f + 18f * u, rr = l + bw, t = 296f * u, b = t + 30f * u;
// The spell icon.
float il = l - 40f * u;
r.set(il, t - 3f * u, il + 36f * u, b + 3f * u);
fill.setColor(0xFF140F0A);
c.drawRect(r, fill);
line.setColor(GOLD);
line.setStrokeWidth(2f * u);
c.drawRect(r, line);
drawSpellIcon(c, r.centerX(), r.centerY(), 13f * u, spell, colour, u);
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
r.set(l, t, l + bw * Math.max(0f, Math.min(1f, frac)), b);
fill.setColor(colour);
c.drawRect(r, fill);
fill.setColor(0x33FFFFFF);
c.drawRect(r.left, t, r.right, t + 10f * u, fill);
line.setColor(GOLD);
line.setStrokeWidth(2.5f * u);
c.drawRect(l, t, rr, b, line);
outlined(c, spell, l + 10f * u, b - 8f * u, 20f * u, 0xFFFFFFFF, sans, Paint.Align.LEFT, u);
outlined(c, p == null ? "—" : String.format(Locale.US, "%.1f kW", Math.abs(p)), rr - 10f * u, b - 8f * u,
20f * u, 0xFFFFFFFF, sans, Paint.Align.RIGHT, u);
}
private void drawSpellIcon(Canvas c, float cx, float cy, float s, String spell, int colour, float u) {
fill.setColor(colour);
path.reset();
switch (spell) {
case "Accelerating": // a lightning bolt
path.moveTo(cx + s * 0.2f, cy - s);
path.lineTo(cx - s * 0.6f, cy + s * 0.1f);
path.lineTo(cx, cy + s * 0.1f);
path.lineTo(cx - s * 0.2f, cy + s);
path.lineTo(cx + s * 0.6f, cy - s * 0.15f);
path.lineTo(cx, cy - s * 0.15f);
path.close();
c.drawPath(path, fill);
break;
case "Regenerating": // a leaf
path.moveTo(cx - s * 0.8f, cy + s * 0.8f);
path.quadTo(cx - s, cy - s, cx + s * 0.8f, cy - s * 0.8f);
path.quadTo(cx + s, cy + s * 0.9f, cx - s * 0.8f, cy + s * 0.8f);
c.drawPath(path, fill);
break;
case "Resting":
outlined(c, "Zz", cx, cy + s * 0.5f, s * 1.5f, colour, serif, Paint.Align.CENTER, u);
break;
default:
c.drawCircle(cx, cy, s * 0.5f, fill);
}
}
// ---------- Quest tracker and chat ----------
private void drawQuests(Canvas c, float w, float u) {
float x = w - 400f * u, y = 310f * u;
fill.setColor(YELLOW);
c.drawCircle(x - 18f * u, y - 9f * u, 11f * u, fill);
outlined(c, "!", x - 18f * u, y - 1f * u, 18f * u, 0xFF000000, serif, Paint.Align.CENTER, u);
outlined(c, "Road trip", x, y, 26f * u, YELLOW, serif, Paint.Align.LEFT, u);
String[] objectives = {
"Distance: " + fmt(v.tripKm, "%.1f km", "—"),
"Usage, 5 min: " + fmt(v.usage5Min, "%.1f kWh/100 km", "—"),
"Since start: " + fmt(v.usageTrip, "%.1f kWh/100 km", "—"),
"Climate: " + fmt(v.climateCelsius, "%.0f °C", "—"),
};
for (int i = 0; i < objectives.length; i++) {
outlined(c, "– " + objectives[i], x + 8f * u, y + (34f + i * 30f) * u, 21f * u, 0xFFE8E8E8, sans,
Paint.Align.LEFT, u);
}
}
private void drawChat(Canvas c, float u) {
float l = 22f * u, t = 368f * u, rr = l + 470f * u, b = 520f * u;
r.set(l, t, rr, b);
fill.setColor(0x66000000);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
String state = v.gear == null ? "The car is not answering."
: v.gear == 1 ? "You feel rested."
: v.gear == 2 ? "You are moving backwards."
: "You are on the road.";
String[] lines = {
"Range remaining: " + fmt(v.rangeKm, "%.0f km", "—") + ".",
"Odometer: " + fmt(v.odometerKm, "%,.0f km", "—") + ".",
state,
};
for (int i = 0; i < lines.length; i++) {
outlined(c, lines[i], l + 14f * u, t + (38f + i * 40f) * u, 21f * u, SYSTEM, sans, Paint.Align.LEFT, u);
}
}
// ---------- Action bar and experience ----------
private void drawActionBar(Canvas c, float w, float h, float u) {
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float s = 64f * u, gap = 8f * u, total = gears.length * s + (gears.length - 1) * gap;
float x = (w - total) / 2f, top = h - 104f * u;
panel(c, x - 12f * u, top - 10f * u, x + total + 12f * u, top + s + 10f * u, u);
for (int i = 0; i < gears.length; i++) {
boolean on = gears[i].equals(now);
r.set(x, top, x + s, top + s);
fill.setColor(on ? 0xFF3A2A12 : 0xFF141414);
c.drawRect(r, fill);
line.setColor(on ? YELLOW : GOLD_DARK);
line.setStrokeWidth((on ? 4f : 2f) * u);
c.drawRect(r, line);
outlined(c, gears[i], x + s / 2f, top + s * 0.72f, 36f * u, on ? YELLOW : 0xFF9A9A9A, serif,
Paint.Align.CENTER, u);
outlined(c, String.valueOf(i + 1), x + s - 6f * u, top + 16f * u, 13f * u, 0xFFD0D0D0, sans,
Paint.Align.RIGHT, u);
x += s + gap;
}
}
/** The odometer as experience: a level per 1000 km, the bar the way through this one. */
private void drawXp(Canvas c, float w, float h, float u) {
float l = 22f * u, rr = w - 22f * u, t = h - 26f * u, b = h - 10f * u;
r.set(l, t, rr, b);
fill.setColor(0xFF0A0A0A);
c.drawRect(r, fill);
String label = "—";
if (v.odometerKm != null) {
float into = v.odometerKm % 1000f;
r.set(l, t, l + (rr - l) * into / 1000f, b);
fill.setColor(XP);
c.drawRect(r, fill);
label = String.format(Locale.US, "Level %d · %.0f / 1000 km", (int) (v.odometerKm / 1000f) + 1, into);
}
line.setColor(GOLD_DARK);
line.setStrokeWidth(1.5f * u);
for (int i = 1; i < 20; i++) {
float x = l + (rr - l) * i / 20f;
c.drawLine(x, t, x, b, line);
}
c.drawRect(l, t, rr, b, line);
outlined(c, label, w / 2f, b - 3f * u, 13f * u, 0xFFFFFFFF, sans, Paint.Align.CENTER, u);
}
}
@@ -0,0 +1,77 @@
package com.evsuite.abrp;
/**
* Fourteen-segment letters, as on the time circuits the Time circuits style is drawn after: the
* months and the gear words. Pure Java so it can be tested off the car.
*
* Segments as bits: the outer seven like {@link SevenSegment} (a top, b upper right, c lower
* right, d bottom, e lower left, f upper left), the middle bar in two halves (g1 left, g2
* right), and the inner six: h from the upper left corner to the centre, i up the middle, j to
* the upper right corner, k to the lower left, l down the middle, m to the lower right.
*/
final class Segments14 {
static final int A = 1, B = 2, C = 4, D = 8, E = 16, F = 32, G1 = 64, G2 = 128,
H = 256, I = 512, J = 1024, K = 2048, L = 4096, M = 8192;
private Segments14() {}
/** The segments a letter, digit or '-' lights; anything else (a space) lights none. */
static int mask(char ch) {
switch (Character.toUpperCase(ch)) {
case 'A': return A | B | C | E | F | G1 | G2;
case 'B': return A | B | C | D | I | L | G2;
case 'C': return A | D | E | F;
case 'D': return A | B | C | D | I | L;
case 'E': return A | D | E | F | G1 | G2;
case 'F': return A | E | F | G1;
case 'G': return A | C | D | E | F | G2;
case 'H': return B | C | E | F | G1 | G2;
case 'I': return A | D | I | L;
case 'J': return B | C | D | E;
case 'K': return E | F | G1 | J | M;
case 'L': return D | E | F;
case 'M': return B | C | E | F | H | J;
case 'N': return B | C | E | F | H | M;
case 'O': return A | B | C | D | E | F;
case 'P': return A | B | E | F | G1 | G2;
case 'R': return A | B | E | F | G1 | G2 | M;
case 'S': return A | C | D | F | G1 | G2;
case 'T': return A | I | L;
case 'U': return B | C | D | E | F;
case 'V': return E | F | J | K;
case 'W': return B | C | E | F | K | M;
case 'Y': return H | J | L;
case '0': return A | B | C | D | E | F | J | K;
case '1': return B | C;
case '2': return A | B | D | E | G1 | G2;
case '3': return A | B | C | D | G2;
case '4': return B | C | F | G1 | G2;
case '5': return A | C | D | F | G1 | G2;
case '6': return A | C | D | E | F | G1 | G2;
case '7': return A | B | C;
case '8': return A | B | C | D | E | F | G1 | G2;
case '9': return A | B | C | D | F | G1 | G2;
case '-': return G1 | G2;
default: return 0;
}
}
/** The three-letter month as the time circuits show it: JAN … DEC; "---" out of range. */
static String month(int month0) {
String[] m = { "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC" };
return month0 >= 0 && month0 < 12 ? m[month0] : "---";
}
/** The gear as a three-letter word for the time circuits: PRK, REV, NTL, DRV; "---" unread. */
static String gearWord(Integer gear) {
if (gear == null) return "---";
switch (gear) {
case 1: return "PRK";
case 2: return "REV";
case 3: return "NTL";
case 4: return "DRV";
default: return "---";
}
}
}
@@ -18,6 +18,11 @@ final class StylePalette {
case SCIFI: return R.style.ThemeOverlay_EV_Style_SciFi;
case CARTOON: return R.style.ThemeOverlay_EV_Style_Cartoon;
case NIXIE: return R.style.ThemeOverlay_EV_Style_Nixie;
case GRID: return R.style.ThemeOverlay_EV_Style_Grid;
case TIME_CIRCUITS: return R.style.ThemeOverlay_EV_Style_TimeCircuits;
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
default: return 0;
}
}
@@ -0,0 +1,404 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Calendar;
import java.util.Locale;
/**
* The Time circuits Overview style, after the time machine in Back to the Future: three rows of
* LED read-outs in a metal panel, labelled with embossed tape, a red speed display and a flux
* capacitor.
*
* The rows keep the film's look, each in its colour: red "DESTINATION" — the gear as a word
* (DRV, REV, NTL, PRK), the charge, the range (where you can still get to), power or
* regeneration on the lamps, and the usage over 5 minutes; green "PRESENT TIME" — today's date
* and time; amber "LAST TIME DEPARTED" — when the car last left P, dashes until it has been seen
* to. Right, the speed in large red digits and the flux capacitor, whose light runs to its
* centre faster with more power and outwards while regenerating; at 88 km/h it flashes. Along
* the bottom, trip, usage since start, climate and odometer. Unread values show dashes and light
* nothing. It animates (about 30 frames a second) only while there is power to show or at 88,
* and only on screen.
*/
final class TimeCircuitsView extends ClusterCanvasView {
private static final int RED = 0xFFFF3A26, GREEN = 0xFF3DFF5A, AMBER = 0xFFFFA726, WHITE_HOT = 0xFFFFF4D6;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint metal = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF r = new RectF();
private final Typeface tape = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private int metalFor = -1;
/** When the car last left P, wall clock; 0 until that has been seen. */
private long departedAt;
private Integer lastGear;
private float fluxPhase;
private long lastFrame;
TimeCircuitsView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
@Override
void setValues(ClusterValues values) {
// Readings arrive whether or not the style is shown, so the departure is never missed.
Integer gear = values == null ? null : values.gear;
if (gear != null && lastGear != null && lastGear == 1 && gear != 1) {
departedAt = System.currentTimeMillis();
}
if (gear != null) lastGear = gear;
super.setValues(values);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF050505);
float split = w * 0.64f;
drawPanel(c, 12f * u, 10f * u, split - 6f * u, h - 10f * u, u);
float rowLeft = 34f * u, rowRight = split - 24f * u;
drawCarRow(c, rowLeft, rowRight, 22f * u, u);
drawTimeRow(c, rowLeft, rowRight, 170f * u, u, GREEN, "PRESENT TIME", System.currentTimeMillis());
drawTimeRow(c, rowLeft, rowRight, 318f * u, u, AMBER, "LAST TIME DEPARTED", departedAt);
drawBottom(c, rowLeft, rowRight, 470f * u, u);
drawPanel(c, split + 6f * u, 10f * u, w - 12f * u, h - 10f * u, u);
drawSpeed(c, split + 28f * u, w - 28f * u, 26f * u, u);
drawFlux(c, split + 28f * u, w - 28f * u, 262f * u, h - 26f * u, u);
boolean moving = (v.powerKw != null && Math.abs(v.powerKw) > 0.5f) || at88();
keepAnimating(moving, 33);
}
private boolean at88() {
return v.speedKmh != null && v.speedKmh >= 88f;
}
private void advance() {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.powerKw != null) fluxPhase = (fluxPhase + v.powerKw * 0.025f * dt + 1000f) % 1f;
}
// ---------- Panels and tape ----------
private void drawPanel(Canvas c, float l, float t, float rr, float b, float u) {
if (metalFor != (int) (b - t)) {
metal.setShader(new LinearGradient(0, t, 0, b, 0xFF2C2D31, 0xFF18191B, Shader.TileMode.CLAMP));
metalFor = (int) (b - t);
}
r.set(l, t, rr, b);
c.drawRoundRect(r, 10f * u, 10f * u, metal);
line.setColor(0xFF45464B);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 10f * u, 10f * u, line);
fill.setColor(0xFF5A5B60);
for (float x : new float[] { l + 14f * u, rr - 14f * u }) {
for (float y : new float[] { t + 14f * u, b - 14f * u }) c.drawCircle(x, y, 4f * u, fill);
}
}
/** Embossed label tape: black, raised white capitals, centred on x. */
private void tape(Canvas c, String s, float x, float y, float u, int tint) {
text.setTypeface(tape);
text.setTextSize(18f * u);
text.setLetterSpacing(0.18f);
text.setTextAlign(Paint.Align.CENTER);
float tw = text.measureText(s);
r.set(x - tw / 2f - 14f * u, y - 18f * u, x + tw / 2f + 14f * u, y + 7f * u);
fill.setColor(tint);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
fill.setColor(0x22FFFFFF);
c.drawRect(r.left + 4f * u, r.top + 3f * u, r.right - 4f * u, r.top + 5f * u, fill);
text.setColor(0xFFF2F2F2);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
/** A small caption above a field. */
private void caption(Canvas c, String s, float x, float y, float u) {
text.setTypeface(tape);
text.setTextSize(13f * u);
text.setLetterSpacing(0.12f);
text.setTextAlign(Paint.Align.CENTER);
text.setColor(0xFFBFC1C6);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
// ---------- Rows ----------
/** The cells of one field: 'a' alphanumeric (fourteen segments), 'n' digits, 'l' lamps, ':' colon. */
private static final class Field {
final char kind;
final int cells;
final String caption;
Field(char kind, int cells, String caption) {
this.kind = kind;
this.cells = cells;
this.caption = caption;
}
}
private static final Field[] CAR = {
new Field('a', 3, "GEAR"), new Field('n', 3, "CHARGE %"), new Field('n', 4, "RANGE KM"),
new Field('l', 0, ""), new Field('n', 3, "USAGE"),
};
private static final Field[] TIME = {
new Field('a', 3, "MONTH"), new Field('n', 2, "DAY"), new Field('n', 4, "YEAR"),
new Field('l', 0, ""), new Field('n', 2, "HOUR"), new Field(':', 0, ""), new Field('n', 2, "MIN"),
};
private void drawCarRow(Canvas c, float left, float right, float top, float u) {
String charge = v.socPercent == null ? "---" : String.format(Locale.US, "%3.0f", v.socPercent).replace(' ', '0');
String range = v.rangeKm == null ? "----" : String.format(Locale.US, "%04.0f", Math.min(9999f, v.rangeKm));
String usage = v.usage5Min == null ? "---" : String.format(Locale.US, "%.1f", Math.min(99.9, Math.abs(v.usage5Min)));
boolean regen = v.powerKw != null && v.powerKw < -0.5f, power = v.powerKw != null && v.powerKw > 0.5f;
drawRow(c, left, right, top, u, RED, "DESTINATION", CAR,
new String[] { Segments14.gearWord(v.gear), charge, range, null, usage },
new String[] { "POWER", "REGEN" }, new boolean[] { power, regen });
}
private void drawTimeRow(Canvas c, float left, float right, float top, float u, int colour, String title, long when) {
String[] values;
boolean[] lamps = { false, false };
if (when <= 0) {
values = new String[] { "---", "--", "----", null, "--", null, "--" };
} else {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(when);
int hour = cal.get(Calendar.HOUR_OF_DAY);
values = new String[] { Segments14.month(cal.get(Calendar.MONTH)),
String.format(Locale.US, "%02d", cal.get(Calendar.DAY_OF_MONTH)),
String.format(Locale.US, "%04d", cal.get(Calendar.YEAR)), null,
String.format(Locale.US, "%02d", hour), null,
String.format(Locale.US, "%02d", cal.get(Calendar.MINUTE)) };
lamps = new boolean[] { hour < 12, hour >= 12 };
}
drawRow(c, left, right, top, u, colour, title, TIME, values, new String[] { "AM", "PM" }, lamps);
}
private void drawRow(Canvas c, float left, float right, float top, float u, int colour, String title,
Field[] fields, String[] values, String[] lampNames, boolean[] lampsOn) {
float boxTop = top + 22f * u, boxH = 78f * u, gap = 14f * u, pad = 8f * u;
float lampW = 74f * u, colonW = 16f * u;
int cells = 0;
float fixed = 0;
for (Field f : fields) {
cells += f.cells;
if (f.kind == 'l') fixed += lampW;
else if (f.kind == ':') fixed += colonW;
else fixed += 2 * pad;
}
fixed += gap * (fields.length - 1);
float cw = Math.min(boxH * 0.58f, (right - left - fixed) / cells);
float total = cells * cw + fixed, x = left + (right - left - total) / 2f;
for (int i = 0; i < fields.length; i++) {
Field f = fields[i];
if (f.kind == 'l') {
drawLamps(c, x, boxTop, lampW, boxH, u, colour, lampNames, lampsOn);
x += lampW + gap;
continue;
}
if (f.kind == ':') {
fill.setColor(colour);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.36f, 3.5f * u, fill);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.64f, 3.5f * u, fill);
x += colonW + gap;
continue;
}
float bw = f.cells * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, f.caption, x + bw / 2f, top + 14f * u, u);
drawText(c, values[i], f.kind == 'a', x + pad, boxTop + 8f * u, cw, boxH - 16f * u, f.cells, colour, u);
x += bw + gap;
}
tape(c, title, (left + right) / 2f, boxTop + boxH + 34f * u, u, 0xFF0E0E0F);
}
private void drawLamps(Canvas c, float x, float top, float w, float h, float u, int colour,
String[] names, boolean[] on) {
for (int k = 0; k < 2; k++) {
float cy = top + h * (k == 0 ? 0.3f : 0.72f), cx = x + w - 16f * u;
caption(c, names[k], x + 22f * u, cy + 5f * u, u);
fill.setColor(on[k] ? AccentColor.withAlpha(colour, 0x55) : 0x00000000);
c.drawCircle(cx, cy, 11f * u, fill);
fill.setColor(on[k] ? colour : AccentColor.withAlpha(colour, 0x30));
c.drawCircle(cx, cy, 6f * u, fill);
}
}
// ---------- Segments ----------
/** Right-aligned text in cells; a '.' joins the cell before it. */
private void drawText(Canvas c, String s, boolean alpha, float x, float y, float cw, float ch,
int cells, int colour, float u) {
char[] chars = SevenSegment.cells(s == null ? "" : s, cells);
boolean[] dots = SevenSegment.dots(s == null ? "" : s, cells);
for (int i = 0; i < cells; i++) {
int mask = alpha ? Segments14.mask(chars[i]) : from7(SevenSegment.mask(chars[i]));
drawCell(c, x + i * cw, y, cw, ch, mask, alpha, colour, u);
if (dots[i]) {
fill.setColor(colour);
c.drawCircle(x + (i + 1) * cw - cw * 0.06f, y + ch - cw * 0.1f, cw * 0.07f, fill);
}
}
}
/** Seven-segment bits as fourteen-segment ones (the middle bar is both halves). */
private static int from7(int m) {
int out = m & 63; // a–f share their bits
if ((m & SevenSegment.G) != 0) out |= Segments14.G1 | Segments14.G2;
return out;
}
private void drawCell(Canvas c, float x, float y, float cw, float ch, int mask, boolean alpha, int colour, float u) {
float m = cw * 0.16f, t = cw * 0.12f;
float x0 = x + m, x1 = x + cw - m, xc = (x0 + x1) / 2f, y0 = y + t, y1 = y + ch - t, ym = (y0 + y1) / 2f;
float[][] segs = {
{ x0, y0, x1, y0, Segments14.A }, { x1, y0, x1, ym, Segments14.B }, { x1, ym, x1, y1, Segments14.C },
{ x0, y1, x1, y1, Segments14.D }, { x0, ym, x0, y1, Segments14.E }, { x0, y0, x0, ym, Segments14.F },
{ x0, ym, xc, ym, Segments14.G1 }, { xc, ym, x1, ym, Segments14.G2 },
{ x0, y0, xc, ym, Segments14.H }, { xc, y0, xc, ym, Segments14.I }, { x1, y0, xc, ym, Segments14.J },
{ x0, y1, xc, ym, Segments14.K }, { xc, ym, xc, y1, Segments14.L }, { x1, y1, xc, ym, Segments14.M },
};
for (float[] s : segs) {
int bit = (int) s[4];
if (!alpha && bit >= Segments14.H) continue; // digits have no inner segments
boolean on = (mask & bit) != 0;
float dx = s[2] - s[0], dy = s[3] - s[1], len = (float) Math.hypot(dx, dy);
float g = Math.min(t * 0.8f, len * 0.3f), ux = dx / len * g, uy = dy / len * g;
float ax = s[0] + ux, ay = s[1] + uy, bx = s[2] - ux, by = s[3] - uy;
if (on) {
line.setColor(AccentColor.withAlpha(colour, 0x38));
line.setStrokeWidth(t * 2.4f);
c.drawLine(ax, ay, bx, by, line);
}
line.setColor(on ? colour : AccentColor.withAlpha(colour, 0x1C));
line.setStrokeWidth(bit >= Segments14.H ? t * 0.8f : t);
c.drawLine(ax, ay, bx, by, line);
}
}
// ---------- Bottom read-outs ----------
private void drawBottom(Canvas c, float left, float right, float top, float u) {
String[][] cells = {
{ "TRIP KM", fmt(v.tripKm, "%.1f", "---"), "5" },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "---"), "4" },
{ "CLIMATE °C", fmt(v.climateCelsius, "%.0f", "--"), "3" },
{ "ODOMETER KM", fmt(v.odometerKm, "%.0f", "------"), "6" },
};
float gap = 18f * u, boxH = 62f * u, boxTop = top + 22f * u, pad = 8f * u;
int total = 0;
for (String[] cell : cells) total += Integer.parseInt(cell[2]);
float cw = Math.min(boxH * 0.55f, (right - left - gap * (cells.length - 1) - cells.length * 2 * pad) / total);
float width = total * cw + gap * (cells.length - 1) + cells.length * 2 * pad;
float x = left + (right - left - width) / 2f;
for (String[] cell : cells) {
int n = Integer.parseInt(cell[2]);
float bw = n * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, cell[0], x + bw / 2f, top + 14f * u, u);
drawText(c, cell[1], false, x + pad, boxTop + 7f * u, cw, boxH - 14f * u, n, AMBER, u);
x += bw + gap;
}
}
// ---------- Speed and flux capacitor ----------
private void drawSpeed(Canvas c, float left, float right, float top, float u) {
float boxH = 168f * u, cells = 3;
float cw = Math.min(boxH * 0.56f, (right - left - 40f * u) / cells);
float bw = cells * cw + 40f * u, x = (left + right - bw) / 2f;
r.set(x, top, x + bw, top + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2.5f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
String speed = v.speedKmh == null ? "---" : String.format(Locale.US, "%.0f", Math.min(999f, v.speedKmh));
// At 88 the digits burn white-hot, and flicker.
boolean hot = at88() && (SystemClock.uptimeMillis() / 90) % 3 != 0;
drawText(c, speed, false, x + 20f * u, top + 14f * u, cw, boxH - 28f * u, 3, hot ? WHITE_HOT : RED, u);
tape(c, at88() ? "88 KM/H — HOLD ON" : "KM/H", (left + right) / 2f, top + boxH + 34f * u, u, 0xFF0E0E0F);
}
/** Three glass tubes in a Y, light running to the centre with the power (outwards in regen). */
private void drawFlux(Canvas c, float left, float right, float top, float bottom, float u) {
float labelSpace = 70f * u, boxBottom = bottom - labelSpace;
float size = Math.min(right - left, boxBottom - top);
float cx = (left + right) / 2f, bx = cx - size / 2f;
r.set(bx, top, bx + size, boxBottom);
fill.setColor(0xFF0B0C0E);
c.drawRoundRect(r, 8f * u, 8f * u, fill);
line.setColor(0xFF6A6B70);
line.setStrokeWidth(3f * u);
c.drawRoundRect(r, 8f * u, 8f * u, line);
float cy = top + (boxBottom - top) * 0.52f, inset = size * 0.14f;
float[][] ends = { { bx + inset, top + inset }, { bx + size - inset, top + inset }, { cx, boxBottom - inset } };
boolean flash = at88();
boolean live = v.powerKw != null;
float strength = live ? Math.min(1f, Math.abs(v.powerKw) / 60f) : 0f;
for (float[] e : ends) {
line.setColor(0xFF2A2C30);
line.setStrokeWidth(16f * u);
c.drawLine(e[0], e[1], cx, cy, line);
line.setColor(flash ? WHITE_HOT : AccentColor.withAlpha(WHITE_HOT, live ? 0x40 + (int) (0x60 * strength) : 0x18));
line.setStrokeWidth(5f * u);
c.drawLine(e[0], e[1], cx, cy, line);
if (live && Math.abs(v.powerKw) > 0.5f) {
// Pulses along the tube: phase 0 at the end, 1 at the centre.
for (int k = 0; k < 4; k++) {
float p = (fluxPhase + k / 4f) % 1f;
float px = e[0] + (cx - e[0]) * p, py = e[1] + (cy - e[1]) * p;
fill.setColor(AccentColor.withAlpha(WHITE_HOT, 0x50));
c.drawCircle(px, py, 12f * u, fill);
fill.setColor(WHITE_HOT);
c.drawCircle(px, py, 5f * u, fill);
}
}
fill.setColor(0xFF6A6B70);
c.drawCircle(e[0], e[1], 12f * u, fill);
}
fill.setColor(AccentColor.withAlpha(WHITE_HOT, flash ? 0xA0 : live ? 0x30 + (int) (0x70 * strength) : 0x14));
c.drawCircle(cx, cy, 34f * u, fill);
fill.setColor(flash || (live && strength > 0.05f) ? WHITE_HOT : 0xFF3A3B40);
c.drawCircle(cx, cy, 13f * u, fill);
tape(c, "FLUX CAPACITOR", cx, boxBottom + 30f * u, u, 0xFF0E0E0F);
String power = !live ? "— KILOWATTS"
: v.powerKw < -0.5f ? String.format(Locale.US, "REGEN %.1f KILOWATTS", -v.powerKw)
: String.format(Locale.US, "%.1f KILOWATTS", v.powerKw);
tape(c, power, cx, boxBottom + 62f * u, u, 0xFF5A1410);
}
}
@@ -0,0 +1,322 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Visor Overview style, after the helmet display of a science-fiction shooter like Halo:
* thin light-blue lines on a dark visor, slanted panels and a reticle in the middle.
*
* Top centre, the shield bar: the battery, segmented, red and blinking below 20 % with
* "BATTERY LOW". Top right, the ammunition counter: the range as a number and as rounds, one
* per 10 km. Under it, four grenade slots for P R N D, the current one lit. Middle, the
* reticle with the speed inside it and power round it — clockwise while drawing, green and
* anticlockwise while regenerating. Bottom left, the motion tracker: its sweep turns while the
* car moves, and its blips are the last two minutes of power (red drawing, green regenerating), further out the more
* power (the rim is 60 kW, 40 kW regenerating).
* Top left the clock, bottom right the objective with trip, usage, climate and odometer.
* Unread values show "—" and light nothing.
*
* Two palettes: the original visor blue, or the chosen accent colour ({@link #setAccentPalette}).
* The tracker animates (about 30 frames a second) only while the car moves and the view is on
* screen; the low-battery blink rides on the readings that redraw it.
*/
final class VisorView extends ClusterCanvasView {
/** Shield segments, rounds in the counter (10 km each). */
private static final int SHIELD = 24, ROUNDS = 50;
private static final int RED = 0xFFFF3B30, REGEN = 0xFF4CFF7A, ENEMY = 0xFFFF4A3D;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint vignette = new Paint();
private final Path path = new Path();
private final RectF r = new RectF();
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
private final int accent, accentLight;
private int main, light, dim;
private float sweep;
private long lastFrame;
private int vignetteFor = -1;
VisorView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.MITER);
setAccentPalette(false);
}
/** The chosen accent colour instead of the original visor blue. */
void setAccentPalette(boolean useAccent) {
main = useAccent ? accent : 0xFF5CC8FF;
light = useAccent ? accentLight : 0xFFD2F0FF;
dim = AccentColor.withAlpha(main, 0x40);
vignetteFor = -1;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
boolean moving = v.speedKmh != null && v.speedKmh > 0.5f;
advance(moving);
drawVisor(c, w, h, u);
drawClock(c, u);
drawShield(c, w, u);
drawAmmo(c, w, u);
drawReticle(c, w, h, u);
drawTracker(c, h, u);
drawObjective(c, w, h, u);
keepAnimating(moving, 33);
}
private void advance(boolean moving) {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (moving) sweep = (sweep + dt * 180f) % 360f;
}
// ---------- Visor ----------
/** Dark glass, the helmet's rim darkening the corners, and its edge lines at the top. */
private void drawVisor(Canvas c, float w, float h, float u) {
c.drawColor(0xFF03080D);
if (vignetteFor != (int) (w + h)) {
vignette.setShader(new RadialGradient(w / 2f, h * 0.48f, Math.max(w, h) * 0.62f,
new int[] { AccentColor.withAlpha(main, 0x16), 0x00000000, 0xE0000000 },
new float[] { 0f, 0.62f, 1f }, Shader.TileMode.CLAMP));
vignetteFor = (int) (w + h);
}
c.drawRect(0, 0, w, h, vignette);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x50));
path.reset();
path.moveTo(0, 150f * u);
path.quadTo(w * 0.08f, 16f * u, w * 0.3f, 8f * u);
path.moveTo(w, 150f * u);
path.quadTo(w * 0.92f, 16f * u, w * 0.7f, 8f * u);
c.drawPath(path, line);
}
/** A slanted panel, as the display draws its frames. */
private void slant(Canvas c, float l, float t, float rr, float b, float lean, int stroke, int bg, float u) {
path.reset();
path.moveTo(l + lean, t);
path.lineTo(rr, t);
path.lineTo(rr - lean, b);
path.lineTo(l, b);
path.close();
fill.setColor(bg);
c.drawPath(path, fill);
line.setColor(stroke);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
}
private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) {
text.setTypeface(tf);
text.setTextSize(size);
text.setTextAlign(a);
text.setColor(colour);
c.drawText(s, x, y, text);
}
// ---------- Clock ----------
private void drawClock(Canvas c, float u) {
float l = 28f * u, t = 26f * u;
slant(c, l, t, l + 150f * u, t + 44f * u, 12f * u, main, AccentColor.withAlpha(main, 0x18), u);
label(c, v.clock.isEmpty() ? "—" : v.clock, l + 75f * u, t + 33f * u, 30f * u, light, face, Paint.Align.CENTER);
label(c, "LOCAL TIME", l + 12f * u, t + 64f * u, 14f * u, main, face, Paint.Align.LEFT);
}
// ---------- Shield ----------
private void drawShield(Canvas c, float w, float u) {
boolean low = v.socPercent != null && v.socPercent < 20f;
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
int colour = low ? RED : main;
float bw = Math.min(520f * u, w * 0.36f), l = (w - bw) / 2f, t = 28f * u, b = t + 30f * u;
slant(c, l - 14f * u, t - 8f * u, l + bw + 14f * u, b + 8f * u, 14f * u, AccentColor.withAlpha(colour, 0x90),
0x66000000, u);
int lit = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 100f * SHIELD);
float gap = 3f * u, seg = (bw - (SHIELD - 1) * gap) / SHIELD;
for (int i = 0; i < SHIELD; i++) {
float x = l + i * (seg + gap);
path.reset();
path.moveTo(x + 6f * u, t);
path.lineTo(x + seg + 6f * u, t);
path.lineTo(x + seg, b);
path.lineTo(x, b);
path.close();
boolean on = i < lit && (!low || blinkOn);
fill.setColor(on ? colour : AccentColor.withAlpha(colour, 0x22));
c.drawPath(path, fill);
}
label(c, fmt(v.socPercent, "%.0f %%", "—"), w / 2f, b + 36f * u, 26f * u, low ? RED : light, face,
Paint.Align.CENTER);
if (low && blinkOn) {
label(c, "BATTERY LOW", w / 2f, b + 66f * u, 22f * u, RED, face, Paint.Align.CENTER);
}
}
// ---------- Ammunition and grenades ----------
private void drawAmmo(Canvas c, float w, float u) {
float rr = w - 30f * u, t = 26f * u;
// The count: the range.
label(c, fmt(v.rangeKm, "%.0f", "—"), rr, t + 52f * u, 60f * u, light, face, Paint.Align.RIGHT);
label(c, "KM RANGE", rr, t + 74f * u, 15f * u, main, face, Paint.Align.RIGHT);
// The rounds: two rows of 25, one per 10 km.
int lit = v.rangeKm == null ? 0 : Math.round(Math.max(0f, v.rangeKm) / 10f);
float rw = 5f * u, rh = 20f * u, gap = 3f * u, perRow = ROUNDS / 2f;
float rowW = perRow * rw + (perRow - 1) * gap, l = rr - 150f * u - rowW;
for (int i = 0; i < ROUNDS; i++) {
int row = i / (ROUNDS / 2), col = i % (ROUNDS / 2);
float x = l + col * (rw + gap), y = t + 8f * u + row * (rh + 8f * u);
r.set(x, y, x + rw, y + rh);
fill.setColor(i < lit ? light : AccentColor.withAlpha(main, 0x26));
c.drawRect(r, fill);
}
// The grenade slots: P R N D.
String[] gears = { "P", "R", "N", "D" };
String now = Gear.label(v.gear);
float gs = 34f * u, gx = l, gy = t + 66f * u;
for (int i = 0; i < gears.length; i++) {
boolean on = gears[i].equals(now);
float x = gx + i * (gs + 6f * u);
path.reset();
path.moveTo(x + gs / 2f, gy);
path.lineTo(x + gs, gy + gs * 0.5f);
path.lineTo(x + gs / 2f, gy + gs);
path.lineTo(x, gy + gs * 0.5f);
path.close();
fill.setColor(on ? main : 0x55000000);
c.drawPath(path, fill);
line.setColor(on ? light : dim);
line.setStrokeWidth(1.5f * u);
c.drawPath(path, line);
label(c, gears[i], x + gs / 2f, gy + gs * 0.5f + 7f * u, 20f * u, on ? 0xFF03080D : main, face,
Paint.Align.CENTER);
}
}
// ---------- Reticle ----------
private void drawReticle(Canvas c, float w, float h, float u) {
float cx = w / 2f, cy = h * 0.53f, rad = 122f * u;
line.setStrokeWidth(2f * u);
line.setColor(AccentColor.withAlpha(main, 0x70));
c.drawCircle(cx, cy, rad, line);
// Four ticks.
line.setColor(main);
line.setStrokeWidth(3f * u);
c.drawLine(cx, cy - rad - 16f * u, cx, cy - rad + 10f * u, line);
c.drawLine(cx, cy + rad - 10f * u, cx, cy + rad + 16f * u, line);
c.drawLine(cx - rad - 16f * u, cy, cx - rad + 10f * u, cy, line);
c.drawLine(cx + rad - 10f * u, cy, cx + rad + 16f * u, cy, line);
// Power round it: 150 kW is a full turn, regeneration 50 kW the other way.
r.set(cx - rad - 10f * u, cy - rad - 10f * u, cx + rad + 10f * u, cy + rad + 10f * u);
line.setStrokeWidth(8f * u);
line.setStrokeCap(Paint.Cap.BUTT);
line.setColor(AccentColor.withAlpha(main, 0x1C));
c.drawArc(r, 0, 360, false, line);
if (v.powerKw != null && Math.abs(v.powerKw) > 0.1f) {
boolean regen = v.powerKw < 0;
float sweepDeg = regen ? Math.max(-360f, v.powerKw / 50f * 360f) : Math.min(360f, v.powerKw / 150f * 360f);
line.setColor(regen ? REGEN : light);
c.drawArc(r, -90f, sweepDeg, false, line);
}
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy + 34f * u, 104f * u, light, face, Paint.Align.CENTER);
label(c, "KM/H", cx, cy + 66f * u, 18f * u, main, face, Paint.Align.CENTER);
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, v.powerKw < -0.05f ? "+%.1f kW" : "%.1f kW",
Math.abs(v.powerKw));
label(c, power, cx, cy + rad + 48f * u, 24f * u,
v.powerKw != null && v.powerKw < -0.05f ? REGEN : light, face, Paint.Align.CENTER);
}
// ---------- Motion tracker ----------
/** The tracker: rings, the sweep while moving, and the power trail as blips round the car. */
private void drawTracker(Canvas c, float h, float u) {
float rad = 112f * u, cx = 40f * u + rad, cy = h - 34f * u - rad;
fill.setColor(AccentColor.withAlpha(main, 0x14));
c.drawCircle(cx, cy, rad, fill);
line.setStrokeWidth(1.5f * u);
line.setColor(AccentColor.withAlpha(main, 0x90));
c.drawCircle(cx, cy, rad, line);
line.setColor(AccentColor.withAlpha(main, 0x3A));
c.drawCircle(cx, cy, rad * 0.66f, line);
c.drawCircle(cx, cy, rad * 0.33f, line);
c.drawLine(cx - rad, cy, cx + rad, cy, line);
c.drawLine(cx, cy - rad, cx, cy + rad, line);
if (v.speedKmh != null && v.speedKmh > 0.5f) {
float a = (float) Math.toRadians(sweep - 90f);
line.setColor(AccentColor.withAlpha(light, 0x90));
line.setStrokeWidth(2f * u);
c.drawLine(cx, cy, cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, line);
}
// Blips: the last two minutes, the newest nearest the top, each at the distance its power gives.
float[] trail = v.powerTrail;
if (trail != null && trail.length > 0) {
int step = Math.max(1, trail.length / 24);
for (int i = trail.length - 1, k = 0; i >= 0; i -= step, k++) {
float p = trail[i];
if (Float.isNaN(p) || Math.abs(p) < 1f) continue;
float dist = (0.18f + 0.78f * Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f))) * rad;
float ang = (float) Math.toRadians(-90f + k * 15f);
float bx = cx + (float) Math.cos(ang) * dist, by = cy + (float) Math.sin(ang) * dist;
int colour = p < 0 ? REGEN : ENEMY;
fill.setColor(AccentColor.withAlpha(colour, Math.max(0x40, 0xFF - k * 8)));
c.drawCircle(bx, by, 6f * u, fill);
}
}
// The car in the middle.
path.reset();
path.moveTo(cx, cy - 10f * u);
path.lineTo(cx + 7f * u, cy + 8f * u);
path.lineTo(cx - 7f * u, cy + 8f * u);
path.close();
fill.setColor(light);
c.drawPath(path, fill);
label(c, "60 kW", cx, cy - rad - 8f * u, 13f * u, main, thin, Paint.Align.CENTER);
}
// ---------- Objective ----------
private void drawObjective(Canvas c, float w, float h, float u) {
float rr = w - 30f * u, l = rr - 330f * u, t = h - 214f * u, b = h - 34f * u;
slant(c, l, t, rr, b, 18f * u, AccentColor.withAlpha(main, 0x80), 0x80000000, u);
label(c, "OBJECTIVE", l + 30f * u, t + 30f * u, 18f * u, main, face, Paint.Align.LEFT);
String[][] rows = {
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") },
};
for (int i = 0; i < rows.length; i++) {
float y = t + (62f + i * 27f) * u, x = l + 26f * u - i * 3f * u;
label(c, rows[i][0], x, y, 17f * u, main, thin, Paint.Align.LEFT);
label(c, rows[i][1], rr - 30f * u - i * 3f * u, y, 19f * u, light, face, Paint.Align.RIGHT);
}
}
}
+5
View File
@@ -135,6 +135,11 @@
<string name="overview_style_minimal">Minimal</string>
<string name="overview_style_terminal">Terminal</string>
<string name="accent_original">Original</string>
<string name="overview_style_grid">Grid</string>
<string name="overview_style_time_circuits">Time circuits</string>
<string name="overview_style_quest">Quest</string>
<string name="overview_style_visor">Visor</string>
<string name="overview_style_empire">Empire</string>
<string name="overview_style_nixie">Nixie tubes</string>
<string name="overview_style_chronograph">Chronograph</string>
<string name="cockpit_battery">BATTERY</string>
@@ -54,4 +54,54 @@
<item name="evAccentStroke">#80774A26</item>
<item name="evAccentTrack">#6A4224</item>
</style>
<!-- Grid: the neon cyan of its lines. -->
<style name="ThemeOverlay.EV.Style.Grid" parent="">
<item name="evAccent">#18E6FF</item>
<item name="evOnAccent">#00161A</item>
<item name="evAccentLight">#C8FCFF</item>
<item name="evAccentDeep">#06343C</item>
<item name="evAccentSurface">#0A1C20</item>
<item name="evAccentStroke">#80267A86</item>
<item name="evAccentTrack">#1E6672</item>
</style>
<!-- Time circuits: the red of the destination row. -->
<style name="ThemeOverlay.EV.Style.TimeCircuits" parent="">
<item name="evAccent">#FF3A26</item>
<item name="evOnAccent">#1F0603</item>
<item name="evAccentLight">#FF9A8C</item>
<item name="evAccentDeep">#3D0E08</item>
<item name="evAccentSurface">#2A110D</item>
<item name="evAccentStroke">#80773A30</item>
<item name="evAccentTrack">#6A2C24</item>
</style>
<!-- Quest: the gold of the frames. -->
<style name="ThemeOverlay.EV.Style.Quest" parent="">
<item name="evAccent">#C9A24A</item>
<item name="evOnAccent">#1A1408</item>
<item name="evAccentLight">#FFD873</item>
<item name="evAccentDeep">#3A2C10</item>
<item name="evAccentSurface">#241C0E</item>
<item name="evAccentStroke">#806E5628</item>
<item name="evAccentTrack">#6A5426</item>
</style>
<!-- Visor: the blue of the helmet display. -->
<style name="ThemeOverlay.EV.Style.Visor" parent="">
<item name="evAccent">#5CC8FF</item>
<item name="evOnAccent">#03121C</item>
<item name="evAccentLight">#D2F0FF</item>
<item name="evAccentDeep">#0C3048</item>
<item name="evAccentSurface">#0A1A26</item>
<item name="evAccentStroke">#80306A8A</item>
<item name="evAccentTrack">#22587A</item>
</style>
<!-- Empire: the player's blue. -->
<style name="ThemeOverlay.EV.Style.Empire" parent="">
<item name="evAccent">#3D6BFF</item>
<item name="evOnAccent">#FFFFFF</item>
<item name="evAccentLight">#A8BEFF</item>
<item name="evAccentDeep">#101E4A</item>
<item name="evAccentSurface">#121A33</item>
<item name="evAccentStroke">#80344A99</item>
<item name="evAccentTrack">#2A3F82</item>
</style>
</resources>
@@ -0,0 +1,40 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import org.junit.Test;
/** The Empire style's ages by odometer — pure JVM. */
public class EmpireAgeTest {
@Test
public void agesByOdometer() {
assertEquals("Dark Age", EmpireAge.name(0f));
assertEquals("Dark Age", EmpireAge.name(9_999f));
assertEquals("Feudal Age", EmpireAge.name(10_000f));
assertEquals("Castle Age", EmpireAge.name(45_000f));
assertEquals("Imperial Age", EmpireAge.name(60_000f));
assertEquals("Imperial Age", EmpireAge.name(250_000f));
}
@Test
public void theWayToTheNextAge() {
assertEquals("Castle Age", EmpireAge.next(12_000f));
assertEquals(18_000f, EmpireAge.toNext(12_000f), 0.01f);
assertEquals(0.1f, EmpireAge.progress(12_000f), 0.001f);
assertEquals(0.5f, EmpireAge.progress(5_000f), 0.001f);
assertNull(EmpireAge.next(70_000f));
assertNull(EmpireAge.toNext(70_000f));
assertEquals(1f, EmpireAge.progress(70_000f), 0f);
}
@Test
public void unreadIsNoAge() {
assertEquals("—", EmpireAge.name(null));
assertEquals(-1, EmpireAge.index(Float.NaN));
assertNull(EmpireAge.next(null));
assertNull(EmpireAge.toNext(null));
assertEquals(0f, EmpireAge.progress(null), 0f);
}
}
@@ -0,0 +1,39 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
import org.junit.Test;
/** The time circuits' fourteen-segment letters — pure JVM. */
public class Segments14Test {
@Test
public void everyMonthAndGearWordLightsEveryLetter() {
for (int m = 0; m < 12; m++) {
for (char ch : Segments14.month(m).toCharArray()) assertNotEquals(ch + "", 0, Segments14.mask(ch));
}
for (int g = 1; g <= 4; g++) {
for (char ch : Segments14.gearWord(g).toCharArray()) assertNotEquals(ch + "", 0, Segments14.mask(ch));
}
}
@Test
public void unreadIsDashes() {
assertEquals("---", Segments14.gearWord(null));
assertEquals("---", Segments14.gearWord(9));
assertEquals("---", Segments14.month(12));
assertEquals(Segments14.G1 | Segments14.G2, Segments14.mask('-'));
assertEquals(0, Segments14.mask(' '));
}
@Test
public void wordsAsTheCircuitsShowThem() {
assertEquals("OCT", Segments14.month(9));
assertEquals("DRV", Segments14.gearWord(4));
assertEquals("PRK", Segments14.gearWord(1));
// The letters that need the diagonals do use them.
assertNotEquals(0, Segments14.mask('N') & Segments14.H);
assertNotEquals(0, Segments14.mask('V') & Segments14.K);
}
}
+7 -1
View File
@@ -84,11 +84,16 @@ spec:
volumes:
- name: config
emptyDir: {}
# apiVersion, kind and volumeMode are the values Kubernetes fills in itself: written out, a
# repeated apply finds nothing to patch (no "configured" for an unchanged StatefulSet).
volumeClaimTemplates:
- metadata:
- apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
volumeMode: Filesystem
# No storageClassName: the cluster's default class is used.
resources:
requests:
@@ -132,3 +137,4 @@ spec:
app.kubernetes.io/name: grafana
ports:
- port: 8086
protocol: TCP
+2 -2
View File
@@ -29,8 +29,8 @@ spec:
type: RuntimeDefault
containers:
- name: server
# 2.6.1, pinned by digest so a re-pushed tag cannot change what runs.
image: git.stinnesbeck.com/nils/mg4-server@sha256:df1d40e423b7ee365e8379e2eaefb4c0db3a599e6429728a37d46827f83d82cd
# 2.6.2, pinned by digest so a re-pushed tag cannot change what runs.
image: git.stinnesbeck.com/nils/mg4-server@sha256:e4de1e75958d58ece8430a1913f9c1614d8ea1bf566f2e1ec5b449ef51844f77
ports:
- name: http
containerPort: 8080
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
# The car server: one static Go binary on distroless, running as non-root.
# Configuration comes from the environment at runtime; nothing secret is baked in.
FROM docker.io/library/golang:1.26.9-alpine AS build
FROM docker.io/library/golang:1.27.3-alpine AS build
WORKDIR /src
ENV CGO_ENABLED=0 GOFLAGS=-trimpath GOTOOLCHAIN=local
COPY go.mod go.sum ./
+5
View File
@@ -52,6 +52,11 @@ with download URLs pointing back at this server, and their attachments streamed
repository stays private and the forge token never reaches the car; redirects away from the
forge are not followed. The car verifies everything itself (`OTA.md`). 404 when unset.
Each check and each download is logged with the car's installation ID ("shared token" for the
shared ingest token): `update check` (how many releases, the newest pre-release), `update
download started` (file, size), then `update handed out` (bytes sent, time taken) or `update
download cut short`. The forge's address and token never appear in the log.
## Settings from a phone
The ABRP credentials are long to type on the car. With pairing on, the car (with its own token)
+1 -1
View File
@@ -2,7 +2,7 @@ module git.stinnesbeck.com/nils/mg4/server
go 1.26.0
toolchain go1.26.9
toolchain go1.27.3
require github.com/influxdata/influxdb-client-go/v2 v2.14.0
+30 -7
View File
@@ -113,8 +113,17 @@ func (a *API) releasesOff(w http.ResponseWriter) bool {
return true
}
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error {
// who names the car in the update log: its installation ID, or the shared ingest token.
func who(c caller) string {
if c.installationID == "" {
return "shared token"
}
return c.installationID
}
func (a *API) appReleases(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) {
slog.Info("update check while app updates are off", "installation", who(c))
return nil
}
resp, err := a.Releases.get(r.Context(), "/api/v1/repos/"+a.Releases.Repo+"/releases?limit=20")
@@ -123,7 +132,7 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("forge refused the release list", "status", resp.StatusCode)
slog.Warn("forge refused the release list", "installation", who(c), "status", resp.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
@@ -133,8 +142,13 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
}
base := a.publicURL(r) + "/v1/app/assets/"
out := make([]carRelease, 0, len(releases))
newest := "none"
for _, rel := range releases {
cr := carRelease{TagName: rel.TagName, Prerelease: rel.Prerelease, Draft: rel.Draft, Assets: []carAsset{}}
// The forge lists the newest first; the car takes pre-releases only.
if newest == "none" && rel.Prerelease && !rel.Draft {
newest = rel.TagName
}
for _, as := range rel.Assets {
cr.Assets = append(cr.Assets, carAsset{Name: as.Name, Size: as.Size,
BrowserDownloadURL: base + strconv.FormatInt(rel.ID, 10) + "/" + strconv.FormatInt(as.ID, 10) +
@@ -142,13 +156,14 @@ func (a *API) appReleases(w http.ResponseWriter, r *http.Request, _ caller, _ []
}
out = append(out, cr)
}
slog.Info("update check", "installation", who(c), "releases", len(out), "newest", newest)
writeJSON(w, http.StatusOK, out)
return nil
}
// appAsset streams one release attachment. The forge's own metadata names the file, so a car
// cannot ask for anything but an attachment of this repository's releases.
func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byte) error {
func (a *API) appAsset(w http.ResponseWriter, r *http.Request, c caller, _ []byte) error {
if a.releasesOff(w) {
return nil
}
@@ -173,7 +188,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
return nil
}
if meta.StatusCode != http.StatusOK || decodeErr != nil || asset.UUID == "" {
slog.Warn("forge refused the asset", "status", meta.StatusCode)
slog.Warn("forge refused the asset", "installation", who(c), "status", meta.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
@@ -187,7 +202,7 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
}
defer file.Body.Close()
if file.StatusCode != http.StatusOK {
slog.Warn("forge refused the download", "status", file.StatusCode)
slog.Warn("forge refused the download", "installation", who(c), "asset", asset.Name, "status", file.StatusCode)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "the forge did not answer"})
return nil
}
@@ -201,9 +216,17 @@ func (a *API) appAsset(w http.ResponseWriter, r *http.Request, _ caller, _ []byt
if file.ContentLength > 0 && file.ContentLength <= maxAssetBytes {
h.Set("Content-Length", strconv.FormatInt(file.ContentLength, 10))
}
slog.Info("update download started", "installation", who(c), "asset", asset.Name,
"bytes", file.ContentLength)
started := time.Now()
w.WriteHeader(http.StatusOK)
if _, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes)); err != nil {
slog.Warn("asset transfer cut short", "asset", asset.Name, "error", err)
sent, err := io.Copy(w, io.LimitReader(file.Body, maxAssetBytes))
took := time.Since(started).Round(time.Millisecond)
if err != nil {
slog.Warn("update download cut short", "installation", who(c), "asset", asset.Name,
"sent", sent, "took", took, "error", err)
return nil
}
slog.Info("update handed out", "installation", who(c), "asset", asset.Name, "sent", sent, "took", took)
return nil
}
+35
View File
@@ -1,7 +1,9 @@
package main
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
@@ -113,3 +115,36 @@ func TestForgeURLMustBeHTTPS(t *testing.T) {
t.Fatal("plain http forge accepted")
}
}
// The server's log says who checked for and fetched an update, and how it went — the car's
// installation (here the shared token), the newest pre-release, the file and the bytes — and
// never the forge's token or address.
func TestUpdatesAreLogged(t *testing.T) {
var logged bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logged, nil)))
defer slog.SetDefault(previous)
forge := fakeForge(t)
defer forge.Close()
h := releasesAPI(t, forge)
if rec := do(t, h, "GET", "/v1/app/releases", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
if rec := do(t, h, "GET", "/v1/app/assets/7/11/NilsMG4-unstable-2.16.0.apk", testToken, ""); rec.Code != 200 {
t.Fatalf("%d", rec.Code)
}
out := logged.String()
for _, want := range []string{
`msg="update check" installation="shared token" releases=1 newest=car-2.16.0`,
`msg="update download started" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk`,
`msg="update handed out" installation="shared token" asset=NilsMG4-unstable-2.16.0.apk sent=4`,
} {
if !strings.Contains(out, want) {
t.Errorf("log lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, testForgeToken) || strings.Contains(out, forge.URL) {
t.Fatal("the forge or its token leaked into the log")
}
}