Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee7cf85255 | ||
|
|
1686b791bc | ||
|
|
12752c41e2 | ||
|
|
293464e297 | ||
|
|
adc2c86303 | ||
|
|
1f49cdc410 | ||
|
|
9de9387460 | ||
|
|
f9154c051f |
@@ -65,10 +65,10 @@ drive shows the same.
|
|||||||
## Music-sources checks
|
## Music-sources checks
|
||||||
|
|
||||||
`fetch-logs` also brings `media-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check music sources",
|
`fetch-logs` also brings `media-probe-YYYYMMDD-HHMMSS.txt` (Logs tab, "Check music sources",
|
||||||
`MediaProbe`). Three parts: audio players (use, app, state), active media sessions, and every
|
`MediaProbe`). Three parts: audio players (use, app, state), active media sessions with state
|
||||||
media browser service with `connected` / `refused or failed` / `no answer`, then its state and
|
and song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size or ALBUM_ART_URI), and the media browser
|
||||||
song (TITLE, ARTIST, ALBUM, ALBUM_ART as a size). CarPlay's is
|
services by name (2.42.1 on; 2.42.0 also connected to them, which stopped the music). CarPlay's
|
||||||
`com.allgo.remoteui.mediabrowserservice/...RemoteUIMediaBrowserService`. Ask what was playing
|
session is `com.allgo.remoteui.mediabrowserservice`, Bluetooth's `com.android.bluetooth`. Ask what was playing
|
||||||
when the check ran.
|
when the check ran.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
|||||||
|
|
||||||
| Tab | Contents |
|
| 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, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), 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) |
|
| 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`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), 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`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`; the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; 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 |
|
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
| 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 |
|
| 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, Terminal, Grid (and Grid with map, sharing Grid's choice) 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 |
|
| 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 Grid with map, sharing Grid's choice) 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 change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players, media sessions and every media browser service, CarPlay's among them, with their song; read only, saved and sent like a recording), update log (every install step, with the platform's words), upload log |
|
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus change events of the driver-assistance block it subscribes to, `EVHardware.listenToProperties` with `AssistBlock`, and steering-wheel key broadcasts; lists what changed, rarest first; saved to the app, a USB stick and the server, through `LogOutbox`, which sends it again later when the car is offline; how to find where the car publishes something), music-sources check (`MediaProbe`: audio players and the active media sessions, CarPlay's and Bluetooth's among them, with their song, and the media browser services by name — never connected to: binding them stopped the music in 2.42.0; saved and sent like a recording), update log (every install step, with the platform's words), upload log |
|
||||||
|
|
||||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ APIs.
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
**Overview styles:** the Overview comes in eighteen looks, from an 80s fluorescent display to a
|
**Overview styles:** the Overview comes in twenty looks, from an 80s fluorescent display to a
|
||||||
starship console, a watch face, a neon grid (also over a map of the roads round the car), a time machine, a fantasy game, a helmet display and a strategy game. [THEMES.md](THEMES.md) shows each one, with and without the
|
starship console, a watch face, a neon grid (also over a map of the roads round the car), 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.
|
navigation, and what inspired it.
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,42 @@ 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
|
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`
|
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).
|
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||||
|
- 2026-10-31 — App 2.43.2: Halloween's teeth are triangles hanging from the grin's upper lip, each in the colour of the rib behind it (the right one was the darker orange on the lighter rib; owner: "the color on the right side is off"). Two screenshots redone. Tested: unit tests, lint, emulator with sample values (drawing in panel and full screen, regenerating in full screen) and with no data. Not tried on the car.
|
||||||
|
- 2026-10-31 — App 2.43.1 published (`car-2.43.1`).
|
||||||
|
- 2026-10-31 — App 2.43.1: Halloween's bats can be seen. They were near-black and small on the darkest part of the sky, and the owner did not spot them; now a moonlit purple-grey with a light edge, a third larger. Two screenshots redone. Tested: unit tests, lint, emulator with sample values (panel and full screen) and with no data (no bats). Not tried on the car.
|
||||||
|
- 2026-10-31 — App 2.43.0 published (`car-2.43.0`).
|
||||||
|
- 2026-10-31 — App 2.43.0: Overview styles **Día de Muertos** (`MuertosView`) and **Halloween**
|
||||||
|
(`HalloweenView`; owner: "let's design a 'dia de muertos' and a 'halloween' style"), each with
|
||||||
|
its own palette (the papel picado's pink, the pumpkin's orange). Día de Muertos, an ofrenda and
|
||||||
|
kept a celebration: papel picado = battery (one flag per 10 %), calavera with the speed on its
|
||||||
|
forehead, a ring of marigolds = power (turquoise and anticlockwise regenerating), candles =
|
||||||
|
range (one per 50 km), a path of petals = the last two minutes of power. Halloween: a
|
||||||
|
jack-o'-lantern with the speed in its grin, burning yellow or, regenerating, green; an arch of
|
||||||
|
embers = power; a potion flask = battery; a tombstone and bats = range; the hill = the last two
|
||||||
|
minutes of power. What a value lights is `Tally` (pure, unit-tested: an unread value lights
|
||||||
|
nothing). The static scene is drawn once per size into a bitmap (about 4.5 MB while the view is
|
||||||
|
attached), so a frame only draws the readings; flags, flames, bats, bubbles and the ghost move
|
||||||
|
only while the car moves. Tests and lint pass. Emulator with sample values, panel and full
|
||||||
|
screen: normal, 12 % with regeneration, 40 % (the flask's words over the potion's surface); real
|
||||||
|
build with no data: "—" and nothing lit. No crash of the app. Not tried on the car, so the frame
|
||||||
|
rate on the head unit is unmeasured.
|
||||||
|
- 2026-10-28 — App 2.42.1 published (`car-2.42.1`).
|
||||||
|
- 2026-10-28 — App 2.42.1: **the music-sources check no longer connects to other apps' media
|
||||||
|
services.** In the car, 2.42.0's check (14:26, Bluetooth music playing) made the head unit ask
|
||||||
|
whether to allow access to media (the owner agreed) and **stopped the Bluetooth music**: it
|
||||||
|
connected to every media browser service, and among them the AOSP car radio's
|
||||||
|
(`com.android.car.radio/.RadioService`, which answered "connecting", most likely taking the
|
||||||
|
audio) and the Local Media Player (`com.android.car.media.localmediaplayer`, whose permission
|
||||||
|
request is most likely the question; it may now read media files). My claim that the check only
|
||||||
|
read was wrong: binding another app's service starts it. Now the services are only listed.
|
||||||
|
Nothing lost: the active sessions alone carried everything — **Bluetooth**
|
||||||
|
(`com.android.bluetooth`: title, artist, album, duration, track, cover as a file path, playing)
|
||||||
|
and **CarPlay** (`com.allgo.remoteui.mediabrowserservice`: title, artist, duration, paused) —
|
||||||
|
readable with MEDIA_CONTENT_CONTROL, no connection needed. Services on the car: CarPlay's,
|
||||||
|
Bluetooth A2DP, Local Media Player, the AOSP car radio, SAIC's radio (DAB, "Brillux Radio"),
|
||||||
|
SAIC's USB 1/2 and online media, and SAIC's video browser. Tests and lint pass; not run on the
|
||||||
|
emulator (stopped for low memory; the change only removes code); not tried on the car.
|
||||||
|
- 2026-10-28 — App 2.42.0 published (`car-2.42.0`).
|
||||||
- 2026-10-28 — App 2.42.0: **music-sources check** on the Logs tab (`MediaProbe`,
|
- 2026-10-28 — App 2.42.0: **music-sources check** on the Logs tab (`MediaProbe`,
|
||||||
`MediaProbeText`; owner: "is it possible to show playing music in our dashboard as well?", then
|
`MediaProbeText`; owner: "is it possible to show playing music in our dashboard as well?", then
|
||||||
"yes start the probe"). Before a now-playing display, it finds where the head unit tells what is
|
"yes start the probe"). Before a now-playing display, it finds where the head unit tells what is
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Overview styles
|
# Overview styles
|
||||||
|
|
||||||
The Overview can look eighteen ways: pick one under **Settings → Overview style**. Every style shows
|
The Overview can look twenty ways: pick one under **Settings → Overview style**. Every style shows
|
||||||
the same readings (battery, range, power, speed, gear, usage, trip, climate, odometer, time) and
|
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.
|
shows "—" for anything the car did not answer, never a made-up zero.
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ 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 →
|
**Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings →
|
||||||
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original
|
Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) 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
|
colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de Muertos and Halloween always do; while one of those is shown, the whole app
|
||||||
(navigation, buttons, switches) takes on its colours too.
|
(navigation, buttons, switches) takes on its colours too.
|
||||||
|
|
||||||
| Style | Look | Colours |
|
| Style | Look | Colours |
|
||||||
@@ -34,6 +34,8 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest and Empire alway
|
|||||||
| [Quest](#quest) | a fantasy game's interface | own |
|
| [Quest](#quest) | a fantasy game's interface | own |
|
||||||
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
| [Visor](#visor) | a soldier's helmet display | original or accent |
|
||||||
| [Empire](#empire) | a strategy game's interface | own |
|
| [Empire](#empire) | a strategy game's interface | own |
|
||||||
|
| [Día de Muertos](#día-de-muertos) | an ofrenda with papel picado and a calavera | own |
|
||||||
|
| [Halloween](#halloween) | a jack-o'-lantern on a graveyard hill | own |
|
||||||
|
|
||||||
## Tiles
|
## Tiles
|
||||||
|
|
||||||
@@ -233,3 +235,34 @@ stats, and a diamond minimap with the odometer and the way to the next age. Belo
|
|||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## Día de Muertos
|
||||||
|
|
||||||
|
After the ofrenda, the altar families in Mexico build to welcome their dead back for a visit: a
|
||||||
|
celebration, so bright and warm rather than frightening. The papel picado across the top is the
|
||||||
|
battery, one cut-paper flag per 10 %, those not lit hanging dark, turning red with "BATTERY LOW"
|
||||||
|
below 20 %. The calavera in the middle, a decorated sugar skull with marigolds (cempasúchil) for
|
||||||
|
eyes, has the speed on its forehead; the ring of marigolds round it is the power, clockwise while
|
||||||
|
drawing, turquoise and anticlockwise while regenerating. The altar's candles are the range, one
|
||||||
|
per 50 km. The path of marigold petals along the bottom, which in the tradition leads the visitors
|
||||||
|
home, is the last two minutes of power. On the right the clock in a tin frame, P R N D as small
|
||||||
|
marigolds, and trip, usage, climate and odometer on the altar cloth. While you drive the flags
|
||||||
|
sway and the flames flicker. The app turns the papel picado's pink with it.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
## Halloween
|
||||||
|
|
||||||
|
A graveyard hill under a full moon. The jack-o'-lantern has the speed cut into its grin, and its
|
||||||
|
carving burns candle yellow while drawing and witch-fire green while regenerating; the arch of
|
||||||
|
embers over it is the power, from the left while drawing and from the right while regenerating.
|
||||||
|
The potion flask is the battery, red with "BATTERY LOW" blinking below 20 %. The tombstone reads
|
||||||
|
the range ("here lies … km"), and the bats are the range too, one per 50 km; P R N D are small
|
||||||
|
gravestones, the one engaged lit. The moon holds the clock, a sign hanging from a cobweb has trip,
|
||||||
|
usage, climate and odometer, and the hill along the bottom is the last two minutes of power. While
|
||||||
|
you drive the bats fly, the potion bubbles and the ghost floats (where there is room for it, as on
|
||||||
|
this screen). The app turns the pumpkin's orange with it.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|||||||
+2
-2
@@ -34,8 +34,8 @@ android {
|
|||||||
applicationId "com.stinnesbeck.nilsmg4"
|
applicationId "com.stinnesbeck.nilsmg4"
|
||||||
minSdk 27
|
minSdk 27
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 151
|
versionCode 155
|
||||||
versionName "2.42.0"
|
versionName "2.43.2"
|
||||||
|
|
||||||
// Nothing about the server ships in the APK: its URL and ingest token are entered in
|
// 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.
|
// the app's Server tab (ServerConfig), and the database is only reachable by the server.
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.LinearGradient;
|
||||||
|
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 Halloween Overview style: a graveyard hill under a full moon.
|
||||||
|
*
|
||||||
|
* In the middle a jack-o'-lantern with the speed cut into its grin; its carving burns candle yellow
|
||||||
|
* while drawing and witch-fire green while regenerating, and the arch of embers over it is the
|
||||||
|
* power, left to right while drawing, green and right to left while regenerating. On the left a
|
||||||
|
* potion flask: the battery, red with "BATTERY LOW" blinking below 20 %, under a sign hanging from
|
||||||
|
* a cobweb with trip, usage, climate and odometer. On the right the moon holds the clock, and a
|
||||||
|
* tombstone reads the range ("here lies … km"); P R N D are four small gravestones, the one engaged
|
||||||
|
* lit. The bats are the range too, one per 50 km. The hill along the bottom is the last two minutes
|
||||||
|
* of power, rising with it, in orange or, where it regenerated, green. Unread values show "—" and
|
||||||
|
* light nothing.
|
||||||
|
*
|
||||||
|
* The night sky is drawn once into a bitmap per size; only the rest goes over it. Bats, bubbles and
|
||||||
|
* the ghost move (about 30 frames a second) only while the car moves and the view is on screen.
|
||||||
|
*/
|
||||||
|
final class HalloweenView extends ClusterCanvasView {
|
||||||
|
|
||||||
|
private static final int EMBERS = 25, BATS = 8;
|
||||||
|
private static final int SKY_TOP = 0xFF0B0620, SKY_LOW = 0xFF2A1140, HILL = 0xFF0A0612;
|
||||||
|
private static final int PUMPKIN = 0xFFF2760F, PUMPKIN_DARK = 0xFFB84A06, PUMPKIN_LIT = 0xFFFF9A2E;
|
||||||
|
private static final int CANDLE = 0xFFFFD84A, FIRE = 0xFF6BFF5A, RED = 0xFFFF3B30, STONE = 0xFF6C6A78;
|
||||||
|
private static final int STONE_DARK = 0xFF3E3C4A, MOON = 0xFFF4E9B8, MOON_SHADE = 0xFFD8C98C;
|
||||||
|
private static final int POTION = 0xFF7BF03A, GLASS = 0xFFB9D8D0, BONE = 0xFFF2EAD8, ORANGE = 0xFFFF8C1A;
|
||||||
|
private static final int STEM = 0xFF4E7A2B, WOOD = 0xFF2E1B10, INK = 0xFF140A08;
|
||||||
|
private static final int BAT = 0xFF6F6390, BAT_EDGE = 0xFFD6CCEA;
|
||||||
|
|
||||||
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG);
|
||||||
|
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Path path = new Path();
|
||||||
|
private final RectF r = new RectF();
|
||||||
|
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
|
||||||
|
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
|
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
|
||||||
|
|
||||||
|
private Bitmap sky;
|
||||||
|
private int skyW, skyH;
|
||||||
|
private float time;
|
||||||
|
private long lastFrame;
|
||||||
|
|
||||||
|
HalloweenView(Context context) {
|
||||||
|
super(context);
|
||||||
|
line.setStyle(Paint.Style.STROKE);
|
||||||
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
sky = null;
|
||||||
|
skyW = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas c) {
|
||||||
|
super.onDraw(c);
|
||||||
|
int 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;
|
||||||
|
if (moving) {
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
|
lastFrame = now;
|
||||||
|
} else {
|
||||||
|
lastFrame = 0;
|
||||||
|
}
|
||||||
|
if (sky == null || skyW != w || skyH != h) buildSky(w, h, u);
|
||||||
|
c.drawBitmap(sky, 0, 0, image);
|
||||||
|
drawBats(c, w, u);
|
||||||
|
drawMoon(c, w, u);
|
||||||
|
drawGhost(c, w, u);
|
||||||
|
drawHill(c, w, h, u);
|
||||||
|
drawEmbers(c, w, u);
|
||||||
|
drawPumpkin(c, w, u);
|
||||||
|
drawSign(c, u);
|
||||||
|
drawFlask(c, u);
|
||||||
|
drawTombstone(c, w, u);
|
||||||
|
drawGears(c, w, u);
|
||||||
|
keepAnimating(moving, 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean regen() {
|
||||||
|
return v.powerKw != null && v.powerKw < -0.05f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Sky ----------
|
||||||
|
|
||||||
|
private void buildSky(int w, int h, float u) {
|
||||||
|
sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
skyW = w;
|
||||||
|
skyH = h;
|
||||||
|
Canvas c = new Canvas(sky);
|
||||||
|
Paint p = new Paint();
|
||||||
|
p.setShader(new LinearGradient(0, 0, 0, h, new int[] { SKY_TOP, SKY_LOW, 0xFF4A1A3A },
|
||||||
|
new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP));
|
||||||
|
c.drawRect(0, 0, w, h, p);
|
||||||
|
Paint star = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
star.setColor(0x80FFFFFF);
|
||||||
|
for (int i = 0; i < 90; i++) {
|
||||||
|
float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u;
|
||||||
|
c.drawCircle(x, y, (i % 4 == 0 ? 1.7f : 0.9f) * u, star);
|
||||||
|
}
|
||||||
|
// Bare trees in the distance.
|
||||||
|
tree(c, w * 0.235f, 590f * u, 150f * u, u);
|
||||||
|
tree(c, w * 0.745f, 590f * u, 118f * u, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bare tree: a trunk and branches that fork twice. */
|
||||||
|
private void tree(Canvas c, float x, float y, float tall, float u) {
|
||||||
|
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
p.setStyle(Paint.Style.STROKE);
|
||||||
|
p.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
p.setColor(0xFF160B20);
|
||||||
|
branch(c, p, x, y, -90f, tall * 0.5f, 7f * u, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void branch(Canvas c, Paint p, float x, float y, float deg, float len, float width, int depth) {
|
||||||
|
double a = Math.toRadians(deg);
|
||||||
|
float nx = x + (float) Math.cos(a) * len, ny = y + (float) Math.sin(a) * len;
|
||||||
|
p.setStrokeWidth(width);
|
||||||
|
c.drawLine(x, y, nx, ny, p);
|
||||||
|
if (depth == 0) return;
|
||||||
|
branch(c, p, nx, ny, deg - 24f - depth * 3f, len * 0.68f, width * 0.65f, depth - 1);
|
||||||
|
branch(c, p, nx, ny, deg + 28f - depth * 2f, len * 0.62f, width * 0.65f, depth - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Moon and clock ----------
|
||||||
|
|
||||||
|
private void drawMoon(Canvas c, int w, float u) {
|
||||||
|
float rad = 74f * u, cx = w - 100f * u - rad * 0.2f, cy = 96f * u;
|
||||||
|
glow.setShader(new RadialGradient(cx, cy, rad * 2.2f, new int[] { 0x55F4E9B8, 0x00F4E9B8 }, null,
|
||||||
|
Shader.TileMode.CLAMP));
|
||||||
|
c.drawCircle(cx, cy, rad * 2.2f, glow);
|
||||||
|
fill.setColor(MOON);
|
||||||
|
c.drawCircle(cx, cy, rad, fill);
|
||||||
|
fill.setColor(MOON_SHADE);
|
||||||
|
c.drawCircle(cx - 42f * u, cy - 34f * u, 12f * u, fill);
|
||||||
|
c.drawCircle(cx + 40f * u, cy + 38f * u, 16f * u, fill);
|
||||||
|
c.drawCircle(cx + 46f * u, cy - 26f * u, 7f * u, fill);
|
||||||
|
c.drawCircle(cx - 30f * u, cy + 44f * u, 8f * u, fill);
|
||||||
|
label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 17f * u, 50f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bats: the range ----------
|
||||||
|
|
||||||
|
private void drawBats(Canvas c, int w, float u) {
|
||||||
|
int lit = Tally.lit(v.rangeKm, 50f, BATS);
|
||||||
|
float cx = w / 2f;
|
||||||
|
for (int i = 0; i < lit; i++) {
|
||||||
|
// Each its own lane high in the sky, clear of the pumpkin's middle.
|
||||||
|
float lane = (40f + (i * 37) % 70) * u;
|
||||||
|
float span = w * 0.92f, speed = (26f + i * 5f) * u;
|
||||||
|
float x = ((i * 0.13f * span + time * speed) % span) + w * 0.04f;
|
||||||
|
float y = lane + (float) Math.sin(time * 2f + i) * 8f * u;
|
||||||
|
if (Math.abs(x - cx) < 60f * u) y -= 10f * u;
|
||||||
|
bat(c, x, y, (i % 2 == 0 ? 20f : 16f) * u, (float) Math.sin(time * 14f + i * 2f), u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bat(Canvas c, float x, float y, float s, float flap, float u) {
|
||||||
|
float tip = -s * 0.5f * flap;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x, y - s * 0.3f);
|
||||||
|
path.quadTo(x + s * 0.6f, y - s * 0.7f + tip, x + s * 1.5f, y - s * 0.3f + tip);
|
||||||
|
path.quadTo(x + s * 1.1f, y + s * 0.05f, x + s * 0.9f, y + s * 0.35f);
|
||||||
|
path.quadTo(x + s * 0.6f, y + s * 0.05f, x + s * 0.35f, y + s * 0.4f);
|
||||||
|
path.quadTo(x + s * 0.15f, y + s * 0.15f, x, y + s * 0.5f);
|
||||||
|
path.quadTo(x - s * 0.15f, y + s * 0.15f, x - s * 0.35f, y + s * 0.4f);
|
||||||
|
path.quadTo(x - s * 0.6f, y + s * 0.05f, x - s * 0.9f, y + s * 0.35f);
|
||||||
|
path.quadTo(x - s * 1.1f, y + s * 0.05f, x - s * 1.5f, y - s * 0.3f + tip);
|
||||||
|
path.quadTo(x - s * 0.6f, y - s * 0.7f + tip, x, y - s * 0.3f);
|
||||||
|
path.close();
|
||||||
|
// Lit by the moon: black would vanish in the night sky.
|
||||||
|
fill.setColor(BAT);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(BAT_EDGE);
|
||||||
|
line.setStrokeWidth(1.5f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Ghost: only where there is room ----------
|
||||||
|
|
||||||
|
private void drawGhost(Canvas c, int w, float u) {
|
||||||
|
if (w / u < 1400f) return;
|
||||||
|
float x = (w / 2f + 272f * u + w - 290f * u) / 2f, y = 330f * u + (float) Math.sin(time * 1.4f) * 12f * u, s = 34f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x - s, y + s * 1.5f);
|
||||||
|
path.lineTo(x - s, y);
|
||||||
|
path.cubicTo(x - s, y - s * 1.4f, x + s, y - s * 1.4f, x + s, y);
|
||||||
|
path.lineTo(x + s, y + s * 1.5f);
|
||||||
|
float wob = (float) Math.sin(time * 3f) * s * 0.12f;
|
||||||
|
path.quadTo(x + s * 0.66f, y + s * 1.1f + wob, x + s * 0.33f, y + s * 1.5f);
|
||||||
|
path.quadTo(x, y + s * 1.1f - wob, x - s * 0.33f, y + s * 1.5f);
|
||||||
|
path.quadTo(x - s * 0.66f, y + s * 1.1f + wob, x - s, y + s * 1.5f);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(0xB8F0F4FF);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
fill.setColor(0xFF1A1030);
|
||||||
|
c.drawCircle(x - s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
|
||||||
|
c.drawCircle(x + s * 0.34f, y - s * 0.2f, s * 0.14f, fill);
|
||||||
|
r.set(x - s * 0.14f, y + s * 0.08f, x + s * 0.14f, y + s * 0.44f);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The hill: the last two minutes of power ----------
|
||||||
|
|
||||||
|
private void drawHill(Canvas c, int w, int h, float u) {
|
||||||
|
float base = h - 44f * u, amp = 36f * u;
|
||||||
|
float[] trail = v.powerTrail;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(0, h);
|
||||||
|
int n = trail == null ? 0 : trail.length;
|
||||||
|
if (n == 0) {
|
||||||
|
path.lineTo(0, base);
|
||||||
|
path.lineTo(w, base);
|
||||||
|
} else {
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float p = trail[i];
|
||||||
|
float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
|
||||||
|
path.lineTo(i / (n - 1f) * w, base - height * amp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
path.lineTo(w, h);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(HILL);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The ridge line, each stretch in the colour of what the car was doing then.
|
||||||
|
if (n > 1) {
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
for (int i = 1; i < n; i++) {
|
||||||
|
float a = trail[i - 1], b = trail[i];
|
||||||
|
if (Float.isNaN(a) || Float.isNaN(b)) continue;
|
||||||
|
float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f));
|
||||||
|
float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f));
|
||||||
|
line.setColor(b < 0 ? FIRE : ORANGE);
|
||||||
|
c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The arch of embers: the power ----------
|
||||||
|
|
||||||
|
private void drawEmbers(Canvas c, int w, float u) {
|
||||||
|
float cx = w / 2f, cy = 348f * u, radX = 272f * u, radY = 244f * u;
|
||||||
|
int lit = Tally.ofPower(v.powerKw, 150f, 50f, EMBERS);
|
||||||
|
boolean regen = v.powerKw != null && v.powerKw < 0f;
|
||||||
|
for (int i = 0; i < EMBERS; i++) {
|
||||||
|
// Left to right over the top; regeneration comes back from the right.
|
||||||
|
int k = regen ? EMBERS - 1 - i : i;
|
||||||
|
double a = Math.PI + Math.PI * k / (EMBERS - 1);
|
||||||
|
float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY;
|
||||||
|
boolean on = i < lit;
|
||||||
|
if (on) {
|
||||||
|
fill.setColor(AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x44));
|
||||||
|
c.drawCircle(x, y, 15f * u, fill);
|
||||||
|
}
|
||||||
|
fill.setColor(on ? (regen ? FIRE : CANDLE) : 0x38FFFFFF);
|
||||||
|
c.drawCircle(x, y, 7.5f * u, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The jack-o'-lantern: the speed ----------
|
||||||
|
|
||||||
|
private void drawPumpkin(Canvas c, int w, float u) {
|
||||||
|
float cx = w / 2f, cy = 348f * u, rx = 200f * u, ry = 158f * u;
|
||||||
|
boolean regen = regen();
|
||||||
|
int burn = regen ? FIRE : CANDLE;
|
||||||
|
// The light it throws, stronger the more power.
|
||||||
|
float share = v.powerKw == null ? 0f : Math.min(1f, Math.abs(v.powerKw) / 80f);
|
||||||
|
glow.setShader(new RadialGradient(cx, cy, rx * 1.6f,
|
||||||
|
new int[] { AccentColor.withAlpha(regen ? FIRE : ORANGE, 0x30 + (int) (share * 0x50)),
|
||||||
|
AccentColor.withAlpha(regen ? FIRE : ORANGE, 0) }, null, Shader.TileMode.CLAMP));
|
||||||
|
c.drawCircle(cx, cy, rx * 1.6f, glow);
|
||||||
|
// The stem.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - 22f * u, cy - ry + 12f * u);
|
||||||
|
path.quadTo(cx - 14f * u, cy - ry - 26f * u, cx + 14f * u, cy - ry - 44f * u);
|
||||||
|
path.lineTo(cx + 34f * u, cy - ry - 30f * u);
|
||||||
|
path.quadTo(cx + 14f * u, cy - ry - 12f * u, cx + 22f * u, cy - ry + 12f * u);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(STEM);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The ribs, outermost first, overlapping.
|
||||||
|
float[][] ribs = { { -0.70f, 0.42f }, { 0.70f, 0.42f }, { -0.40f, 0.48f }, { 0.40f, 0.48f }, { 0f, 0.50f } };
|
||||||
|
for (int i = 0; i < ribs.length; i++) {
|
||||||
|
float rcx = cx + ribs[i][0] * rx, half = ribs[i][1] * rx;
|
||||||
|
r.set(rcx - half, cy - ry * (i < 2 ? 0.86f : 0.98f), rcx + half, cy + ry * (i < 2 ? 0.86f : 0.98f));
|
||||||
|
fill.setColor(i % 2 == 0 ? PUMPKIN : PUMPKIN_LIT);
|
||||||
|
if (i == ribs.length - 1) fill.setColor(PUMPKIN);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
line.setColor(PUMPKIN_DARK);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawOval(r, line);
|
||||||
|
}
|
||||||
|
// Eyes and nose: triangles, burning.
|
||||||
|
triangle(c, cx - 88f * u, cy - 92f * u, cx - 34f * u, cy - 28f * u, cx - 128f * u, cy - 28f * u, burn, u);
|
||||||
|
triangle(c, cx + 88f * u, cy - 92f * u, cx + 128f * u, cy - 28f * u, cx + 34f * u, cy - 28f * u, burn, u);
|
||||||
|
triangle(c, cx, cy - 28f * u, cx + 19f * u, cy + 4f * u, cx - 19f * u, cy + 4f * u, burn, u);
|
||||||
|
// The grin, with the speed cut dark into its light.
|
||||||
|
float gt = cy + 8f * u, gb = cy + 122f * u, gw = 156f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - gw, gt);
|
||||||
|
path.quadTo(cx, gt + 26f * u, cx + gw, gt);
|
||||||
|
path.quadTo(cx + gw * 0.82f, gb, cx, gb);
|
||||||
|
path.quadTo(cx - gw * 0.82f, gb, cx - gw, gt);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(0xFF4A1F04);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
c.save();
|
||||||
|
c.scale(0.965f, 0.93f, cx, (gt + gb) / 2f);
|
||||||
|
fill.setColor(burn);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
c.restore();
|
||||||
|
// Two teeth at the grin's corners, clear of the digits, each the colour of the rib it hangs from.
|
||||||
|
tooth(c, cx, -120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN, u);
|
||||||
|
tooth(c, cx, 120f * u, gw, gt, 26f * u, 17f * u, gt + 40f * u, PUMPKIN_LIT, u);
|
||||||
|
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, gb - 18f * u, 92f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM/H", cx + 80f * u, cy + 64f * u, 16f * u, INK, face, Paint.Align.LEFT);
|
||||||
|
// The power under the pumpkin, above the ridge.
|
||||||
|
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
|
||||||
|
Math.abs(v.powerKw));
|
||||||
|
label(c, power, cx, cy + ry + 44f * u, 34f * u, regen ? FIRE : BONE, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int burn, float u) {
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x1, y1);
|
||||||
|
path.lineTo(x2, y2);
|
||||||
|
path.lineTo(x3, y3);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(burn);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(0xFF4A1F04);
|
||||||
|
line.setStrokeWidth(4f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A triangle hanging from the grin's upper lip (a curve sagging by {@code sag}) to {@code tipY}. */
|
||||||
|
private void tooth(Canvas c, float cx, float dx, float gw, float gt, float sag, float halfW, float tipY, int colour, float u) {
|
||||||
|
float left = lip(dx - halfW, gw, gt, sag), right = lip(dx + halfW, gw, gt, sag);
|
||||||
|
path.reset();
|
||||||
|
// A little above the lip, so none of its dark edge shows across the tooth's root.
|
||||||
|
path.moveTo(cx + dx - halfW, left - 1.5f * u);
|
||||||
|
path.lineTo(cx + dx + halfW, right - 1.5f * u);
|
||||||
|
path.lineTo(cx + dx, tipY);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(0xFF4A1F04);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawLine(cx + dx - halfW, left + 2f * u, cx + dx, tipY, line);
|
||||||
|
c.drawLine(cx + dx + halfW, right + 2f * u, cx + dx, tipY, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The height of the grin's upper lip {@code dx} from the middle. */
|
||||||
|
private static float lip(float dx, float gw, float gt, float sag) {
|
||||||
|
float t = (gw - Math.abs(dx)) / (2f * gw);
|
||||||
|
return gt + 2f * t * (1f - t) * sag;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Left: the sign under the cobweb ----------
|
||||||
|
|
||||||
|
private void drawSign(Canvas c, float u) {
|
||||||
|
// The cobweb in the corner.
|
||||||
|
float cs = 150f * u;
|
||||||
|
line.setColor(0x70E8E8F4);
|
||||||
|
line.setStrokeWidth(1.2f * u);
|
||||||
|
for (int i = 0; i <= 5; i++) {
|
||||||
|
double a = Math.PI / 2 * i / 5;
|
||||||
|
c.drawLine(0, 0, (float) Math.cos(a) * cs, (float) Math.sin(a) * cs, line);
|
||||||
|
}
|
||||||
|
for (int ring = 1; ring <= 4; ring++) {
|
||||||
|
float d = cs * ring / 4f;
|
||||||
|
path.reset();
|
||||||
|
for (int i = 0; i <= 5; i++) {
|
||||||
|
double a = Math.PI / 2 * i / 5;
|
||||||
|
float x = (float) Math.cos(a) * d, y = (float) Math.sin(a) * d;
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
double mid = Math.PI / 2 * (i - 0.5) / 5;
|
||||||
|
path.quadTo((float) Math.cos(mid) * d * 0.86f, (float) Math.sin(mid) * d * 0.86f, x, y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
// The sign, on two threads.
|
||||||
|
float l = 28f * u, rr = l + 300f * u, t = 50f * u, b = 244f * u;
|
||||||
|
line.setColor(0xA0E8E8F4);
|
||||||
|
c.drawLine(l + 60f * u, 0, l + 60f * u, t, line);
|
||||||
|
c.drawLine(rr - 60f * u, 0, rr - 60f * u, t, line);
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(WOOD);
|
||||||
|
c.drawRoundRect(r, 8f * u, 8f * u, fill);
|
||||||
|
line.setColor(0xFF6B4226);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawRoundRect(r, 8f * u, 8f * u, line);
|
||||||
|
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 + (36f + i * 34f) * u;
|
||||||
|
label(c, rows[i][0], l + 16f * u, y, 19f * u, ORANGE, thin, Paint.Align.LEFT);
|
||||||
|
label(c, rows[i][1], rr - 16f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The potion flask: the battery ----------
|
||||||
|
|
||||||
|
private void drawFlask(Canvas c, float u) {
|
||||||
|
float rad = 104f * u, cx = 28f * u + 150f * u, cy = 452f * u;
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0;
|
||||||
|
int colour = low ? RED : POTION;
|
||||||
|
float neckW = 34f * u, neckTop = cy - rad - 42f * u;
|
||||||
|
// The glass shape: a round belly and a neck.
|
||||||
|
path.reset();
|
||||||
|
path.addCircle(cx, cy, rad, Path.Direction.CW);
|
||||||
|
r.set(cx - neckW, neckTop, cx + neckW, cy - rad + 16f * u);
|
||||||
|
path.addRect(r, Path.Direction.CW);
|
||||||
|
fill.setColor(0x38B9D8D0);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// The potion: as high as the battery is full, across the belly only.
|
||||||
|
if (v.socPercent != null) {
|
||||||
|
float share = Math.max(0f, Math.min(1f, v.socPercent / 100f));
|
||||||
|
float level = cy + rad - share * 2f * rad;
|
||||||
|
c.save();
|
||||||
|
c.clipRect(cx - rad, level, cx + rad, cy + rad + 1f);
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawCircle(cx, cy, rad - 5f * u, fill);
|
||||||
|
c.restore();
|
||||||
|
// The surface, a brighter line.
|
||||||
|
float half = (float) Math.sqrt(Math.max(0f, (rad - 5f * u) * (rad - 5f * u) - (level - cy) * (level - cy)));
|
||||||
|
line.setColor(0xCCFFFFFF);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
if (half > 0f) c.drawLine(cx - half, level, cx + half, level, line);
|
||||||
|
// Bubbles rise only while driving.
|
||||||
|
fill.setColor(0x99FFFFFF);
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float span = Math.max(1f, cy + rad - level - 12f * u);
|
||||||
|
float y = cy + rad - 12f * u - ((time * (24f + i * 7f) * u + i * 31f * u) % span);
|
||||||
|
float x = cx + (float) Math.sin(i * 2.1f + time * 1.5f) * rad * 0.5f;
|
||||||
|
c.drawCircle(x, y, (3f + i % 3) * u, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The glass, and its cork.
|
||||||
|
line.setColor(GLASS);
|
||||||
|
line.setStrokeWidth(4f * u);
|
||||||
|
c.drawCircle(cx, cy, rad, line);
|
||||||
|
c.drawLine(cx - neckW, neckTop + 12f * u, cx - neckW, cy - rad + 14f * u, line);
|
||||||
|
c.drawLine(cx + neckW, neckTop + 12f * u, cx + neckW, cy - rad + 14f * u, line);
|
||||||
|
r.set(cx - neckW - 4f * u, neckTop - 8f * u, cx + neckW + 4f * u, neckTop + 16f * u);
|
||||||
|
fill.setColor(0xFF9B6B3A);
|
||||||
|
c.drawRoundRect(r, 5f * u, 5f * u, fill);
|
||||||
|
// Dark where the potion is behind the words; outlined where its surface cuts through them.
|
||||||
|
float level = v.socPercent == null ? cy + rad
|
||||||
|
: cy + rad - Math.max(0f, Math.min(1f, v.socPercent / 100f)) * 2f * rad;
|
||||||
|
boolean numberOn = level <= cy - 18f * u, wordOn = level <= cy + 38f * u;
|
||||||
|
String number = fmt(v.socPercent, "%.0f %%", "—");
|
||||||
|
if (!numberOn && level < cy + 30f * u) {
|
||||||
|
text.setStyle(Paint.Style.STROKE);
|
||||||
|
text.setStrokeWidth(7f * u);
|
||||||
|
label(c, number, cx, cy + 22f * u, 62f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
text.setStyle(Paint.Style.FILL);
|
||||||
|
}
|
||||||
|
label(c, number, cx, cy + 22f * u, 62f * u, numberOn ? INK : low ? RED : BONE, serif, Paint.Align.CENTER);
|
||||||
|
if (low && blinkOn) {
|
||||||
|
label(c, "BATTERY LOW", cx, cy + 56f * u, 21f * u, BONE, face, Paint.Align.CENTER);
|
||||||
|
} else if (!low) {
|
||||||
|
label(c, "BATTERY", cx, cy + 56f * u, 19f * u, wordOn ? INK : POTION, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The tombstone: the range; small gravestones: the gears ----------
|
||||||
|
|
||||||
|
private void stone(Canvas c, float l, float t, float rr, float b, int colour, int edge, float u) {
|
||||||
|
float mid = (l + rr) / 2f, half = (rr - l) / 2f;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l, b);
|
||||||
|
path.lineTo(l, t + half);
|
||||||
|
path.cubicTo(l, t + half * 0.1f, mid - half * 0.5f, t, mid, t);
|
||||||
|
path.cubicTo(mid + half * 0.5f, t, rr, t + half * 0.1f, rr, t + half);
|
||||||
|
path.lineTo(rr, b);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(colour);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(edge);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawTombstone(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 40f * u, l = rr - 250f * u, t = 196f * u, b = 484f * u, mid = (l + rr) / 2f;
|
||||||
|
stone(c, l, t, rr, b, STONE, STONE_DARK, u);
|
||||||
|
label(c, "R.I.P.", mid, t + 70f * u, 36f * u, STONE_DARK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "HERE LIES", mid, t + 106f * u, 19f * u, 0xFF242230, face, Paint.Align.CENTER);
|
||||||
|
label(c, fmt(v.rangeKm, "%.0f", "—"), mid, t + 186f * u, 86f * u, BONE, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM OF RANGE", mid, t + 224f * u, 21f * u, 0xFF242230, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawGears(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 40f * u, l = rr - 250f * u, t = 496f * u, b = 566f * u;
|
||||||
|
String[] gears = { "P", "R", "N", "D" };
|
||||||
|
String now = Gear.label(v.gear);
|
||||||
|
float step = (rr - l) / 4f, half = 24f * u;
|
||||||
|
for (int i = 0; i < gears.length; i++) {
|
||||||
|
float mid = l + (i + 0.5f) * step;
|
||||||
|
boolean on = gears[i].equals(now);
|
||||||
|
if (on) {
|
||||||
|
fill.setColor(0x55FF8C1A);
|
||||||
|
c.drawCircle(mid, (t + b) / 2f, 42f * u, fill);
|
||||||
|
}
|
||||||
|
stone(c, mid - half, t, mid + half, b, on ? ORANGE : STONE_DARK, on ? CANDLE : 0xFF242230, u);
|
||||||
|
label(c, gears[i], mid, b - 18f * u, 32f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,7 +355,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
visorView = new VisorView(this);
|
visorView = new VisorView(this);
|
||||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||||
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this),
|
||||||
new QuestView(this), visorView, new EmpireView(this));
|
new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), new HalloweenView(this));
|
||||||
View[] canvasPanes = new View[canvasStyles.size()];
|
View[] canvasPanes = new View[canvasStyles.size()];
|
||||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||||
// Grid with map: its map layer goes under it, in the same pane.
|
// Grid with map: its map layer goes under it, in the same pane.
|
||||||
@@ -1823,7 +1823,8 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_grid),
|
||||||
getString(R.string.overview_style_grid_map),
|
getString(R.string.overview_style_grid_map),
|
||||||
getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest),
|
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) };
|
getString(R.string.overview_style_visor), getString(R.string.overview_style_empire),
|
||||||
|
getString(R.string.overview_style_muertos), getString(R.string.overview_style_halloween) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
|
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
|
||||||
|
|||||||
@@ -9,25 +9,17 @@ import android.graphics.Bitmap;
|
|||||||
import android.media.AudioManager;
|
import android.media.AudioManager;
|
||||||
import android.media.AudioPlaybackConfiguration;
|
import android.media.AudioPlaybackConfiguration;
|
||||||
import android.media.MediaMetadata;
|
import android.media.MediaMetadata;
|
||||||
import android.media.browse.MediaBrowser;
|
|
||||||
import android.media.session.MediaController;
|
import android.media.session.MediaController;
|
||||||
import android.media.session.MediaSessionManager;
|
import android.media.session.MediaSessionManager;
|
||||||
import android.media.session.PlaybackState;
|
import android.media.session.PlaybackState;
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.HandlerThread;
|
|
||||||
import android.service.media.MediaBrowserService;
|
import android.service.media.MediaBrowserService;
|
||||||
|
|
||||||
import java.text.SimpleDateFormat;
|
import java.text.SimpleDateFormat;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.concurrent.CountDownLatch;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The music-sources check on the Logs tab: where the head unit tells what is playing, before
|
* The music-sources check on the Logs tab: where the head unit tells what is playing, before
|
||||||
@@ -37,23 +29,15 @@ import java.util.concurrent.atomic.AtomicReference;
|
|||||||
* <li>the active media sessions ({@code MediaSessionManager.getActiveSessions}, which needs
|
* <li>the active media sessions ({@code MediaSessionManager.getActiveSessions}, which needs
|
||||||
* MEDIA_CONTENT_CONTROL: the car build has it as the system user), with their song and
|
* MEDIA_CONTENT_CONTROL: the car build has it as the system user), with their song and
|
||||||
* state;</li>
|
* state;</li>
|
||||||
* <li>every media browser service on the head unit, CarPlay's
|
* <li>every media browser service on the head unit, listed but not connected to (2.42.1:
|
||||||
* ({@link #CARPLAY}, where the car's own media library reads CarPlay's song) among them:
|
* a connection is not harmless, see {@link #browsers}).</li>
|
||||||
* connected to as the car's media library does, its session's song and state read, and
|
|
||||||
* disconnected.</li>
|
|
||||||
* </ol>
|
* </ol>
|
||||||
* Nothing is played, paused or skipped. The report goes where a signal recording goes
|
* No other app's service is bound; nothing is played, paused or skipped. The report goes where
|
||||||
* ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
|
* a signal recording goes ({@link SignalRecorder#saveCopies}): the app, a USB stick, the server.
|
||||||
*/
|
*/
|
||||||
final class MediaProbe {
|
final class MediaProbe {
|
||||||
|
|
||||||
static final String FILE = "media_probe.txt";
|
static final String FILE = "media_probe.txt";
|
||||||
/** CarPlay's song, as {@code com.saicmotor.sdk.media…MediaPlayControlManager} reads it. */
|
|
||||||
static final ComponentName CARPLAY = new ComponentName("com.allgo.remoteui.mediabrowserservice",
|
|
||||||
"com.allgo.remoteui.mediabrowserservice.RemoteUIMediaBrowserService");
|
|
||||||
static final long CONNECT_TIMEOUT_MS = 8_000L;
|
|
||||||
/** More browser services than this are listed but not connected to. */
|
|
||||||
static final int MAX_BROWSERS = 12;
|
|
||||||
|
|
||||||
private static volatile boolean running;
|
private static volatile boolean running;
|
||||||
private static long startedMs;
|
private static long startedMs;
|
||||||
@@ -182,98 +166,31 @@ final class MediaProbe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every media browser service, each connected to, read and disconnected. */
|
/**
|
||||||
|
* Every media browser service, listed only. 2.42.0 connected to each, as the car's media
|
||||||
|
* library does; on the car that was not harmless: the Bluetooth music stopped (the AOSP car
|
||||||
|
* radio's service, `com.android.car.radio/.RadioService`, answered "connecting", most likely
|
||||||
|
* taking the audio) and the Local Media Player asked for access to media. The active sessions
|
||||||
|
* above already carry the song of whatever plays, CarPlay's and Bluetooth's included, so no
|
||||||
|
* service is bound any more.
|
||||||
|
*/
|
||||||
private static void browsers(Context app, List<String> out) {
|
private static void browsers(Context app, List<String> out) {
|
||||||
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "):");
|
out.add("Media browser services (" + MediaBrowserService.SERVICE_INTERFACE + "), listed, not connected to:");
|
||||||
Set<ComponentName> services = new LinkedHashSet<>();
|
|
||||||
try {
|
try {
|
||||||
List<ResolveInfo> found = app.getPackageManager()
|
List<ResolveInfo> found = app.getPackageManager()
|
||||||
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
|
.queryIntentServices(new Intent(MediaBrowserService.SERVICE_INTERFACE), 0);
|
||||||
for (ResolveInfo ri : found == null ? new ArrayList<ResolveInfo>() : found) {
|
if (found == null || found.isEmpty()) {
|
||||||
if (ri.serviceInfo != null) {
|
out.add(" none");
|
||||||
services.add(new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name));
|
return;
|
||||||
}
|
}
|
||||||
|
for (ResolveInfo ri : found) {
|
||||||
|
if (ri.serviceInfo == null) continue;
|
||||||
|
out.add(" " + new ComponentName(ri.serviceInfo.packageName, ri.serviceInfo.name)
|
||||||
|
.flattenToShortString());
|
||||||
}
|
}
|
||||||
} catch (RuntimeException e) {
|
} catch (RuntimeException e) {
|
||||||
out.add(" list not readable: " + e.getClass().getSimpleName());
|
out.add(" list not readable: " + e.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
if (!services.contains(CARPLAY)) out.add(" CarPlay's service is not listed; tried all the same");
|
|
||||||
services.add(CARPLAY);
|
|
||||||
HandlerThread thread = new HandlerThread("media-probe-browser");
|
|
||||||
thread.start();
|
|
||||||
try {
|
|
||||||
Handler handler = new Handler(thread.getLooper());
|
|
||||||
int n = 0;
|
|
||||||
for (ComponentName service : services) {
|
|
||||||
if (++n > MAX_BROWSERS) {
|
|
||||||
out.add(" " + service.flattenToShortString() + ": not tried (limit)");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
browse(app, handler, service, out);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
thread.quitSafely();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Connects to one browser service on {@code handler}'s thread and reads its session. */
|
|
||||||
private static void browse(Context app, Handler handler, ComponentName service, List<String> out) {
|
|
||||||
out.add(" " + service.flattenToShortString() + ":");
|
|
||||||
CountDownLatch answered = new CountDownLatch(1);
|
|
||||||
AtomicReference<String> outcome = new AtomicReference<>();
|
|
||||||
AtomicReference<MediaBrowser> browser = new AtomicReference<>();
|
|
||||||
boolean posted = handler.post(() -> {
|
|
||||||
try {
|
|
||||||
MediaBrowser b = new MediaBrowser(app, service, new MediaBrowser.ConnectionCallback() {
|
|
||||||
@Override public void onConnected() {
|
|
||||||
outcome.compareAndSet(null, "connected");
|
|
||||||
answered.countDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onConnectionFailed() {
|
|
||||||
outcome.compareAndSet(null, "refused or failed");
|
|
||||||
answered.countDown();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override public void onConnectionSuspended() {
|
|
||||||
outcome.compareAndSet(null, "suspended");
|
|
||||||
answered.countDown();
|
|
||||||
}
|
|
||||||
}, null);
|
|
||||||
browser.set(b);
|
|
||||||
b.connect();
|
|
||||||
} catch (Throwable t) {
|
|
||||||
outcome.compareAndSet(null, "could not try: " + t.getClass().getSimpleName());
|
|
||||||
answered.countDown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
if (!posted || !answered.await(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
|
|
||||||
out.add(" no answer in " + CONNECT_TIMEOUT_MS / 1000 + " s");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
out.add(" " + outcome.get());
|
|
||||||
MediaBrowser b = browser.get();
|
|
||||||
if (!"connected".equals(outcome.get()) || b == null) return;
|
|
||||||
try {
|
|
||||||
out.add(" root: " + MediaProbeText.clean(b.getRoot()));
|
|
||||||
describe(new MediaController(app, b.getSessionToken()), out, " ");
|
|
||||||
} catch (Throwable t) {
|
|
||||||
out.add(" session not readable: " + t.getClass().getSimpleName());
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
out.add(" interrupted");
|
|
||||||
} finally {
|
|
||||||
handler.post(() -> {
|
|
||||||
try {
|
|
||||||
MediaBrowser b = browser.get();
|
|
||||||
if (b != null) b.disconnect();
|
|
||||||
} catch (Throwable ignored) {
|
|
||||||
// already gone
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A session's state and everything its song's metadata holds. */
|
/** A session's state and everything its song's metadata holds. */
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
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 Día de Muertos Overview style: an ofrenda at night, the altar families build to welcome
|
||||||
|
* their dead back for a visit. A celebration, so bright and warm, not frightening.
|
||||||
|
*
|
||||||
|
* Across the top the papel picado, cut-paper flags on a string: the battery, one flag per 10 %,
|
||||||
|
* those not lit hanging dark. In the middle a calavera, a decorated sugar skull, with the speed
|
||||||
|
* on its forehead and marigolds (cempasúchil) for eyes; round it a ring of marigolds is the power,
|
||||||
|
* clockwise while drawing, turquoise and anticlockwise while regenerating. On the left the candles
|
||||||
|
* of the altar: the range, one candle per 50 km. Along the bottom the path of marigold petals that
|
||||||
|
* leads the visitors home: the last two minutes of power, higher the more power. On the right the
|
||||||
|
* clock in a tin frame, P R N D as four small marigolds, and trip, usage, climate and odometer on
|
||||||
|
* the altar cloth. Below 20 % the battery turns red and says so. Unread values show "—" and light
|
||||||
|
* nothing.
|
||||||
|
*
|
||||||
|
* The altar is drawn once into a bitmap per size; only the readings go over it. The flags sway and
|
||||||
|
* the flames flicker (about 30 frames a second) only while the car moves and the view is on screen.
|
||||||
|
*/
|
||||||
|
final class MuertosView extends ClusterCanvasView {
|
||||||
|
|
||||||
|
private static final int FLAGS = 10, PETALS = 24, CANDLES = 8;
|
||||||
|
private static final int NIGHT = 0xFF160A26, PLUM = 0xFF2C1242, CLOTH = 0xFF3A0F3E, BONE = 0xFFF8EEDC;
|
||||||
|
private static final int INK = 0xFF2A0E36, MARIGOLD = 0xFFFF9A1F, GOLD = 0xFFFFC94A, TURQUOISE = 0xFF2EE6D0;
|
||||||
|
private static final int PINK = 0xFFFF2E93, RED = 0xFFFF3B47, FLAME = 0xFFFFD36B, WAX = 0xFFF4E6C8;
|
||||||
|
private static final int[] PAPER = { 0xFFFF2E93, 0xFFFF9A1F, 0xFF2EE6D0, 0xFFFFD23F, 0xFF9B5CFF };
|
||||||
|
|
||||||
|
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint image = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.FILTER_BITMAP_FLAG);
|
||||||
|
private final Path path = new Path();
|
||||||
|
private final Path flag = new Path();
|
||||||
|
private final RectF r = new RectF();
|
||||||
|
private final Typeface serif = Typeface.create("serif", Typeface.BOLD);
|
||||||
|
private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD);
|
||||||
|
private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL);
|
||||||
|
|
||||||
|
private Bitmap scene, marigold, turquoise;
|
||||||
|
private int sceneW, sceneH;
|
||||||
|
private float time;
|
||||||
|
private long lastFrame;
|
||||||
|
|
||||||
|
MuertosView(Context context) {
|
||||||
|
super(context);
|
||||||
|
line.setStyle(Paint.Style.STROKE);
|
||||||
|
line.setStrokeCap(Paint.Cap.ROUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDetachedFromWindow() {
|
||||||
|
super.onDetachedFromWindow();
|
||||||
|
scene = null;
|
||||||
|
marigold = null;
|
||||||
|
turquoise = null;
|
||||||
|
sceneW = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas c) {
|
||||||
|
super.onDraw(c);
|
||||||
|
int 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;
|
||||||
|
if (moving) {
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
|
||||||
|
lastFrame = now;
|
||||||
|
} else {
|
||||||
|
lastFrame = 0;
|
||||||
|
}
|
||||||
|
if (scene == null || sceneW != w || sceneH != h) buildScene(w, h, u);
|
||||||
|
c.drawBitmap(scene, 0, 0, image);
|
||||||
|
float cx = w / 2f, cy = 345f * u;
|
||||||
|
drawFlags(c, w, u);
|
||||||
|
drawRing(c, cx, cy, u);
|
||||||
|
drawSpeed(c, cx, cy, u);
|
||||||
|
drawBattery(c, u);
|
||||||
|
drawCandles(c, u, moving);
|
||||||
|
drawPetals(c, w, h, u);
|
||||||
|
drawClock(c, w, u);
|
||||||
|
drawGears(c, w, u);
|
||||||
|
drawRows(c, w, h, u);
|
||||||
|
keepAnimating(moving, 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The altar, once per size ----------
|
||||||
|
|
||||||
|
private void buildScene(int w, int h, float u) {
|
||||||
|
scene = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
||||||
|
sceneW = w;
|
||||||
|
sceneH = h;
|
||||||
|
marigold = flower(44f * u, MARIGOLD, GOLD, 0xFFE06A00);
|
||||||
|
turquoise = flower(44f * u, TURQUOISE, 0xFFA8FFF4, 0xFF0E9C90);
|
||||||
|
buildFlag(u);
|
||||||
|
Canvas c = new Canvas(scene);
|
||||||
|
c.drawColor(NIGHT);
|
||||||
|
Paint glow = new Paint();
|
||||||
|
glow.setShader(new RadialGradient(w / 2f, 345f * u, Math.max(w, h) * 0.6f,
|
||||||
|
new int[] { 0xFF4A1C5E, PLUM, NIGHT }, new float[] { 0f, 0.45f, 1f }, Shader.TileMode.CLAMP));
|
||||||
|
c.drawRect(0, 0, w, h, glow);
|
||||||
|
// A scatter of stars, always the same.
|
||||||
|
fill.setColor(0x66FFFFFF);
|
||||||
|
for (int i = 0; i < 70; i++) {
|
||||||
|
float x = ((i * 137 + 31) % 997) / 997f * w, y = (110f + (i * 71 % 113) * 3f) * u;
|
||||||
|
c.drawCircle(x, y, (i % 3 == 0 ? 1.8f : 1f) * u, fill);
|
||||||
|
}
|
||||||
|
// The string the flags hang on.
|
||||||
|
line.setColor(0x99FFFFFF);
|
||||||
|
line.setStrokeWidth(1.5f * u);
|
||||||
|
c.drawLine(0, 12f * u, w, 12f * u, line);
|
||||||
|
drawSkull(c, w / 2f, 345f * u, u);
|
||||||
|
drawAltarCloth(c, w, u);
|
||||||
|
drawCandleStands(c, u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One flower on a small bitmap: outer petals, inner petals, heart. */
|
||||||
|
private Bitmap flower(float size, int outer, int inner, int shade) {
|
||||||
|
int s = (int) Math.ceil(size);
|
||||||
|
Bitmap b = Bitmap.createBitmap(s, s, Bitmap.Config.ARGB_8888);
|
||||||
|
Canvas c = new Canvas(b);
|
||||||
|
drawFlower(c, s / 2f, s / 2f, s / 2f, outer, inner, shade);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawFlower(Canvas c, float x, float y, float rad, int outer, int inner, int shade) {
|
||||||
|
Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
for (int ring = 0; ring < 3; ring++) {
|
||||||
|
int n = 12 - ring * 3;
|
||||||
|
float d = rad * (0.68f - ring * 0.24f), pr = rad * (0.32f - ring * 0.05f);
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
double a = Math.PI * 2 * i / n + ring * 0.3;
|
||||||
|
p.setColor(ring == 0 ? (i % 2 == 0 ? outer : shade) : ring == 1 ? (i % 2 == 0 ? inner : outer) : inner);
|
||||||
|
c.drawCircle(x + (float) Math.cos(a) * d, y + (float) Math.sin(a) * d, pr, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.setColor(shade);
|
||||||
|
c.drawCircle(x, y, rad * 0.14f, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A paper flag with its holes cut out: scalloped below, a flower and diamonds in it. */
|
||||||
|
private void buildFlag(float u) {
|
||||||
|
float w = 100f * u, h = 84f * u;
|
||||||
|
flag.reset();
|
||||||
|
flag.setFillType(Path.FillType.EVEN_ODD);
|
||||||
|
flag.moveTo(0, 0);
|
||||||
|
flag.lineTo(w, 0);
|
||||||
|
flag.lineTo(w, h - 8f * u);
|
||||||
|
int scallops = 6;
|
||||||
|
float sw = w / scallops;
|
||||||
|
for (int i = scallops; i > 0; i--) {
|
||||||
|
float x = i * sw;
|
||||||
|
flag.quadTo(x - sw / 2f, h + 8f * u, x - sw, h - 8f * u);
|
||||||
|
}
|
||||||
|
flag.close();
|
||||||
|
// The middle: a flower of eight petals round a round hole.
|
||||||
|
float cx = w / 2f, cy = h * 0.46f;
|
||||||
|
flag.addCircle(cx, cy, 6f * u, Path.Direction.CW);
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
double a = Math.PI / 4 * i;
|
||||||
|
float px = cx + (float) Math.cos(a) * 17f * u, py = cy + (float) Math.sin(a) * 17f * u;
|
||||||
|
diamond(flag, px, py, 7f * u, 5f * u, a);
|
||||||
|
}
|
||||||
|
// Corners and borders.
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
float x = (i + 0.5f) * w / 6f;
|
||||||
|
diamond(flag, x, 8f * u, 5f * u, 3f * u, 0);
|
||||||
|
if (i == 0 || i == 5) {
|
||||||
|
diamond(flag, x, h * 0.3f, 4f * u, 6f * u, 0);
|
||||||
|
diamond(flag, x, h * 0.62f, 4f * u, 6f * u, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void diamond(Path p, float x, float y, float a, float b, double turn) {
|
||||||
|
float ca = (float) Math.cos(turn), sa = (float) Math.sin(turn);
|
||||||
|
p.moveTo(x + a * ca, y + a * sa);
|
||||||
|
p.lineTo(x - b * sa, y + b * ca);
|
||||||
|
p.lineTo(x - a * ca, y - a * sa);
|
||||||
|
p.lineTo(x + b * sa, y - b * ca);
|
||||||
|
p.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The calavera: the bone, the decoration, the teeth. Its speed and ring go over it. */
|
||||||
|
private void drawSkull(Canvas c, float cx, float cy, float u) {
|
||||||
|
float top = cy - 150f * u, jaw = cy + 150f * u;
|
||||||
|
// Bone.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx, top);
|
||||||
|
path.cubicTo(cx + 110f * u, top, cx + 148f * u, cy - 70f * u, cx + 142f * u, cy + 8f * u);
|
||||||
|
path.cubicTo(cx + 140f * u, cy + 48f * u, cx + 112f * u, cy + 62f * u, cx + 84f * u, cy + 74f * u);
|
||||||
|
path.lineTo(cx + 78f * u, jaw - 8f * u);
|
||||||
|
path.quadTo(cx, jaw + 6f * u, cx - 78f * u, jaw - 8f * u);
|
||||||
|
path.lineTo(cx - 84f * u, cy + 74f * u);
|
||||||
|
path.cubicTo(cx - 112f * u, cy + 62f * u, cx - 140f * u, cy + 48f * u, cx - 142f * u, cy + 8f * u);
|
||||||
|
path.cubicTo(cx - 148f * u, cy - 70f * u, cx - 110f * u, top, cx, top);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(BONE);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
line.setColor(PINK);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
// A band of scallops under the forehead, where the speed stands above.
|
||||||
|
line.setColor(PINK);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
for (int i = -4; i < 4; i++) {
|
||||||
|
float x = cx + i * 26f * u;
|
||||||
|
r.set(x, cy - 54f * u, x + 26f * u, cy - 28f * u);
|
||||||
|
c.drawArc(r, 0, 180, false, line);
|
||||||
|
}
|
||||||
|
fill.setColor(TURQUOISE);
|
||||||
|
for (int i = -4; i <= 4; i++) c.drawCircle(cx + i * 26f * u, cy - 41f * u, 3f * u, fill);
|
||||||
|
// Eyes: dark sockets with a marigold each, a ring of dots round them.
|
||||||
|
for (int side = -1; side <= 1; side += 2) {
|
||||||
|
float ex = cx + side * 62f * u, ey = cy + 4f * u;
|
||||||
|
fill.setColor(INK);
|
||||||
|
c.drawCircle(ex, ey, 36f * u, fill);
|
||||||
|
drawFlower(c, ex, ey, 31f * u, MARIGOLD, GOLD, 0xFFE06A00);
|
||||||
|
fill.setColor(PINK);
|
||||||
|
for (int i = 0; i < 14; i++) {
|
||||||
|
double a = Math.PI * 2 * i / 14;
|
||||||
|
c.drawCircle(ex + (float) Math.cos(a) * 43f * u, ey + (float) Math.sin(a) * 43f * u, 2.5f * u, fill);
|
||||||
|
}
|
||||||
|
// A small flower on each cheek.
|
||||||
|
drawFlower(c, cx + side * 92f * u, cy + 56f * u, 10f * u, 0xFF9B5CFF, 0xFFD2B8FF, 0xFF6A32C8);
|
||||||
|
}
|
||||||
|
// Nose: an upside-down heart.
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx, cy + 30f * u);
|
||||||
|
path.cubicTo(cx + 18f * u, cy + 46f * u, cx + 20f * u, cy + 66f * u, cx + 8f * u, cy + 66f * u);
|
||||||
|
path.quadTo(cx + 2f * u, cy + 66f * u, cx, cy + 58f * u);
|
||||||
|
path.quadTo(cx - 2f * u, cy + 66f * u, cx - 8f * u, cy + 66f * u);
|
||||||
|
path.cubicTo(cx - 20f * u, cy + 66f * u, cx - 18f * u, cy + 46f * u, cx, cy + 30f * u);
|
||||||
|
path.close();
|
||||||
|
fill.setColor(INK);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
// Teeth: a smile stitched across.
|
||||||
|
line.setColor(INK);
|
||||||
|
line.setStrokeWidth(3f * u);
|
||||||
|
float my = cy + 106f * u;
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(cx - 70f * u, my - 6f * u);
|
||||||
|
path.quadTo(cx, my + 12f * u, cx + 70f * u, my - 6f * u);
|
||||||
|
c.drawPath(path, line);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
for (int i = -5; i <= 5; i++) {
|
||||||
|
float x = cx + i * 12f * u;
|
||||||
|
float y = my + 3f * u * (1f - (i * i) / 25f);
|
||||||
|
c.drawLine(x, y - 15f * u, x, y + 15f * u, line);
|
||||||
|
}
|
||||||
|
// The chin's little flower.
|
||||||
|
drawFlower(c, cx, jaw - 13f * u, 10f * u, PINK, 0xFFFFA8D4, 0xFFC4186E);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawAltarCloth(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, t = 340f * u, b = 548f * u;
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(CLOTH);
|
||||||
|
c.drawRoundRect(r, 10f * u, 10f * u, fill);
|
||||||
|
// A fringe of paper colours on top.
|
||||||
|
float seg = (rr - l) / 15f;
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
fill.setColor(PAPER[i % PAPER.length]);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l + i * seg, t);
|
||||||
|
path.lineTo(l + (i + 1) * seg, t);
|
||||||
|
path.lineTo(l + (i + 0.5f) * seg, t + 13f * u);
|
||||||
|
path.close();
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tier the candles stand on. */
|
||||||
|
private void drawCandleStands(Canvas c, float u) {
|
||||||
|
float l = 28f * u, rr = l + 300f * u, b = 548f * u;
|
||||||
|
r.set(l, b - 26f * u, rr, b);
|
||||||
|
fill.setColor(CLOTH);
|
||||||
|
c.drawRoundRect(r, 6f * u, 6f * u, fill);
|
||||||
|
float seg = (rr - l) / 15f;
|
||||||
|
for (int i = 0; i < 15; i++) {
|
||||||
|
fill.setColor(PAPER[i % PAPER.length]);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(l + i * seg, b - 26f * u);
|
||||||
|
path.lineTo(l + (i + 1) * seg, b - 26f * u);
|
||||||
|
path.lineTo(l + (i + 0.5f) * seg, b - 15f * u);
|
||||||
|
path.close();
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Papel picado: the battery ----------
|
||||||
|
|
||||||
|
private void drawFlags(Canvas c, int w, float u) {
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
int lit = Tally.lit(v.socPercent, 10f, FLAGS);
|
||||||
|
float fw = 100f * u, gap = (w - FLAGS * fw) / (FLAGS + 1f);
|
||||||
|
fill.setStyle(Paint.Style.FILL);
|
||||||
|
for (int i = 0; i < FLAGS; i++) {
|
||||||
|
float x = gap + i * (fw + gap);
|
||||||
|
c.save();
|
||||||
|
c.translate(x, 12f * u);
|
||||||
|
// The breeze of driving; still when parked.
|
||||||
|
c.skew((float) Math.sin(time * 2.2f + i * 0.8f) * 0.05f, 0);
|
||||||
|
boolean on = i < lit;
|
||||||
|
fill.setColor(!on ? 0x38FFFFFF : low ? RED : PAPER[i % PAPER.length]);
|
||||||
|
c.drawPath(flag, fill);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawBattery(Canvas c, float u) {
|
||||||
|
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||||
|
float x = 36f * u;
|
||||||
|
label(c, low ? "BATTERY LOW" : "BATTERY", x, 134f * u, 19f * u, low ? RED : GOLD, face, Paint.Align.LEFT);
|
||||||
|
label(c, fmt(v.socPercent, "%.0f %%", "—"), x, 200f * u, 66f * u, low ? RED : BONE, serif, Paint.Align.LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The marigold ring: the power ----------
|
||||||
|
|
||||||
|
private void drawRing(Canvas c, float cx, float cy, float u) {
|
||||||
|
float rad = 188f * u, half = marigold.getWidth() / 2f;
|
||||||
|
int lit = Tally.ofPower(v.powerKw, 150f, 50f, PETALS);
|
||||||
|
boolean regen = v.powerKw != null && v.powerKw < 0f;
|
||||||
|
for (int i = 0; i < PETALS; i++) {
|
||||||
|
// Clockwise from the top; regeneration runs the other way.
|
||||||
|
int k = regen ? PETALS - i : i;
|
||||||
|
double a = Math.PI * 2 * k / PETALS - Math.PI / 2;
|
||||||
|
float x = cx + (float) Math.cos(a) * rad, y = cy + (float) Math.sin(a) * rad;
|
||||||
|
boolean on = i < lit;
|
||||||
|
image.setAlpha(on ? 255 : 46);
|
||||||
|
c.drawBitmap(on && regen ? turquoise : marigold, x - half, y - half, image);
|
||||||
|
}
|
||||||
|
image.setAlpha(255);
|
||||||
|
// The power's number on a plate at the foot of the ring.
|
||||||
|
String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW",
|
||||||
|
Math.abs(v.powerKw));
|
||||||
|
float py = cy + rad + 2f * u;
|
||||||
|
r.set(cx - 78f * u, py - 22f * u, cx + 78f * u, py + 20f * u);
|
||||||
|
fill.setColor(0xF02C1242);
|
||||||
|
c.drawRoundRect(r, 20f * u, 20f * u, fill);
|
||||||
|
line.setColor(regen ? TURQUOISE : MARIGOLD);
|
||||||
|
line.setStrokeWidth(2f * u);
|
||||||
|
c.drawRoundRect(r, 20f * u, 20f * u, line);
|
||||||
|
label(c, power, cx, py + 10f * u, 28f * u, regen ? TURQUOISE : GOLD, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void drawSpeed(Canvas c, float cx, float cy, float u) {
|
||||||
|
label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy - 66f * u, 74f * u, INK, serif, Paint.Align.CENTER);
|
||||||
|
label(c, "KM/H", cx, cy - 128f * u, 14f * u, 0xFFC4186E, face, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Candles: the range ----------
|
||||||
|
|
||||||
|
private void drawCandles(Canvas c, float u, boolean moving) {
|
||||||
|
float x0 = 36f * u;
|
||||||
|
label(c, "RANGE", x0, 246f * u, 19f * u, GOLD, face, Paint.Align.LEFT);
|
||||||
|
label(c, fmt(v.rangeKm, "%.0f km", "—"), x0, 298f * u, 48f * u, BONE, serif, Paint.Align.LEFT);
|
||||||
|
int lit = Tally.lit(v.rangeKm, 50f, CANDLES);
|
||||||
|
float base = 522f * u, step = 36f * u;
|
||||||
|
for (int i = 0; i < CANDLES; i++) {
|
||||||
|
float x = x0 + 14f * u + i * step;
|
||||||
|
float tall = (130f - (i % 4) * 22f - (i % 3) * 8f) * u;
|
||||||
|
float top = base - tall;
|
||||||
|
r.set(x - 11f * u, top, x + 11f * u, base);
|
||||||
|
boolean on = i < lit;
|
||||||
|
fill.setColor(on ? WAX : 0x40F4E6C8);
|
||||||
|
c.drawRoundRect(r, 3f * u, 3f * u, fill);
|
||||||
|
// A band of colour.
|
||||||
|
r.set(x - 11f * u, top + tall * 0.55f, x + 11f * u, top + tall * 0.55f + 10f * u);
|
||||||
|
fill.setColor(AccentColor.withAlpha(PAPER[i % PAPER.length], on ? 0xFF : 0x40));
|
||||||
|
c.drawRect(r, fill);
|
||||||
|
// The wick.
|
||||||
|
line.setColor(on ? INK : 0x60FFFFFF);
|
||||||
|
line.setStrokeWidth(2f * u);
|
||||||
|
c.drawLine(x, top, x, top - 6f * u, line);
|
||||||
|
if (!on) continue;
|
||||||
|
// The flame, flickering only while driving, with its halo.
|
||||||
|
float flick = moving ? (float) Math.sin(time * 9f + i * 1.7f) * 2.5f * u : 0f;
|
||||||
|
fill.setColor(0x33FFB84A);
|
||||||
|
c.drawCircle(x, top - 18f * u, 19f * u, fill);
|
||||||
|
path.reset();
|
||||||
|
path.moveTo(x, top - 32f * u - Math.abs(flick));
|
||||||
|
path.quadTo(x + 10f * u + flick, top - 12f * u, x, top - 4f * u);
|
||||||
|
path.quadTo(x - 10f * u + flick, top - 12f * u, x, top - 32f * u - Math.abs(flick));
|
||||||
|
path.close();
|
||||||
|
fill.setColor(FLAME);
|
||||||
|
c.drawPath(path, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The petal path: the last two minutes of power ----------
|
||||||
|
|
||||||
|
private void drawPetals(Canvas c, int w, int h, float u) {
|
||||||
|
float base = h - 16f * u, amp = 58f * u;
|
||||||
|
float[] trail = v.powerTrail;
|
||||||
|
// The path itself, faint, whatever the readings.
|
||||||
|
fill.setColor(0x30FF9A1F);
|
||||||
|
r.set(0, base - 4f * u, w, base + 4f * u);
|
||||||
|
c.drawRect(r, fill);
|
||||||
|
if (trail == null || trail.length == 0) return;
|
||||||
|
int n = trail.length;
|
||||||
|
for (int i = 0; i < n; i++) {
|
||||||
|
float p = trail[i];
|
||||||
|
if (Float.isNaN(p)) continue;
|
||||||
|
float x = (i + 0.5f) / n * w;
|
||||||
|
float share = Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f));
|
||||||
|
// Each stack of petals rises with the power, each petal tilted its own way.
|
||||||
|
int stack = 1 + Math.round(share * 4f);
|
||||||
|
for (int s = 0; s < stack; s++) {
|
||||||
|
float y = base - s * amp / 4f;
|
||||||
|
c.save();
|
||||||
|
c.rotate(((i * 47 + s * 83) % 180) - 90f, x, y);
|
||||||
|
r.set(x - 8f * u, y - 3.5f * u, x + 8f * u, y + 3.5f * u);
|
||||||
|
fill.setColor(p < 0 ? TURQUOISE : s % 2 == 0 ? MARIGOLD : GOLD);
|
||||||
|
c.drawOval(r, fill);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- The right side ----------
|
||||||
|
|
||||||
|
/** The clock in a tin frame. */
|
||||||
|
private void drawClock(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, t = 118f * u, b = 202f * u;
|
||||||
|
r.set(l, t, rr, b);
|
||||||
|
fill.setColor(0xCC160A26);
|
||||||
|
c.drawRoundRect(r, 12f * u, 12f * u, fill);
|
||||||
|
line.setColor(GOLD);
|
||||||
|
line.setStrokeWidth(2.5f * u);
|
||||||
|
c.drawRoundRect(r, 12f * u, 12f * u, line);
|
||||||
|
fill.setColor(GOLD);
|
||||||
|
for (int i = 0; i <= 12; i++) {
|
||||||
|
float x = l + 12f * u + i * (rr - l - 24f * u) / 12f;
|
||||||
|
c.drawCircle(x, t + 8f * u, 2f * u, fill);
|
||||||
|
c.drawCircle(x, b - 8f * u, 2f * u, fill);
|
||||||
|
}
|
||||||
|
label(c, v.clock.isEmpty() ? "—" : v.clock, (l + rr) / 2f, b - 22f * u, 50f * u, BONE, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P R N D as four small marigolds: the one engaged in bloom. */
|
||||||
|
private void drawGears(Canvas c, int w, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, y = 270f * u;
|
||||||
|
String[] gears = { "P", "R", "N", "D" };
|
||||||
|
String now = Gear.label(v.gear);
|
||||||
|
float step = (rr - l) / 4f, half = marigold.getWidth() / 2f, scale = 58f * u / marigold.getWidth();
|
||||||
|
for (int i = 0; i < gears.length; i++) {
|
||||||
|
float x = l + (i + 0.5f) * step;
|
||||||
|
boolean on = gears[i].equals(now);
|
||||||
|
if (on) {
|
||||||
|
c.save();
|
||||||
|
c.scale(scale, scale, x, y);
|
||||||
|
c.drawBitmap(marigold, x - half, y - half, image);
|
||||||
|
c.restore();
|
||||||
|
} else {
|
||||||
|
fill.setColor(0x40FFFFFF);
|
||||||
|
c.drawCircle(x, y, 22f * u, fill);
|
||||||
|
}
|
||||||
|
label(c, gears[i], x, y + 11f * u, 30f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trip, usage, climate, odometer on the altar cloth. */
|
||||||
|
private void drawRows(Canvas c, int w, int h, float u) {
|
||||||
|
float rr = w - 28f * u, l = rr - 300f * u, top = 378f * u;
|
||||||
|
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 = top + i * 35f * u;
|
||||||
|
label(c, rows[i][0], l + 18f * u, y, 19f * u, GOLD, thin, Paint.Align.LEFT);
|
||||||
|
label(c, rows[i][1], rr - 18f * u, y, 23f * u, BONE, face, Paint.Align.RIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,9 @@ enum OverviewStyle {
|
|||||||
TIME_CIRCUITS(false, false),
|
TIME_CIRCUITS(false, false),
|
||||||
QUEST(false, false),
|
QUEST(false, false),
|
||||||
VISOR(true, true),
|
VISOR(true, true),
|
||||||
EMPIRE(false, false);
|
EMPIRE(false, false),
|
||||||
|
MUERTOS(false, false),
|
||||||
|
HALLOWEEN(false, false);
|
||||||
|
|
||||||
static final String PREF_KEY = "overview_style";
|
static final String PREF_KEY = "overview_style";
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ final class StylePalette {
|
|||||||
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
case QUEST: return R.style.ThemeOverlay_EV_Style_Quest;
|
||||||
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
case VISOR: return R.style.ThemeOverlay_EV_Style_Visor;
|
||||||
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
|
case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire;
|
||||||
|
case MUERTOS: return R.style.ThemeOverlay_EV_Style_Muertos;
|
||||||
|
case HALLOWEEN: return R.style.ThemeOverlay_EV_Style_Halloween;
|
||||||
default: return 0;
|
default: return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many of a row of things a value lights: flags, candles, petals, embers, bats. An unread
|
||||||
|
* value lights none, never a made-up amount. Android-free and unit-tested.
|
||||||
|
*/
|
||||||
|
final class Tally {
|
||||||
|
|
||||||
|
private Tally() {}
|
||||||
|
|
||||||
|
/** One thing per {@code step} of the value, rounded, from 0 to {@code max}; null or NaN is 0. */
|
||||||
|
static int lit(Float value, float step, int max) {
|
||||||
|
if (value == null || value.isNaN() || step <= 0f || value <= 0f) return 0;
|
||||||
|
return Math.min(max, Math.round(value / step));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Of {@code count} things round a gauge, how many the power lights: all of them at
|
||||||
|
* {@code fullDraw} kW drawn, or at {@code fullRegen} kW regenerated (negative power).
|
||||||
|
* Any real power lights at least one, so the gauge never looks idle while it is not.
|
||||||
|
*/
|
||||||
|
static int ofPower(Float kw, float fullDraw, float fullRegen, int count) {
|
||||||
|
if (kw == null || kw.isNaN() || Math.abs(kw) < 0.1f) return 0;
|
||||||
|
float share = Math.abs(kw) / (kw < 0f ? fullRegen : fullDraw);
|
||||||
|
return Math.max(1, Math.min(count, Math.round(share * count)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,8 @@
|
|||||||
<string name="overview_style_quest">Quest</string>
|
<string name="overview_style_quest">Quest</string>
|
||||||
<string name="overview_style_visor">Visor</string>
|
<string name="overview_style_visor">Visor</string>
|
||||||
<string name="overview_style_empire">Empire</string>
|
<string name="overview_style_empire">Empire</string>
|
||||||
|
<string name="overview_style_muertos">Día de Muertos</string>
|
||||||
|
<string name="overview_style_halloween">Halloween</string>
|
||||||
<string name="overview_style_nixie">Nixie tubes</string>
|
<string name="overview_style_nixie">Nixie tubes</string>
|
||||||
<string name="overview_style_chronograph">Chronograph</string>
|
<string name="overview_style_chronograph">Chronograph</string>
|
||||||
<string name="cockpit_battery">BATTERY</string>
|
<string name="cockpit_battery">BATTERY</string>
|
||||||
@@ -229,7 +231,7 @@
|
|||||||
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
|
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures, %4$d change events heard. Keys reported, then assistance properties heard, then values changed so far, the rarest first:</string>
|
||||||
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
|
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$d change events heard, %4$s. Keys reported, then assistance properties heard, then values changed, the rarest first:</string>
|
||||||
<string name="label_media_probe">Music sources</string>
|
<string name="label_media_probe">Music sources</string>
|
||||||
<string name="media_probe_hint">Finds where the head unit tells what is playing: the playback Android reports, the media sessions apps publish, and every media service of the head unit, CarPlay\'s among them, each with its song. Play something first (CarPlay, then the radio, Bluetooth or USB) and check once for each. Like a recording, the result goes to your server and to a USB stick. It only reads: nothing is played, paused or skipped.</string>
|
<string name="media_probe_hint">Finds where the head unit tells what is playing: the playback Android reports and the media sessions apps publish (CarPlay, Bluetooth, radio, USB), each with its song, and the head unit\'s media services by name. Play something first and check once for each source. Like a recording, the result goes to your server and to a USB stick. It only reads and connects to no other app: nothing is played, paused or skipped.</string>
|
||||||
<string name="btn_media_probe">Check music sources</string>
|
<string name="btn_media_probe">Check music sources</string>
|
||||||
<string name="media_probe_running">Checking… (up to a minute)</string>
|
<string name="media_probe_running">Checking… (up to a minute)</string>
|
||||||
<string name="media_probe_done">Check from %1$s, %2$s:</string>
|
<string name="media_probe_done">Check from %1$s, %2$s:</string>
|
||||||
|
|||||||
@@ -104,4 +104,24 @@
|
|||||||
<item name="evAccentStroke">#80344A99</item>
|
<item name="evAccentStroke">#80344A99</item>
|
||||||
<item name="evAccentTrack">#2A3F82</item>
|
<item name="evAccentTrack">#2A3F82</item>
|
||||||
</style>
|
</style>
|
||||||
|
<!-- Día de Muertos: the pink of the papel picado. -->
|
||||||
|
<style name="ThemeOverlay.EV.Style.Muertos" parent="">
|
||||||
|
<item name="evAccent">#FF2E93</item>
|
||||||
|
<item name="evOnAccent">#22041A</item>
|
||||||
|
<item name="evAccentLight">#FFA8D4</item>
|
||||||
|
<item name="evAccentDeep">#4A0E32</item>
|
||||||
|
<item name="evAccentSurface">#2C1242</item>
|
||||||
|
<item name="evAccentStroke">#80993366</item>
|
||||||
|
<item name="evAccentTrack">#7A2558</item>
|
||||||
|
</style>
|
||||||
|
<!-- Halloween: the pumpkin's orange. -->
|
||||||
|
<style name="ThemeOverlay.EV.Style.Halloween" parent="">
|
||||||
|
<item name="evAccent">#FF8C1A</item>
|
||||||
|
<item name="evOnAccent">#1F0E02</item>
|
||||||
|
<item name="evAccentLight">#FFC27A</item>
|
||||||
|
<item name="evAccentDeep">#3D2008</item>
|
||||||
|
<item name="evAccentSurface">#22102E</item>
|
||||||
|
<item name="evAccentStroke">#80804A1E</item>
|
||||||
|
<item name="evAccentTrack">#6A3F18</item>
|
||||||
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
public class TallyTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void anUnreadValueLightsNothing() {
|
||||||
|
assertEquals(0, Tally.lit(null, 10f, 10));
|
||||||
|
assertEquals(0, Tally.lit(Float.NaN, 10f, 10));
|
||||||
|
assertEquals(0, Tally.ofPower(null, 150f, 50f, 24));
|
||||||
|
assertEquals(0, Tally.ofPower(Float.NaN, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void oneThingPerStepUpToTheRow() {
|
||||||
|
assertEquals(8, Tally.lit(76f, 10f, 10));
|
||||||
|
assertEquals(10, Tally.lit(100f, 10f, 10));
|
||||||
|
assertEquals(8, Tally.lit(900f, 50f, 8));
|
||||||
|
assertEquals(6, Tally.lit(312f, 50f, 8));
|
||||||
|
assertEquals(0, Tally.lit(0f, 10f, 10));
|
||||||
|
assertEquals(0, Tally.lit(-5f, 10f, 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void powerFillsTheGaugeEachWay() {
|
||||||
|
assertEquals(0, Tally.ofPower(0f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(150f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(400f, 150f, 50f, 24));
|
||||||
|
assertEquals(12, Tally.ofPower(-25f, 150f, 50f, 24));
|
||||||
|
assertEquals(24, Tally.ofPower(-50f, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void realPowerAlwaysShows() {
|
||||||
|
assertEquals(1, Tally.ofPower(0.5f, 150f, 50f, 24));
|
||||||
|
assertEquals(1, Tally.ofPower(-0.2f, 150f, 50f, 24));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 334 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 441 KiB |
Reference in New Issue
Block a user