Merge styles-retro-future: Overview styles, Mascot, drive mode (app 2.34.1)
App 2.22.3 → 2.34.1. Server, Grafana and the k8s manifests are unchanged. Overview - Read every 0.2 s. Nineteen styles in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (VFD), Futuristic (energy flow), Sci-fi, Cartoon, Blueprint (top view with tyre pressures), MG (octagonal dials), Minimal, Terminal, Aviation, Nixie tubes, Chronograph and Mascot. The drawn styles share ClusterValues and take the accent colour; Retro and Terminal also offer their original palette. - Mascot: an anime co-pilot whose mood follows the drive (MascotMood: reverse, low battery, asleep in P, regenerating, accelerating hard, fast, content, waiting), with lines in a speech bubble. Three characters: the classic one, a chibi and a chibi fox (ChibiBase). - Full width: a button beside the MG4 logo hides the navigation; a swipe in from the left edge brings it back, or, beside the car's own sidebar, a tab at the left edge. Pages change only by the navigation. Drive mode (the first vehicle setting the app writes; AGENTS.md rule 3) - Settings: Eco, Normal, Sport, Snow, Custom. The lit one is what the car reports. Writes only in P at 0 km/h, both read fresh, through EVHardware's standstill gate; the result is the mode read back. - "Set at start": the last confirmed choice is set again once when the head unit starts, only before the car first leaves P. Logs - Signal recorder: every property and vendor value the head unit offers, read for 3 minutes with the steering-wheel keys, changes listed rarest first, saved to a USB stick. - Adaptive cruise control was looked for and is not there: the head unit gets neither the set speed nor the distance (SPECS.md, "Signals the car does not provide"). The cruise reads are removed again. Tested: unit tests, lint, the emulator. Not tried on the car: the drive mode writes, the tab beside the car's sidebar, the Mascot moods with real data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
@@ -35,9 +35,15 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
||||
`secrets.example.yaml` holds `CHANGE-ME` placeholders only. Stage files explicitly, check the
|
||||
index for changes that are not yours (the owner works in parallel), and scan the staged diff
|
||||
for secrets before every commit. Never print a token.
|
||||
3. **Telemetry is read-only.** The app never writes a vehicle setting. The only system setting it
|
||||
touches is `policy_control`, and only to remove the CarPlay entry the withdrawn full-screen
|
||||
experiment (2.19.2–2.19.5) may have left.
|
||||
3. **Telemetry is read-only; the drive mode is the one exception.** The app writes no vehicle
|
||||
setting except the drive mode (owner's decision, 2026-10-25): a tap on Settings, or — with
|
||||
"Set at start" on — once when the head unit starts, the mode last chosen there and confirmed
|
||||
by the car, and only before the car first leaves P. Every write only in P at 0 km/h with both
|
||||
read fresh (unreadable means no write), through EVHardware's standstill gate; the result
|
||||
shown is what the car reports back (`DriveModeWrite`, `DriveModeSetter`). Nothing else is
|
||||
written. The only system
|
||||
setting it touches is `policy_control`, and only to remove the CarPlay entry the withdrawn
|
||||
full-screen experiment (2.19.2–2.19.5) may have left.
|
||||
4. **A failed read is not a zero.** Unreadable values are `null`, omitted from payloads and
|
||||
shown as "—". Sending 0 for SOC tells ABRP the battery is empty.
|
||||
5. **Fail closed.** Updates (origin, SHA-256, package, signature), credentials, unknown state.
|
||||
@@ -69,12 +75,12 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
|
||||
|
||||
| Tab | Contents |
|
||||
|---|---|
|
||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread. Three styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace) |
|
||||
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph, Mascot (`MascotView`, an anime co-pilot whose face follows `MascotMood`; classic, `ChibiFigure` or `FoxFigure` on `ChibiBase`, chosen by `MascotCharacter`); the drawn styles share `ClusterValues`. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) |
|
||||
| Consumption / Charging | period totals, daily log, charging sessions and curve |
|
||||
| Live data | on/off, ABRP API key and user token, upload frequency |
|
||||
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
|
||||
| Settings | full screen, voice key, tyre unit, accent colour, Overview style, battery, charging price, version, update check, install from USB, About |
|
||||
| Logs | update log (every install step, with the platform's words), upload log |
|
||||
| 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, the Mascot character (only for that style) (offered only for styles drawn in it, `OverviewStyle.usesAccent`; Retro and Terminal add "Original" for their own palette, `hasOriginal`), tyre unit, battery and charging price (one card on wide screens), version, update check, install from USB, About |
|
||||
| Logs | signal recorder (`SignalRecorder`/`SignalRecording`: every property and vendor getter via `VendorSurfaceProbe.capture`, again and again for 3 minutes, plus steering-wheel key broadcasts; lists what changed, rarest first; saved to the app and a USB stick; how to find where the car publishes something), update log (every install step, with the platform's words), upload log |
|
||||
|
||||
Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service,
|
||||
15 s tick), `TelemetryUploader` (+ `TelemetryQueue`, persisted), `TelemetryPayload` (server
|
||||
|
||||
@@ -2432,6 +2432,16 @@ object EVHardware {
|
||||
return ok
|
||||
}
|
||||
|
||||
/**
|
||||
* The drive mode write Nils' MG4 uses: the CarPropertyManager write alone, behind
|
||||
* [VehicleWriteGate] like every write here, without [setDriveMode]'s fallback binder
|
||||
* transaction or its remembered "last mode". True means the write call went through, not
|
||||
* that the car took it: read [getDriveModeRaw] back.
|
||||
*/
|
||||
@RequiresStandstill
|
||||
fun writeDriveModeProperty(mode: DriveMode): Boolean =
|
||||
setIntPropertyCPM(PROP_DRIVE_MODE, AREA_GLOBAL, mode.value)
|
||||
|
||||
@RequiresStandstill
|
||||
fun setRegenLevel(level: RegenLevel): Boolean {
|
||||
if (logEnabled) AppLogger.i(TAG, "setRegenLevel → ${level.label} (${level.value})")
|
||||
|
||||
@@ -31,7 +31,8 @@ top. Those are Fatih's reads the car needs and upstream does not have — the ve
|
||||
ids `0x21401553`–`56`, the vendor mileage `0x21401566` for the odometer, the charging status
|
||||
from `0x2140F409`, SoC, range, power, pack voltage and current on SWI69 as on SWI68, and SWI69
|
||||
speed already in km/h — plus translated comments and, from this fork, the gear's raw code
|
||||
(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`). To take upstream again, merge three ways with
|
||||
(`EVHardware.getGearRaw`, `EnergySnapshot.gearRaw`) and a drive mode write without the binder
|
||||
fallback (`writeDriveModeProperty`). To take upstream again, merge three ways with
|
||||
`e4e1158` as the base.
|
||||
|
||||
The car app links EVHardware into its APK, so the **built app** may only be used and passed on
|
||||
|
||||
@@ -79,12 +79,12 @@ with the server using a token of its own, obtained by pairing with a phone (belo
|
||||
|
||||
| Tab | What it shows |
|
||||
|---|---|
|
||||
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read every half second |
|
||||
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro, Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes, Chronograph and Mascot (an anime co-pilot who reacts to the drive, classic, chibi or a chibi fox). The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back; with Settings → Full screen off, the car's own sidebar sits at that edge, so tap the small tab at the left edge (›) instead. Pages change only by the navigation, not by swiping sideways |
|
||||
| **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months |
|
||||
| **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve |
|
||||
| **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
|
||||
| **Server** | Server URL, pairing by QR code, ingest token, installation ID, config import |
|
||||
| **Settings** | Full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
|
||||
| **Settings** | Drive mode (Eco, Normal, Sport, Snow, Custom: changes only in P at a standstill; the highlighted one is what the car reports; with "Set at start" the last confirmed choice is set again when the car starts, while it is still in P), full screen, voice key, tyre pressure unit, accent colour, battery (manual SoH, usable capacity), charging price, entry on the phone, version, update check, install from USB stick, About |
|
||||
| **Logs** | Update log (every install step), upload log |
|
||||
|
||||
- **Opening the app:** the MG4 launcher does not list third-party apps. **Double-press the
|
||||
|
||||
@@ -70,6 +70,13 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
||||
- **Pack energy (`soe`) and capacity:** not published. The Live dashboard computes energy in pack
|
||||
as SOC × usable capacity, from the hidden dashboard constant `usableKwh` (50.8 kWh for the
|
||||
51 kWh battery) in `k8s/grafana/dashboards/live.json`.
|
||||
- **Adaptive cruise control (set speed, distance to the car ahead, ACC state):** the head unit gets
|
||||
none of it (SWI68, checked 2026-10-25). The `AAD_*` properties of `CarAdvancedAssistedDrivingManager`
|
||||
exist and answer, but stay at their defaults with ACC engaged behind another car
|
||||
(`AAD_EPTCCTRGTSPD` `0x2160A15B` 0.0, `AAD_ACC_OBJ_*` 0.0 / -128.0, modes 0). A signal recording
|
||||
of all 1,194 values while the set speed was 101, 90 and 80 km/h shows none of those numbers
|
||||
(also tried as m/s, mph and ×2/×10/×16/×64) outside the actual speed, and the ACC buttons send
|
||||
no key broadcast. Most likely only the instrument cluster has it.
|
||||
|
||||
## To do
|
||||
|
||||
@@ -131,6 +138,248 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
|
||||
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
|
||||
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
|
||||
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
|
||||
- 2026-10-25 — App 2.34.1 (branch `styles-retro-future`): the Overview's full screen without the
|
||||
app's full screen. With the car's bars shown, the car's own sidebar sits at the left edge, so a
|
||||
swipe from the edge lands on it, not on the app (owner, on the car). There a small tab now sits
|
||||
at the app's left edge instead of the faint handle (48 × 96 dp touch area, a dark tongue with
|
||||
›): a tap brings the navigation back, and so does a swipe starting in the app's first 56 dp
|
||||
(`tabSwipe`). In the app's full screen nothing changed (handle and edge swipe, the owner's
|
||||
preference: least clutter). Switching Full screen on Settings updates it at once. Emulator:
|
||||
the tab shows, a tap and a swipe on the left strip both bring the navigation back. Not tried
|
||||
on the car.
|
||||
- 2026-10-25 — App 2.34.0 (branch `styles-retro-future`): **drive mode at start.** The car falls
|
||||
back to its own default mode when switched on; with "Set at start" (on by default, the switch
|
||||
in the drive mode card) the app sets the mode last chosen on Settings again. Only a choice the
|
||||
car confirmed counts (`PREF_CHOSEN`, saved on APPLIED). `AbrpUploadService` starts with the
|
||||
head unit and runs `DriveModeWrite.atStart` (pure, tested) once a second on its own thread:
|
||||
wait for gear, speed and mode; nothing when the car already reports the mode; one write, in P
|
||||
at 0 km/h, through the same `DriveModeSetter` as the buttons (write, read back up to 3 s); give
|
||||
up for this start as soon as the car is out of P or moving, or after 2 minutes. So a mode set
|
||||
with the car's own button after driving off stands. The result is kept with its time and
|
||||
shown under the buttons ("At start (25.10. 20:31): Sport set."). Assumes the head unit starts
|
||||
(and the service with it) when the car is switched on; a start without that (head unit kept
|
||||
running) is not caught. A service restart (app update, after a kill) counts as a start, still
|
||||
only in P at 0 km/h. Hints shortened to fit the page. Emulator: page fits, the start check
|
||||
waits and gives up (no readings), no crash. Not tried on the car.
|
||||
- 2026-10-25 — App 2.33.0 (branch `styles-retro-future`): **drive mode from Settings**, the first
|
||||
vehicle setting the app writes. The owner lifted rule 3 for the drive mode only (AGENTS.md).
|
||||
A card at the top of Settings' second column: Eco, Normal, Sport, Snow, Custom (codes 2, 3, 4,
|
||||
6, 7 at `0x2140A17C`; the 2.32.0 recording read 2, Eco). The lit button is the mode the car
|
||||
reports, from every 0.2 s reading. A tap reads gear and speed fresh and writes only in P at
|
||||
0 km/h (`DriveModeWrite`, pure and unit-tested; unreadable gear or speed is a refusal), through
|
||||
the new `EVHardware.writeDriveModeProperty`: the CarPropertyManager write behind
|
||||
`VehicleWriteGate` (0 km/h, supported firmware), without `setDriveMode`'s fallback binder
|
||||
transaction. Then the mode is read back for up to 3 s, and the status beside the title says
|
||||
what the car reports ("set", "not applied: the car still reports …", "could not be read
|
||||
back"). One change at a time, on the settings thread; each attempt goes to logcat. To fit the
|
||||
page, the wide layout's charging price row moved into the battery card ("Battery and charging
|
||||
price"). Emulator: the card fits, a tap is refused (no gear or speed there). Whether the car
|
||||
takes the write at all is untested: the first try on the car decides.
|
||||
- 2026-10-25 — App 2.32.1 (branch `styles-retro-future`): the cruise control work ends. The ACC
|
||||
recording of 2.32.0 (ACC set to 101, 90 and 80 km/h, a car ahead) found nothing: see "Signals
|
||||
the car does not provide". Removed: `CruiseLog` and its section on the Logs tab, and in
|
||||
EVHardware the set-speed read (`getCruiseTargetSpeedRaw`, `EnergySnapshot.cruiseTargetSpeedRaw`),
|
||||
`readAccDebug` and `ReadFailureLog.reason` — EVHardware is again as before 2.27.3. The old log
|
||||
files are deleted on start. The recorder stays as a general tool, renamed **Signal recorder**
|
||||
(`SignalRecorder`, `SignalRecording`, `signal-recording-<time>.txt`). What the recording did
|
||||
show, for later: `0x2140A15A` (`AAD_ELECVEHSYSMD`) flips 3/7 with accelerating and slowing,
|
||||
`0x21409326` is the turn indicator (0 off, 1 and 2 the sides), `0x2140159D` (unnamed) shows
|
||||
values like 300–1425 only while slowing down, `0x2140B970`/`0x2140B97B` are the parking sensors
|
||||
in R. A full capture takes about 5 s on the car. Tests and lint; emulator: the recorder starts,
|
||||
stops and saves under its new name. Not tried on the car.
|
||||
- 2026-10-25 — App 2.32.0 (branch `styles-retro-future`): the **ACC recorder** on the Logs tab.
|
||||
The 2.31.1 log on the car (a drive with ACC engaged behind another car) showed every assist
|
||||
property constant: `cc 0.00`, `ica 0`, `tja 1`, `sas 0`, `sam 0`, `obj 0.00`, `dist 0.00`,
|
||||
`rel -128.00` (the field's minimum, i.e. no data). The `AAD_*` properties named in the head
|
||||
unit's manager are not fed on this car. The recorder looks everywhere instead: for 3 minutes it
|
||||
reads every value the head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager
|
||||
property and every value getter of the vendor objects, read only), waiting at least 2 s and at
|
||||
least as long as the last capture took (at most half the time busy), and every
|
||||
`com.saic.keyevent.hardkey.report` broadcast. `AccRecording` (pure, unit-tested) keeps the
|
||||
first capture whole, then what changed and when, and per value how often and through which
|
||||
values; the Logs tab shows the keys, then the changed values, the rarest first (the leads).
|
||||
At the end the whole recording goes to `files/acc_recording.txt` and to the app's folder on
|
||||
every removable volume (`Android/data/<package>/files/acc-recording-<time>.txt`). The ACC
|
||||
buttons do nothing on the head unit's screen (owner). Emulator: the recorder starts, stops,
|
||||
records a sent key broadcast and saves (its CarPropertyManager is not connected there, so no
|
||||
values); the USB stick copy was not tried. Not tried on the car.
|
||||
- 2026-10-25 — App 2.31.1 (branch `styles-retro-future`): the cruise control log now logs while
|
||||
driving. 2.27.3 wrote a line only when the target speed changed, so on the car it showed one
|
||||
line at the start. Now `CruiseLog` writes a line at once when a mode or the target changes and
|
||||
every 2 s while driving (D or R, or moving), with every driver-assistance property that may
|
||||
describe the ACC, raw (`EVHardware.readAccDebug`, read once a second on the reader thread):
|
||||
the target speed (`cc`, `0x2160A15B`), the traffic-jam / intelligent cruise assist operation
|
||||
mode (`ica`, `AAD_LANE_TJAICAOPMD` `0x2140A12A`), the traffic-jam assist (`tja`, `0x2140A107`),
|
||||
the speed assist system and mode (`sas`/`sam`, `0x2140A101`/`0x2140A102`) and the object the
|
||||
ACC follows (`obj`/`dist`/`rel`, `AAD_ACC_OBJ_*` `0x2160A12F`/`0x2160A12C`/`0x2160A12E`; these
|
||||
alone never write a line). An unread value carries the reason the car gave
|
||||
(`ReadFailureLog.reason`). The last 600 lines live in `files/cruise_log.txt`, appended to; the
|
||||
Logs tab shows the newest 150. The old change-only log in preferences is dropped. Only while the
|
||||
app is open (the reader runs with the screen). Emulator: every value unread, the file written
|
||||
and kept over a restart. Not tried on the car.
|
||||
- 2026-10-25 — App 2.31.0 (branch `styles-retro-future`): the Overview's full screen moved into
|
||||
the navigation: its button now sits beside the MG4 logo (at the end of the bottom bar on narrow
|
||||
screens) instead of over the page's top-right corner, and only shows on the Overview. A swipe
|
||||
in from the left edge brings the navigation back (`EdgeSwipe`, pure and unit-tested: starts
|
||||
within 48 dp of the edge, 64 dp to the right, more sideways than up or down; the page under it
|
||||
gets a cancel). A faint handle at the left edge marks it in full screen. Swiping sideways no
|
||||
longer changes the page (`ViewPager2.setUserInputEnabled(false)`); the navigation does. The
|
||||
exit icon and its string went. Checked on the emulator: the button enters full screen, a
|
||||
swipe in the middle does nothing, a swipe from the edge brings the navigation back, sideways
|
||||
swipes keep the page, the narrow layout opens. Not tried on the car.
|
||||
- 2026-10-24 — App 2.30.0 (branch `styles-retro-future`): a third Mascot, the **chibi fox**
|
||||
(`FoxFigure`): a sitting fox with a big head, pointed ears, cream cheeks, chest and tail tip,
|
||||
dark paws and a scarf in the accent colour. Ears and tail act the mood with the face: the
|
||||
tail wags while content and fast while cheering, stands stiff when determined and hangs low
|
||||
when worried or asleep; ears droop when worried or asleep and lie back when fast, when the
|
||||
scarf also flies. Its own lines (`fox_*`, "Kon!"). The chibi's shared parts (frame, eyes,
|
||||
mouths, limbs by mood, effects) moved into `ChibiBase`, which both figures extend. Settings →
|
||||
Mascot: Classic / Chibi / Fox. Emulator: all moods checked with sample values in a throwaway
|
||||
build; the tail's swing is kept to about 18° so it stays clear of the values. Not tried on the
|
||||
car.
|
||||
- 2026-10-24 — App 2.29.0 (branch `styles-retro-future`): a second Mascot, the **chibi**
|
||||
(`ChibiFigure`): small and full-figure, a big round head with a coral bob and a star clip,
|
||||
a hoodie in the accent colour, dark outlines like a sticker; an adult character in chibi
|
||||
proportions, clothed. Her whole body acts the mood: waves when content, both arms up and
|
||||
hopping with star eyes when cheering, fist at her chest when determined, hands on her cheeks
|
||||
with a tear when worried, a nose bubble asleep in P, glancing and pointing back in R, leaning
|
||||
into the wind when fast, "?" while waiting. Her own lines (`chibi_*`). Settings → Mascot
|
||||
(Classic / Chibi, `MascotCharacter`, pref `mascot_character`) sits beside Tyre pressure and
|
||||
shows only while the Mascot style is chosen. Emulator: all moods checked with sample values in
|
||||
a throwaway build, and the Settings drop-down. Not tried on the car.
|
||||
- 2026-10-24 — App 2.28.0 (branch `styles-retro-future`): Overview style **Mascot**
|
||||
(`MascotView` on `ClusterCanvasView`): an anime co-pilot, head and shoulders, drawn on a
|
||||
100 × 100 grid — an adult woman, fully dressed, with a headset and a racing jacket in the
|
||||
accent colour (so the style takes the accent). Her face follows `MascotMood`, chosen from the
|
||||
reading in this order: waiting (nothing read), reverse (glances back), worried under 20 %
|
||||
(sweat drop), asleep in P (drifting Zs), cheering while regenerating more than 5 kW
|
||||
(sparkles), determined over 60 kW, enjoying 100 km/h and more (wind lines), else content. A
|
||||
new mood shows after it lasts 1.5 s (`MascotMood.Holder`), so values hovering at a threshold
|
||||
do not make her flicker; R and "waiting" show at once. Unread values are never a reason for a
|
||||
mood. She says a line per mood in a speech bubble (`mascot_*` string arrays, one picked every
|
||||
30 s, shrunk to fit). Beside her: speed, the gear in a ring, battery bar with percent and
|
||||
range, power, 5-minute usage, trip, and the clock. She blinks and the Zs, sparkles and wind
|
||||
move with the 0.2 s readings; no animation loop of its own. Emulator: all eight moods checked
|
||||
with sample values in a throwaway build, and "waiting" with no data. Not tried on the car.
|
||||
- 2026-10-24 — App 2.27.3 (branch `styles-retro-future`): first step to the cruise control (ACC)
|
||||
set speed. The head unit's `CarAdvancedAssistedDrivingManager` names a vendor float
|
||||
`AAD_EPTCCTRGTSPD` = `0x2160A15B`, by its name the powertrain's cruise control target speed;
|
||||
nothing else in the decompiled SDK looks like a set speed (the other ACC properties are
|
||||
objects ahead: distances, relative speeds). EVHardware reads it raw
|
||||
(`getCruiseTargetSpeedRaw`, `EnergySnapshot.cruiseTargetSpeedRaw`; read-only), and the Logs
|
||||
tab lists every change with time, speed and gear (`CruiseLog`, last 12). Unit and the value
|
||||
while cruise control is off are unknown until read on the car; the dashboards show nothing of
|
||||
it yet. Emulator: the property is absent there, one "unread" line, no crash. Not tried on the
|
||||
car.
|
||||
- 2026-10-24 — App 2.27.2 (branch `styles-retro-future`): Terminal can be drawn in the accent
|
||||
colour too (text in its light shade, the command line in the accent, plain black; the low
|
||||
battery stays amber). Instead of separate "(accent colour)" entries, Retro and Terminal are one
|
||||
entry each and the accent colour drop-down beside the style offers "Original" first (their own
|
||||
palette, the default) and then the accent colours; the choice is kept per style
|
||||
(`original_colours_<STYLE>`). Picking "Original" leaves the app's accent as it is. The
|
||||
"Retro (accent colour)" entry is gone: a stored `RETRO_ACCENT` becomes Retro in the accent
|
||||
colour (`OverviewStyle.hasOriginal`, `wasAccentEntry`; unit-tested). Fourteen entries in the
|
||||
style list. Checked on the emulator: Terminal original → Dynamic Red, Retro still original,
|
||||
and a stored `RETRO_ACCENT` migrated to Retro in Dynamic Red
|
||||
(`screenshots/nilsmg4-2.27.2-terminal-accent.png`); no crash. Not tried on the car.
|
||||
- 2026-10-24 — App 2.27.1 (branch `styles-retro-future`): Settings → Display rearranged:
|
||||
Overview style first, the accent colour beside it, tyre pressure on its own row below. The
|
||||
accent colour is only offered for styles drawn in it (Tiles, Gauges, Cockpit, Retro (accent
|
||||
colour), Futuristic, MG, Minimal, Chronograph); for the styles with their own palette (Retro,
|
||||
Sci-fi, Cartoon, Blueprint, Terminal, Aviation, Nixie tubes) the field is greyed out and
|
||||
ignores taps, keeping the colour chosen before, and a line says "… has its own colours. To
|
||||
change the accent colour, pick a style that uses it." The styles are now an enum,
|
||||
`OverviewStyle` (stored names, `usesAccent`, the old `DASHBOARD` value; unit-tested), in place
|
||||
of the string array. The style list is wider than its field so "Retro (accent colour)" fits.
|
||||
Checked on the emulator: Tiles (accent open), Cartoon (greyed, tap ignored, note), back to
|
||||
Tiles; the page fits without scrolling
|
||||
(`screenshots/nilsmg4-2.27.1-settings-own-colours.png`). Not tried on the car.
|
||||
- 2026-10-24 — App 2.27.0 (branch `styles-retro-future`): the Overview can go full width. A
|
||||
small round button in its top-right corner (40 dp disc in a 72 dp touch area, over the pages)
|
||||
hides the app's navigation (sidebar, or the bottom bar on narrow screens); its icon turns to
|
||||
"shrink" and a second tap brings the navigation back. Only on the Overview: other pages
|
||||
always show the navigation and no button, swiping between pages still works, and coming back
|
||||
to the Overview restores full width. Remembered (`overview_fullscreen`). The top-right corner
|
||||
was chosen after looking at all fifteen styles (top left covered Tiles' "Battery"); Minimal's
|
||||
clock moved left of it. Checked on the emulator: toggle, swipe away and back, restart, Tiles
|
||||
and MG at full width (`screenshots/nilsmg4-2.27.0-overview-fullscreen.png`); no crash. Not
|
||||
tried on the car.
|
||||
- 2026-10-24 — App 2.26.0 (branch `styles-retro-future`): five more Overview styles, chosen to
|
||||
differ in arrangement as much as in look, on a new base class `ClusterCanvasView` (values,
|
||||
easing, null-safe formatting, animating only while something moves and the view is shown).
|
||||
**Minimal** (`MinimalView`): the speed alone, very large and thin; power as a short line
|
||||
growing left (green, regenerating) or right from the centre; the battery as a hairline along
|
||||
the bottom edge. **Terminal** (`TerminalView`): text only, green monospace with scan lines —
|
||||
rows with ASCII bars, the gear as [D], tyres, a prompt with a cursor that blinks as readings
|
||||
redraw it; a low battery row turns amber. **Aviation** (`AviationView`): a primary flight
|
||||
display — speed and battery on scrolling tapes with readout boxes (blank, not centred on 0,
|
||||
when unread), a horizon that pitches with power (up drawing, down regenerating) with a kW
|
||||
ladder and the aircraft symbol, annunciators on top, data boxes below. **Nixie tubes**
|
||||
(`NixieView`): orange glowing digits in glass tubes with the unlit numerals behind and a mesh,
|
||||
brass name plates on walnut; speed, gear and battery large, power, range, usage and clock
|
||||
smaller (`SevenSegment` lays the text out in tubes). **Chronograph** (`ChronographView`): a
|
||||
watch face — the speed on the main hand over a bezel scale, sub-dials for battery, power and
|
||||
usage, the gear in a window, engraved columns either side (its radial gradient is a Canvas
|
||||
shader made in `onSizeChanged`). Checked on the emulator with a throwaway build cycling the
|
||||
five styles with sample values (drawing and regenerating;
|
||||
`screenshots/nilsmg4-2.26.0-minimal.png`, `-terminal`, `-aviation`, `-nixie`, `-chronograph`)
|
||||
and without data; no crash. Not tried on the car.
|
||||
- 2026-10-24 — App 2.25.0 (branch `styles-retro-future`): Overview style "MG" (`OctagonView`),
|
||||
after the classic MG dashboards whose dials sat in octagonal bezels — not a copy of the MG4's
|
||||
own driver display, and without MG's octagon logo. Three octagonal dials with chrome bezels,
|
||||
cream scales and needles in the accent colour: speed in the middle (digits and P R N D under
|
||||
the hub), battery E…F on the left (red zone below 20 %, range), power −50…150 kW on the right
|
||||
(green charge zone below zero). A cream line below: odometer, trip, 5-minute usage, climate,
|
||||
clock, separated by small accent octagons. Needles ease and redraw only until settled, only
|
||||
on screen; unread values draw no needle and "—". Palette `mg_*`. Checked on the emulator with
|
||||
sample values (`screenshots/nilsmg4-2.25.0-mg.png`) and without data; no crash. Not tried on
|
||||
the car.
|
||||
- 2026-10-24 — App 2.24.1 (branch `styles-retro-future`): the line of text above every page
|
||||
("Your drive at a glance.", "Settings. Display, battery…" and so on) is gone, at the owner's
|
||||
request; the pages start at the top, level with the sidebar, and gain about 45 dp. Its 14
|
||||
strings and the title arrays are removed. Checked on the emulator: no crash, and every tab
|
||||
still fits 1778×640 without scrolling. Not tried on the car.
|
||||
- 2026-10-24 — App 2.24.0 (branch `styles-retro-future`): three more Overview styles, each one
|
||||
Canvas view fed the shared `ClusterValues` (now with the tyre pressures, formatted in the
|
||||
chosen unit). **Sci-fi** (`SciFiView`): a starship console — rounded elbow frames in orange
|
||||
and lilac, pill labels, condensed capitals; velocity large, power as blocks (regeneration
|
||||
blue), rows for reserves, range, efficiency, since start, trip and climate, the reserves as a
|
||||
stack of blocks, gears as blocks in the frame; below 20 % the frames turn red ("red alert").
|
||||
**Cartoon** (`CartoonView`): comic panels with ink outlines and hard shadows on night blue; a
|
||||
battery with a face (happy, unsure, worried and sweating), a speedometer whose needle springs
|
||||
into place (animated only until it settles, only on screen), the speed in a speech bubble,
|
||||
the gear on a sticker, a "zap-o-meter" with a sound word (PSST, PURR, VROOM, ZOOM!,
|
||||
RECHARGE!), stats on pills. **Blueprint** (`BlueprintView`): a top-view drawing of the car on a
|
||||
gridded navy sheet — the battery hatched as far as it is charged, leader lines to each tyre's
|
||||
pressure, power as a force arrow (dashed and reversed while regenerating), range and trip as
|
||||
dimension lines, the cabin setpoint as a note, and a title block with speed, power, gear,
|
||||
usage, odometer and time. Palettes `scifi_*`, `cartoon_*`, `blueprint_*` in colors.xml.
|
||||
`MainActivity` now maps each style to its pane in a table (`stylePanes`); the new panes are
|
||||
built in code (`drawnPane`). Checked on the emulator with sample values in a throwaway build
|
||||
(`screenshots/nilsmg4-2.24.0-scifi.png`, `-cartoon.png`, `-blueprint.png`) and without data
|
||||
through the real setting; no crash. Not tried on the car.
|
||||
- 2026-10-24 — App 2.23.1 (branch `styles-retro-future`): "Retro (accent colour)", the Retro
|
||||
style in the chosen paint colour — display in `evAccent`, labels in `evAccentLight`;
|
||||
regeneration stays green, a low battery turns amber (not red, so it never looks like Dynamic
|
||||
Red). Same `RetroView`, `setAccentPalette`; listed under Retro, stored as `RETRO_ACCENT`.
|
||||
Checked on the emulator in Dynamic Red with sample values
|
||||
(`screenshots/nilsmg4-2.23.1-retro-accent.png`). Not tried on the car.
|
||||
- 2026-10-24 — App 2.23.0 (branch `styles-retro-future`): two more Overview styles, each one
|
||||
Canvas view fed a `ClusterValues` per reading. **Retro** (`RetroView`): an 80s digital cluster
|
||||
as a vacuum fluorescent display — slanted seven-segment digits (`SevenSegment`, unit-tested)
|
||||
with the unlit segments faintly visible, amber labels, a lit P R N D selector, the speed large,
|
||||
power as a −50…150 kW bar (regeneration green), charge as a vertical bar (red below 20 %),
|
||||
range, 5-minute and since-start usage, trip, climate, clock and odometer; its fixed palette is
|
||||
`retro_*` in colors.xml (a style's own colours, not the accent). **Futuristic** (`FlowView`): a
|
||||
hairline HUD in the accent colour — a battery capsule of scan lines, an energy channel whose
|
||||
particles flow to the wheel while drawing and back in green while regenerating (faster with
|
||||
more power), the speed inside a ring of dashes that turns with the speed and a power arc,
|
||||
P R N D in a column, and a readout line. It animates at about 30 frames a second only while
|
||||
power or speed is non-zero and the view is shown. Unread values show dashes and light or move
|
||||
nothing in both. The Overview now reads the car every 0.2 s instead of 0.5 s (a read still
|
||||
running skips the tick, as before). Checked on the emulator with sample values in a throwaway
|
||||
build (`screenshots/nilsmg4-2.23.0-retro.png`, `…-futuristic.png`) and without data through
|
||||
the real setting; no crash. Not tried on the car, including the 0.2 s reading rate.
|
||||
- 2026-10-24 — App 2.22.3: the Overview style "Dashboard" is now called "Gauges" (stored as
|
||||
`GAUGES`; the old value `DASHBOARD` still selects it, so the choice survives the update;
|
||||
`pane_dashboard.xml` is `pane_gauges.xml`). The branches `dashboard-gauges` and
|
||||
|
||||
@@ -34,8 +34,8 @@ android {
|
||||
applicationId "com.stinnesbeck.nilsmg4"
|
||||
minSdk 27
|
||||
targetSdk 36
|
||||
versionCode 114
|
||||
versionName "2.22.3"
|
||||
versionCode 134
|
||||
versionName "2.34.1"
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -69,6 +69,8 @@ public class AbrpUploadService extends Service {
|
||||
private TelemetryUploader telemetryUploader;
|
||||
private SteeringKeyListener steeringKeys;
|
||||
private ScheduledExecutorService scheduler;
|
||||
/** Sets the chosen drive mode at start, then ends (DriveModeWrite.atStart). */
|
||||
private Thread driveModeAtStart;
|
||||
private SharedPreferences prefs;
|
||||
|
||||
/** What the foreground notification currently says, so re-asserting it does not blank it. */
|
||||
@@ -100,6 +102,7 @@ public class AbrpUploadService extends Service {
|
||||
telemetryUploader = new TelemetryUploader(getApplicationContext());
|
||||
steeringKeys = new SteeringKeyListener(this, prefs);
|
||||
steeringKeys.register();
|
||||
startDriveModeAtStart();
|
||||
|
||||
scheduler.scheduleWithFixedDelay(
|
||||
this::safeUploadCycle,
|
||||
@@ -145,9 +148,62 @@ public class AbrpUploadService extends Service {
|
||||
telemetryUploader.stop();
|
||||
}
|
||||
if (steeringKeys != null) steeringKeys.unregister();
|
||||
if (driveModeAtStart != null) driveModeAtStart.interrupt();
|
||||
Log.i(TAG, "Service stopped");
|
||||
}
|
||||
|
||||
// ---------- The drive mode at start ----------
|
||||
|
||||
/**
|
||||
* The service starts with the head unit, i.e. when the car is switched on: the mode the
|
||||
* driver chose on Settings (and the car confirmed) is set again, once, while the car is in
|
||||
* P at 0 km/h — readings checked every second, for at most two minutes, and never after
|
||||
* the car has left P. Its own thread: the upload scheduler may be busy with the network.
|
||||
*/
|
||||
private void startDriveModeAtStart() {
|
||||
driveModeAtStart = new Thread(() -> {
|
||||
long started = android.os.SystemClock.elapsedRealtime();
|
||||
try {
|
||||
while (true) {
|
||||
Integer chosen = prefs.contains(DriveModeWrite.PREF_CHOSEN)
|
||||
? prefs.getInt(DriveModeWrite.PREF_CHOSEN, 0) : null;
|
||||
boolean enabled = prefs.getBoolean(DriveModeWrite.PREF_AT_START, true);
|
||||
Integer gear = com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw();
|
||||
Float speed = com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh();
|
||||
Integer current = com.evsuite.hardware.EVHardware.INSTANCE.getDriveModeRaw();
|
||||
DriveModeWrite.StartStep step = DriveModeWrite.atStart(chosen, enabled, gear, speed,
|
||||
current, android.os.SystemClock.elapsedRealtime() - started);
|
||||
if (step == DriveModeWrite.StartStep.WAIT) {
|
||||
Thread.sleep(1000);
|
||||
continue;
|
||||
}
|
||||
if (step == DriveModeWrite.StartStep.WRITE) {
|
||||
DriveModeSetter.Result r = DriveModeSetter.set(chosen);
|
||||
noteDriveModeAtStart(chosen, !r.written ? "REFUSED"
|
||||
: DriveModeWrite.outcome(chosen, r.reported).name(), r.reported);
|
||||
} else if (step == DriveModeWrite.StartStep.ALREADY) {
|
||||
noteDriveModeAtStart(chosen, "ALREADY", current);
|
||||
}
|
||||
// GIVE_UP: nothing chosen, switched off, driven off or too late — nothing to say.
|
||||
return;
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
// The service is stopping.
|
||||
} catch (Throwable t) {
|
||||
Log.w(TAG, "Drive mode at start failed: " + t);
|
||||
}
|
||||
}, "drive-mode-at-start");
|
||||
driveModeAtStart.start();
|
||||
}
|
||||
|
||||
/** Kept for the status line on Settings and logged: "25.10. 20:31|4|APPLIED|4". */
|
||||
private void noteDriveModeAtStart(int code, String outcome, Integer reported) {
|
||||
String line = new java.text.SimpleDateFormat("dd.MM. HH:mm", java.util.Locale.US).format(new java.util.Date())
|
||||
+ "|" + code + "|" + outcome + "|" + (reported == null ? "" : reported);
|
||||
prefs.edit().putString(DriveModeWrite.PREF_START_RESULT, line).apply();
|
||||
Log.i(TAG, "Drive mode at start: " + line);
|
||||
}
|
||||
|
||||
// ---------- Sampling cycle (runs on scheduler thread) ----------
|
||||
|
||||
private void safeUploadCycle() {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
|
||||
/**
|
||||
* The Aviation Overview style: a pilot's primary flight display.
|
||||
*
|
||||
* The speed on a scrolling tape at the left and the battery on one at the right (where the
|
||||
* altitude would be), each with a readout box. In the middle, a horizon that "pitches" with the
|
||||
* power: drawing power raises the nose (the horizon drops), regenerating lowers it; a ladder of
|
||||
* kW marks and the yellow aircraft symbol over it. Green annunciators at the top (gear, power,
|
||||
* clock), boxes of range, trip, usage, climate and odometer along the bottom. Unread values
|
||||
* show "—" and leave the horizon level.
|
||||
*
|
||||
* The tapes and the horizon ease to new values and redraw only until settled, only on screen.
|
||||
*/
|
||||
final class AviationView extends ClusterCanvasView {
|
||||
|
||||
private static final long FRAME_MS = 33;
|
||||
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int sky = 0xFF1E4C80, ground = 0xFF5A3A1E, white = 0xFFF2F4F7, green = 0xFF3CE07A,
|
||||
cyan = 0xFF4FD8F0, magenta = 0xFFE05CE0, yellow = 0xFFFFD21F, tape = 0xCC1A1F26, amber = 0xFFFFB000;
|
||||
private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN;
|
||||
|
||||
AviationView(Context context) {
|
||||
super(context);
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
text.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD));
|
||||
text.setFontFeatureSettings("tnum");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
speedShown = ease(speedShown, v.speedKmh, 180f);
|
||||
socShown = ease(socShown, v.socPercent, 100f);
|
||||
powerShown = ease(powerShown, v.powerKw, 200f);
|
||||
boolean moving = (v.speedKmh != null && speedShown != v.speedKmh)
|
||||
|| (v.socPercent != null && socShown != v.socPercent)
|
||||
|| (v.powerKw != null && powerShown != v.powerKw);
|
||||
|
||||
c.drawColor(0xFF05070A);
|
||||
float top = h * 0.10f, bottom = h * 0.84f;
|
||||
float tapeW = w * 0.10f;
|
||||
float leftTape = w * 0.04f, rightTape = w - w * 0.04f - tapeW;
|
||||
drawHorizon(c, leftTape + tapeW + 16f * dp, top, rightTape - 16f * dp, bottom);
|
||||
drawTape(c, leftTape, top, tapeW, bottom, v.speedKmh == null ? null : speedShown, 0f, 250f, 10f, 20f,
|
||||
fmt(v.speedKmh, "%.0f", "—"), "KM/H", true);
|
||||
drawTape(c, rightTape, top, tapeW, bottom, v.socPercent == null ? null : socShown, 0f, 100f, 5f, 10f,
|
||||
fmt(v.socPercent, "%.0f", "—"), "BATT %", false);
|
||||
drawAnnunciators(c, w, top);
|
||||
drawBoxes(c, w, h, bottom);
|
||||
keepAnimating(moving, FRAME_MS);
|
||||
}
|
||||
|
||||
/** Sky and ground split by a horizon that moves with the power, a kW ladder, the aircraft. */
|
||||
private void drawHorizon(Canvas c, float l, float t, float r, float b) {
|
||||
float cx = (l + r) / 2f, cy = (t + b) / 2f, half = (b - t) / 2f;
|
||||
// 150 kW lifts the nose by 0.8 of the half height; regeneration lowers it, 50 kW as far.
|
||||
float pitch = v.powerKw == null ? 0f
|
||||
: powerShown >= 0f ? Math.min(150f, powerShown) / 150f : Math.max(-50f, powerShown) / 50f * 0.6f;
|
||||
float horizon = cy + pitch * half * 0.8f;
|
||||
float perKw = half * 0.8f / 150f;
|
||||
c.save();
|
||||
rect.set(l, t, r, b);
|
||||
c.clipRect(rect);
|
||||
fill.setColor(sky);
|
||||
c.drawRect(l, t, r, horizon, fill);
|
||||
fill.setColor(ground);
|
||||
c.drawRect(l, horizon, r, b, fill);
|
||||
line.setColor(white);
|
||||
line.setStrokeWidth(2.5f * dp);
|
||||
c.drawLine(l, horizon, r, horizon, line);
|
||||
// The ladder: a mark every 25 kW above the horizon (drawing), below it (regenerating).
|
||||
text.setTextSize(13f * dp);
|
||||
for (int kw = -50; kw <= 150; kw += 25) {
|
||||
if (kw == 0) continue;
|
||||
float y = horizon - kw * (kw > 0 ? perKw : perKw * 150f / 50f * 0.6f);
|
||||
float half2 = kw % 50 == 0 ? (r - l) * 0.10f : (r - l) * 0.05f;
|
||||
line.setStrokeWidth(1.5f * dp);
|
||||
c.drawLine(cx - half2, y, cx + half2, y, line);
|
||||
if (kw % 50 == 0) {
|
||||
text.setColor(white);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
c.drawText(String.valueOf(kw), cx + half2 + 6f * dp, y + 5f * dp, text);
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
c.drawText(String.valueOf(kw), cx - half2 - 6f * dp, y + 5f * dp, text);
|
||||
}
|
||||
}
|
||||
c.restore();
|
||||
// The aircraft symbol, fixed: two wings and a dot, yellow outlined in black.
|
||||
float wing = (r - l) * 0.14f;
|
||||
for (int pass = 0; pass < 2; pass++) {
|
||||
line.setColor(pass == 0 ? 0xFF000000 : yellow);
|
||||
line.setStrokeWidth(pass == 0 ? 9f * dp : 5f * dp);
|
||||
c.drawLine(cx - wing * 2f, cy, cx - wing, cy, line);
|
||||
c.drawLine(cx - wing, cy, cx - wing, cy + wing * 0.25f, line);
|
||||
c.drawLine(cx + wing * 2f, cy, cx + wing, cy, line);
|
||||
c.drawLine(cx + wing, cy, cx + wing, cy + wing * 0.25f, line);
|
||||
}
|
||||
fill.setColor(yellow);
|
||||
c.drawCircle(cx, cy, 5f * dp, fill);
|
||||
// The power readout in magenta under the symbol.
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(22f * dp);
|
||||
text.setColor(v.powerKw != null && v.powerKw < 0f ? cyan : magenta);
|
||||
c.drawText(fmt(v.powerKw, "%.1f kW", "— kW"), cx, b - 14f * dp, text);
|
||||
line.setColor(0xFF2A3038);
|
||||
line.setStrokeWidth(2f * dp);
|
||||
c.drawRect(l, t, r, b, line);
|
||||
}
|
||||
|
||||
/**
|
||||
* A vertical tape scrolling with its value: ticks every {@code minor}, numbers every
|
||||
* {@code major}, and a readout box at the middle pointing in (right for the left tape).
|
||||
*/
|
||||
private void drawTape(Canvas c, float l, float t, float tw, float b, Float value, float min, float max,
|
||||
float minor, float major, String readout, String title, boolean left) {
|
||||
float cy = (t + b) / 2f, perUnit = (b - t) / (max - min) * (max > 150f ? 3.2f : 2.2f);
|
||||
fill.setColor(tape);
|
||||
c.drawRect(l, t, l + tw, b, fill);
|
||||
c.save();
|
||||
c.clipRect(l, t, l + tw, b);
|
||||
float centre = value == null ? min : value;
|
||||
text.setTextSize(16f * dp);
|
||||
text.setColor(white);
|
||||
line.setColor(white);
|
||||
// No value, no scale: a tape centred on zero would read as 0.
|
||||
for (float u = min; value != null && u <= max; u += minor) {
|
||||
float y = cy - (u - centre) * perUnit;
|
||||
if (y < t - 20f * dp || y > b + 20f * dp) continue;
|
||||
boolean isMajor = Math.round(u) % Math.round(major) == 0;
|
||||
float len = isMajor ? tw * 0.22f : tw * 0.12f;
|
||||
line.setStrokeWidth(2f * dp);
|
||||
float edge = left ? l + tw : l;
|
||||
c.drawLine(edge, y, edge + (left ? -len : len), y, line);
|
||||
if (isMajor) {
|
||||
text.setTextAlign(left ? Paint.Align.RIGHT : Paint.Align.LEFT);
|
||||
c.drawText(String.valueOf(Math.round(u)), left ? edge - len - 6f * dp : edge + len + 6f * dp, y + 6f * dp, text);
|
||||
}
|
||||
}
|
||||
c.restore();
|
||||
// The readout box with its pointer.
|
||||
boolean low = !left && v.socPercent != null && v.socPercent < 20f;
|
||||
float bh = 44f * dp;
|
||||
path.reset();
|
||||
if (left) {
|
||||
path.moveTo(l + 2f * dp, cy - bh / 2f);
|
||||
path.lineTo(l + tw - 12f * dp, cy - bh / 2f);
|
||||
path.lineTo(l + tw + 2f * dp, cy);
|
||||
path.lineTo(l + tw - 12f * dp, cy + bh / 2f);
|
||||
path.lineTo(l + 2f * dp, cy + bh / 2f);
|
||||
} else {
|
||||
path.moveTo(l + tw - 2f * dp, cy - bh / 2f);
|
||||
path.lineTo(l + 12f * dp, cy - bh / 2f);
|
||||
path.lineTo(l - 2f * dp, cy);
|
||||
path.lineTo(l + 12f * dp, cy + bh / 2f);
|
||||
path.lineTo(l + tw - 2f * dp, cy + bh / 2f);
|
||||
}
|
||||
path.close();
|
||||
fill.setColor(0xFF000000);
|
||||
c.drawPath(path, fill);
|
||||
line.setColor(low ? amber : white);
|
||||
line.setStrokeWidth(2f * dp);
|
||||
c.drawPath(path, line);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(30f * dp);
|
||||
text.setColor(low ? amber : green);
|
||||
c.drawText(readout, l + tw / 2f + (left ? -4f * dp : 4f * dp), cy + 11f * dp, text);
|
||||
text.setTextSize(14f * dp);
|
||||
text.setColor(cyan);
|
||||
c.drawText(title, l + tw / 2f, t - 8f * dp, text);
|
||||
}
|
||||
|
||||
/** The annunciators: gear, drive state and the clock, green on black in boxes. */
|
||||
private void drawAnnunciators(Canvas c, float w, float top) {
|
||||
String gear = Gear.label(v.gear);
|
||||
String state = v.powerKw == null ? "—" : v.powerKw < -0.3f ? "REGEN" : v.powerKw > 0.3f ? "DRIVE PWR" : "IDLE";
|
||||
String[] items = { "GEAR " + (gear.isEmpty() ? "—" : gear), state, v.clock.isEmpty() ? "—" : v.clock };
|
||||
float bw = w * 0.16f, gap = w * 0.02f, x = (w - 3 * bw - 2 * gap) / 2f, bt = 4f * dp, bb = top - 26f * dp;
|
||||
if (bb - bt < 18f * dp) bb = bt + 22f * dp;
|
||||
text.setTextSize((bb - bt) * 0.6f);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
for (String item : items) {
|
||||
line.setColor(0xFF2A3038);
|
||||
line.setStrokeWidth(1.5f * dp);
|
||||
c.drawRect(x, bt, x + bw, bb, line);
|
||||
text.setColor(green);
|
||||
c.drawText(item, x + bw / 2f, bb - (bb - bt) * 0.25f, text);
|
||||
x += bw + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/** Data boxes along the bottom, cyan labels over white values. */
|
||||
private void drawBoxes(Canvas c, float w, float h, float top) {
|
||||
String[][] boxes = {
|
||||
{ "RANGE", fmt(v.rangeKm, "%.0f km", "—") },
|
||||
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
|
||||
{ "USAGE 5 MIN", fmt(v.usage5Min, "%.1f", "—") },
|
||||
{ "SINCE START", fmt(v.usageTrip, "%.1f", "—") },
|
||||
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
|
||||
{ "ODOMETER", fmt(v.odometerKm, "%,.0f", "—") },
|
||||
};
|
||||
float gap = 8f * dp, bt = top + 14f * dp, bb = h - 4f * dp;
|
||||
float bw = (w - gap * (boxes.length - 1)) / boxes.length;
|
||||
for (int i = 0; i < boxes.length; i++) {
|
||||
float x = i * (bw + gap);
|
||||
line.setColor(0xFF2A3038);
|
||||
line.setStrokeWidth(1.5f * dp);
|
||||
c.drawRect(x, bt, x + bw, bb, line);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(12f * dp);
|
||||
text.setColor(cyan);
|
||||
c.drawText(boxes[i][0], x + bw / 2f, bt + 17f * dp, text);
|
||||
text.setTextSize(Math.min(24f * dp, (bb - bt) * 0.42f));
|
||||
text.setColor(white);
|
||||
c.drawText(boxes[i][1], x + bw / 2f, bb - 10f * dp, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.DashPathEffect;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Blueprint Overview style: a technical drawing of the car from above, white lines on a navy
|
||||
* sheet with a grid.
|
||||
*
|
||||
* The battery pack between the axles is hatched as far as it is charged. Each tyre has a leader
|
||||
* line to its pressure. Power is a force arrow at the front — out of the car while drawing,
|
||||
* into it (dashed) while regenerating — its length scaled to the power. Dimension lines give the
|
||||
* range and the trip, the cabin is annotated with the climate setpoint, and a title block in the
|
||||
* corner holds speed, power, gear, usage, odometer and time. Unread values show "—", no hatch and
|
||||
* no arrow. Redrawn only when a reading arrives.
|
||||
*/
|
||||
public final class BlueprintView extends View {
|
||||
|
||||
/** The MG4's length over its width, 4.287 m by 1.836 m. */
|
||||
private static final float ASPECT = 4.287f / 1.836f;
|
||||
|
||||
private final Paint sheet = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint grid = new Paint();
|
||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint faint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint dashed = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int ink;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
|
||||
public BlueprintView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
float d = context.getResources().getDisplayMetrics().density;
|
||||
ink = context.getColor(R.color.blueprint_line);
|
||||
sheet.setColor(context.getColor(R.color.blueprint_sheet));
|
||||
grid.setStyle(Paint.Style.STROKE);
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
line.setColor(ink);
|
||||
line.setStrokeJoin(Paint.Join.ROUND);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
faint.setStyle(Paint.Style.STROKE);
|
||||
faint.setColor(AccentColor.withAlpha(ink, 0x99));
|
||||
dashed.setStyle(Paint.Style.STROKE);
|
||||
dashed.setColor(AccentColor.withAlpha(ink, 0xB0));
|
||||
dashed.setPathEffect(new DashPathEffect(new float[] { 8f * d, 6f * d }, 0f));
|
||||
text.setColor(ink);
|
||||
text.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL));
|
||||
text.setFontFeatureSettings("tnum");
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
|
||||
// The sheet and its grid: fine every 20 dp, bolder every 100 dp; a border inset.
|
||||
rect.set(0, 0, w, h);
|
||||
c.drawRect(rect, sheet);
|
||||
for (float step : new float[] { 20f * d, 100f * d }) {
|
||||
grid.setColor(AccentColor.withAlpha(ink, step > 50f * d ? 0x22 : 0x10));
|
||||
grid.setStrokeWidth(Math.max(1f, d * 0.75f));
|
||||
for (float x = 0; x <= w; x += step) c.drawLine(x, 0, x, h, grid);
|
||||
for (float y = 0; y <= h; y += step) c.drawLine(0, y, w, y, grid);
|
||||
}
|
||||
line.setStrokeWidth(2f * d);
|
||||
c.drawRect(8f * d, 8f * d, w - 8f * d, h - 8f * d, line);
|
||||
|
||||
// The car, front to the right, its size from the space left of the title block.
|
||||
// Centred, with its power arrow, in the space left of the title block.
|
||||
float carH = Math.min(h * 0.40f, w * 0.44f / ASPECT), carW = carH * ASPECT;
|
||||
float l = w * 0.04f + (w * 0.60f - carW - w * 0.12f) / 2f, t = (h - carH) / 2f, r = l + carW, b = t + carH;
|
||||
drawCar(c, l, t, r, b, d);
|
||||
drawTyres(c, l, t, r, b, d);
|
||||
drawPower(c, r, (t + b) / 2f, w * 0.10f, d);
|
||||
drawDimensions(c, l, t, r, b, d);
|
||||
drawTitleBlock(c, w * 0.66f, h * 0.10f, w - 22f * d, h - 22f * d, d);
|
||||
}
|
||||
|
||||
private void drawCar(Canvas c, float l, float t, float r, float b, float d) {
|
||||
float len = r - l, wid = b - t;
|
||||
// Body: rounded, fuller at the front.
|
||||
path.reset();
|
||||
path.moveTo(l + len * 0.10f, t);
|
||||
path.lineTo(r - len * 0.14f, t);
|
||||
path.cubicTo(r - len * 0.02f, t, r, t + wid * 0.12f, r, t + wid * 0.30f);
|
||||
path.lineTo(r, b - wid * 0.30f);
|
||||
path.cubicTo(r, b - wid * 0.12f, r - len * 0.02f, b, r - len * 0.14f, b);
|
||||
path.lineTo(l + len * 0.10f, b);
|
||||
path.cubicTo(l + len * 0.02f, b, l, b - wid * 0.14f, l, b - wid * 0.32f);
|
||||
path.lineTo(l, t + wid * 0.32f);
|
||||
path.cubicTo(l, t + wid * 0.14f, l + len * 0.02f, t, l + len * 0.10f, t);
|
||||
line.setStrokeWidth(2.5f * d);
|
||||
c.drawPath(path, line);
|
||||
// Glass: windscreen, roof and rear window, dashed.
|
||||
rect.set(l + len * 0.24f, t + wid * 0.12f, l + len * 0.74f, b - wid * 0.12f);
|
||||
c.drawRoundRect(rect, wid * 0.12f, wid * 0.12f, dashed);
|
||||
c.drawLine(l + len * 0.62f, t + wid * 0.13f, l + len * 0.62f, b - wid * 0.13f, dashed);
|
||||
c.drawLine(l + len * 0.33f, t + wid * 0.13f, l + len * 0.33f, b - wid * 0.13f, dashed);
|
||||
// Mirrors.
|
||||
float mx = l + len * 0.66f;
|
||||
line.setStrokeWidth(2f * d);
|
||||
c.drawLine(mx, t, mx - len * 0.03f, t - wid * 0.09f, line);
|
||||
c.drawLine(mx - len * 0.03f, t - wid * 0.09f, mx - len * 0.07f, t - wid * 0.02f, line);
|
||||
c.drawLine(mx, b, mx - len * 0.03f, b + wid * 0.09f, line);
|
||||
c.drawLine(mx - len * 0.03f, b + wid * 0.09f, mx - len * 0.07f, b + wid * 0.02f, line);
|
||||
|
||||
// The battery between the axles, hatched as far as it is charged (from the rear).
|
||||
float pl = l + len * 0.25f, pr = l + len * 0.73f, pt = t + wid * 0.24f, pb = b - wid * 0.24f;
|
||||
faint.setStrokeWidth(1.5f * d);
|
||||
c.drawRect(pl, pt, pr, pb, faint);
|
||||
if (v.socPercent != null) {
|
||||
float fx = pl + (pr - pl) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
|
||||
c.save();
|
||||
c.clipRect(pl, pt, fx, pb);
|
||||
float step = 9f * d;
|
||||
for (float x = pl - (pb - pt); x < fx; x += step) c.drawLine(x, pb, x + (pb - pt), pt, faint);
|
||||
c.restore();
|
||||
line.setStrokeWidth(2f * d);
|
||||
c.drawLine(fx, pt - 6f * d, fx, pb + 6f * d, line);
|
||||
}
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(14f * d);
|
||||
String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent);
|
||||
float labelY = (pt + pb) / 2f + 5f * d;
|
||||
rect.set((pl + pr) / 2f - 70f * d, labelY - 18f * d, (pl + pr) / 2f + 70f * d, labelY + 8f * d);
|
||||
c.drawRect(rect, sheet);
|
||||
c.drawText("BATTERY " + soc, (pl + pr) / 2f, labelY, text);
|
||||
|
||||
// The cabin's climate setpoint, as a note.
|
||||
String climate = v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius);
|
||||
text.setTextSize(12f * d);
|
||||
c.drawText("CABIN SET " + climate, l + len * 0.49f, t + wid * 0.19f, text);
|
||||
}
|
||||
|
||||
private void drawTyres(Canvas c, float l, float t, float r, float b, float d) {
|
||||
float len = r - l, wid = b - t, tw = len * 0.13f, th = wid * 0.13f;
|
||||
float rear = l + len * 0.20f, front = l + len * 0.80f;
|
||||
// Driving to the right, the car's left side is at the top.
|
||||
Object[][] tyres = {
|
||||
{ front, t, v.tyreFl, true, "FL" }, { front, b, v.tyreFr, false, "FR" },
|
||||
{ rear, t, v.tyreRl, true, "RL" }, { rear, b, v.tyreRr, false, "RR" },
|
||||
};
|
||||
line.setStrokeWidth(2f * d);
|
||||
text.setTextSize(15f * d);
|
||||
for (Object[] tyre : tyres) {
|
||||
float x = (Float) tyre[0], y = (Float) tyre[1];
|
||||
boolean top = (Boolean) tyre[3];
|
||||
rect.set(x - tw / 2f, y - th / 2f, x + tw / 2f, y + th / 2f);
|
||||
c.drawRect(rect, sheet);
|
||||
c.drawRoundRect(rect, 4f * d, 4f * d, line);
|
||||
// A leader line out to the pressure.
|
||||
float ly = top ? y - th / 2f - wid * 0.24f : y + th / 2f + wid * 0.24f;
|
||||
float lx = x + len * 0.06f;
|
||||
faint.setStrokeWidth(1.5f * d);
|
||||
c.drawLine(x, top ? y - th / 2f : y + th / 2f, lx, ly, faint);
|
||||
c.drawLine(lx, ly, lx + 110f * d, ly, faint);
|
||||
c.drawCircle(x, top ? y - th / 2f : y + th / 2f, 3f * d, line);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
c.drawText(tyre[4] + " " + tyre[2], lx + 4f * d, ly - 6f * d, text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Power as a force arrow at the front; length scaled to 150 kW; regeneration points back. */
|
||||
private void drawPower(Canvas c, float x, float y, float maxLen, float d) {
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
text.setTextSize(15f * d);
|
||||
if (v.powerKw == null || Math.abs(v.powerKw) < 0.1f) {
|
||||
c.drawText(v.powerKw == null ? "P = —" : "P = 0 kW", x + 16f * d, y + 5f * d, text);
|
||||
return;
|
||||
}
|
||||
float kw = v.powerKw;
|
||||
float len = Math.max(24f * d, maxLen * Math.min(150f, Math.abs(kw)) / 150f);
|
||||
float x0 = x + 10f * d, x1 = x0 + len;
|
||||
Paint p = kw >= 0f ? line : dashed;
|
||||
p.setStrokeWidth(2.5f * d);
|
||||
c.drawLine(x0, y, x1, y, p);
|
||||
// The head: at the far end drawing, at the car regenerating.
|
||||
float hx = kw >= 0f ? x1 : x0, dir = kw >= 0f ? 1f : -1f, hs = 10f * d;
|
||||
line.setStrokeWidth(2.5f * d);
|
||||
c.drawLine(hx, y, hx - dir * hs, y - hs * 0.6f, line);
|
||||
c.drawLine(hx, y, hx - dir * hs, y + hs * 0.6f, line);
|
||||
c.drawText(String.format(Locale.US, "P = %.1f kW", kw), x0, y - 10f * d, text);
|
||||
if (kw < 0f) c.drawText("(REGEN)", x0, y + 22f * d, text);
|
||||
}
|
||||
|
||||
/** Dimension lines: the range under the car, the trip above it. */
|
||||
private void drawDimensions(Canvas c, float l, float t, float r, float b, float d) {
|
||||
float wid = b - t;
|
||||
String range = v.rangeKm == null ? "RANGE —" : String.format(Locale.US, "RANGE %.0f km", v.rangeKm);
|
||||
dimension(c, l, r, b + wid * 0.50f, range, d);
|
||||
String trip = v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm);
|
||||
if (v.usageTrip != null) trip += String.format(Locale.US, " @ %.1f kWh/100 km", v.usageTrip);
|
||||
dimension(c, l, r, t - wid * 0.50f, trip, d);
|
||||
}
|
||||
|
||||
private void dimension(Canvas c, float x0, float x1, float y, String label, float d) {
|
||||
faint.setStrokeWidth(1.5f * d);
|
||||
c.drawLine(x0, y - 10f * d, x0, y + 10f * d, faint);
|
||||
c.drawLine(x1, y - 10f * d, x1, y + 10f * d, faint);
|
||||
c.drawLine(x0, y, x1, y, faint);
|
||||
float hs = 9f * d;
|
||||
c.drawLine(x0, y, x0 + hs, y - hs * 0.45f, faint);
|
||||
c.drawLine(x0, y, x0 + hs, y + hs * 0.45f, faint);
|
||||
c.drawLine(x1, y, x1 - hs, y - hs * 0.45f, faint);
|
||||
c.drawLine(x1, y, x1 - hs, y + hs * 0.45f, faint);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(15f * d);
|
||||
float tw = text.measureText(label);
|
||||
float mid = (x0 + x1) / 2f;
|
||||
rect.set(mid - tw / 2f - 8f * d, y - 12f * d, mid + tw / 2f + 8f * d, y + 12f * d);
|
||||
c.drawRect(rect, sheet);
|
||||
c.drawText(label, mid, y + 5f * d, text);
|
||||
}
|
||||
|
||||
/** The drawing's title block: rows of fields, the speed large. */
|
||||
private void drawTitleBlock(Canvas c, float l, float t, float r, float b, float d) {
|
||||
line.setStrokeWidth(2f * d);
|
||||
rect.set(l, t, r, b);
|
||||
c.drawRect(rect, sheet);
|
||||
c.drawRect(rect, line);
|
||||
// Eight rows: the speed takes two, the last holds scale and time.
|
||||
float rowH = (b - t) / 8f;
|
||||
String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh);
|
||||
String[][] rows = {
|
||||
{ "DRAWING", "NILS' MG4 · TOP VIEW" },
|
||||
{ "SPEED", null },
|
||||
{ "POWER", v.powerKw == null ? "—" : String.format(Locale.US, "%.1f kW", v.powerKw) },
|
||||
{ "GEAR", Gear.label(v.gear).isEmpty() ? "—" : Gear.label(v.gear) },
|
||||
{ "USAGE 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min) },
|
||||
{ "ODOMETER", v.odometerKm == null ? "—" : String.format(Locale.US, "%,.0f km", v.odometerKm) },
|
||||
};
|
||||
float split = l + (r - l) * 0.34f;
|
||||
float y = t;
|
||||
for (int i = 0; i < rows.length; i++) {
|
||||
float rh = i == 1 ? rowH * 2f : rowH;
|
||||
if (i > 0) c.drawLine(l, y, r, y, faint);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
text.setTextSize(11f * d);
|
||||
c.drawText(rows[i][0], l + 10f * d, y + 18f * d, text);
|
||||
if (i == 1) {
|
||||
text.setTextSize(rh * 0.62f);
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
c.drawText(speed, r - (r - l) * 0.22f, y + rh * 0.80f, text);
|
||||
text.setTextSize(14f * d);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
c.drawText("km/h", r - (r - l) * 0.20f, y + rh * 0.80f, text);
|
||||
} else {
|
||||
text.setTextSize(Math.min(rh * 0.42f, 18f * d));
|
||||
c.drawText(rows[i][1], i == 0 ? l + 10f * d : split + 12f * d, y + rh * 0.72f, text);
|
||||
}
|
||||
y += rh;
|
||||
}
|
||||
// The last row: scale and time, split in two.
|
||||
c.drawLine(l, y, r, y, faint);
|
||||
c.drawLine(split, y, split, b, faint);
|
||||
text.setTextSize(11f * d);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
c.drawText("SCALE 1:20", l + 10f * d, y + rowH * 0.62f, text);
|
||||
c.drawText("TIME", split + 10f * d, y + 18f * d, text);
|
||||
text.setTextSize(Math.min(rowH * 0.5f, 22f * d));
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
c.drawText(v.clock.isEmpty() ? "—" : v.clock, r - 12f * d, y + rowH * 0.72f, text);
|
||||
c.drawLine(split, t + rowH * 3f, split, t + rowH * 7f, faint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.SystemClock;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Cartoon Overview style: comic panels in flat colours with thick ink outlines and hard
|
||||
* shadows, on a night-blue page (no white page at night).
|
||||
*
|
||||
* Left, a battery with a face: happy above 50 %, unsure from 20 %, worried and sweating below.
|
||||
* Centre, a chunky speedometer whose needle springs and wobbles into place, the speed in a
|
||||
* speech bubble and the gear on a sticker. Right, the "zap-o-meter" for power with a sound word
|
||||
* in a burst — PURR, VROOM, ZOOM! or RECHARGE! while regenerating. Below, stats on pills.
|
||||
* Unread values show "—", a face with no expression and no needle.
|
||||
*
|
||||
* The needle animates (about 30 frames a second) only until it settles, and only on screen.
|
||||
*/
|
||||
public final class CartoonView extends View {
|
||||
|
||||
private static final float START_DEG = 150f, SWEEP_DEG = 240f, MAX_KMH = 180f;
|
||||
private static final long FRAME_MS = 33;
|
||||
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint ink = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint textInk = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int night, yellow, cyan, pink, cream, inkColour, green, orange, red;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
/** The needle in km/h and its speed, for the spring. */
|
||||
private float needle = Float.NaN, needleVelocity;
|
||||
private long lastFrame;
|
||||
|
||||
public CartoonView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
night = context.getColor(R.color.cartoon_night);
|
||||
yellow = context.getColor(R.color.cartoon_yellow);
|
||||
cyan = context.getColor(R.color.cartoon_cyan);
|
||||
pink = context.getColor(R.color.cartoon_pink);
|
||||
cream = context.getColor(R.color.cartoon_cream);
|
||||
inkColour = context.getColor(R.color.cartoon_ink);
|
||||
green = 0xFF5BD35B;
|
||||
orange = 0xFFFF9F1C;
|
||||
red = 0xFFE63946;
|
||||
ink.setStyle(Paint.Style.STROKE);
|
||||
ink.setStrokeJoin(Paint.Join.ROUND);
|
||||
ink.setStrokeCap(Paint.Cap.ROUND);
|
||||
ink.setColor(inkColour);
|
||||
Typeface black = Typeface.create("sans-serif-black", Typeface.NORMAL);
|
||||
text.setTypeface(black);
|
||||
text.setFontFeatureSettings("tnum");
|
||||
textInk.setTypeface(black);
|
||||
textInk.setFontFeatureSettings("tnum");
|
||||
textInk.setStyle(Paint.Style.STROKE);
|
||||
textInk.setStrokeJoin(Paint.Join.ROUND);
|
||||
textInk.setColor(inkColour);
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
if (v.speedKmh != null && Float.isNaN(needle)) needle = v.speedKmh;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
rect.set(0, 0, w, h);
|
||||
fill.setColor(night);
|
||||
c.drawRoundRect(rect, 24f * d, 24f * d, fill);
|
||||
|
||||
boolean moving = stepNeedle();
|
||||
|
||||
drawBattery(c, w * 0.02f, h * 0.05f, w * 0.27f, h * 0.76f, d);
|
||||
drawSpeedometer(c, w * 0.50f, h * 0.43f, h * 0.38f, d);
|
||||
drawZap(c, w * 0.73f, h * 0.05f, w * 0.98f, h * 0.76f, d);
|
||||
drawPills(c, w, h, d);
|
||||
|
||||
if (moving && isShown()) postInvalidateDelayed(FRAME_MS);
|
||||
else lastFrame = 0;
|
||||
}
|
||||
|
||||
/** A spring towards the speed: overshoots a little and wobbles to rest. True while moving. */
|
||||
private boolean stepNeedle() {
|
||||
if (v.speedKmh == null || Float.isNaN(needle)) return false;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
float dt = lastFrame == 0 ? FRAME_MS / 1000f : Math.min(100, now - lastFrame) / 1000f;
|
||||
lastFrame = now;
|
||||
float target = Math.max(0f, Math.min(MAX_KMH, v.speedKmh));
|
||||
needleVelocity += (target - needle) * 60f * dt;
|
||||
needleVelocity *= (float) Math.pow(0.02, dt);
|
||||
needle += needleVelocity * dt;
|
||||
if (Math.abs(target - needle) < 0.2f && Math.abs(needleVelocity) < 0.5f) {
|
||||
needle = target;
|
||||
needleVelocity = 0f;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------- Panels ----------
|
||||
|
||||
/** A comic panel: a hard shadow, the colour, a thick outline. */
|
||||
private void panel(Canvas c, float l, float t, float r, float b, int colour, float d) {
|
||||
float off = 6f * d, radius = 18f * d;
|
||||
fill.setColor(inkColour);
|
||||
rect.set(l + off, t + off, r + off, b + off);
|
||||
c.drawRoundRect(rect, radius, radius, fill);
|
||||
fill.setColor(colour);
|
||||
rect.set(l, t, r, b);
|
||||
c.drawRoundRect(rect, radius, radius, fill);
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawRoundRect(rect, radius, radius, ink);
|
||||
}
|
||||
|
||||
private void drawBattery(Canvas c, float l, float t, float r, float b, float d) {
|
||||
panel(c, l, t, r, b, yellow, d);
|
||||
Float soc = v.socPercent;
|
||||
// The body: a battery standing up, filled to the charge.
|
||||
float bw = (r - l) * 0.46f, bl = (l + r) / 2f - bw / 2f, bt = t + (b - t) * 0.14f, bb = t + (b - t) * 0.66f;
|
||||
fill.setColor(inkColour);
|
||||
rect.set(bl + bw * 0.32f, bt - 12f * d, bl + bw * 0.68f, bt + 2f * d);
|
||||
c.drawRoundRect(rect, 4f * d, 4f * d, fill);
|
||||
fill.setColor(cream);
|
||||
rect.set(bl, bt, bl + bw, bb);
|
||||
c.drawRoundRect(rect, 16f * d, 16f * d, fill);
|
||||
if (soc != null) {
|
||||
float level = bb - (bb - bt) * Math.max(0f, Math.min(100f, soc)) / 100f;
|
||||
fill.setColor(soc < 20f ? red : soc < 50f ? orange : green);
|
||||
c.save();
|
||||
c.clipRect(bl, level, bl + bw, bb);
|
||||
c.drawRoundRect(rect, 16f * d, 16f * d, fill);
|
||||
c.restore();
|
||||
}
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawRoundRect(rect, 16f * d, 16f * d, ink);
|
||||
|
||||
// The face.
|
||||
float fx = bl + bw / 2f, fy = bt + (bb - bt) * 0.42f, eye = bw * 0.09f;
|
||||
fill.setColor(inkColour);
|
||||
c.drawOval(fx - bw * 0.22f - eye, fy - eye * 1.4f, fx - bw * 0.22f + eye, fy + eye * 1.4f, fill);
|
||||
c.drawOval(fx + bw * 0.22f - eye, fy - eye * 1.4f, fx + bw * 0.22f + eye, fy + eye * 1.4f, fill);
|
||||
fill.setColor(0xFFFFFFFF);
|
||||
c.drawCircle(fx - bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill);
|
||||
c.drawCircle(fx + bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill);
|
||||
float my = fy + bw * 0.30f, mw = bw * 0.26f;
|
||||
ink.setStrokeWidth(4f * d);
|
||||
path.reset();
|
||||
path.moveTo(fx - mw, my);
|
||||
if (soc == null) path.lineTo(fx + mw, my);
|
||||
else if (soc >= 50f) path.quadTo(fx, my + mw * 0.9f, fx + mw, my); // smile
|
||||
else if (soc >= 20f) path.quadTo(fx, my + mw * 0.15f, fx + mw, my); // unsure
|
||||
else path.quadTo(fx, my - mw * 0.8f, fx + mw, my); // worried
|
||||
c.drawPath(path, ink);
|
||||
if (soc != null && soc < 20f) {
|
||||
// A drop of sweat.
|
||||
float sx = bl + bw + 10f * d, sy = bt + 14f * d;
|
||||
path.reset();
|
||||
path.moveTo(sx, sy);
|
||||
path.quadTo(sx + 12f * d, sy + 18f * d, sx, sy + 24f * d);
|
||||
path.quadTo(sx - 12f * d, sy + 18f * d, sx, sy);
|
||||
fill.setColor(0xFF7FD3FF);
|
||||
c.drawPath(path, fill);
|
||||
ink.setStrokeWidth(2.5f * d);
|
||||
c.drawPath(path, ink);
|
||||
}
|
||||
|
||||
outlined(c, soc == null ? "—" : String.format(Locale.US, "%.0f%%", soc), (l + r) / 2f,
|
||||
t + (b - t) * 0.84f, (b - t) * 0.15f, 0xFFFFFFFF, d);
|
||||
outlined(c, v.rangeKm == null ? "— km to go" : String.format(Locale.US, "%.0f km to go!", v.rangeKm),
|
||||
(l + r) / 2f, t + (b - t) * 0.95f, (b - t) * 0.065f, 0xFFFFFFFF, d);
|
||||
}
|
||||
|
||||
private void drawSpeedometer(Canvas c, float cx, float cy, float r, float d) {
|
||||
// Shadow, face, coloured zones, outline.
|
||||
float off = 6f * d;
|
||||
fill.setColor(inkColour);
|
||||
c.drawCircle(cx + off, cy + off, r, fill);
|
||||
fill.setColor(cream);
|
||||
c.drawCircle(cx, cy, r, fill);
|
||||
float zr = r * 0.80f;
|
||||
rect.set(cx - zr, cy - zr, cx + zr, cy + zr);
|
||||
ink.setStrokeCap(Paint.Cap.BUTT);
|
||||
int[] zones = { green, orange, red };
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ink.setColor(zones[i]);
|
||||
ink.setStrokeWidth(r * 0.14f);
|
||||
c.drawArc(rect, START_DEG + i * SWEEP_DEG / 3f, SWEEP_DEG / 3f, false, ink);
|
||||
}
|
||||
ink.setColor(inkColour);
|
||||
ink.setStrokeCap(Paint.Cap.ROUND);
|
||||
ink.setStrokeWidth(5f * d);
|
||||
c.drawCircle(cx, cy, r, ink);
|
||||
// Chunky ticks and numbers every 30 km/h.
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setColor(inkColour);
|
||||
text.setTextSize(r * 0.12f);
|
||||
for (int kmh = 0; kmh <= MAX_KMH; kmh += 30) {
|
||||
double a = Math.toRadians(START_DEG + SWEEP_DEG * kmh / MAX_KMH);
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
ink.setStrokeWidth(5f * d);
|
||||
c.drawLine(cx + r * 0.88f * cos, cy + r * 0.88f * sin, cx + r * 0.72f * cos, cy + r * 0.72f * sin, ink);
|
||||
c.drawText(String.valueOf(kmh), cx + r * 0.56f * cos, cy + r * 0.56f * sin + text.getTextSize() * 0.36f, text);
|
||||
}
|
||||
// The needle, springy.
|
||||
if (v.speedKmh != null && !Float.isNaN(needle)) {
|
||||
double a = Math.toRadians(START_DEG + SWEEP_DEG * Math.max(-4f, Math.min(MAX_KMH + 4f, needle)) / MAX_KMH);
|
||||
float nx = cx + r * 0.78f * (float) Math.cos(a), ny = cy + r * 0.78f * (float) Math.sin(a);
|
||||
ink.setStrokeWidth(12f * d);
|
||||
c.drawLine(cx, cy, nx, ny, ink);
|
||||
fill.setColor(red);
|
||||
ink.setStrokeWidth(7f * d);
|
||||
ink.setColor(red);
|
||||
c.drawLine(cx, cy, nx, ny, ink);
|
||||
ink.setColor(inkColour);
|
||||
}
|
||||
fill.setColor(pink);
|
||||
c.drawCircle(cx, cy, r * 0.09f, fill);
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawCircle(cx, cy, r * 0.09f, ink);
|
||||
|
||||
// The speed in a speech bubble below the hub.
|
||||
String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh);
|
||||
text.setTextSize(r * 0.30f);
|
||||
float tw = Math.max(text.measureText(speed), r * 0.4f);
|
||||
float bt = cy + r * 0.22f, bb = cy + r * 0.62f;
|
||||
rect.set(cx - tw / 2f - r * 0.12f, bt, cx + tw / 2f + r * 0.12f, bb);
|
||||
fill.setColor(0xFFFFFFFF);
|
||||
c.drawRoundRect(rect, 14f * d, 14f * d, fill);
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawRoundRect(rect, 14f * d, 14f * d, ink);
|
||||
text.setColor(inkColour);
|
||||
c.drawText(speed, cx, bb - (bb - bt) * 0.22f, text);
|
||||
text.setTextSize(r * 0.09f);
|
||||
c.drawText("km/h", cx, cy + r * 0.76f, text);
|
||||
|
||||
// The gear on a round sticker at the top right of the dial.
|
||||
String gear = Gear.label(v.gear);
|
||||
if (!gear.isEmpty()) {
|
||||
float gx = cx + r * 0.80f, gy = cy - r * 0.78f, gr = r * 0.20f;
|
||||
fill.setColor(inkColour);
|
||||
c.drawCircle(gx + 4f * d, gy + 4f * d, gr, fill);
|
||||
fill.setColor(pink);
|
||||
c.drawCircle(gx, gy, gr, fill);
|
||||
c.drawCircle(gx, gy, gr, ink);
|
||||
outlined(c, gear, gx, gy + gr * 0.40f, gr * 1.1f, 0xFFFFFFFF, d);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawZap(Canvas c, float l, float t, float r, float b, float d) {
|
||||
panel(c, l, t, r, b, cyan, d);
|
||||
Float kw = v.powerKw;
|
||||
outlined(c, "ZAP-O-METER", (l + r) / 2f, t + (b - t) * 0.13f, (b - t) * 0.07f, yellow, d);
|
||||
|
||||
// The sound word in a burst.
|
||||
String word = kw == null ? "…" : kw < -5f ? "RECHARGE!" : kw < 0.5f ? "PSST" : kw < 10f ? "PURR"
|
||||
: kw < 40f ? "VROOM" : "ZOOM!";
|
||||
float bx = (l + r) / 2f, by = t + (b - t) * 0.40f, br = Math.min(r - l, b - t) * 0.30f;
|
||||
path.reset();
|
||||
for (int i = 0; i < 24; i++) {
|
||||
double a = Math.PI * 2 * i / 24;
|
||||
float rr = i % 2 == 0 ? br : br * 0.74f;
|
||||
float x = bx + rr * 1.25f * (float) Math.cos(a), y = by + rr * 0.85f * (float) Math.sin(a);
|
||||
if (i == 0) path.moveTo(x, y); else path.lineTo(x, y);
|
||||
}
|
||||
path.close();
|
||||
fill.setColor(kw != null && kw < -5f ? green : pink);
|
||||
c.drawPath(path, fill);
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawPath(path, ink);
|
||||
text.setTextSize(br * 0.42f);
|
||||
float size = br * 0.42f * Math.min(1f, (br * 2.1f) / Math.max(1f, text.measureText(word)));
|
||||
c.save();
|
||||
c.rotate(-8f, bx, by);
|
||||
outlined(c, word, bx, by + size * 0.36f, size, yellow, d);
|
||||
c.restore();
|
||||
|
||||
// The tube: regeneration fills left of the middle mark in green, power right of it.
|
||||
float tl = l + (r - l) * 0.10f, tr = r - (r - l) * 0.10f, tt = t + (b - t) * 0.70f, tb = t + (b - t) * 0.80f;
|
||||
float zero = tl + (tr - tl) * 0.25f;
|
||||
rect.set(tl, tt, tr, tb);
|
||||
fill.setColor(cream);
|
||||
c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill);
|
||||
if (kw != null && Math.abs(kw) >= 0.1f) {
|
||||
float x = kw >= 0f ? zero + (tr - zero) * Math.min(150f, kw) / 150f : zero - (zero - tl) * Math.min(50f, -kw) / 50f;
|
||||
c.save();
|
||||
c.clipRect(Math.min(zero, x), tt, Math.max(zero, x), tb);
|
||||
fill.setColor(kw >= 0f ? orange : green);
|
||||
c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill);
|
||||
c.restore();
|
||||
}
|
||||
ink.setStrokeWidth(4f * d);
|
||||
c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, ink);
|
||||
ink.setStrokeWidth(3f * d);
|
||||
c.drawLine(zero, tt - 6f * d, zero, tb + 6f * d, ink);
|
||||
outlined(c, kw == null ? "— kW" : String.format(Locale.US, "%.1f kW", kw), (l + r) / 2f,
|
||||
t + (b - t) * 0.94f, (b - t) * 0.10f, 0xFFFFFFFF, d);
|
||||
}
|
||||
|
||||
private void drawPills(Canvas c, float w, float h, float d) {
|
||||
String[] items = {
|
||||
v.usage5Min == null ? "— kWh/100" : String.format(Locale.US, "%.1f kWh/100", v.usage5Min),
|
||||
v.tripKm == null ? "Trip —" : String.format(Locale.US, "Trip %.1f km", v.tripKm),
|
||||
v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius),
|
||||
v.odometerKm == null ? "— km" : String.format(Locale.US, "%,.0f km", v.odometerKm),
|
||||
v.clock.isEmpty() ? "—" : v.clock,
|
||||
};
|
||||
int[] colours = { pink, yellow, cyan, yellow, pink };
|
||||
float t = h * 0.84f, b = h * 0.97f, gap = 16f * d, x = w * 0.02f;
|
||||
float pw = (w * 0.96f - gap * (items.length - 1)) / items.length;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
float off = 4f * d;
|
||||
fill.setColor(inkColour);
|
||||
rect.set(x + off, t + off, x + pw + off, b + off);
|
||||
c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
|
||||
fill.setColor(colours[i]);
|
||||
rect.set(x, t, x + pw, b);
|
||||
c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
|
||||
ink.setStrokeWidth(3.5f * d);
|
||||
c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, ink);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize((b - t) * 0.46f);
|
||||
text.setColor(inkColour);
|
||||
c.drawText(items[i], x + pw / 2f, b - (b - t) * 0.30f, text);
|
||||
x += pw + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/** Comic lettering: the text in ink, thick, then the colour on top. */
|
||||
private void outlined(Canvas c, String s, float x, float baseline, float size, int colour, float d) {
|
||||
textInk.setTextSize(size);
|
||||
textInk.setTextAlign(Paint.Align.CENTER);
|
||||
textInk.setStrokeWidth(Math.max(3f * d, size * 0.12f));
|
||||
c.drawText(s, x, baseline, textInk);
|
||||
text.setTextSize(size);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setColor(colour);
|
||||
c.drawText(s, x, baseline, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
|
||||
/**
|
||||
* What the chibi Mascot figures ({@link ChibiFigure}, {@link FoxFigure}) share: the frame
|
||||
* (scaled down and moved left so the big head stays clear of the speed, a shadow, a hop while
|
||||
* cheering, a lean), their big eyes and mouths per {@link MascotMood}, limbs placed by the mood,
|
||||
* and the effects around them (Zs, sparkles, wind, "?", drops). Sticker style: dark outlines.
|
||||
*
|
||||
* Draws on {@link MascotView}'s 100 × 100 grid; holds no state.
|
||||
*/
|
||||
abstract class ChibiBase {
|
||||
|
||||
protected final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
protected final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
protected final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
protected final Path p = new Path();
|
||||
protected final Path q = new Path();
|
||||
protected final RectF r = new RectF();
|
||||
protected final int accent, accentLight, regen;
|
||||
protected final int ink = 0xFF3A2A33, blush = 0x66FF7A8A, mouth = 0xFF8E3A48, tongue = 0xFFE88A95;
|
||||
/** The eyes' colours, and what a narrowed lid is filled with (skin or fur). */
|
||||
private final int iris, irisLight, lid;
|
||||
/** How far left the figure moves, in grid units. */
|
||||
private final float shiftX;
|
||||
|
||||
ChibiBase(int accent, int accentLight, int regen, int iris, int irisLight, int lid, float shiftX) {
|
||||
this.accent = accent;
|
||||
this.accentLight = accentLight;
|
||||
this.regen = regen;
|
||||
this.iris = iris;
|
||||
this.irisLight = irisLight;
|
||||
this.lid = lid;
|
||||
this.shiftX = shiftX;
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
line.setStrokeJoin(Paint.Join.ROUND);
|
||||
line.setColor(ink);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setFakeBoldText(true);
|
||||
}
|
||||
|
||||
/** Where the speech bubble's tail points, on the grid. */
|
||||
abstract float tipX();
|
||||
|
||||
abstract float tipY();
|
||||
|
||||
/** The figure itself, standing or sitting on y = 96, centred on x = 50. */
|
||||
protected abstract void drawFigure(Canvas c, MascotMood mood, long t, boolean blink);
|
||||
|
||||
final void draw(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
c.save();
|
||||
c.translate(-shiftX, 0f);
|
||||
c.scale(0.92f, 0.92f, 50f, 100f);
|
||||
// A hop while cheering.
|
||||
float hop = mood == MascotMood.CHEERING ? -3.5f * (float) Math.abs(Math.sin(t / 180.0)) : 0f;
|
||||
fill.setColor(0x40000000);
|
||||
float shadow = 18f + hop * 0.8f;
|
||||
c.drawOval(oval(50f - shadow, 96.5f, 50f + shadow, 100f), fill);
|
||||
|
||||
c.save();
|
||||
c.translate(0f, hop);
|
||||
float lean = mood == MascotMood.FAST ? 5f : mood == MascotMood.SLEEPING ? -6f : 0f;
|
||||
c.rotate(lean, 50f, 96f);
|
||||
drawFigure(c, mood, t, blink);
|
||||
c.restore();
|
||||
|
||||
switch (mood) {
|
||||
case SLEEPING: zs(c, t); break;
|
||||
case CHEERING: sparkles(c, t); break;
|
||||
case FAST: wind(c, t); break;
|
||||
case WAITING: question(c, t); break;
|
||||
default: break;
|
||||
}
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** Head tilt per mood, degrees. */
|
||||
static float tilt(MascotMood mood) {
|
||||
return mood == MascotMood.REVERSE ? -10f : mood == MascotMood.SLEEPING ? 10f
|
||||
: mood == MascotMood.WAITING ? 6f : 0f;
|
||||
}
|
||||
|
||||
/** Hands on the cheeks and a fist at the chest are drawn in front of the head. */
|
||||
static boolean handsInFront(MascotMood mood) {
|
||||
return mood == MascotMood.WORRIED || mood == MascotMood.DETERMINED;
|
||||
}
|
||||
|
||||
/** Left and right hand positions for the mood: {lx, ly, rx, ry}. */
|
||||
static float[] hands(MascotMood mood, long t) {
|
||||
float wave = (float) Math.sin(t / 220.0) * 3f;
|
||||
switch (mood) {
|
||||
case CONTENT: return new float[] { 33f, 82f, 82f + wave * 0.5f, 64f + wave };
|
||||
case CHEERING: return new float[] { 16f, 56f, 84f, 56f };
|
||||
case DETERMINED: return new float[] { 33f, 82f, 57f, 72f };
|
||||
case WORRIED: return new float[] { 34f, 55f, 66f, 55f };
|
||||
// Pointing back.
|
||||
case REVERSE: return new float[] { 20f, 66f, 67f, 82f };
|
||||
case FAST: return new float[] { 30f, 76f, 70f, 76f };
|
||||
default: return new float[] { 33f, 82f, 67f, 82f };
|
||||
}
|
||||
}
|
||||
|
||||
/** A limb from the shoulder to a round hand or paw, outlined. */
|
||||
protected void limb(Canvas c, float sx, float sy, float hx, float hy, int sleeve, int hand) {
|
||||
line.setStrokeWidth(7.4f);
|
||||
c.drawLine(sx, sy, hx, hy, line);
|
||||
line.setColor(sleeve);
|
||||
line.setStrokeWidth(5f);
|
||||
c.drawLine(sx, sy, hx, hy, line);
|
||||
line.setColor(ink);
|
||||
fill.setColor(hand);
|
||||
line.setStrokeWidth(1.1f);
|
||||
c.drawCircle(hx, hy, 3.2f, fill);
|
||||
c.drawCircle(hx, hy, 3.2f, line);
|
||||
}
|
||||
|
||||
/** Cheeks, eyes at y 44, brows, mouth around y 55 and the worried drops. */
|
||||
protected void face(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
if (mood == MascotMood.CHEERING || mood == MascotMood.FAST || mood == MascotMood.CONTENT
|
||||
|| mood == MascotMood.SLEEPING || mood == MascotMood.WORRIED) {
|
||||
fill.setColor(blush);
|
||||
c.drawOval(oval(29.5f, 50f, 37f, 54f), fill);
|
||||
c.drawOval(oval(63f, 50f, 70.5f, 54f), fill);
|
||||
}
|
||||
eye(c, 39f, 44f, mood, blink);
|
||||
eye(c, 61f, 44f, mood, blink);
|
||||
brows(c, mood);
|
||||
mouth(c, mood, t);
|
||||
if (mood == MascotMood.WORRIED) {
|
||||
fill.setColor(0xFF8FD3FF);
|
||||
drop(c, 72f, 30f, 3f);
|
||||
drop(c, 34.5f, 52f, 1.8f);
|
||||
}
|
||||
}
|
||||
|
||||
private void eye(Canvas c, float cx, float cy, MascotMood mood, boolean blink) {
|
||||
line.setColor(ink);
|
||||
line.setStrokeWidth(1.5f);
|
||||
if (mood == MascotMood.SLEEPING || (blink && mood != MascotMood.CHEERING && mood != MascotMood.FAST)) {
|
||||
c.drawArc(oval(cx - 4.5f, cy - 3f, cx + 4.5f, cy + 2f), 20f, 140f, false, line);
|
||||
return;
|
||||
}
|
||||
if (mood == MascotMood.FAST) {
|
||||
// ^ ^
|
||||
c.drawArc(oval(cx - 4.5f, cy - 2f, cx + 4.5f, cy + 5f), 200f, 140f, false, line);
|
||||
return;
|
||||
}
|
||||
float look = mood == MascotMood.REVERSE ? -2f : 0f;
|
||||
oval(cx - 4.6f, cy - 5.6f, cx + 4.6f, cy + 5.6f);
|
||||
if (mood == MascotMood.REVERSE) {
|
||||
fill.setColor(Color.WHITE);
|
||||
c.drawOval(r, fill);
|
||||
fill.setColor(iris);
|
||||
c.drawOval(oval(cx - 3f + look, cy - 4f, cx + 3f + look, cy + 4.5f), fill);
|
||||
} else {
|
||||
fill.setColor(iris);
|
||||
c.drawOval(r, fill);
|
||||
fill.setColor(irisLight);
|
||||
c.drawOval(oval(cx - 3.6f, cy + 0.5f, cx + 3.6f, cy + 5f), fill);
|
||||
}
|
||||
fill.setColor(Color.WHITE);
|
||||
if (mood == MascotMood.CHEERING) {
|
||||
star(c, cx, cy - 0.5f, 3.6f);
|
||||
} else {
|
||||
c.drawCircle(cx - 1.6f + look, cy - 2.4f, 2f, fill);
|
||||
c.drawCircle(cx + 1.8f + look, cy + 2.4f, 0.9f, fill);
|
||||
}
|
||||
if (mood == MascotMood.DETERMINED) {
|
||||
// A flat lid over the top: focused.
|
||||
fill.setColor(lid);
|
||||
c.drawRect(cx - 5.2f, cy - 6.4f, cx + 5.2f, cy - 2f, fill);
|
||||
line.setStrokeWidth(1.6f);
|
||||
c.drawLine(cx - 5f, cy - 2f, cx + 5f, cy - 2f, line);
|
||||
return;
|
||||
}
|
||||
line.setStrokeWidth(1.6f);
|
||||
c.drawArc(oval(cx - 5f, cy - 5.9f, cx + 5f, cy + 5.6f), 200f, 140f, false, line);
|
||||
}
|
||||
|
||||
private void brows(Canvas c, MascotMood mood) {
|
||||
line.setColor(ink);
|
||||
line.setStrokeWidth(1.4f);
|
||||
if (mood == MascotMood.DETERMINED) {
|
||||
c.drawLine(34f, 33.5f, 43.5f, 36.5f, line);
|
||||
c.drawLine(56.5f, 36.5f, 66f, 33.5f, line);
|
||||
} else if (mood == MascotMood.WORRIED) {
|
||||
c.drawLine(34f, 36f, 43f, 33f, line);
|
||||
c.drawLine(57f, 33f, 66f, 36f, line);
|
||||
}
|
||||
}
|
||||
|
||||
private void mouth(Canvas c, MascotMood mood, long t) {
|
||||
line.setColor(ink);
|
||||
line.setStrokeWidth(1.2f);
|
||||
switch (mood) {
|
||||
case CHEERING:
|
||||
case FAST:
|
||||
p.reset();
|
||||
p.moveTo(45f, 53.5f);
|
||||
p.quadTo(50f, 61.5f, 55f, 53.5f);
|
||||
p.close();
|
||||
fill.setColor(mouth);
|
||||
c.drawPath(p, fill);
|
||||
fill.setColor(tongue);
|
||||
c.drawOval(oval(47.5f, 56.4f, 52.5f, 58.4f), fill);
|
||||
c.drawPath(p, line);
|
||||
break;
|
||||
case DETERMINED:
|
||||
fill.setColor(Color.WHITE);
|
||||
oval(44.5f, 53f, 55.5f, 57f);
|
||||
c.drawRoundRect(r, 1.5f, 1.5f, fill);
|
||||
c.drawRoundRect(r, 1.5f, 1.5f, line);
|
||||
c.drawLine(44.8f, 55f, 55.2f, 55f, line);
|
||||
break;
|
||||
case WORRIED:
|
||||
p.reset();
|
||||
p.moveTo(45.5f, 56f);
|
||||
p.quadTo(47f, 54.3f, 48.5f, 56f);
|
||||
p.quadTo(50f, 57.7f, 51.5f, 56f);
|
||||
p.quadTo(53f, 54.3f, 54.5f, 56f);
|
||||
c.drawPath(p, line);
|
||||
break;
|
||||
case SLEEPING: {
|
||||
fill.setColor(mouth);
|
||||
c.drawOval(oval(48.6f, 54.5f, 51.4f, 57f), fill);
|
||||
// A nose bubble that swells and pops.
|
||||
float grow = (t % 2400) / 2400f;
|
||||
fill.setColor(0x7090D8FF);
|
||||
c.drawCircle(53.5f, 51f, 1f + grow * 4f, fill);
|
||||
break;
|
||||
}
|
||||
case REVERSE:
|
||||
fill.setColor(mouth);
|
||||
c.drawOval(oval(48.6f, 54f, 51.4f, 57f), fill);
|
||||
break;
|
||||
case WAITING:
|
||||
c.drawLine(47.5f, 55.5f, 52.5f, 55.5f, line);
|
||||
break;
|
||||
default:
|
||||
// A cat smile: w.
|
||||
p.reset();
|
||||
p.moveTo(45.5f, 54f);
|
||||
p.quadTo(47.75f, 57.5f, 50f, 54.5f);
|
||||
p.quadTo(52.25f, 57.5f, 54.5f, 54f);
|
||||
c.drawPath(p, line);
|
||||
}
|
||||
}
|
||||
|
||||
private void zs(Canvas c, long t) {
|
||||
float phase = (t % 3000) / 3000f;
|
||||
float[][] zs = { { 20f, 24f, 6f }, { 13f, 14f, 7.5f }, { 5f, 4f, 9f } };
|
||||
for (float[] z : zs) {
|
||||
text.setTextSize(z[2]);
|
||||
text.setColor(AccentColor.withAlpha(accentLight, Math.round(255 * (1f - phase * 0.5f))));
|
||||
c.drawText("Z", z[0] - phase * 2f, z[1] + z[2] - phase * 3f, text);
|
||||
}
|
||||
}
|
||||
|
||||
private void sparkles(Canvas c, long t) {
|
||||
float[][] at = { { 14f, 30f }, { 86f, 26f }, { 12f, 62f }, { 88f, 60f }, { 50f, 1.5f } };
|
||||
for (int i = 0; i < at.length; i++) {
|
||||
float s = 2.4f + 1.8f * (float) Math.abs(Math.sin(t / 260.0 + i * 1.3));
|
||||
fill.setColor(i % 2 == 0 ? regen : Color.WHITE);
|
||||
star(c, at[i][0], at[i][1], s);
|
||||
}
|
||||
}
|
||||
|
||||
private void wind(Canvas c, long t) {
|
||||
line.setStrokeWidth(1f);
|
||||
line.setColor(0x88FFFFFF);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
float x = 18f - ((t / 6f + i * 37f) % 20f);
|
||||
float y = 20f + i * 14f;
|
||||
c.drawLine(x - 10f, y, x, y, line);
|
||||
}
|
||||
line.setColor(ink);
|
||||
}
|
||||
|
||||
private void question(Canvas c, long t) {
|
||||
text.setTextSize(14f);
|
||||
text.setColor(accentLight);
|
||||
float bob = (float) Math.sin(t / 400.0) * 1.2f;
|
||||
c.drawText("?", 16f, 18f + bob, text);
|
||||
}
|
||||
|
||||
protected void drop(Canvas c, float x, float y, float s) {
|
||||
p.reset();
|
||||
p.moveTo(x, y - s * 1.6f);
|
||||
p.quadTo(x + s * 1.2f, y + s * 0.2f, x, y + s);
|
||||
p.quadTo(x - s * 1.2f, y + s * 0.2f, x, y - s * 1.6f);
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
/** A four-pointed sparkle; leaves its outline in {@link #p}. */
|
||||
protected void star(Canvas c, float x, float y, float s) {
|
||||
p.reset();
|
||||
p.moveTo(x, y - s);
|
||||
p.quadTo(x, y, x + s, y);
|
||||
p.quadTo(x, y, x, y + s);
|
||||
p.quadTo(x, y, x - s, y);
|
||||
p.quadTo(x, y, x, y - s);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
protected RectF oval(float left, float top, float right, float bottom) {
|
||||
r.set(left, top, right, bottom);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Path;
|
||||
|
||||
/**
|
||||
* The chibi co-pilot for the Mascot Overview style ({@link MascotCharacter#CHIBI}): the small,
|
||||
* full-figure version — a big round head with a coral bob and a star clip, a hoodie in the
|
||||
* accent colour, short legs, white trainers — drawn with dark outlines like a sticker.
|
||||
*
|
||||
* Her whole body acts the mood: waves while content, throws both arms up and hops while
|
||||
* cheering (star eyes), pumps a fist when determined, holds her cheeks with a tear when worried,
|
||||
* sleeps with a nose bubble in P, glances and points back in R, leans into the wind when
|
||||
* fast, and wonders "?" while waiting. The shared parts are in {@link ChibiBase}.
|
||||
*/
|
||||
final class ChibiFigure extends ChibiBase {
|
||||
|
||||
private final int hair = 0xFFF0855C, hairLight = 0xFFFFB08A, skin = 0xFFFFE6D6, trousers = 0xFF2D3142,
|
||||
shoe = 0xFFF6F6F8;
|
||||
|
||||
ChibiFigure(int accent, int accentLight, int regen) {
|
||||
super(accent, accentLight, regen, 0xFF6E45B8, 0xFFA27BE6, 0xFFFFE6D6, 6f);
|
||||
}
|
||||
|
||||
@Override
|
||||
float tipX() {
|
||||
return 70f;
|
||||
}
|
||||
|
||||
@Override
|
||||
float tipY() {
|
||||
return 34f;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void drawFigure(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
drawLegs(c);
|
||||
drawBody(c);
|
||||
boolean inFront = handsInFront(mood);
|
||||
if (!inFront) drawArms(c, mood, t);
|
||||
drawHead(c, mood, t, blink);
|
||||
if (inFront) drawArms(c, mood, t);
|
||||
}
|
||||
|
||||
private void drawLegs(Canvas c) {
|
||||
fill.setColor(trousers);
|
||||
line.setStrokeWidth(1.2f);
|
||||
for (float x : new float[] { 42f, 52f }) {
|
||||
oval(x, 84f, x + 6f, 95f);
|
||||
c.drawRect(r, fill);
|
||||
c.drawRect(r, line);
|
||||
}
|
||||
fill.setColor(shoe);
|
||||
for (float x : new float[] { 39.5f, 50.5f }) {
|
||||
oval(x, 92.5f, x + 10f, 98.5f);
|
||||
c.drawOval(r, fill);
|
||||
c.drawOval(r, line);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBody(Canvas c) {
|
||||
p.reset();
|
||||
p.moveTo(35.5f, 88f);
|
||||
p.lineTo(38f, 67f);
|
||||
p.quadTo(50f, 60f, 62f, 67f);
|
||||
p.lineTo(64.5f, 88f);
|
||||
p.quadTo(50f, 91f, 35.5f, 88f);
|
||||
p.close();
|
||||
fill.setColor(accent);
|
||||
c.drawPath(p, fill);
|
||||
line.setStrokeWidth(1.2f);
|
||||
c.drawPath(p, line);
|
||||
// The pocket and the drawstrings.
|
||||
line.setStrokeWidth(0.9f);
|
||||
p.reset();
|
||||
p.moveTo(42.5f, 88.5f);
|
||||
p.lineTo(43.5f, 79.5f);
|
||||
p.quadTo(50f, 78f, 56.5f, 79.5f);
|
||||
p.lineTo(57.5f, 88.5f);
|
||||
c.drawPath(p, line);
|
||||
line.setColor(Color.WHITE);
|
||||
line.setStrokeWidth(1f);
|
||||
c.drawLine(46.5f, 66f, 46f, 74f, line);
|
||||
c.drawLine(53.5f, 66f, 54f, 74f, line);
|
||||
line.setColor(ink);
|
||||
}
|
||||
|
||||
private void drawArms(Canvas c, MascotMood mood, long t) {
|
||||
float[] h = hands(mood, t);
|
||||
limb(c, 39.5f, 69f, h[0], h[1], accent, skin);
|
||||
limb(c, 60.5f, 69f, h[2], h[3], accent, skin);
|
||||
}
|
||||
|
||||
private void drawHead(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
c.save();
|
||||
c.rotate(tilt(mood), 50f, 62f);
|
||||
// The bob behind the face.
|
||||
fill.setColor(hair);
|
||||
line.setStrokeWidth(1.3f);
|
||||
p.reset();
|
||||
p.addOval(oval(20f, 5f, 80f, 62f), Path.Direction.CW);
|
||||
q.reset();
|
||||
q.addRoundRect(oval(19.5f, 30f, 80.5f, 64f), 7f, 7f, Path.Direction.CW);
|
||||
p.op(q, Path.Op.UNION);
|
||||
c.drawPath(p, fill);
|
||||
c.drawPath(p, line);
|
||||
// The face.
|
||||
fill.setColor(skin);
|
||||
oval(25f, 15f, 75f, 65f);
|
||||
c.drawOval(r, fill);
|
||||
c.drawOval(r, line);
|
||||
// Bangs.
|
||||
p.reset();
|
||||
p.moveTo(22.5f, 40f);
|
||||
p.cubicTo(19f, 7f, 81f, 7f, 77.5f, 40f);
|
||||
p.lineTo(73f, 27f);
|
||||
p.lineTo(66f, 34f);
|
||||
p.lineTo(60f, 23f);
|
||||
p.lineTo(52f, 32f);
|
||||
p.lineTo(45f, 22f);
|
||||
p.lineTo(38f, 32f);
|
||||
p.lineTo(32f, 25f);
|
||||
p.lineTo(27f, 35f);
|
||||
p.close();
|
||||
fill.setColor(hair);
|
||||
c.drawPath(p, fill);
|
||||
c.drawPath(p, line);
|
||||
line.setColor(hairLight);
|
||||
line.setStrokeWidth(1.6f);
|
||||
c.drawArc(oval(30f, 10f, 56f, 30f), 200f, 60f, false, line);
|
||||
line.setColor(ink);
|
||||
// The star clip.
|
||||
fill.setColor(accentLight);
|
||||
star(c, 70f, 19f, 4.5f);
|
||||
line.setStrokeWidth(0.8f);
|
||||
c.drawPath(p, line);
|
||||
|
||||
face(c, mood, t, blink);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RadialGradient;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Shader;
|
||||
import android.graphics.Typeface;
|
||||
|
||||
/**
|
||||
* The Chronograph Overview style: a watch face.
|
||||
*
|
||||
* One large dial, deep blue with a steel bezel carrying the speed scale; the long main hand is
|
||||
* the speed. Three sub-dials inside it: battery at nine o'clock, power at three (charge zone
|
||||
* below zero in green), 5-minute usage at six. A window under twelve shows the gear. Either side
|
||||
* of the watch, engraved columns: range and trip on the left, time, climate and odometer on the
|
||||
* right. Unread values draw no hand and "—".
|
||||
*
|
||||
* Hands ease to new values and redraw only until settled, only on screen. The gradient is a
|
||||
* Canvas shader with fixed colours (not a drawable with theme colours, which Android 9 rejects).
|
||||
*/
|
||||
final class ChronographView extends ClusterCanvasView {
|
||||
|
||||
private static final long FRAME_MS = 33;
|
||||
private static final float START = 135f, SWEEP = 270f, MAX_KMH = 200f;
|
||||
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint dial = 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 RectF rect = new RectF();
|
||||
private final int accent, steel = 0xFFC9CED3, steelDark = 0xFF6E757C, ivory = 0xFFF1EBDD,
|
||||
green, red;
|
||||
private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN, usageShown = Float.NaN;
|
||||
|
||||
ChronographView(Context context) {
|
||||
super(context);
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
green = context.getColor(R.color.ev_ok);
|
||||
red = context.getColor(R.color.ev_error);
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
text.setTypeface(Typeface.create("serif", Typeface.NORMAL));
|
||||
text.setFontFeatureSettings("tnum");
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldW, int oldH) {
|
||||
super.onSizeChanged(w, h, oldW, oldH);
|
||||
float r = h * 0.47f;
|
||||
if (r > 0f) dial.setShader(new RadialGradient(0f, 0f, r * 0.88f, 0xFF20365A, 0xFF0A1424, Shader.TileMode.CLAMP));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
speedShown = ease(speedShown, v.speedKmh, MAX_KMH);
|
||||
socShown = ease(socShown, v.socPercent, 100f);
|
||||
powerShown = ease(powerShown, v.powerKw, 200f);
|
||||
usageShown = ease(usageShown, v.usage5Min == null ? null : v.usage5Min.floatValue(), 30f);
|
||||
boolean moving = (v.speedKmh != null && speedShown != v.speedKmh)
|
||||
|| (v.socPercent != null && socShown != v.socPercent)
|
||||
|| (v.powerKw != null && powerShown != v.powerKw)
|
||||
|| (v.usage5Min != null && usageShown != v.usage5Min.floatValue());
|
||||
|
||||
float r = h * 0.47f, cx = w / 2f, cy = h / 2f;
|
||||
drawCase(c, cx, cy, r);
|
||||
drawScale(c, cx, cy, r);
|
||||
|
||||
// Sub-dials.
|
||||
float sr = r * 0.24f;
|
||||
subDial(c, cx - r * 0.45f, cy, sr, 0f, 100f, 25f, v.socPercent == null ? null : socShown,
|
||||
"BATTERY", fmt(v.socPercent, "%.0f%%", "—"), 20f, false);
|
||||
subDial(c, cx + r * 0.45f, cy, sr, -50f, 150f, 50f, v.powerKw == null ? null : powerShown,
|
||||
"kW", fmt(v.powerKw, "%.1f", "—"), null, true);
|
||||
subDial(c, cx, cy + r * 0.45f, sr, 0f, 30f, 10f, v.usage5Min == null ? null : usageShown,
|
||||
"kWh/100", fmt(v.usage5Min, "%.1f", "—"), null, false);
|
||||
|
||||
// The gear window under twelve, the maker's line above it.
|
||||
text.setTextSize(r * 0.065f);
|
||||
text.setColor(ivory);
|
||||
text.setLetterSpacing(0.25f);
|
||||
c.drawText("NILS' MG4", cx, cy - r * 0.50f, text);
|
||||
text.setTextSize(r * 0.04f);
|
||||
c.drawText("ELECTRIC · CHRONOGRAPH", cx, cy - r * 0.43f, text);
|
||||
text.setLetterSpacing(0f);
|
||||
String gear = Gear.label(v.gear);
|
||||
rect.set(cx - r * 0.07f, cy - r * 0.36f, cx + r * 0.07f, cy - r * 0.22f);
|
||||
fill.setColor(ivory);
|
||||
c.drawRect(rect, fill);
|
||||
line.setColor(steelDark);
|
||||
line.setStrokeWidth(1.5f * dp);
|
||||
c.drawRect(rect, line);
|
||||
text.setColor(0xFF111111);
|
||||
text.setTextSize(r * 0.10f);
|
||||
c.drawText(gear.isEmpty() ? " " : gear, cx, cy - r * 0.255f, text);
|
||||
|
||||
// The speed: the long main hand, and the value small above the hub.
|
||||
text.setColor(ivory);
|
||||
text.setTextSize(r * 0.075f);
|
||||
c.drawText(fmt(v.speedKmh, "%.0f km/h", "— km/h"), cx, cy - r * 0.10f, text);
|
||||
if (v.speedKmh != null) hand(c, cx, cy, r * 0.86f, r * 0.15f, angle(speedShown, 0f, MAX_KMH), r * 0.022f, steel, accent);
|
||||
fill.setColor(steel);
|
||||
c.drawCircle(cx, cy, r * 0.035f, fill);
|
||||
fill.setColor(accent);
|
||||
c.drawCircle(cx, cy, r * 0.015f, fill);
|
||||
|
||||
drawColumns(c, w, h, cx, r);
|
||||
keepAnimating(moving, FRAME_MS);
|
||||
}
|
||||
|
||||
private static float angle(float value, float min, float max) {
|
||||
return START + SWEEP * (Math.max(min, Math.min(max, value)) - min) / (max - min);
|
||||
}
|
||||
|
||||
/** The case and bezel in steel, the dial deep blue with a soft light in the middle. */
|
||||
private void drawCase(Canvas c, float cx, float cy, float r) {
|
||||
fill.setShader(null);
|
||||
fill.setColor(steelDark);
|
||||
c.drawCircle(cx, cy, r * 1.04f, fill);
|
||||
fill.setColor(steel);
|
||||
c.drawCircle(cx, cy, r, fill);
|
||||
fill.setColor(0xFF2A2F35);
|
||||
c.drawCircle(cx, cy, r * 0.90f, fill);
|
||||
c.save();
|
||||
c.translate(cx, cy);
|
||||
c.drawCircle(0f, 0f, r * 0.88f, dial);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** The speed scale on the bezel ring: numbers every 20, ticks every 5; applied indices inside. */
|
||||
private void drawScale(Canvas c, float cx, float cy, float r) {
|
||||
text.setTextSize(r * 0.06f);
|
||||
text.setColor(0xFF1A1D21);
|
||||
for (int kmh = 0; kmh <= MAX_KMH; kmh += 5) {
|
||||
double a = Math.toRadians(angle(kmh, 0f, MAX_KMH));
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
boolean major = kmh % 20 == 0;
|
||||
line.setColor(major ? ivory : 0xAAF1EBDD);
|
||||
line.setStrokeWidth(major ? r * 0.012f : r * 0.006f);
|
||||
c.drawLine(cx + r * 0.87f * cos, cy + r * 0.87f * sin, cx + r * (major ? 0.79f : 0.83f) * cos,
|
||||
cy + r * (major ? 0.79f : 0.83f) * sin, line);
|
||||
if (major) {
|
||||
c.drawText(String.valueOf(kmh), cx + r * 0.95f * cos, cy + r * 0.95f * sin + text.getTextSize() * 0.36f, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A small dial: ticks, end labels, a zone, the hand, its caption and value. */
|
||||
private void subDial(Canvas c, float cx, float cy, float r, float min, float max, float major,
|
||||
Float value, String caption, String readout, Float warnBelow, boolean chargeZone) {
|
||||
fill.setColor(0xFF0C1626);
|
||||
c.drawCircle(cx, cy, r, fill);
|
||||
line.setColor(steelDark);
|
||||
line.setStrokeWidth(1.5f * dp);
|
||||
c.drawCircle(cx, cy, r, line);
|
||||
rect.set(cx - r * 0.82f, cy - r * 0.82f, cx + r * 0.82f, cy + r * 0.82f);
|
||||
line.setStrokeWidth(r * 0.06f);
|
||||
line.setStrokeCap(Paint.Cap.BUTT);
|
||||
if (warnBelow != null) {
|
||||
line.setColor(red);
|
||||
c.drawArc(rect, START, SWEEP * (warnBelow - min) / (max - min), false, line);
|
||||
}
|
||||
if (chargeZone) {
|
||||
line.setColor(green);
|
||||
c.drawArc(rect, START, SWEEP * (0f - min) / (max - min), false, line);
|
||||
}
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
line.setColor(ivory);
|
||||
for (float u = min; u <= max + 0.01f; u += major / 2f) {
|
||||
double a = Math.toRadians(angle(u, min, max));
|
||||
boolean isMajor = Math.round(u * 10) % Math.round(major * 10) == 0;
|
||||
line.setStrokeWidth(isMajor ? r * 0.04f : r * 0.02f);
|
||||
float outer = r * 0.92f, inner = isMajor ? r * 0.72f : r * 0.80f;
|
||||
c.drawLine(cx + outer * (float) Math.cos(a), cy + outer * (float) Math.sin(a),
|
||||
cx + inner * (float) Math.cos(a), cy + inner * (float) Math.sin(a), line);
|
||||
}
|
||||
text.setColor(ivory);
|
||||
text.setTextSize(r * 0.16f);
|
||||
c.drawText(caption, cx, cy - r * 0.32f, text);
|
||||
text.setTextSize(r * 0.24f);
|
||||
c.drawText(readout, cx, cy + r * 0.55f, text);
|
||||
if (value != null) hand(c, cx, cy, r * 0.78f, r * 0.12f, angle(value, min, max), r * 0.05f, ivory, accent);
|
||||
fill.setColor(ivory);
|
||||
c.drawCircle(cx, cy, r * 0.07f, fill);
|
||||
}
|
||||
|
||||
/** A hand: a tail behind the hub, a shaft, and a coloured tip. */
|
||||
private void hand(Canvas c, float cx, float cy, float length, float tail, float deg, float width, int colour, int tip) {
|
||||
double a = Math.toRadians(deg);
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
line.setColor(0x66000000);
|
||||
line.setStrokeWidth(width * 1.6f);
|
||||
c.drawLine(cx - tail * cos + 2f * dp, cy - tail * sin + 3f * dp, cx + length * cos + 2f * dp, cy + length * sin + 3f * dp, line);
|
||||
line.setColor(colour);
|
||||
line.setStrokeWidth(width);
|
||||
c.drawLine(cx - tail * cos, cy - tail * sin, cx + length * cos, cy + length * sin, line);
|
||||
line.setColor(tip);
|
||||
c.drawLine(cx + length * 0.78f * cos, cy + length * 0.78f * sin, cx + length * cos, cy + length * sin, line);
|
||||
}
|
||||
|
||||
/** Engraved columns either side of the watch. */
|
||||
private void drawColumns(Canvas c, float w, float h, float cx, float r) {
|
||||
float leftX = (cx - r * 1.04f) / 2f, rightX = w - leftX;
|
||||
String[][] left = { { "RANGE", fmt(v.rangeKm, "%.0f km", "—") }, { "TRIP", fmt(v.tripKm, "%.1f km", "—") },
|
||||
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") } };
|
||||
String[][] right = { { "TIME", v.clock.isEmpty() ? "—" : v.clock }, { "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
|
||||
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") } };
|
||||
column(c, leftX, h, left);
|
||||
column(c, rightX, h, right);
|
||||
}
|
||||
|
||||
private void column(Canvas c, float x, float h, String[][] rows) {
|
||||
float y = h * 0.26f;
|
||||
for (String[] row : rows) {
|
||||
text.setTextSize(12f * dp);
|
||||
text.setLetterSpacing(0.25f);
|
||||
text.setColor(0xFF9AA3AD);
|
||||
c.drawText(row[0], x, y, text);
|
||||
text.setLetterSpacing(0f);
|
||||
text.setTextSize(30f * dp);
|
||||
text.setColor(ivory);
|
||||
c.drawText(row[1], x, y + 38f * dp, text);
|
||||
y += h * 0.27f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The plumbing the drawn Overview styles share: the latest {@link ClusterValues}, the density,
|
||||
* needle easing and null-safe formatting ("—" for an unread value, never 0).
|
||||
*/
|
||||
abstract class ClusterCanvasView extends View {
|
||||
|
||||
protected ClusterValues v = new ClusterValues();
|
||||
protected final float dp;
|
||||
|
||||
ClusterCanvasView(Context context) {
|
||||
super(context);
|
||||
dp = context.getResources().getDisplayMetrics().density;
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
/** One step of a needle towards its value; jumps there when close or on the first value. */
|
||||
static float ease(float shown, Float target, float span) {
|
||||
if (target == null || !Float.isFinite(target)) return shown;
|
||||
if (Float.isNaN(shown)) return target;
|
||||
float next = shown + (target - shown) * 0.25f;
|
||||
return Math.abs(target - next) < span * 0.002f ? target : next;
|
||||
}
|
||||
|
||||
/** The value in the pattern, or {@code none} when unread. */
|
||||
static String fmt(Number value, String pattern, String none) {
|
||||
if (value == null || Double.isNaN(value.doubleValue())) return none;
|
||||
return String.format(Locale.US, pattern, value);
|
||||
}
|
||||
|
||||
/** Keeps drawing while something moves, but only while the view is on screen. */
|
||||
protected void keepAnimating(boolean moving, long frameMs) {
|
||||
if (moving && isShown()) postInvalidateDelayed(frameMs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* One reading for the drawn Overview styles (Retro, Futuristic, Sci-fi, Cartoon, Blueprint):
|
||||
* what they show, already in display units. Every field is null when unread, and the styles show that as dashes, never 0.
|
||||
*/
|
||||
final class ClusterValues {
|
||||
Float socPercent;
|
||||
Float rangeKm;
|
||||
Float powerKw;
|
||||
Float speedKmh;
|
||||
/** The car's raw gear code ({@link Gear}). */
|
||||
Integer gear;
|
||||
/** kWh/100 km over the last 5 minutes and since start; km since start. */
|
||||
Double usage5Min;
|
||||
Double usageTrip;
|
||||
Double tripKm;
|
||||
Float climateCelsius;
|
||||
Float odometerKm;
|
||||
/** "HH:mm", never null. */
|
||||
String clock = "";
|
||||
/** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */
|
||||
String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—";
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import com.evsuite.hardware.EVHardware;
|
||||
import com.evsuite.hardware.model.DriveMode;
|
||||
|
||||
/**
|
||||
* The drive mode write and its read-back, shared by the Settings buttons and the start
|
||||
* (DriveModeWrite has the rules; the caller checks them first). Off the main thread: it
|
||||
* waits up to three seconds for the car to report the new mode.
|
||||
*/
|
||||
final class DriveModeSetter {
|
||||
|
||||
private DriveModeSetter() {}
|
||||
|
||||
static final class Result {
|
||||
/** False when the write was refused or failed: nothing was sent. */
|
||||
final boolean written;
|
||||
/** The mode the car reports afterwards; null when it could not be read. */
|
||||
final Integer reported;
|
||||
|
||||
Result(boolean written, Integer reported) {
|
||||
this.written = written;
|
||||
this.reported = reported;
|
||||
}
|
||||
}
|
||||
|
||||
static Result set(int code) throws InterruptedException {
|
||||
DriveMode mode = DriveMode.Companion.ofRaw(code);
|
||||
if (mode == null || !EVHardware.INSTANCE.writeDriveModeProperty(mode)) return new Result(false, null);
|
||||
Integer now = null;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
Thread.sleep(300);
|
||||
now = EVHardware.INSTANCE.getDriveModeRaw();
|
||||
if (now != null && now == code) break;
|
||||
}
|
||||
return new Result(true, now);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Changing the drive mode from Settings — the one vehicle setting this app writes (owner's
|
||||
* decision, 2026-10-25; AGENTS.md rule 3). The rules, kept here so they are tested:
|
||||
*
|
||||
* - only in P and at 0 km/h, and nothing when the gear or the speed cannot be read (fail
|
||||
* closed); EVHardware's own {@code VehicleWriteGate} checks the speed again at the write;
|
||||
* - the result is what the car reports afterwards, never what was asked for;
|
||||
* - at start ({@link #atStart}), the mode the driver last chose and the car confirmed is set
|
||||
* again, once, while the car is still in P before it first drives off (owner's decision,
|
||||
* 2026-10-25): the car falls back to its own default mode when it is switched on.
|
||||
*
|
||||
* Codes are the car's (`0x2140A17C`, EVHardware's {@code DriveMode}): Eco 2, Normal 3, Sport 4,
|
||||
* Snow 6, Custom 7. Pure Java, unit-tested.
|
||||
*/
|
||||
final class DriveModeWrite {
|
||||
|
||||
/** The modes offered, in the order of the buttons. */
|
||||
static final int[] CODES = { 2, 3, 4, 6, 7 };
|
||||
static final int P = 1;
|
||||
|
||||
enum Verdict { ALLOWED, NOT_IN_P, MOVING, UNREADABLE }
|
||||
|
||||
enum Outcome { APPLIED, NOT_APPLIED, UNREAD }
|
||||
|
||||
/** The mode last chosen on Settings that the car confirmed; absent until one is. */
|
||||
static final String PREF_CHOSEN = "drive_mode_chosen";
|
||||
/** Whether the chosen mode is set again at start (the switch on Settings, on by default). */
|
||||
static final String PREF_AT_START = "drive_mode_at_start";
|
||||
/** The last start's result, with its time, for the status line on Settings. */
|
||||
static final String PREF_START_RESULT = "drive_mode_start_result";
|
||||
/** How long after the head unit starts the start check keeps waiting for P and readings. */
|
||||
static final long START_WINDOW_MS = 2 * 60_000L;
|
||||
|
||||
/** What the start check does with one reading. */
|
||||
enum StartStep {
|
||||
/** Readings not in yet: look again. */
|
||||
WAIT,
|
||||
/** The car already reports the chosen mode: nothing to write. */
|
||||
ALREADY,
|
||||
/** Write the chosen mode, once. */
|
||||
WRITE,
|
||||
/** Nothing chosen, switched off, out of P or moving (driven off), or too late. */
|
||||
GIVE_UP
|
||||
}
|
||||
private DriveModeWrite() {}
|
||||
|
||||
static Verdict check(Float speedKmh, Integer gear) {
|
||||
if (speedKmh == null || speedKmh.isNaN() || speedKmh < 0f || gear == null) return Verdict.UNREADABLE;
|
||||
if (gear != P) return Verdict.NOT_IN_P;
|
||||
if (speedKmh != 0f) return Verdict.MOVING;
|
||||
return Verdict.ALLOWED;
|
||||
}
|
||||
|
||||
static Outcome outcome(int asked, Integer readBack) {
|
||||
if (readBack == null) return Outcome.UNREAD;
|
||||
return readBack == asked ? Outcome.APPLIED : Outcome.NOT_APPLIED;
|
||||
}
|
||||
|
||||
/**
|
||||
* The start check, one reading at a time. Out of P or moving means the drive has begun,
|
||||
* and then the driver's own choice on the car stands: no write until the next start.
|
||||
*/
|
||||
static StartStep atStart(Integer chosen, boolean enabled, Integer gear, Float speedKmh,
|
||||
Integer current, long sinceStartMs) {
|
||||
if (!enabled || indexOf(chosen) < 0 || sinceStartMs > START_WINDOW_MS) return StartStep.GIVE_UP;
|
||||
if (gear == null || speedKmh == null || speedKmh.isNaN() || current == null) return StartStep.WAIT;
|
||||
if (check(speedKmh, gear) != Verdict.ALLOWED) return StartStep.GIVE_UP;
|
||||
return current.equals(chosen) ? StartStep.ALREADY : StartStep.WRITE;
|
||||
}
|
||||
|
||||
/** The button for a reported code, or -1 when the code is unread or not one offered. */
|
||||
static int indexOf(Integer code) {
|
||||
if (code == null) return -1;
|
||||
for (int i = 0; i < CODES.length; i++) if (CODES[i] == code) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* The swipe in from the left edge that brings the navigation back in the Overview's full
|
||||
* screen: a touch that starts within {@code edge} of the left side and travels at least
|
||||
* {@code travel} to the right, more sideways than up or down. Distances in pixels.
|
||||
* Pure Java, unit-tested.
|
||||
*/
|
||||
final class EdgeSwipe {
|
||||
|
||||
private final float edge, travel;
|
||||
private boolean tracking;
|
||||
private float startX, startY;
|
||||
|
||||
EdgeSwipe(float edge, float travel) {
|
||||
this.edge = edge;
|
||||
this.travel = travel;
|
||||
}
|
||||
|
||||
/** A finger went down: only one near the left edge can become the swipe. */
|
||||
void down(float x, float y) {
|
||||
tracking = x <= edge;
|
||||
startX = x;
|
||||
startY = y;
|
||||
}
|
||||
|
||||
/** The finger moved; true once, when this touch has become the swipe. */
|
||||
boolean move(float x, float y) {
|
||||
if (!tracking) return false;
|
||||
float dx = x - startX, dy = Math.abs(y - startY);
|
||||
if (dx >= travel && dx > dy) {
|
||||
tracking = false;
|
||||
return true;
|
||||
}
|
||||
// Mostly up or down already: a scroll, not this swipe.
|
||||
if (dy > travel) tracking = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The finger lifted or the touch was cancelled. */
|
||||
void up() {
|
||||
tracking = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.SystemClock;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Futuristic Overview style: a holographic HUD in hairlines and the accent colour.
|
||||
*
|
||||
* Left, the battery as a capsule filled to the charge. Right, the "wheel": the speed inside a
|
||||
* ring of dashes that turns with the speed, and an arc for the power. Between them, an energy
|
||||
* channel in which particles flow from the battery to the wheel while drawing power, and back
|
||||
* in green while regenerating, faster with more power. A column of P R N D, and a line of
|
||||
* readouts at the bottom. Unread values show "—", fill nothing and move nothing.
|
||||
*
|
||||
* It animates only while something moves (power or speed) and the view is on screen, at about
|
||||
* 30 frames a second; parked and idle it draws once per reading like the other styles.
|
||||
*/
|
||||
public final class FlowView extends View {
|
||||
|
||||
private static final long FRAME_MS = 33;
|
||||
|
||||
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint dim = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint thin = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint small = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF rect = new RectF();
|
||||
private final int accent, light, regen, warn, white, grey;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
/** Particle offset along the channel (px) and the wheel's turn (degrees). */
|
||||
private float flow, turn;
|
||||
private long lastFrame;
|
||||
|
||||
public FlowView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
light = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
regen = context.getColor(R.color.ev_ok);
|
||||
warn = context.getColor(R.color.ev_error);
|
||||
white = context.getColor(R.color.ev_text_primary);
|
||||
grey = context.getColor(R.color.ev_text_secondary);
|
||||
line.setStyle(Paint.Style.STROKE);
|
||||
line.setStrokeCap(Paint.Cap.ROUND);
|
||||
dim.setStyle(Paint.Style.STROKE);
|
||||
dim.setColor(AccentColor.withAlpha(accent, 0x40));
|
||||
glow.setStyle(Paint.Style.STROKE);
|
||||
glow.setStrokeCap(Paint.Cap.ROUND);
|
||||
Typeface thinFace = Typeface.create("sans-serif-thin", Typeface.NORMAL);
|
||||
thin.setTypeface(thinFace);
|
||||
thin.setColor(white);
|
||||
thin.setFontFeatureSettings("tnum");
|
||||
small.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
|
||||
small.setLetterSpacing(0.2f);
|
||||
small.setFontFeatureSettings("tnum");
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
|
||||
long now = SystemClock.uptimeMillis();
|
||||
float dt = lastFrame == 0 ? 0f : Math.min(100, now - lastFrame) / 1000f;
|
||||
lastFrame = now;
|
||||
float power = v.powerKw == null ? 0f : v.powerKw;
|
||||
float speed = v.speedKmh == null ? 0f : v.speedKmh;
|
||||
boolean flowing = Math.abs(power) >= 0.3f;
|
||||
if (flowing) flow += Math.signum(power) * (60f + Math.min(150f, Math.abs(power)) * 7f) * d * dt;
|
||||
if (speed >= 0.5f) turn += Math.min(180f, speed) * 1.2f * dt;
|
||||
|
||||
drawBrackets(c, w, h, d);
|
||||
float cy = h * 0.44f;
|
||||
float bl = w * 0.05f, br = w * 0.115f;
|
||||
drawBattery(c, bl, h * 0.12f, br, h * 0.78f, d);
|
||||
float wheelR = h * 0.34f, wheelX = w * 0.68f;
|
||||
drawWheel(c, wheelX, cy, wheelR, d);
|
||||
drawChannel(c, br + 10f * d, wheelX - wheelR - 14f * d, cy, d, power, flowing);
|
||||
drawGears(c, w * 0.935f, h, d);
|
||||
drawReadouts(c, w, h, d);
|
||||
|
||||
if ((flowing || speed >= 0.5f) && isShown()) postInvalidateDelayed(FRAME_MS);
|
||||
else lastFrame = 0;
|
||||
}
|
||||
|
||||
private void drawBrackets(Canvas c, float w, float h, float d) {
|
||||
line.setColor(AccentColor.withAlpha(accent, 0x90));
|
||||
line.setStrokeWidth(1.5f * d);
|
||||
float l = 18f * d, m = 4f * d;
|
||||
float[][] corners = { { m, m, 1, 1 }, { w - m, m, -1, 1 }, { m, h - m, 1, -1 }, { w - m, h - m, -1, -1 } };
|
||||
for (float[] k : corners) {
|
||||
c.drawLine(k[0], k[1], k[0] + k[2] * l, k[1], line);
|
||||
c.drawLine(k[0], k[1], k[0], k[1] + k[3] * l, line);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawBattery(Canvas c, float l, float t, float r, float b, float d) {
|
||||
float radius = (r - l) * 0.35f;
|
||||
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||
int colour = low ? warn : accent;
|
||||
// The terminal, then the outline.
|
||||
fill.setColor(AccentColor.withAlpha(colour, 0xB0));
|
||||
rect.set(l + (r - l) * 0.32f, t - 8f * d, r - (r - l) * 0.32f, t - 2f * d);
|
||||
c.drawRoundRect(rect, 2f * d, 2f * d, fill);
|
||||
line.setColor(colour);
|
||||
line.setStrokeWidth(1.5f * d);
|
||||
rect.set(l, t, r, b);
|
||||
c.drawRoundRect(rect, radius, radius, line);
|
||||
// The charge as scan lines from the bottom.
|
||||
if (v.socPercent != null) {
|
||||
float inset = 6f * d, top = b - inset - (b - t - 2 * inset) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
|
||||
line.setStrokeWidth(2f * d);
|
||||
for (float y = b - inset; y >= top; y -= 5f * d) {
|
||||
float k = (y - top) / Math.max(1f, b - inset - top);
|
||||
line.setColor(AccentColor.withAlpha(colour, (int) (0x50 + 0xAF * (1f - k * 0.6f))));
|
||||
c.drawLine(l + inset, y, r - inset, y, line);
|
||||
}
|
||||
glow.setColor(AccentColor.withAlpha(colour, 0x50));
|
||||
glow.setStrokeWidth(5f * d);
|
||||
c.drawLine(l + inset, top, r - inset, top, glow);
|
||||
}
|
||||
|
||||
float x = r + 28f * d;
|
||||
caption(c, "BATTERY", x, t + 10f * d, Paint.Align.LEFT);
|
||||
thin.setTextAlign(Paint.Align.LEFT);
|
||||
thin.setTextSize((b - t) * 0.30f);
|
||||
thin.setColor(low ? warn : white);
|
||||
String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f", v.socPercent);
|
||||
c.drawText(soc, x - 4f * d, t + (b - t) * 0.40f, thin);
|
||||
float sw = thin.measureText(soc);
|
||||
thin.setTextSize((b - t) * 0.10f);
|
||||
thin.setColor(light);
|
||||
c.drawText("%", x + sw + 2f * d, t + (b - t) * 0.40f, thin);
|
||||
thin.setColor(white);
|
||||
thin.setTextSize((b - t) * 0.10f);
|
||||
c.drawText(v.rangeKm == null ? "— km" : String.format(Locale.US, "%.0f km", v.rangeKm), x, b - (b - t) * 0.10f, thin);
|
||||
caption(c, "RANGE", x, b - (b - t) * 0.25f, Paint.Align.LEFT);
|
||||
}
|
||||
|
||||
private void drawChannel(Canvas c, float x0, float x1, float cy, float d, float power, boolean flowing) {
|
||||
if (x1 <= x0) return;
|
||||
boolean regenerating = v.powerKw != null && v.powerKw < 0f;
|
||||
int colour = regenerating ? regen : accent;
|
||||
float half = 9f * d;
|
||||
dim.setStrokeWidth(d);
|
||||
c.drawLine(x0, cy - half, x1, cy - half, dim);
|
||||
c.drawLine(x0, cy + half, x1, cy + half, dim);
|
||||
|
||||
if (flowing) {
|
||||
// Particles every 34 dp, fading in and out at the ends.
|
||||
float spacing = 34f * d, len = 12f * d;
|
||||
float offset = ((flow % spacing) + spacing) % spacing;
|
||||
line.setStrokeWidth(4f * d);
|
||||
glow.setStrokeWidth(10f * d);
|
||||
for (float x = x0 + offset - spacing; x < x1; x += spacing) {
|
||||
float a = Math.max(x0, x), b = Math.min(x1, x + len);
|
||||
if (b <= a) continue;
|
||||
float edge = Math.min(a - x0, x1 - b) / (spacing * 2f);
|
||||
int alpha = (int) (0xFF * Math.max(0.15f, Math.min(1f, edge)));
|
||||
glow.setColor(AccentColor.withAlpha(colour, alpha / 5));
|
||||
c.drawLine(a, cy, b, cy, glow);
|
||||
line.setColor(AccentColor.withAlpha(colour, alpha));
|
||||
c.drawLine(a, cy, b, cy, line);
|
||||
}
|
||||
}
|
||||
|
||||
float mid = (x0 + x1) / 2f;
|
||||
thin.setTextAlign(Paint.Align.CENTER);
|
||||
thin.setTextSize(30f * d);
|
||||
thin.setColor(regenerating ? regen : white);
|
||||
c.drawText(v.powerKw == null ? "— kW" : String.format(Locale.US, "%.1f kW", v.powerKw), mid, cy - half - 16f * d, thin);
|
||||
thin.setColor(white);
|
||||
String state = v.powerKw == null ? "POWER" : regenerating ? "REGENERATING" : flowing ? "DRAWING" : "IDLE";
|
||||
caption(c, state, mid, cy + half + 26f * d, Paint.Align.CENTER);
|
||||
}
|
||||
|
||||
private void drawWheel(Canvas c, float cx, float cy, float r, float d) {
|
||||
// An outer ring of dashes that turns with the speed.
|
||||
dim.setStrokeWidth(d);
|
||||
c.drawCircle(cx, cy, r * 0.84f, dim);
|
||||
line.setColor(AccentColor.withAlpha(accent, 0xA0));
|
||||
line.setStrokeWidth(3f * d);
|
||||
for (int i = 0; i < 72; i++) {
|
||||
double a = Math.toRadians(turn + i * 5f);
|
||||
float len = i % 6 == 0 ? r * 0.07f : r * 0.035f;
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
c.drawLine(cx + r * cos, cy + r * sin, cx + (r - len) * cos, cy + (r - len) * sin, line);
|
||||
}
|
||||
// Power as an arc from the top: clockwise drawing (150 kW = 300°), anticlockwise regen.
|
||||
if (v.powerKw != null && Math.abs(v.powerKw) >= 0.1f) {
|
||||
float sweep = Math.max(-100f, Math.min(300f, v.powerKw * 2f));
|
||||
int colour = sweep < 0 ? regen : accent;
|
||||
rect.set(cx - r * 0.92f, cy - r * 0.92f, cx + r * 0.92f, cy + r * 0.92f);
|
||||
glow.setColor(AccentColor.withAlpha(colour, 0x40));
|
||||
glow.setStrokeWidth(12f * d);
|
||||
c.drawArc(rect, -90f, sweep, false, glow);
|
||||
line.setColor(colour);
|
||||
line.setStrokeWidth(4f * d);
|
||||
c.drawArc(rect, -90f, sweep, false, line);
|
||||
}
|
||||
|
||||
thin.setTextAlign(Paint.Align.CENTER);
|
||||
thin.setColor(white);
|
||||
thin.setTextSize(r * 0.62f);
|
||||
c.drawText(v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), cx, cy + r * 0.18f, thin);
|
||||
caption(c, "KM/H", cx, cy + r * 0.42f, Paint.Align.CENTER);
|
||||
}
|
||||
|
||||
private void drawGears(Canvas c, float x, float h, float d) {
|
||||
String[] letters = { "P", "R", "N", "D" };
|
||||
thin.setTextAlign(Paint.Align.CENTER);
|
||||
thin.setTextSize(h * 0.075f);
|
||||
float y0 = h * 0.20f, pitch = h * 0.13f;
|
||||
for (int i = 0; i < letters.length; i++) {
|
||||
boolean on = v.gear != null && v.gear == i + 1;
|
||||
float y = y0 + i * pitch;
|
||||
thin.setColor(on ? white : AccentColor.withAlpha(grey, 0x50));
|
||||
thin.setFakeBoldText(on);
|
||||
c.drawText(letters[i], x, y, thin);
|
||||
if (on) {
|
||||
line.setColor(accent);
|
||||
line.setStrokeWidth(2f * d);
|
||||
float s = h * 0.05f, top = y - h * 0.075f;
|
||||
c.drawLine(x - s - 6f * d, top, x - s - 6f * d, y + 6f * d, line);
|
||||
c.drawLine(x + s + 6f * d, top, x + s + 6f * d, y + 6f * d, line);
|
||||
}
|
||||
}
|
||||
thin.setFakeBoldText(false);
|
||||
}
|
||||
|
||||
private void drawReadouts(Canvas c, float w, float h, float d) {
|
||||
float top = h * 0.84f;
|
||||
dim.setStrokeWidth(d);
|
||||
c.drawLine(w * 0.04f, top, w * 0.96f, top, dim);
|
||||
String[] labels = { "USAGE 5 MIN", "SINCE START", "TRIP", "CLIMATE", "ODOMETER", "TIME" };
|
||||
String[] values = {
|
||||
v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f", v.usage5Min),
|
||||
v.usageTrip == null ? "—" : String.format(Locale.US, "%.1f", v.usageTrip),
|
||||
v.tripKm == null ? "— km" : String.format(Locale.US, "%.1f km", v.tripKm),
|
||||
v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius),
|
||||
v.odometerKm == null ? "— km" : String.format(Locale.US, "%,.0f km", v.odometerKm),
|
||||
v.clock.isEmpty() ? "—" : v.clock,
|
||||
};
|
||||
float pitch = (w * 0.92f) / labels.length;
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
float x = w * 0.04f + pitch * i + pitch / 2f;
|
||||
caption(c, labels[i], x, top + 22f * d, Paint.Align.CENTER);
|
||||
thin.setTextAlign(Paint.Align.CENTER);
|
||||
thin.setTextSize(i == labels.length - 1 ? 34f * d : 28f * d);
|
||||
thin.setColor(white);
|
||||
c.drawText(values[i], x, top + 58f * d, thin);
|
||||
if (i > 0) c.drawLine(w * 0.04f + pitch * i, top + 10f * d, w * 0.04f + pitch * i, top + 60f * d, dim);
|
||||
}
|
||||
}
|
||||
|
||||
private void caption(Canvas c, String text, float x, float baseline, Paint.Align align) {
|
||||
small.setTextSize(12f * getResources().getDisplayMetrics().density);
|
||||
small.setTextAlign(align);
|
||||
small.setColor(light);
|
||||
c.drawText(text, x, baseline, small);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Path;
|
||||
|
||||
/**
|
||||
* The chibi fox for the Mascot Overview style ({@link MascotCharacter#FOX}): a sitting fox with
|
||||
* a big head, pointed ears, cream cheeks and chest, dark paws, a scarf in the accent colour and
|
||||
* a large brush tail with a cream tip.
|
||||
*
|
||||
* Ears and tail act the mood with the face ({@link ChibiBase}): the tail wags while content and
|
||||
* fast while cheering (paws up, star eyes), stands stiff when determined (paw at the chest),
|
||||
* hangs low with drooping ears when worried (paws on the cheeks), lies flat in P (ears down,
|
||||
* nose bubble); ears go back and the scarf flies when fast; a paw points back in R.
|
||||
*/
|
||||
final class FoxFigure extends ChibiBase {
|
||||
|
||||
private final int fur = 0xFFF08A3C, furLight = 0xFFFFB46E, cream = 0xFFFFF4E6, paw = 0xFF4A3028,
|
||||
earInside = 0xFF5A3A30;
|
||||
|
||||
FoxFigure(int accent, int accentLight, int regen) {
|
||||
super(accent, accentLight, regen, 0xFF9A5A1A, 0xFFE0A040, 0xFFF08A3C, 10f);
|
||||
}
|
||||
|
||||
@Override
|
||||
float tipX() {
|
||||
return 66f;
|
||||
}
|
||||
|
||||
@Override
|
||||
float tipY() {
|
||||
return 38f;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void drawFigure(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
drawTail(c, mood, t);
|
||||
drawBody(c);
|
||||
drawScarf(c, mood, t);
|
||||
boolean inFront = handsInFront(mood);
|
||||
if (!inFront) drawPaws(c, mood, t);
|
||||
drawHead(c, mood, t, blink);
|
||||
if (inFront) drawPaws(c, mood, t);
|
||||
}
|
||||
|
||||
/** The brush, rotated about its root by the mood; wagging is a swing over time. */
|
||||
private void drawTail(Canvas c, MascotMood mood, long t) {
|
||||
// Kept within about 18° to the right, so the tail stays clear of the values.
|
||||
float angle;
|
||||
switch (mood) {
|
||||
case CONTENT: angle = (float) Math.sin(t / 250.0) * 8f; break;
|
||||
case CHEERING: angle = (float) Math.sin(t / 110.0) * 14f; break;
|
||||
case DETERMINED: angle = -8f; break;
|
||||
case WORRIED: angle = 15f; break;
|
||||
case SLEEPING: angle = 18f; break;
|
||||
case FAST: angle = 16f; break;
|
||||
case REVERSE: angle = (float) Math.sin(t / 400.0) * 5f; break;
|
||||
default: angle = (float) Math.sin(t / 600.0) * 4f;
|
||||
}
|
||||
c.save();
|
||||
c.rotate(angle, 60f, 90f);
|
||||
p.reset();
|
||||
p.moveTo(57f, 92f);
|
||||
p.cubicTo(80f, 96f, 94f, 78f, 88f, 56f);
|
||||
p.cubicTo(86f, 46f, 78f, 40f, 73f, 44f);
|
||||
p.cubicTo(78f, 56f, 76f, 74f, 57f, 86f);
|
||||
p.close();
|
||||
fill.setColor(fur);
|
||||
c.drawPath(p, fill);
|
||||
c.save();
|
||||
c.clipPath(p);
|
||||
fill.setColor(cream);
|
||||
c.drawCircle(83f, 46f, 10f, fill);
|
||||
c.restore();
|
||||
line.setStrokeWidth(1.3f);
|
||||
c.drawPath(p, line);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
private void drawBody(Canvas c) {
|
||||
p.reset();
|
||||
p.moveTo(37f, 93f);
|
||||
p.cubicTo(33f, 80f, 40f, 66f, 50f, 66f);
|
||||
p.cubicTo(60f, 66f, 67f, 80f, 63f, 93f);
|
||||
p.quadTo(50f, 96f, 37f, 93f);
|
||||
p.close();
|
||||
fill.setColor(fur);
|
||||
c.drawPath(p, fill);
|
||||
line.setStrokeWidth(1.2f);
|
||||
c.drawPath(p, line);
|
||||
fill.setColor(cream);
|
||||
c.drawOval(oval(43f, 71f, 57f, 91f), fill);
|
||||
// Hind paws.
|
||||
fill.setColor(paw);
|
||||
for (float x : new float[] { 37.5f, 52.5f }) {
|
||||
oval(x, 90f, x + 10f, 97.5f);
|
||||
c.drawOval(r, fill);
|
||||
c.drawOval(r, line);
|
||||
}
|
||||
}
|
||||
|
||||
/** The scarf round the neck; its end hangs, or flies back when fast. */
|
||||
private void drawScarf(Canvas c, MascotMood mood, long t) {
|
||||
fill.setColor(accent);
|
||||
line.setStrokeWidth(1.1f);
|
||||
if (mood == MascotMood.FAST) {
|
||||
float flap = (float) Math.sin(t / 90.0) * 2f;
|
||||
p.reset();
|
||||
p.moveTo(40f, 64f);
|
||||
p.quadTo(30f, 60f + flap, 20f, 63f - flap);
|
||||
p.lineTo(21f, 69f - flap);
|
||||
p.quadTo(30f, 67f + flap, 40f, 70f);
|
||||
p.close();
|
||||
} else {
|
||||
p.reset();
|
||||
p.addRoundRect(oval(54f, 65f, 61f, 80f), 2f, 2f, Path.Direction.CW);
|
||||
}
|
||||
c.drawPath(p, fill);
|
||||
c.drawPath(p, line);
|
||||
oval(36f, 62f, 64f, 70f);
|
||||
c.drawRoundRect(r, 4f, 4f, fill);
|
||||
c.drawRoundRect(r, 4f, 4f, line);
|
||||
}
|
||||
|
||||
private void drawPaws(Canvas c, MascotMood mood, long t) {
|
||||
float[] h = hands(mood, t);
|
||||
limb(c, 41f, 72f, h[0], h[1], fur, paw);
|
||||
limb(c, 59f, 72f, h[2], h[3], fur, paw);
|
||||
}
|
||||
|
||||
private void drawHead(Canvas c, MascotMood mood, long t, boolean blink) {
|
||||
c.save();
|
||||
c.rotate(tilt(mood), 50f, 62f);
|
||||
// Ears behind the head, drooping or laid back by the mood; the right one mirrored.
|
||||
float droop = mood == MascotMood.WORRIED ? 38f : mood == MascotMood.SLEEPING ? 26f
|
||||
: mood == MascotMood.FAST ? 22f : mood == MascotMood.CONTENT ? (float) Math.sin(t / 900.0) * 3f : 0f;
|
||||
ear(c, droop);
|
||||
c.save();
|
||||
c.scale(-1f, 1f, 50f, 0f);
|
||||
ear(c, droop);
|
||||
c.restore();
|
||||
|
||||
// The head: round on top, a tuft at each cheek.
|
||||
p.reset();
|
||||
p.moveTo(50f, 16f);
|
||||
p.cubicTo(66f, 16f, 78f, 26f, 79f, 40f);
|
||||
p.lineTo(86f, 52f);
|
||||
p.lineTo(77f, 53f);
|
||||
p.cubicTo(70f, 63f, 60f, 66f, 50f, 66f);
|
||||
p.cubicTo(40f, 66f, 30f, 63f, 23f, 53f);
|
||||
p.lineTo(14f, 52f);
|
||||
p.lineTo(21f, 40f);
|
||||
p.cubicTo(22f, 26f, 34f, 16f, 50f, 16f);
|
||||
p.close();
|
||||
fill.setColor(fur);
|
||||
c.drawPath(p, fill);
|
||||
// Cream cheeks and muzzle, inside the head.
|
||||
c.save();
|
||||
c.clipPath(p);
|
||||
fill.setColor(cream);
|
||||
c.drawOval(oval(22f, 47f, 78f, 84f), fill);
|
||||
c.drawOval(oval(12f, 45f, 34f, 60f), fill);
|
||||
c.drawOval(oval(66f, 45f, 88f, 60f), fill);
|
||||
c.restore();
|
||||
line.setStrokeWidth(1.3f);
|
||||
c.drawPath(p, line);
|
||||
line.setColor(furLight);
|
||||
line.setStrokeWidth(1.6f);
|
||||
c.drawArc(oval(30f, 19f, 56f, 36f), 200f, 60f, false, line);
|
||||
line.setColor(ink);
|
||||
|
||||
// Whiskers, then the nose and the face.
|
||||
line.setStrokeWidth(0.6f);
|
||||
c.drawLine(28f, 53f, 19f, 51f, line);
|
||||
c.drawLine(28f, 55.5f, 19f, 56.5f, line);
|
||||
c.drawLine(72f, 53f, 81f, 51f, line);
|
||||
c.drawLine(72f, 55.5f, 81f, 56.5f, line);
|
||||
face(c, mood, t, blink);
|
||||
fill.setColor(ink);
|
||||
c.drawOval(oval(47.6f, 49f, 52.4f, 52f), fill);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
/** The left ear, rotated outwards about its root by {@code droop} degrees. */
|
||||
private void ear(Canvas c, float droop) {
|
||||
c.save();
|
||||
c.rotate(-droop, 35f, 23f);
|
||||
p.reset();
|
||||
p.moveTo(23f, 34f);
|
||||
p.lineTo(26f, 3f);
|
||||
p.lineTo(47f, 20f);
|
||||
p.close();
|
||||
fill.setColor(fur);
|
||||
c.drawPath(p, fill);
|
||||
line.setStrokeWidth(1.3f);
|
||||
c.drawPath(p, line);
|
||||
p.reset();
|
||||
p.moveTo(27.5f, 27f);
|
||||
p.lineTo(29f, 10f);
|
||||
p.lineTo(40f, 20f);
|
||||
p.close();
|
||||
fill.setColor(earInside);
|
||||
c.drawPath(p, fill);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
{ R.id.tabVehicle, R.id.tabConsumption, R.id.tabCharging, R.id.tabService, R.id.tabServer,
|
||||
R.id.tabSettings, R.id.tabLog };
|
||||
|
||||
private static final int[] PAGE_TITLES = {
|
||||
R.string.design_overview_title, R.string.consumption_title, R.string.charging_title,
|
||||
R.string.design_service_title, R.string.design_server_title, R.string.design_settings_title,
|
||||
R.string.design_log_title
|
||||
};
|
||||
private static final int[] PAGE_SUBTITLES = {
|
||||
R.string.design_overview_subtitle, R.string.consumption_subtitle, R.string.charging_subtitle,
|
||||
R.string.design_service_subtitle, R.string.design_server_subtitle, R.string.design_settings_subtitle,
|
||||
R.string.design_log_subtitle
|
||||
};
|
||||
|
||||
// Status colours come from the palette, not from the Material swatches: #4CAF50 and
|
||||
// #F44336 sit around 4:1 on this background, which disappears behind a sunlit
|
||||
@@ -68,6 +58,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
private View vehiclePane;
|
||||
private View gaugesPane;
|
||||
private View cockpitPane;
|
||||
private View retroPane, futuristicPane;
|
||||
/** The drawn styles' views, each fed the same {@link ClusterValues} per reading. */
|
||||
private RetroView retroView;
|
||||
private FlowView flowView;
|
||||
private SciFiView sciFiView;
|
||||
private CartoonView cartoonView;
|
||||
private BlueprintView blueprintView;
|
||||
private OctagonView octagonView;
|
||||
/** The styles built on {@link ClusterCanvasView}, fed together. */
|
||||
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
|
||||
private TerminalView terminalView;
|
||||
private MascotView mascotView;
|
||||
/** The pane of each Overview style, by its {@link OverviewStyle} ordinal. */
|
||||
private View[] stylePanes;
|
||||
private View consumptionPane;
|
||||
private View chargingPane;
|
||||
/** Display, battery, charging price and Wi-Fi. */
|
||||
@@ -179,21 +183,105 @@ public class MainActivity extends AppCompatActivity {
|
||||
// Keep all pages alive: the log and vehicle readings are refreshed
|
||||
// by the tick even when their page is off screen.
|
||||
pager.setOffscreenPageLimit(panes.length - 1);
|
||||
// Pages change only by the navigation: a sideways swipe on a page (a slider, a chart,
|
||||
// the swipe that brings the navigation back) must not turn it.
|
||||
pager.setUserInputEnabled(false);
|
||||
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
|
||||
@Override public void onPageSelected(int position) { markCurrentPage(position); }
|
||||
@Override public void onPageSelected(int position) {
|
||||
markCurrentPage(position);
|
||||
applyOverviewFullScreen(position);
|
||||
}
|
||||
});
|
||||
findViewById(R.id.edge_tab).setOnClickListener(v -> leaveOverviewFullScreen());
|
||||
findViewById(R.id.overview_fullscreen).setOnClickListener(v -> {
|
||||
prefs.edit().putBoolean(PREF_OVERVIEW_FULLSCREEN, !prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false)).apply();
|
||||
applyOverviewFullScreen(pager.getCurrentItem());
|
||||
});
|
||||
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
final int index = i;
|
||||
// Animated, so the button does the same thing the swipe does: the direction of
|
||||
// travel is what tells the driver where they are in the row.
|
||||
findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false));
|
||||
}
|
||||
markCurrentPage(pager.getCurrentItem());
|
||||
applyOverviewFullScreen(pager.getCurrentItem());
|
||||
}
|
||||
|
||||
/** Whether the Overview hides the navigation (the button in the navigation's header). */
|
||||
static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen";
|
||||
|
||||
/** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */
|
||||
private boolean overviewFull;
|
||||
|
||||
/**
|
||||
* The Overview full screen: the navigation hidden while the Overview is the page and the
|
||||
* driver chose it, with a handle at the left edge where a swipe brings it back. Every
|
||||
* other page always has its navigation. The button sits in the navigation and only shows
|
||||
* on the Overview (invisible elsewhere, so the header keeps its layout).
|
||||
*/
|
||||
private void applyOverviewFullScreen(int position) {
|
||||
boolean overview = position == 0;
|
||||
overviewFull = overview && prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false);
|
||||
findViewById(R.id.nav_bar).setVisibility(overviewFull ? View.GONE : View.VISIBLE);
|
||||
// The way back: the edge swipe with a faint handle in the app's full screen; with the
|
||||
// car's bars shown its sidebar takes the left edge, so a tab to tap (or swipe from).
|
||||
boolean appFull = UploadSettings.fullScreen(prefs);
|
||||
findViewById(R.id.edge_handle).setVisibility(overviewFull && appFull ? View.VISIBLE : View.GONE);
|
||||
findViewById(R.id.edge_tab).setVisibility(overviewFull && !appFull ? View.VISIBLE : View.GONE);
|
||||
findViewById(R.id.overview_fullscreen).setVisibility(overview ? View.VISIBLE : View.INVISIBLE);
|
||||
}
|
||||
|
||||
/** Back to the navigation: the edge swipe or the tab. */
|
||||
private void leaveOverviewFullScreen() {
|
||||
prefs.edit().putBoolean(PREF_OVERVIEW_FULLSCREEN, false).apply();
|
||||
applyOverviewFullScreen(((ViewPager2) findViewById(R.id.content)).getCurrentItem());
|
||||
}
|
||||
|
||||
/** The swipe in from the left edge, set up with this screen's density; null until first used. */
|
||||
private EdgeSwipe edgeSwipe, tabSwipe;
|
||||
/** The rest of a touch that became the edge swipe: kept from the page underneath. */
|
||||
private boolean swallowTouch;
|
||||
|
||||
/**
|
||||
* Watches every touch in the Overview's full screen for the swipe in from the left edge,
|
||||
* which brings the navigation back. The page still gets the touch until it becomes the
|
||||
* swipe; then it gets a cancel and nothing more of that touch.
|
||||
*/
|
||||
@Override
|
||||
public boolean dispatchTouchEvent(android.view.MotionEvent ev) {
|
||||
int action = ev.getActionMasked();
|
||||
if (swallowTouch) {
|
||||
if (action == android.view.MotionEvent.ACTION_UP
|
||||
|| action == android.view.MotionEvent.ACTION_CANCEL) swallowTouch = false;
|
||||
return true;
|
||||
}
|
||||
if (overviewFull) {
|
||||
if (edgeSwipe == null) {
|
||||
float density = getResources().getDisplayMetrics().density;
|
||||
edgeSwipe = new EdgeSwipe(48 * density, 64 * density);
|
||||
// With the car's sidebar beside the app, the swipe starts on the tab instead.
|
||||
tabSwipe = new EdgeSwipe(56 * density, 64 * density);
|
||||
}
|
||||
EdgeSwipe swipe = UploadSettings.fullScreen(prefs) ? edgeSwipe : tabSwipe;
|
||||
if (action == android.view.MotionEvent.ACTION_DOWN) {
|
||||
swipe.down(ev.getX(), ev.getY());
|
||||
} else if (action == android.view.MotionEvent.ACTION_MOVE && swipe.move(ev.getX(), ev.getY())) {
|
||||
android.view.MotionEvent cancel = android.view.MotionEvent.obtain(ev);
|
||||
cancel.setAction(android.view.MotionEvent.ACTION_CANCEL);
|
||||
super.dispatchTouchEvent(cancel);
|
||||
cancel.recycle();
|
||||
swallowTouch = true;
|
||||
leaveOverviewFullScreen();
|
||||
return true;
|
||||
} else if (action == android.view.MotionEvent.ACTION_UP
|
||||
|| action == android.view.MotionEvent.ACTION_CANCEL) {
|
||||
swipe.up();
|
||||
}
|
||||
}
|
||||
return super.dispatchTouchEvent(ev);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the destination and labels the page on screen.
|
||||
* Marks the destination in the navigation (the pages have no header line).
|
||||
*
|
||||
* Only isSelected is set: fill, text, icon and stroke come from the
|
||||
* res/color/nav_tab_*.xml selectors applied by the Widget.EV.NavTab style. isSelected
|
||||
@@ -203,8 +291,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
for (int i = 0; i < TAB_IDS.length; i++) {
|
||||
findViewById(TAB_IDS[i]).setSelected(i == position);
|
||||
}
|
||||
((TextView) findViewById(R.id.header_title)).setText(PAGE_TITLES[position]);
|
||||
((TextView) findViewById(R.id.header_subtitle)).setText(PAGE_SUBTITLES[position]);
|
||||
}
|
||||
|
||||
/** Background thread for the one system-setting write (clearCarPlayFullScreen). */
|
||||
@@ -246,7 +332,28 @@ public class MainActivity extends AppCompatActivity {
|
||||
vehiclePane = inflatePane(R.layout.pane_vehicle);
|
||||
gaugesPane = inflatePane(R.layout.pane_gauges);
|
||||
cockpitPane = inflatePane(R.layout.pane_cockpit);
|
||||
// The Overview page holds both styles; Settings → Overview style picks the one shown.
|
||||
retroPane = inflatePane(R.layout.pane_retro);
|
||||
futuristicPane = inflatePane(R.layout.pane_futuristic);
|
||||
retroView = retroPane.findViewById(R.id.retro_view);
|
||||
flowView = futuristicPane.findViewById(R.id.flow_view);
|
||||
sciFiView = new SciFiView(this, null);
|
||||
cartoonView = new CartoonView(this, null);
|
||||
blueprintView = new BlueprintView(this, null);
|
||||
octagonView = new OctagonView(this, null);
|
||||
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
|
||||
blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView);
|
||||
terminalView = new TerminalView(this);
|
||||
mascotView = new MascotView(this);
|
||||
mascotView.setCharacter(MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null)));
|
||||
java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView,
|
||||
new AviationView(this), new NixieView(this), new ChronographView(this), mascotView);
|
||||
View[] canvasPanes = new View[canvasStyles.size()];
|
||||
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
|
||||
stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane,
|
||||
futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane,
|
||||
canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3], canvasPanes[4],
|
||||
canvasPanes[5] };
|
||||
// The Overview page holds every style; Settings → Overview style picks the one shown.
|
||||
android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
|
||||
overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -254,6 +361,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
overviewPage.addView(vehiclePane);
|
||||
overviewPage.addView(gaugesPane);
|
||||
overviewPage.addView(cockpitPane);
|
||||
overviewPage.addView(retroPane);
|
||||
overviewPage.addView(futuristicPane);
|
||||
overviewPage.addView(sciFiPane);
|
||||
overviewPage.addView(cartoonPane);
|
||||
overviewPage.addView(blueprintPane);
|
||||
overviewPage.addView(mgPane);
|
||||
for (View pane : canvasPanes) overviewPage.addView(pane);
|
||||
consumptionPane = inflatePane(R.layout.pane_consumption);
|
||||
chargingPane = inflatePane(R.layout.pane_charging);
|
||||
settingsPane = inflatePane(R.layout.pane_settings);
|
||||
@@ -262,6 +376,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
settingsPane, logPane };
|
||||
|
||||
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
|
||||
// The cruise control logs of 2.31.1–2.32.0, gone with the feature (SPECS.md).
|
||||
deleteFile("cruise_log.txt");
|
||||
deleteFile("acc_recording.txt");
|
||||
bindVehiclePane();
|
||||
bindGauges();
|
||||
bindCockpit();
|
||||
@@ -274,6 +391,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
statusText = servicePane.findViewById(R.id.status_text);
|
||||
callLogText = logPane.findViewById(R.id.call_log_text);
|
||||
logPane.findViewById(R.id.flush_now_button).setOnClickListener(v -> manualFlush());
|
||||
logPane.findViewById(R.id.signal_record_button).setOnClickListener(v -> {
|
||||
if (SignalRecorder.running()) SignalRecorder.stop();
|
||||
else SignalRecorder.start(this);
|
||||
refreshSignalRecorder();
|
||||
});
|
||||
|
||||
setUpPager();
|
||||
// Back on the page that was open when the activity was recreated (accent colour change).
|
||||
@@ -1067,6 +1189,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
voiceKeySwitch.setOnCheckedChangeListener((btn, checked) ->
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_VOICE_KEY_OPENS, checked).apply());
|
||||
bindTireUnit();
|
||||
bindMascotCharacter();
|
||||
bindDriveMode();
|
||||
bindOverviewStyle();
|
||||
bindAccentColor();
|
||||
fullScreenSwitch = settingsPane.findViewById(R.id.full_screen_switch);
|
||||
@@ -1074,6 +1198,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
fullScreenSwitch.setOnCheckedChangeListener((btn, checked) -> {
|
||||
prefs.edit().putBoolean(UploadSettings.KEY_FULL_SCREEN, checked).apply();
|
||||
applyFullScreen();
|
||||
applyOverviewFullScreen(((ViewPager2) findViewById(R.id.content)).getCurrentItem());
|
||||
});
|
||||
clearCarPlayFullScreen();
|
||||
liveTelemetrySwitch = servicePane.findViewById(R.id.abrp_upload_switch);
|
||||
@@ -1166,16 +1291,35 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
/** The accent colour; a change recreates the activity so every view takes it. */
|
||||
/**
|
||||
* The accent colour; a change recreates the activity so every view takes it. For a style
|
||||
* with an original palette, "Original colours" comes first: it keeps the style in its own
|
||||
* colours and leaves the app's accent as it is.
|
||||
*/
|
||||
private void bindAccentColor() {
|
||||
OverviewStyle style = overviewStyle();
|
||||
AccentColor[] colors = AccentColor.values();
|
||||
String[] labels = new String[colors.length];
|
||||
for (int i = 0; i < colors.length; i++) labels[i] = colors[i].label;
|
||||
int offset = style.hasOriginal ? 1 : 0;
|
||||
String[] labels = new String[colors.length + offset];
|
||||
if (offset == 1) labels[0] = getString(R.string.accent_original);
|
||||
for (int i = 0; i < colors.length; i++) labels[i + offset] = colors[i].label;
|
||||
AccentColor current = AccentColor.fromPref(prefs.getString(AccentColor.PREF_KEY, null));
|
||||
bindChoices(settingsPane.findViewById(R.id.accent_choices), labels, current.ordinal(), position -> {
|
||||
if (colors[position] == current) return;
|
||||
prefs.edit().putString(AccentColor.PREF_KEY, colors[position].name()).apply();
|
||||
int selected = originalColours(style) ? 0 : current.ordinal() + offset;
|
||||
bindChoices(settingsPane.findViewById(R.id.accent_choices), labels, selected, position -> {
|
||||
if (style.hasOriginal) prefs.edit().putBoolean(style.originalKey(), position == 0).apply();
|
||||
if (position < offset) {
|
||||
applyOverviewStyle();
|
||||
return;
|
||||
}
|
||||
AccentColor picked = colors[position - offset];
|
||||
if (picked == current) {
|
||||
applyOverviewStyle();
|
||||
return;
|
||||
}
|
||||
prefs.edit().putString(AccentColor.PREF_KEY, picked.name()).apply();
|
||||
recreate();
|
||||
});
|
||||
applyAccentAvailability();
|
||||
}
|
||||
|
||||
/** Tyre pressure unit: display only, read on every refresh of the Overview. */
|
||||
@@ -1188,6 +1332,128 @@ public class MainActivity extends AppCompatActivity {
|
||||
prefs.edit().putString(PressureUnit.PREF_KEY, units[position].name()).apply());
|
||||
}
|
||||
|
||||
/** Who sits in the Mascot style; the drop-down shows only while that style is chosen. */
|
||||
private static final int[] DRIVE_MODE_IDS = { R.id.drive_mode_eco, R.id.drive_mode_normal,
|
||||
R.id.drive_mode_sport, R.id.drive_mode_snow, R.id.drive_mode_custom };
|
||||
private static final int[] DRIVE_MODE_LABELS = { R.string.drive_mode_eco, R.string.drive_mode_normal,
|
||||
R.string.drive_mode_sport, R.string.drive_mode_snow, R.string.drive_mode_custom };
|
||||
/** True from a tap on a drive mode until the car's answer is in: one change at a time. */
|
||||
private final java.util.concurrent.atomic.AtomicBoolean driveModeBusy =
|
||||
new java.util.concurrent.atomic.AtomicBoolean();
|
||||
|
||||
/** The drive mode buttons on Settings: a tap asks the car, the highlight shows what it reports. */
|
||||
private void bindDriveMode() {
|
||||
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
|
||||
final int code = DriveModeWrite.CODES[i];
|
||||
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setOnClickListener(v -> requestDriveMode(code));
|
||||
}
|
||||
SwitchMaterial atStart = settingsPane.findViewById(R.id.drive_mode_at_start_switch);
|
||||
atStart.setChecked(prefs.getBoolean(DriveModeWrite.PREF_AT_START, true));
|
||||
atStart.setOnCheckedChangeListener((b, checked) ->
|
||||
prefs.edit().putBoolean(DriveModeWrite.PREF_AT_START, checked).apply());
|
||||
String started = driveModeAtStartText();
|
||||
if (started != null) ((TextView) settingsPane.findViewById(R.id.drive_mode_status)).setText(started);
|
||||
}
|
||||
|
||||
/** The last start's result as a sentence, from "25.10. 20:31|4|APPLIED|4"; null when none. */
|
||||
private String driveModeAtStartText() {
|
||||
String stored = prefs.getString(DriveModeWrite.PREF_START_RESULT, null);
|
||||
if (stored == null) return null;
|
||||
String[] p = stored.split("\\|", -1);
|
||||
if (p.length < 4) return null;
|
||||
try {
|
||||
String when = p[0];
|
||||
String name = driveModeName(Integer.valueOf(p[1]));
|
||||
Integer reported = p[3].isEmpty() ? null : Integer.valueOf(p[3]);
|
||||
switch (p[2]) {
|
||||
case "APPLIED": return getString(R.string.drive_mode_start_applied, when, name);
|
||||
case "ALREADY": return getString(R.string.drive_mode_start_already, when, name);
|
||||
case "NOT_APPLIED": return getString(R.string.drive_mode_start_not_applied, when, name, driveModeName(reported));
|
||||
case "UNREAD": return getString(R.string.drive_mode_start_unread, when, name);
|
||||
default: return getString(R.string.drive_mode_start_refused, when, name);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The highlight follows the car's reported mode, from every reading. */
|
||||
private void showDriveMode(Integer code) {
|
||||
if (settingsPane == null) return;
|
||||
int selected = DriveModeWrite.indexOf(code);
|
||||
for (int i = 0; i < DRIVE_MODE_IDS.length; i++) {
|
||||
settingsPane.findViewById(DRIVE_MODE_IDS[i]).setSelected(i == selected);
|
||||
}
|
||||
}
|
||||
|
||||
private String driveModeName(Integer code) {
|
||||
int i = DriveModeWrite.indexOf(code);
|
||||
return i >= 0 ? getString(DRIVE_MODE_LABELS[i]) : getString(R.string.drive_mode_unknown, String.valueOf(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the drive mode, on the settings thread: gear and speed read fresh (P, 0 km/h, or
|
||||
* nothing), the write itself behind EVHardware's standstill gate, then the car's mode read
|
||||
* back for up to three seconds. The status line says what the car reports, not what was asked.
|
||||
*/
|
||||
private void requestDriveMode(int code) {
|
||||
if (!driveModeBusy.compareAndSet(false, true)) return;
|
||||
TextView status = settingsPane.findViewById(R.id.drive_mode_status);
|
||||
String name = driveModeName(code);
|
||||
status.setText(getString(R.string.drive_mode_setting, name));
|
||||
try {
|
||||
settingsWriter.execute(() -> {
|
||||
String message;
|
||||
try {
|
||||
message = writeDriveMode(code, name);
|
||||
} catch (Throwable t) {
|
||||
message = getString(R.string.drive_mode_write_failed, name);
|
||||
}
|
||||
android.util.Log.i("NilsMG4.DriveMode", "Drive mode " + code + ": " + message);
|
||||
final String result = message;
|
||||
runOnUiThread(() -> {
|
||||
driveModeBusy.set(false);
|
||||
if (!isDestroyed()) status.setText(result);
|
||||
});
|
||||
});
|
||||
} catch (java.util.concurrent.RejectedExecutionException e) {
|
||||
driveModeBusy.set(false); // the activity is going away
|
||||
}
|
||||
}
|
||||
|
||||
private String writeDriveMode(int code, String name) throws InterruptedException {
|
||||
switch (DriveModeWrite.check(com.evsuite.hardware.EVHardware.INSTANCE.getVehicleSpeedKmh(),
|
||||
com.evsuite.hardware.EVHardware.INSTANCE.getGearRaw())) {
|
||||
case NOT_IN_P: return getString(R.string.drive_mode_not_in_p);
|
||||
case MOVING: return getString(R.string.drive_mode_moving);
|
||||
case UNREADABLE: return getString(R.string.drive_mode_unreadable);
|
||||
default: break;
|
||||
}
|
||||
DriveModeSetter.Result result = DriveModeSetter.set(code);
|
||||
if (!result.written) return getString(R.string.drive_mode_write_failed, name);
|
||||
final Integer now = result.reported;
|
||||
runOnUiThread(() -> showDriveMode(now));
|
||||
switch (DriveModeWrite.outcome(code, now)) {
|
||||
case APPLIED:
|
||||
// Confirmed by the car: this is the mode set again at start.
|
||||
prefs.edit().putInt(DriveModeWrite.PREF_CHOSEN, code).apply();
|
||||
return getString(R.string.drive_mode_applied, name);
|
||||
case NOT_APPLIED: return getString(R.string.drive_mode_not_applied, name, driveModeName(now));
|
||||
default: return getString(R.string.drive_mode_unread_back, name);
|
||||
}
|
||||
}
|
||||
|
||||
private void bindMascotCharacter() {
|
||||
MascotCharacter[] characters = MascotCharacter.values();
|
||||
String[] labels = { getString(R.string.mascot_character_classic), getString(R.string.mascot_character_chibi),
|
||||
getString(R.string.mascot_character_fox) };
|
||||
int current = MascotCharacter.fromPref(prefs.getString(MascotCharacter.PREF_KEY, null)).ordinal();
|
||||
bindChoices(settingsPane.findViewById(R.id.mascot_choices), labels, current, position -> {
|
||||
prefs.edit().putString(MascotCharacter.PREF_KEY, characters[position].name()).apply();
|
||||
mascotView.setCharacter(characters[position]);
|
||||
});
|
||||
}
|
||||
|
||||
/** The service reads these settings on every tick, so a change applies without a restart. */
|
||||
private void bindCadenceControls() {
|
||||
UploadSettings current = UploadSettings.from(prefs);
|
||||
@@ -1343,10 +1609,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
* is not work to repeat every two seconds. A read that throws leaves the cells as they
|
||||
* were rather than blanking the page — a transient miss is not news worth showing.
|
||||
*/
|
||||
// ---------- Overview: read every half second, off the main thread ----------
|
||||
// ---------- Overview: read five times a second, off the main thread ----------
|
||||
|
||||
/** How often the Overview reads the car while it is on screen. */
|
||||
private static final long OVERVIEW_REFRESH_MS = 500L;
|
||||
/**
|
||||
* How often the Overview reads the car while it is on screen: 0.2 s, so needles, rings and
|
||||
* the speed follow the car closely. A read still running makes the next tick skip.
|
||||
*/
|
||||
private static final long OVERVIEW_REFRESH_MS = 200L;
|
||||
|
||||
/** One reader thread; a read that is still running makes the next tick skip, not queue. */
|
||||
private final java.util.concurrent.ExecutorService carReader =
|
||||
@@ -1426,38 +1695,81 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
// ---------- Gauges (Overview style) ----------
|
||||
|
||||
static final String PREF_OVERVIEW_STYLE = "overview_style";
|
||||
static final String PREF_OVERVIEW_STYLE = OverviewStyle.PREF_KEY;
|
||||
private GaugeView dashPower, dashSpeed, dashBattery;
|
||||
private BatteryBarView dashBatteryBar;
|
||||
private TextView dashGear, dashClimate, dashUsage, dashBatteryText;
|
||||
/** Kilometres since start, from the range estimator; null until it has a distance. */
|
||||
private Double dashTripKm;
|
||||
|
||||
/** The stored values of the Overview styles, in the order of the drop-down. */
|
||||
private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT" };
|
||||
private OverviewStyle overviewStyle() {
|
||||
String stored = prefs.getString(PREF_OVERVIEW_STYLE, null);
|
||||
OverviewStyle style = OverviewStyle.fromPref(stored);
|
||||
if (OverviewStyle.wasAccentEntry(stored)) {
|
||||
// "Retro (accent colour)" before 2.27.2: now Retro, in the accent colour.
|
||||
prefs.edit().putString(PREF_OVERVIEW_STYLE, style.name()).putBoolean(style.originalKey(), false).apply();
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */
|
||||
private int overviewStyle() {
|
||||
String style = prefs.getString(PREF_OVERVIEW_STYLE, "TILES");
|
||||
if ("DASHBOARD".equals(style)) style = "GAUGES"; // its name before 2.22.3
|
||||
for (int i = 0; i < OVERVIEW_STYLES.length; i++) if (OVERVIEW_STYLES[i].equals(style)) return i;
|
||||
return 0;
|
||||
/** Whether a style with an original palette shows it (the default) or the accent colour. */
|
||||
private boolean originalColours(OverviewStyle style) {
|
||||
return style.hasOriginal && prefs.getBoolean(style.originalKey(), true);
|
||||
}
|
||||
|
||||
private void applyOverviewStyle() {
|
||||
int style = overviewStyle();
|
||||
vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE);
|
||||
gaugesPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE);
|
||||
cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE);
|
||||
OverviewStyle style = overviewStyle();
|
||||
for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style.ordinal()] ? View.VISIBLE : View.GONE);
|
||||
// Retro and Terminal: their original palette or the accent colour, as chosen for each.
|
||||
retroView.setAccentPalette(!originalColours(OverviewStyle.RETRO));
|
||||
terminalView.setAccentPalette(!originalColours(OverviewStyle.TERMINAL));
|
||||
// The accent choices depend on the style ("Original colours" for Retro and Terminal).
|
||||
if (settingsPane != null) bindAccentColor();
|
||||
}
|
||||
|
||||
/** Tiles (the cards), Gauges (needles) or Cockpit (rings); all are fed on every reading. */
|
||||
/**
|
||||
* The accent colour can only be picked for a style drawn in it; for the others the drop-down
|
||||
* is greyed out (keeping the colour chosen before) and a line says why.
|
||||
*/
|
||||
private void applyAccentAvailability() {
|
||||
if (settingsPane == null) return;
|
||||
OverviewStyle style = overviewStyle();
|
||||
View field = settingsPane.findViewById(R.id.accent_field);
|
||||
if (field == null) return;
|
||||
field.setEnabled(style.usesAccent);
|
||||
settingsPane.findViewById(R.id.accent_choices).setEnabled(style.usesAccent);
|
||||
field.setAlpha(style.usesAccent ? 1f : 0.45f);
|
||||
TextView note = settingsPane.findViewById(R.id.accent_note);
|
||||
note.setVisibility(style.usesAccent ? View.GONE : View.VISIBLE);
|
||||
note.setText(getString(R.string.accent_not_used, styleLabels()[style.ordinal()]));
|
||||
// The wide layout keeps the Mascot field's place so the page does not jump.
|
||||
View mascot = settingsPane.findViewById(R.id.mascot_field);
|
||||
boolean wide = getResources().getConfiguration().screenWidthDp >= 1000;
|
||||
mascot.setVisibility(style == OverviewStyle.MASCOT ? View.VISIBLE : wide ? View.INVISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
/** The drop-down's labels, by {@link OverviewStyle} ordinal. */
|
||||
private String[] styleLabels() {
|
||||
return new String[] { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges),
|
||||
getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro),
|
||||
getString(R.string.overview_style_futuristic),
|
||||
getString(R.string.overview_style_scifi), getString(R.string.overview_style_cartoon),
|
||||
getString(R.string.overview_style_blueprint), getString(R.string.overview_style_mg),
|
||||
getString(R.string.overview_style_minimal), getString(R.string.overview_style_terminal),
|
||||
getString(R.string.overview_style_aviation), getString(R.string.overview_style_nixie),
|
||||
getString(R.string.overview_style_chronograph), getString(R.string.overview_style_mascot) };
|
||||
}
|
||||
|
||||
/** The style drop-down; every style is fed on every reading, only the chosen one is shown. */
|
||||
private void bindOverviewStyle() {
|
||||
String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges),
|
||||
getString(R.string.overview_style_cockpit) };
|
||||
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(),
|
||||
OverviewStyle[] styles = OverviewStyle.values();
|
||||
com.google.android.material.textfield.MaterialAutoCompleteTextView field =
|
||||
settingsPane.findViewById(R.id.overview_style_choices);
|
||||
// Wider than its field, which shares a row with the accent colour.
|
||||
field.setDropDownWidth(Math.round(320 * getResources().getDisplayMetrics().density));
|
||||
bindChoices(field, styleLabels(), overviewStyle().ordinal(),
|
||||
position -> {
|
||||
prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply();
|
||||
prefs.edit().putString(PREF_OVERVIEW_STYLE, styles[position].name()).apply();
|
||||
applyOverviewStyle();
|
||||
});
|
||||
}
|
||||
@@ -1478,6 +1790,48 @@ public class MainActivity extends AppCompatActivity {
|
||||
dashBattery.setWarnBelow(20f);
|
||||
}
|
||||
|
||||
// ---------- Retro and Futuristic (Overview styles, each one drawn view) ----------
|
||||
|
||||
private final java.text.SimpleDateFormat clockFormat =
|
||||
new java.text.SimpleDateFormat("HH:mm", java.util.Locale.US);
|
||||
|
||||
/** A drawn style's view in a pane with the Overview's margins. */
|
||||
private View drawnPane(View view) {
|
||||
android.widget.FrameLayout pane = new android.widget.FrameLayout(this);
|
||||
float dp = getResources().getDisplayMetrics().density;
|
||||
pane.setPadding(Math.round(24 * dp), Math.round(12 * dp), Math.round(24 * dp), Math.round(12 * dp));
|
||||
pane.addView(view, new android.widget.FrameLayout.LayoutParams(
|
||||
android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
return pane;
|
||||
}
|
||||
|
||||
private void showDrawnStyles(EnergySnapshot s) {
|
||||
ClusterValues v = new ClusterValues();
|
||||
v.socPercent = s.getSocPercent();
|
||||
v.rangeKm = s.getRangeKm();
|
||||
v.powerKw = s.getBatteryPowerKw();
|
||||
v.speedKmh = s.getSpeedKmh();
|
||||
v.gear = s.getGearRaw();
|
||||
v.usage5Min = cockpitAverage == null ? null : cockpitAverage.kwhPer100Km;
|
||||
v.usageTrip = cockpitTrip == null ? null : cockpitTrip.kwhPer100Km;
|
||||
v.tripKm = cockpitTrip == null ? null : cockpitTrip.distanceKm;
|
||||
v.climateCelsius = s.getClimate().getDriverTargetCelsius();
|
||||
v.odometerKm = s.getOdometerKm();
|
||||
v.clock = clockFormat.format(new java.util.Date());
|
||||
PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null));
|
||||
v.tyreFl = unit.format(s.getTirePressures().getFrontLeftKpa());
|
||||
v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa());
|
||||
v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa());
|
||||
v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa());
|
||||
retroView.setValues(v);
|
||||
flowView.setValues(v);
|
||||
sciFiView.setValues(v);
|
||||
cartoonView.setValues(v);
|
||||
blueprintView.setValues(v);
|
||||
octagonView.setValues(v);
|
||||
for (ClusterCanvasView view : canvasStyles) view.setValues(v);
|
||||
}
|
||||
|
||||
// ---------- Cockpit (Overview style) ----------
|
||||
|
||||
/** Power over the last two minutes, a slot per second. */
|
||||
@@ -1578,6 +1932,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
|
||||
showGauges(s);
|
||||
showCockpit(s);
|
||||
showDrawnStyles(s);
|
||||
showDriveMode(s.getDriveModeRaw());
|
||||
} catch (Throwable ignored) {
|
||||
// Leave the last good reading on screen.
|
||||
}
|
||||
@@ -1895,7 +2251,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
}, "cloud-refresh").start();
|
||||
}
|
||||
|
||||
/** The ACC recorder's button and what it has found so far. */
|
||||
private void refreshSignalRecorder() {
|
||||
MaterialButton button = logPane.findViewById(R.id.signal_record_button);
|
||||
button.setText(SignalRecorder.running() ? R.string.btn_signal_stop : R.string.btn_signal_record);
|
||||
TextView text = logPane.findViewById(R.id.signal_record_text);
|
||||
java.util.List<String> status = SignalRecorder.status(this);
|
||||
text.setVisibility(status.isEmpty() ? View.GONE : View.VISIBLE);
|
||||
// The rarest changes are the leads; the rest is in the saved recording.
|
||||
if (status.size() > 81) status = status.subList(0, 81);
|
||||
if (!status.isEmpty()) text.setText(String.join("\n", status));
|
||||
}
|
||||
|
||||
private void refreshCallLog() {
|
||||
refreshSignalRecorder();
|
||||
java.util.List<UploadLog.Entry> entries = AbrpUploadService.log().recent();
|
||||
// Update steps first: after a failed install, they are what the driver came here for.
|
||||
java.util.List<String> updates = UpdateLog.lines(this);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Who sits in the Mascot Overview style, in the order of the Settings drop-down: the classic
|
||||
* co-pilot (head and shoulders, headset, racing jacket), the chibi (small, full figure, big
|
||||
* head, hoodie, bubbly) or the chibi fox (sitting, scarf, a tail that wags). Each has their
|
||||
* own drawing and lines; all follow {@link MascotMood}.
|
||||
* Pure Java, unit-tested.
|
||||
*/
|
||||
enum MascotCharacter {
|
||||
CLASSIC,
|
||||
CHIBI,
|
||||
FOX;
|
||||
|
||||
static final String PREF_KEY = "mascot_character";
|
||||
|
||||
/** The stored character; anything unknown is the classic one. */
|
||||
static MascotCharacter fromPref(String stored) {
|
||||
for (MascotCharacter character : values()) {
|
||||
if (character.name().equals(stored)) return character;
|
||||
}
|
||||
return CLASSIC;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* What the Mascot Overview style's co-pilot feels, from one reading ({@link #of}), and a
|
||||
* {@link Holder} that keeps her from flickering between moods when a value hovers at a
|
||||
* threshold. Pure Java, unit-tested.
|
||||
*/
|
||||
enum MascotMood {
|
||||
/** Nothing read yet (the emulator, or the car's services not up). */
|
||||
WAITING,
|
||||
/** Reversing: looks back, careful. */
|
||||
REVERSE,
|
||||
/** Battery under {@link #LOW_SOC} %: worried, a sweat drop. */
|
||||
WORRIED,
|
||||
/** In P: asleep. */
|
||||
SLEEPING,
|
||||
/** Regenerating more than {@link #REGEN_KW} kW: cheering. */
|
||||
CHEERING,
|
||||
/** Drawing more than {@link #BOOST_KW} kW: determined. */
|
||||
DETERMINED,
|
||||
/** {@link #FAST_KMH} km/h and more: enjoying the speed, wind in her hair. */
|
||||
FAST,
|
||||
/** Anything else: content. */
|
||||
CONTENT;
|
||||
|
||||
static final float LOW_SOC = 20f, REGEN_KW = 5f, BOOST_KW = 60f, FAST_KMH = 100f;
|
||||
/** How long a new mood must last before she shows it. */
|
||||
static final long HOLD_MS = 1500;
|
||||
|
||||
/** The mood for one reading; unread values never count as a reason. */
|
||||
static MascotMood of(ClusterValues v) {
|
||||
if (v == null || (v.gear == null && v.speedKmh == null && v.socPercent == null && v.powerKw == null)) {
|
||||
return WAITING;
|
||||
}
|
||||
if (v.gear != null && v.gear == 2) return REVERSE;
|
||||
if (v.socPercent != null && v.socPercent < LOW_SOC) return WORRIED;
|
||||
if (v.gear != null && v.gear == 1) return SLEEPING;
|
||||
if (v.powerKw != null && v.powerKw < -REGEN_KW) return CHEERING;
|
||||
if (v.powerKw != null && v.powerKw > BOOST_KW) return DETERMINED;
|
||||
if (v.speedKmh != null && v.speedKmh >= FAST_KMH) return FAST;
|
||||
return CONTENT;
|
||||
}
|
||||
|
||||
/** Moods that show at once: shifting into R and losing the data are not to be waited out. */
|
||||
boolean immediate() {
|
||||
return this == REVERSE || this == WAITING;
|
||||
}
|
||||
|
||||
/** The shown mood: a new one takes over after it has lasted {@link #HOLD_MS}. */
|
||||
static final class Holder {
|
||||
private MascotMood shown, candidate;
|
||||
private long candidateSince;
|
||||
|
||||
MascotMood update(MascotMood now, long nowMs) {
|
||||
if (shown == null || now == shown || now.immediate()) {
|
||||
shown = now;
|
||||
candidate = null;
|
||||
return shown;
|
||||
}
|
||||
if (now != candidate) {
|
||||
candidate = now;
|
||||
candidateSince = nowMs;
|
||||
} else if (nowMs - candidateSince >= HOLD_MS) {
|
||||
shown = now;
|
||||
candidate = null;
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.SystemClock;
|
||||
|
||||
/**
|
||||
* The Mascot Overview style: an anime co-pilot beside the values, who reacts to the drive.
|
||||
*
|
||||
* On the left, head and shoulders: a young woman with dark plum hair, a headset with a boom mic
|
||||
* and a racing jacket in the accent colour with an octagon badge. Her face follows
|
||||
* {@link MascotMood} (held for a moment so it does not flicker): content, enjoying the speed
|
||||
* with wind lines, cheering with sparkles while regenerating, determined under hard
|
||||
* acceleration, worried with a sweat drop on a low battery, asleep with drifting Zs in P,
|
||||
* glancing back in R, and patient while nothing is read. She blinks now and then and says a
|
||||
* line for her mood in a speech bubble.
|
||||
*
|
||||
* On the right: speed large, the gear in a ring, battery as a bar with percent and range, then
|
||||
* power, 5-minute usage and the trip; the clock top right, left of the full-screen button.
|
||||
* Unread values show "—".
|
||||
*
|
||||
* Three characters ({@link MascotCharacter}): this classic co-pilot, the chibi
|
||||
* ({@link ChibiFigure}) or the chibi fox ({@link FoxFigure}), each with their own lines.
|
||||
*
|
||||
* Drawn on a 100 × 100 grid scaled to the pane. Nothing animates on its own: blinking, the Zs,
|
||||
* sparkles and wind move because readings redraw the view every 0.2 s.
|
||||
*/
|
||||
final class MascotView extends ClusterCanvasView {
|
||||
|
||||
/** Her lines per {@link MascotCharacter}, then per {@link MascotMood}, in their orders. */
|
||||
private static final int[][] LINES = {
|
||||
{ R.array.mascot_waiting, R.array.mascot_reverse, R.array.mascot_worried, R.array.mascot_sleeping,
|
||||
R.array.mascot_cheering, R.array.mascot_determined, R.array.mascot_fast, R.array.mascot_content },
|
||||
{ R.array.chibi_waiting, R.array.chibi_reverse, R.array.chibi_worried, R.array.chibi_sleeping,
|
||||
R.array.chibi_cheering, R.array.chibi_determined, R.array.chibi_fast, R.array.chibi_content },
|
||||
{ R.array.fox_waiting, R.array.fox_reverse, R.array.fox_worried, R.array.fox_sleeping,
|
||||
R.array.fox_cheering, R.array.fox_determined, R.array.fox_fast, R.array.fox_content } };
|
||||
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint stroke = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path p = new Path();
|
||||
private final RectF r = new RectF();
|
||||
private final MascotMood.Holder holder = new MascotMood.Holder();
|
||||
private final String[][][] lines = new String[LINES.length][][];
|
||||
/** The drawn figures by {@link MascotCharacter} ordinal; null for the classic one, drawn here. */
|
||||
private final ChibiBase[] figures;
|
||||
private MascotCharacter character = MascotCharacter.CLASSIC;
|
||||
private final int accent, accentDark, accentLight, regen, warn, white, grey, track;
|
||||
private final int hair = 0xFF2E2440, hairLight = 0xFF5A4880, skin = 0xFFFBE2D0, skinShade = 0xFFE9BFA9,
|
||||
iris = 0xFF2F8F9D, pupil = 0xFF123238, lash = 0xFF221A2E, lips = 0xFFC2606A, mouth = 0xFF8E3A48,
|
||||
shirt = 0xFFF4F4F6, headset = 0xFF2A2D33, cup = 0xFF3C414A;
|
||||
|
||||
MascotView(Context context) {
|
||||
super(context);
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
accentLight = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
accentDark = Color.rgb(Math.round(Color.red(accent) * 0.6f), Math.round(Color.green(accent) * 0.6f),
|
||||
Math.round(Color.blue(accent) * 0.6f));
|
||||
regen = context.getColor(R.color.ev_ok);
|
||||
warn = context.getColor(R.color.ev_error);
|
||||
white = context.getColor(R.color.ev_text_primary);
|
||||
grey = context.getColor(R.color.ev_text_secondary);
|
||||
track = context.getColor(R.color.ev_outline);
|
||||
for (int i = 0; i < LINES.length; i++) {
|
||||
lines[i] = new String[LINES[i].length][];
|
||||
for (int j = 0; j < LINES[i].length; j++) lines[i][j] = context.getResources().getStringArray(LINES[i][j]);
|
||||
}
|
||||
figures = new ChibiBase[] { null, new ChibiFigure(accent, accentLight, regen),
|
||||
new FoxFigure(accent, accentLight, regen) };
|
||||
stroke.setStyle(Paint.Style.STROKE);
|
||||
stroke.setStrokeCap(Paint.Cap.ROUND);
|
||||
stroke.setStrokeJoin(Paint.Join.ROUND);
|
||||
text.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL));
|
||||
text.setFontFeatureSettings("tnum");
|
||||
}
|
||||
|
||||
/** Who sits beside the values (Settings → Mascot, shown for this style only). */
|
||||
void setCharacter(MascotCharacter character) {
|
||||
this.character = character == null ? MascotCharacter.CLASSIC : character;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
long t = SystemClock.uptimeMillis();
|
||||
MascotMood mood = holder.update(MascotMood.of(v), t);
|
||||
|
||||
// A soft halo behind her.
|
||||
float k = h * 0.98f / 100f, ox = w * 0.02f, oy = h - 100f * k;
|
||||
fill.setColor(AccentColor.withAlpha(accent, 0x30));
|
||||
c.drawCircle(ox + 50f * k, oy + 46f * k, 46f * k, fill);
|
||||
|
||||
c.save();
|
||||
c.translate(ox, oy);
|
||||
c.scale(k, k);
|
||||
ChibiBase figure = figures[character.ordinal()];
|
||||
if (figure != null) figure.draw(c, mood, t, t % 4200 < 260);
|
||||
else drawCharacter(c, mood, t);
|
||||
c.restore();
|
||||
|
||||
float x0 = ox + 78f * k;
|
||||
float tipX = figure != null ? figure.tipX() : 70f, tipY = figure != null ? figure.tipY() : 46f;
|
||||
drawBubble(c, mood, t, x0, h, ox + tipX * k, oy + tipY * k, w);
|
||||
drawValues(c, x0, ox + 93f * k, w, h);
|
||||
}
|
||||
|
||||
// ---------- The co-pilot, on the 100 × 100 grid ----------
|
||||
|
||||
private void drawCharacter(Canvas c, MascotMood mood, long t) {
|
||||
float tilt = mood == MascotMood.SLEEPING ? 8f : mood == MascotMood.REVERSE ? -6f
|
||||
: mood == MascotMood.CHEERING ? -4f : 0f;
|
||||
|
||||
// Hair behind the head, falling behind the shoulders.
|
||||
c.save();
|
||||
c.rotate(tilt, 50f, 62f);
|
||||
fill.setColor(hair);
|
||||
c.drawOval(oval(27f, 9f, 73f, 57f), fill);
|
||||
p.reset();
|
||||
p.moveTo(28.5f, 28f);
|
||||
p.cubicTo(26f, 46f, 25f, 64f, 22f, 84f);
|
||||
p.lineTo(78f, 84f);
|
||||
p.cubicTo(75f, 64f, 74f, 46f, 71.5f, 28f);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
c.restore();
|
||||
|
||||
// Neck, jacket, shirt collar, badge.
|
||||
fill.setColor(skin);
|
||||
c.drawRect(44.5f, 48f, 55.5f, 70f, fill);
|
||||
fill.setColor(skinShade);
|
||||
c.drawOval(oval(44f, 50f, 56f, 57f), fill);
|
||||
p.reset();
|
||||
p.moveTo(10f, 100f);
|
||||
p.lineTo(14f, 80f);
|
||||
p.cubicTo(16f, 71f, 26f, 68f, 40f, 66f);
|
||||
p.lineTo(60f, 66f);
|
||||
p.cubicTo(74f, 68f, 84f, 71f, 86f, 80f);
|
||||
p.lineTo(90f, 100f);
|
||||
p.close();
|
||||
fill.setColor(accent);
|
||||
c.drawPath(p, fill);
|
||||
triangle(c, 40.5f, 66f, 59.5f, 66f, 50f, 85f, shirt);
|
||||
triangle(c, 45f, 66f, 55f, 66f, 50f, 74f, skin);
|
||||
stroke.setColor(accentDark);
|
||||
stroke.setStrokeWidth(2.2f);
|
||||
c.drawLine(40.5f, 66.5f, 49f, 90f, stroke);
|
||||
c.drawLine(59.5f, 66.5f, 51f, 90f, stroke);
|
||||
stroke.setColor(shirt);
|
||||
stroke.setStrokeWidth(1.6f);
|
||||
c.drawLine(16f, 79f, 38f, 70f, stroke);
|
||||
c.drawLine(84f, 79f, 62f, 70f, stroke);
|
||||
badge(c, 68f, 86f, 4f);
|
||||
|
||||
// The head.
|
||||
c.save();
|
||||
c.rotate(tilt, 50f, 62f);
|
||||
p.reset();
|
||||
p.moveTo(35f, 28f);
|
||||
p.cubicTo(35f, 44f, 42f, 55f, 50f, 57f);
|
||||
p.cubicTo(58f, 55f, 65f, 44f, 65f, 28f);
|
||||
p.cubicTo(65f, 14f, 35f, 14f, 35f, 28f);
|
||||
p.close();
|
||||
fill.setColor(skin);
|
||||
c.drawPath(p, fill);
|
||||
|
||||
boolean blink = t % 4200 < 260;
|
||||
boolean blush = mood == MascotMood.CHEERING || mood == MascotMood.FAST || mood == MascotMood.SLEEPING;
|
||||
if (blush) {
|
||||
fill.setColor(0x55FF7A8A);
|
||||
c.drawOval(oval(37.5f, 43.2f, 43.5f, 46f), fill);
|
||||
c.drawOval(oval(56.5f, 43.2f, 62.5f, 46f), fill);
|
||||
}
|
||||
eye(c, 43f, 38f, mood, blink, true);
|
||||
eye(c, 57f, 38f, mood, blink, false);
|
||||
stroke.setColor(skinShade);
|
||||
stroke.setStrokeWidth(0.8f);
|
||||
c.drawLine(50f, 43.5f, 49.3f, 45.2f, stroke);
|
||||
mouth(c, mood);
|
||||
|
||||
drawFrontHair(c);
|
||||
brows(c, mood);
|
||||
drawHeadset(c);
|
||||
if (mood == MascotMood.WORRIED) sweatDrop(c);
|
||||
c.restore();
|
||||
|
||||
if (mood == MascotMood.SLEEPING) sleepZs(c, t);
|
||||
if (mood == MascotMood.CHEERING) sparkles(c, t);
|
||||
if (mood == MascotMood.FAST) wind(c, t);
|
||||
}
|
||||
|
||||
private void drawFrontHair(Canvas c) {
|
||||
fill.setColor(hair);
|
||||
p.reset();
|
||||
p.moveTo(33f, 36f);
|
||||
p.lineTo(33.5f, 24f);
|
||||
p.cubicTo(36f, 10f, 64f, 10f, 66.5f, 24f);
|
||||
p.lineTo(67f, 36f);
|
||||
p.lineTo(64f, 28f);
|
||||
p.lineTo(61.5f, 33f);
|
||||
p.lineTo(58.5f, 26.5f);
|
||||
p.lineTo(55f, 32f);
|
||||
p.lineTo(51.5f, 25.5f);
|
||||
p.lineTo(48f, 31.5f);
|
||||
p.lineTo(44.5f, 26f);
|
||||
p.lineTo(41.5f, 32.5f);
|
||||
p.lineTo(38.5f, 27f);
|
||||
p.lineTo(36f, 34f);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
// Side locks framing the face, the right one mirrored.
|
||||
p.reset();
|
||||
p.moveTo(33.5f, 26f);
|
||||
p.cubicTo(31f, 38f, 31f, 50f, 33f, 61f);
|
||||
p.lineTo(36.2f, 52f);
|
||||
p.cubicTo(35.5f, 44f, 36f, 36f, 37.5f, 30f);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
c.save();
|
||||
c.scale(-1f, 1f, 50f, 0f);
|
||||
c.drawPath(p, fill);
|
||||
c.restore();
|
||||
stroke.setColor(hairLight);
|
||||
stroke.setStrokeWidth(1.3f);
|
||||
c.drawArc(oval(38f, 13.5f, 62f, 30f), 205f, 55f, false, stroke);
|
||||
}
|
||||
|
||||
private void drawHeadset(Canvas c) {
|
||||
stroke.setColor(headset);
|
||||
stroke.setStrokeWidth(2.2f);
|
||||
c.drawArc(oval(31f, 9.5f, 69f, 48f), 180f, 180f, false, stroke);
|
||||
fill.setColor(cup);
|
||||
c.drawRoundRect(oval(28.5f, 29f, 36f, 42f), 2.5f, 2.5f, fill);
|
||||
c.drawRoundRect(oval(64f, 29f, 71.5f, 42f), 2.5f, 2.5f, fill);
|
||||
fill.setColor(accent);
|
||||
c.drawRect(29.5f, 33.5f, 30.6f, 37.5f, fill);
|
||||
c.drawRect(69.4f, 33.5f, 70.5f, 37.5f, fill);
|
||||
stroke.setStrokeWidth(1f);
|
||||
p.reset();
|
||||
p.moveTo(67.5f, 41f);
|
||||
p.quadTo(66f, 50f, 58.5f, 50.5f);
|
||||
c.drawPath(p, stroke);
|
||||
fill.setColor(headset);
|
||||
c.drawCircle(57.6f, 50.5f, 1.3f, fill);
|
||||
}
|
||||
|
||||
private void eye(Canvas c, float cx, float cy, MascotMood mood, boolean blink, boolean left) {
|
||||
stroke.setColor(lash);
|
||||
if (mood == MascotMood.SLEEPING || (blink && mood != MascotMood.CHEERING)) {
|
||||
// Closed, calm: a lid line curving down.
|
||||
stroke.setStrokeWidth(1.2f);
|
||||
c.drawArc(oval(cx - 4f, cy - 3.2f, cx + 4f, cy + 1.6f), 20f, 140f, false, stroke);
|
||||
return;
|
||||
}
|
||||
if (mood == MascotMood.CHEERING) {
|
||||
// Happy, squeezed shut: ^ ^.
|
||||
stroke.setStrokeWidth(1.4f);
|
||||
c.drawArc(oval(cx - 4f, cy - 1.5f, cx + 4f, cy + 4.5f), 200f, 140f, false, stroke);
|
||||
return;
|
||||
}
|
||||
float look = mood == MascotMood.REVERSE ? -1.6f : 0f;
|
||||
float irisW = mood == MascotMood.WORRIED ? 1.7f : 2.3f;
|
||||
fill.setColor(Color.WHITE);
|
||||
oval(cx - 4f, cy - 2.6f, cx + 4f, cy + 2.8f);
|
||||
c.drawOval(r, fill);
|
||||
c.save();
|
||||
p.reset();
|
||||
p.addOval(r, Path.Direction.CW);
|
||||
c.clipPath(p);
|
||||
fill.setColor(iris);
|
||||
c.drawOval(oval(cx - irisW + look, cy - 2.8f, cx + irisW + look, cy + 2.8f), fill);
|
||||
fill.setColor(pupil);
|
||||
c.drawOval(oval(cx - irisW * 0.5f + look, cy - 1.5f, cx + irisW * 0.5f + look, cy + 1.6f), fill);
|
||||
fill.setColor(Color.WHITE);
|
||||
c.drawCircle(cx - 0.8f + look, cy - 1.2f, 0.75f, fill);
|
||||
c.drawCircle(cx + 0.8f + look, cy + 1.2f, 0.35f, fill);
|
||||
if (mood == MascotMood.DETERMINED) {
|
||||
// Narrowed: the lid comes down over the top of the eye.
|
||||
fill.setColor(skin);
|
||||
c.drawRect(cx - 4.5f, cy - 3.2f, cx + 4.5f, cy - 1.0f, fill);
|
||||
}
|
||||
c.restore();
|
||||
stroke.setStrokeWidth(1.25f);
|
||||
if (mood == MascotMood.DETERMINED) {
|
||||
c.drawLine(cx - 4.3f, cy - 0.9f, cx + 4.3f, cy - 1.1f, stroke);
|
||||
} else {
|
||||
c.drawArc(oval(cx - 4.4f, cy - 3f, cx + 4.4f, cy + 3.4f), 195f, 150f, false, stroke);
|
||||
}
|
||||
// The flick at the outer corner.
|
||||
float outer = left ? cx - 4.2f : cx + 4.2f, dir = left ? -1f : 1f;
|
||||
c.drawLine(outer, cy - 0.6f, outer + dir * 1.3f, cy - 1.6f, stroke);
|
||||
}
|
||||
|
||||
private void brows(Canvas c, MascotMood mood) {
|
||||
stroke.setColor(hair);
|
||||
stroke.setStrokeWidth(1.1f);
|
||||
switch (mood) {
|
||||
case WORRIED:
|
||||
c.drawLine(39f, 31.5f, 46.5f, 29.3f, stroke);
|
||||
c.drawLine(53.5f, 29.3f, 61f, 31.5f, stroke);
|
||||
break;
|
||||
case DETERMINED:
|
||||
c.drawLine(39f, 29.6f, 46.8f, 32f, stroke);
|
||||
c.drawLine(53.2f, 32f, 61f, 29.6f, stroke);
|
||||
break;
|
||||
default:
|
||||
float lift = mood == MascotMood.CHEERING || mood == MascotMood.FAST ? -0.8f : 0f;
|
||||
c.drawArc(oval(39f, 30f + lift, 47f, 33.5f + lift), 200f, 120f, false, stroke);
|
||||
c.drawArc(oval(53f, 30f + lift, 61f, 33.5f + lift), 220f, 120f, false, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
private void mouth(Canvas c, MascotMood mood) {
|
||||
stroke.setColor(lips);
|
||||
stroke.setStrokeWidth(1f);
|
||||
fill.setColor(mouth);
|
||||
switch (mood) {
|
||||
case CHEERING:
|
||||
case FAST:
|
||||
p.reset();
|
||||
p.moveTo(45.5f, 48.8f);
|
||||
p.quadTo(50f, 56f, 54.5f, 48.8f);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
fill.setColor(0xFFE88A95);
|
||||
c.drawOval(oval(47.8f, 51.2f, 52.2f, 52.8f), fill);
|
||||
break;
|
||||
case SLEEPING:
|
||||
case REVERSE:
|
||||
c.drawOval(oval(48.8f, 49.3f, 51.2f, 51.6f), fill);
|
||||
break;
|
||||
case WORRIED:
|
||||
p.reset();
|
||||
p.moveTo(46.5f, 51f);
|
||||
p.quadTo(47.8f, 49.6f, 49.2f, 51f);
|
||||
p.quadTo(50.6f, 52.4f, 52f, 51f);
|
||||
p.quadTo(53.2f, 49.8f, 54f, 50.8f);
|
||||
c.drawPath(p, stroke);
|
||||
break;
|
||||
case DETERMINED:
|
||||
p.reset();
|
||||
p.moveTo(46f, 50.6f);
|
||||
p.quadTo(51f, 52.6f, 55f, 49f);
|
||||
c.drawPath(p, stroke);
|
||||
break;
|
||||
default:
|
||||
c.drawArc(oval(46f, 46.5f, 54f, 51.2f), 25f, 130f, false, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
private void sweatDrop(Canvas c) {
|
||||
fill.setColor(0xFF8FD3FF);
|
||||
p.reset();
|
||||
p.moveTo(63f, 21f);
|
||||
p.quadTo(66.5f, 26.5f, 63f, 28f);
|
||||
p.quadTo(59.5f, 26.5f, 63f, 21f);
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
/** Three Zs drifting up and to the left, over three seconds. */
|
||||
private void sleepZs(Canvas c, long t) {
|
||||
float phase = (t % 3000) / 3000f;
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
float[][] zs = { { 24f, 22f, 5f }, { 17f, 14f, 6.5f }, { 9f, 5.5f, 8f } };
|
||||
for (float[] z : zs) {
|
||||
text.setTextSize(z[2]);
|
||||
text.setColor(AccentColor.withAlpha(accentLight, Math.round(255 * (1f - phase * 0.5f))));
|
||||
c.drawText("Z", z[0] - phase * 2f, z[1] + z[2] - phase * 3f, text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Four-pointed sparkles around her head, twinkling. */
|
||||
private void sparkles(Canvas c, long t) {
|
||||
float[][] at = { { 22f, 20f }, { 78f, 14f }, { 18f, 46f }, { 84f, 40f }, { 50f, 3f } };
|
||||
for (int i = 0; i < at.length; i++) {
|
||||
float s = 2.2f + 1.6f * (float) Math.abs(Math.sin(t / 260.0 + i * 1.3));
|
||||
fill.setColor(i % 2 == 0 ? regen : Color.WHITE);
|
||||
star(c, at[i][0], at[i][1], s);
|
||||
}
|
||||
}
|
||||
|
||||
/** Streaks rushing past on her left. */
|
||||
private void wind(Canvas c, long t) {
|
||||
stroke.setStrokeWidth(0.9f);
|
||||
stroke.setColor(0x88FFFFFF);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
float x = 26f - ((t / 6f + i * 37f) % 24f);
|
||||
float y = 16f + i * 10f;
|
||||
c.drawLine(x - 9f, y, x, y, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
private void star(Canvas c, float x, float y, float s) {
|
||||
p.reset();
|
||||
p.moveTo(x, y - s);
|
||||
p.quadTo(x, y, x + s, y);
|
||||
p.quadTo(x, y, x, y + s);
|
||||
p.quadTo(x, y, x - s, y);
|
||||
p.quadTo(x, y, x, y - s);
|
||||
p.close();
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
/** A small silver octagon on the jacket. */
|
||||
private void badge(Canvas c, float x, float y, float s) {
|
||||
p.reset();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
double a = Math.toRadians(22.5 + i * 45);
|
||||
float px = x + s * (float) Math.cos(a), py = y + s * (float) Math.sin(a);
|
||||
if (i == 0) p.moveTo(px, py);
|
||||
else p.lineTo(px, py);
|
||||
}
|
||||
p.close();
|
||||
fill.setColor(0xFFD7DBE0);
|
||||
c.drawPath(p, fill);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(3.4f);
|
||||
text.setColor(accentDark);
|
||||
c.drawText("MG", x, y + 1.2f, text);
|
||||
}
|
||||
|
||||
private void triangle(Canvas c, float x1, float y1, float x2, float y2, float x3, float y3, int colour) {
|
||||
p.reset();
|
||||
p.moveTo(x1, y1);
|
||||
p.lineTo(x2, y2);
|
||||
p.lineTo(x3, y3);
|
||||
p.close();
|
||||
fill.setColor(colour);
|
||||
c.drawPath(p, fill);
|
||||
}
|
||||
|
||||
private RectF oval(float left, float top, float right, float bottom) {
|
||||
r.set(left, top, right, bottom);
|
||||
return r;
|
||||
}
|
||||
|
||||
// ---------- Speech bubble and values, in pixels ----------
|
||||
|
||||
private void drawBubble(Canvas c, MascotMood mood, long t, float x0, float h, float tipX, float tipY, float w) {
|
||||
String[] options = lines[character.ordinal()][mood.ordinal()];
|
||||
String line = options.length == 0 ? "" : options[(int) ((t / 30000 + mood.ordinal()) % options.length)];
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
float pad = h * 0.045f, room = w - 140f * dp - x0 - 2f * pad;
|
||||
// Shrink a long line to fit the room left of the clock.
|
||||
text.setTextSize(h * 0.07f);
|
||||
float width = text.measureText(line);
|
||||
if (width > room && room > 0f) {
|
||||
text.setTextSize(h * 0.07f * room / width);
|
||||
width = room;
|
||||
}
|
||||
float right = x0 + width + 2f * pad;
|
||||
float top = h * 0.05f, bottom = top + h * 0.07f + 2f * pad;
|
||||
fill.setColor(0xFFF7F7FA);
|
||||
c.drawRoundRect(oval(x0, top, right, bottom), h * 0.05f, h * 0.05f, fill);
|
||||
triangle(c, x0 + h * 0.03f, bottom - 2f, x0 + h * 0.11f, bottom - 2f, tipX, tipY, 0xFFF7F7FA);
|
||||
text.setColor(0xFF1C1A24);
|
||||
c.drawText(line, x0 + pad, bottom - pad - h * 0.012f, text);
|
||||
}
|
||||
|
||||
/** Speed and gear from x0; the battery and the bottom row from xLow, clear of her shoulder. */
|
||||
private void drawValues(Canvas c, float x0, float xLow, float w, float h) {
|
||||
float right = w - 16f * dp;
|
||||
// The clock, left of the full-screen button.
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
text.setTextSize(20f * dp);
|
||||
text.setColor(grey);
|
||||
c.drawText(v.clock.isEmpty() ? "—" : v.clock, w - 72f * dp, 30f * dp, text);
|
||||
|
||||
// Speed, large, and the gear in a ring at the right.
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
text.setTextSize(h * 0.34f);
|
||||
text.setColor(white);
|
||||
String speed = fmt(v.speedKmh, "%.0f", "—");
|
||||
float baseline = h * 0.64f;
|
||||
c.drawText(speed, x0, baseline, text);
|
||||
float after = x0 + text.measureText(speed) + 14f * dp;
|
||||
text.setTextSize(h * 0.065f);
|
||||
text.setColor(grey);
|
||||
c.drawText("km/h", after, baseline, text);
|
||||
|
||||
float gr = h * 0.11f, gx = right - gr, gy = h * 0.50f;
|
||||
stroke.setColor(accent);
|
||||
stroke.setStrokeWidth(4f * dp);
|
||||
c.drawCircle(gx, gy, gr, stroke);
|
||||
String gear = Gear.label(v.gear);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setTextSize(gr * 1.1f);
|
||||
text.setColor(white);
|
||||
c.drawText(gear.isEmpty() ? " " : gear, gx, gy + gr * 0.38f, text);
|
||||
|
||||
// Battery: percent and range over a bar.
|
||||
float barY = h * 0.77f, barH = 12f * dp;
|
||||
boolean low = v.socPercent != null && v.socPercent < MascotMood.LOW_SOC;
|
||||
text.setTextSize(h * 0.055f);
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
text.setColor(low ? warn : white);
|
||||
c.drawText(fmt(v.socPercent, "%.0f %%", "— %"), xLow, barY - 10f * dp, text);
|
||||
text.setTextAlign(Paint.Align.RIGHT);
|
||||
text.setColor(white);
|
||||
c.drawText(fmt(v.rangeKm, "%.0f km", "— km"), right, barY - 10f * dp, text);
|
||||
fill.setColor(track);
|
||||
c.drawRoundRect(oval(xLow, barY, right, barY + barH), barH / 2f, barH / 2f, fill);
|
||||
if (v.socPercent != null) {
|
||||
float soc = Math.max(0f, Math.min(100f, v.socPercent)) / 100f;
|
||||
fill.setColor(low ? warn : accent);
|
||||
c.drawRoundRect(oval(xLow, barY, xLow + Math.max(barH, (right - xLow) * soc), barY + barH), barH / 2f, barH / 2f, fill);
|
||||
}
|
||||
|
||||
// Power, usage, trip.
|
||||
float col = (right - xLow) / 3f, labelY = h * 0.885f, valueY = h * 0.965f;
|
||||
boolean regenerating = v.powerKw != null && v.powerKw < 0f;
|
||||
cell(c, xLow, labelY, valueY, "POWER", fmt(v.powerKw, "%.1f kW", "— kW"), regenerating ? regen : white);
|
||||
cell(c, xLow + col, labelY, valueY, "USAGE · 5 MIN", fmt(v.usage5Min, "%.1f kWh/100 km", "—"), white);
|
||||
cell(c, xLow + 2f * col, labelY, valueY, "TRIP", fmt(v.tripKm, "%.1f km", "— km"), white);
|
||||
}
|
||||
|
||||
private void cell(Canvas c, float x, float labelY, float valueY, String label, String value, int colour) {
|
||||
text.setTextAlign(Paint.Align.LEFT);
|
||||
text.setTextSize(13f * dp);
|
||||
text.setLetterSpacing(0.12f);
|
||||
text.setColor(grey);
|
||||
c.drawText(label, x, labelY, text);
|
||||
text.setLetterSpacing(0f);
|
||||
text.setTextSize(getHeight() * 0.055f);
|
||||
text.setColor(colour);
|
||||
c.drawText(value, x, valueY, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Typeface;
|
||||
|
||||
/**
|
||||
* The Minimal Overview style: one number and almost nothing else.
|
||||
*
|
||||
* The speed, very large and thin, in the middle, the gear above it. Under it, power as a short
|
||||
* line growing from the centre — right in the accent colour while drawing, left in green while
|
||||
* regenerating. Along the bottom edge, the battery as a hairline filled to the charge, with the
|
||||
* percentage and range at its ends. Power and clock sit small in the top corners, usage at the
|
||||
* bottom. Unread values show "—" and draw no line.
|
||||
*/
|
||||
final class MinimalView extends ClusterCanvasView {
|
||||
|
||||
private final Paint big = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint small = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint bar = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final int accent, light, regen, warn, white, grey, track;
|
||||
|
||||
MinimalView(Context context) {
|
||||
super(context);
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
light = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
regen = context.getColor(R.color.ev_ok);
|
||||
warn = context.getColor(R.color.ev_error);
|
||||
white = context.getColor(R.color.ev_text_primary);
|
||||
grey = context.getColor(R.color.ev_text_secondary);
|
||||
track = context.getColor(R.color.ev_outline);
|
||||
big.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL));
|
||||
big.setTextAlign(Paint.Align.CENTER);
|
||||
big.setFontFeatureSettings("tnum");
|
||||
big.setColor(white);
|
||||
small.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));
|
||||
small.setFontFeatureSettings("tnum");
|
||||
small.setLetterSpacing(0.06f);
|
||||
bar.setStrokeCap(Paint.Cap.ROUND);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
// The gear, the speed, its unit.
|
||||
String gear = Gear.label(v.gear);
|
||||
text(c, gear.isEmpty() ? " " : gear, w / 2f, h * 0.17f, 26f * dp, light, Paint.Align.CENTER);
|
||||
big.setTextSize(h * 0.50f);
|
||||
c.drawText(fmt(v.speedKmh, "%.0f", "—"), w / 2f, h * 0.58f, big);
|
||||
text(c, "km/h", w / 2f, h * 0.67f, 18f * dp, grey, Paint.Align.CENTER);
|
||||
|
||||
// Power: a line from the centre, its length up to a quarter of the width at 150 kW.
|
||||
float y = h * 0.75f;
|
||||
bar.setStrokeWidth(4f * dp);
|
||||
bar.setColor(track);
|
||||
c.drawLine(w / 2f - 1f * dp, y, w / 2f + 1f * dp, y, bar);
|
||||
if (v.powerKw != null && Math.abs(v.powerKw) >= 0.1f) {
|
||||
float len = w * 0.25f * Math.min(150f, Math.abs(v.powerKw)) / 150f * (v.powerKw < 0f ? 3f : 1f);
|
||||
len = Math.min(len, w * 0.25f);
|
||||
bar.setColor(v.powerKw < 0f ? regen : accent);
|
||||
c.drawLine(w / 2f, y, w / 2f + (v.powerKw < 0f ? -len : len), y, bar);
|
||||
}
|
||||
|
||||
// Corners: power and the clock; bottom middle: usage.
|
||||
text(c, fmt(v.powerKw, "%.1f kW", "— kW"), 8f * dp, 30f * dp, 20f * dp, grey, Paint.Align.LEFT);
|
||||
// Left of the full-screen button, which sits in the top-right corner.
|
||||
text(c, v.clock.isEmpty() ? "—" : v.clock, w - 72f * dp, 30f * dp, 20f * dp, grey, Paint.Align.RIGHT);
|
||||
text(c, fmt(v.usage5Min, "%.1f kWh/100 km", "— kWh/100 km"), w / 2f, h * 0.86f, 16f * dp, grey, Paint.Align.CENTER);
|
||||
|
||||
// The battery: a hairline along the bottom, filled to the charge.
|
||||
float by = h - 6f * dp;
|
||||
bar.setStrokeWidth(3f * dp);
|
||||
bar.setColor(track);
|
||||
c.drawLine(8f * dp, by, w - 8f * dp, by, bar);
|
||||
if (v.socPercent != null) {
|
||||
bar.setColor(v.socPercent < 20f ? warn : accent);
|
||||
c.drawLine(8f * dp, by, 8f * dp + (w - 16f * dp) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f, by, bar);
|
||||
}
|
||||
text(c, fmt(v.socPercent, "%.0f %%", "— %"), 8f * dp, by - 12f * dp, 20f * dp, white, Paint.Align.LEFT);
|
||||
text(c, fmt(v.rangeKm, "%.0f km", "— km"), w - 8f * dp, by - 12f * dp, 20f * dp, white, Paint.Align.RIGHT);
|
||||
}
|
||||
|
||||
private void text(Canvas c, String s, float x, float y, float size, int colour, Paint.Align align) {
|
||||
small.setTextSize(size);
|
||||
small.setColor(colour);
|
||||
small.setTextAlign(align);
|
||||
c.drawText(s, x, y, small);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
|
||||
/**
|
||||
* The Nixie Overview style: glowing orange digits in glass tubes, on a dark walnut panel with
|
||||
* brass name plates.
|
||||
*
|
||||
* Two rows of tube groups. Top, large: speed, the gear in a single tube, battery. Bottom,
|
||||
* smaller: power (with its sign), range, 5-minute usage and the clock (with a neon colon).
|
||||
* Each tube shows the unlit numerals faintly behind the lit one, and a fine mesh. Decimal
|
||||
* points are small neon dots ({@link SevenSegment} lays the text out in tubes). Unread values
|
||||
* light dashes. Redrawn only when a reading arrives.
|
||||
*/
|
||||
final class NixieView extends ClusterCanvasView {
|
||||
|
||||
private final Paint glass = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint rim = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint mesh = new Paint();
|
||||
private final Paint ghost = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint digit = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint plate = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint engraving = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint bg = new Paint();
|
||||
private final Paint grain = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint shine = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final RectF rect = new RectF();
|
||||
private final int neon = 0xFFFF8A1F, panel = 0xFF140C07;
|
||||
|
||||
NixieView(Context context) {
|
||||
super(context);
|
||||
glass.setColor(0xFF1C130D);
|
||||
rim.setStyle(Paint.Style.STROKE);
|
||||
rim.setColor(0xFF4A3524);
|
||||
mesh.setColor(0x16FFB070);
|
||||
Typeface face = Typeface.create("sans-serif-light", Typeface.NORMAL);
|
||||
ghost.setTypeface(face);
|
||||
ghost.setTextAlign(Paint.Align.CENTER);
|
||||
ghost.setColor(0x22FF8A1F);
|
||||
glow.setTypeface(face);
|
||||
glow.setTextAlign(Paint.Align.CENTER);
|
||||
glow.setStyle(Paint.Style.STROKE);
|
||||
glow.setColor(0x55FF6A00);
|
||||
digit.setTypeface(face);
|
||||
digit.setTextAlign(Paint.Align.CENTER);
|
||||
digit.setColor(neon);
|
||||
plate.setColor(0xFF6B5230);
|
||||
engraving.setColor(0xFFF0DDB2);
|
||||
engraving.setTextAlign(Paint.Align.CENTER);
|
||||
engraving.setTypeface(Typeface.create("serif", Typeface.BOLD));
|
||||
engraving.setLetterSpacing(0.18f);
|
||||
bg.setColor(panel);
|
||||
grain.setColor(0x0EFFC890);
|
||||
shine.setColor(0x18FFFFFF);
|
||||
shine.setStyle(Paint.Style.STROKE);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
rect.set(0, 0, w, h);
|
||||
c.drawRoundRect(rect, 20f * dp, 20f * dp, bg);
|
||||
// Walnut grain: a few faint horizontal strokes.
|
||||
grain.setStrokeWidth(dp);
|
||||
for (int i = 0; i < 14; i++) {
|
||||
float y = h * (0.04f + i * 0.07f);
|
||||
c.drawLine(w * 0.02f, y + (i % 3) * 2f * dp, w * 0.98f, y - (i % 2) * 3f * dp, grain);
|
||||
}
|
||||
|
||||
// Top row, large: speed, gear, battery.
|
||||
float th = h * 0.42f, tw = th * 0.50f, gap = tw * 0.12f, ty = h * 0.06f;
|
||||
String speed = fmt(v.speedKmh, "%.0f", "---");
|
||||
String soc = fmt(v.socPercent, "%.0f", "---");
|
||||
String gear = Gear.label(v.gear);
|
||||
float groupGap = tw * 0.9f;
|
||||
float total = 3 * tw + 2 * gap + groupGap + tw + groupGap + 3 * tw + 2 * gap;
|
||||
float x = (w - total) / 2f;
|
||||
x = tubes(c, speed, 3, x, ty, tw, th, gap, "KM/H");
|
||||
x += groupGap;
|
||||
x = tubes(c, gear.isEmpty() ? " " : gear.equals("—") ? "-" : gear, 1, x, ty, tw, th, gap, "GEAR");
|
||||
x += groupGap;
|
||||
tubes(c, soc, 3, x, ty, tw, th, gap, "BATTERY %");
|
||||
|
||||
// Bottom row, smaller: power, range, usage, clock.
|
||||
// 14 tubes, 14 gaps, a colon and three group gaps: about 18.6 tube widths, fitted to the width.
|
||||
float bw = Math.min(h * 0.24f * 0.52f, w * 0.96f / 18.6f), bh = bw / 0.52f, bgap = bw * 0.12f, by = h * 0.62f;
|
||||
float bGroup = bw * 0.8f;
|
||||
String power = v.powerKw == null ? "---" : String.format(java.util.Locale.US, "%.1f", v.powerKw);
|
||||
String range = fmt(v.rangeKm, "%.0f", "---");
|
||||
String usage = fmt(v.usage5Min, "%.1f", "--.-");
|
||||
String clock = v.clock.length() == 5 ? v.clock : "--:--";
|
||||
float colon = bw * 0.5f;
|
||||
float bTotal = 14 * bw + 14 * bgap + colon + 3 * bGroup;
|
||||
x = (w - bTotal) / 2f;
|
||||
x = tubes(c, power, 4, x, by, bw, bh, bgap, "POWER KW");
|
||||
x += bGroup;
|
||||
x = tubes(c, range, 3, x, by, bw, bh, bgap, "RANGE KM");
|
||||
x += bGroup;
|
||||
x = tubes(c, usage, 3, x, by, bw, bh, bgap, "KWH/100");
|
||||
x += bGroup;
|
||||
float clockStart = x;
|
||||
x = tubes(c, clock.substring(0, 2), 2, x, by, bw, bh, bgap, null);
|
||||
// The colon: two neon dots.
|
||||
float cx = x - bgap + colon / 2f + bgap / 2f;
|
||||
neonDot(c, cx, by + bh * 0.38f, bw * 0.07f);
|
||||
neonDot(c, cx, by + bh * 0.62f, bw * 0.07f);
|
||||
x += colon;
|
||||
x = tubes(c, clock.substring(3, 5), 2, x, by, bw, bh, bgap, null);
|
||||
nameplate(c, (clockStart + x - bgap) / 2f, by + bh + 8f * dp, "TIME");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tubes for the text, right-aligned in {@code cells}, from {@code x}; a name plate under
|
||||
* them unless null. Returns the x after the last tube and its gap.
|
||||
*/
|
||||
private float tubes(Canvas c, String text, int cells, float x, float y, float tw, float th, float gap, String name) {
|
||||
char[] chars = SevenSegment.cells(text, cells);
|
||||
boolean[] dots = SevenSegment.dots(text, cells);
|
||||
float start = x;
|
||||
for (int i = 0; i < cells; i++) {
|
||||
tube(c, x, y, tw, th, chars[i], dots[i]);
|
||||
x += tw + gap;
|
||||
}
|
||||
if (name != null) nameplate(c, (start + x - gap) / 2f, y + th + 8f * dp, name);
|
||||
return x;
|
||||
}
|
||||
|
||||
private void tube(Canvas c, float x, float y, float tw, float th, char ch, boolean dot) {
|
||||
rect.set(x, y, x + tw, y + th);
|
||||
float radius = tw * 0.45f;
|
||||
c.drawRoundRect(rect, radius, radius, glass);
|
||||
// The mesh in front of the digits.
|
||||
c.save();
|
||||
c.clipRect(x + tw * 0.08f, y + th * 0.06f, x + tw * 0.92f, y + th * 0.94f);
|
||||
float step = Math.max(3f * dp, tw * 0.07f);
|
||||
for (float mx = x; mx < x + tw; mx += step) c.drawLine(mx, y, mx, y + th, mesh);
|
||||
for (float my = y; my < y + th; my += step) c.drawLine(x, my, x + tw, my, mesh);
|
||||
c.restore();
|
||||
// The stacked cathodes, faint, then the lit one with its glow.
|
||||
float size = th * 0.66f, base = y + th * 0.5f + size * 0.36f;
|
||||
ghost.setTextSize(size);
|
||||
c.drawText("8", x + tw / 2f, base, ghost);
|
||||
c.drawText("0", x + tw / 2f, base, ghost);
|
||||
if (ch != ' ') {
|
||||
glow.setTextSize(size);
|
||||
glow.setStrokeWidth(size * 0.08f);
|
||||
c.drawText(String.valueOf(ch), x + tw / 2f, base, glow);
|
||||
digit.setTextSize(size);
|
||||
c.drawText(String.valueOf(ch), x + tw / 2f, base, digit);
|
||||
}
|
||||
if (dot) neonDot(c, x + tw * 0.85f, y + th * 0.84f, tw * 0.06f);
|
||||
rim.setStrokeWidth(Math.max(1.5f * dp, tw * 0.025f));
|
||||
c.drawRoundRect(rect, radius, radius, rim);
|
||||
// A highlight on the glass.
|
||||
shine.setStrokeWidth(tw * 0.05f);
|
||||
c.drawLine(x + tw * 0.2f, y + th * 0.12f, x + tw * 0.2f, y + th * 0.45f, shine);
|
||||
}
|
||||
|
||||
private void neonDot(Canvas c, float x, float y, float r) {
|
||||
dotPaint.setColor(0x55FF6A00);
|
||||
c.drawCircle(x, y, r * 2f, dotPaint);
|
||||
dotPaint.setColor(neon);
|
||||
c.drawCircle(x, y, r, dotPaint);
|
||||
}
|
||||
|
||||
/** A brass plate with engraved capitals, centred at x. */
|
||||
private void nameplate(Canvas c, float x, float y, String name) {
|
||||
engraving.setTextSize(12f * dp);
|
||||
float tw = engraving.measureText(name) + 20f * dp;
|
||||
rect.set(x - tw / 2f, y, x + tw / 2f, y + 22f * dp);
|
||||
c.drawRoundRect(rect, 4f * dp, 4f * dp, plate);
|
||||
c.drawText(name, x, y + 16f * dp, engraving);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The MG Overview style, after the classic MG dashboards whose dials sat in octagonal bezels.
|
||||
*
|
||||
* Three octagonal dials with chrome bezels, cream scales and needles in the accent colour: the
|
||||
* speedometer in the middle (with the speed as digits and P R N D underneath), the battery on
|
||||
* the left (E to F, red below 20 %, range underneath) and power on the right (−50 to 150 kW,
|
||||
* the charge zone below zero in green). Below, a cream line of odometer, trip, usage, climate
|
||||
* and clock, separated by small octagons. Unread values draw no needle and "—".
|
||||
*
|
||||
* Needles ease to a new value and the view redraws only until they are there, only on screen.
|
||||
* The MG octagon logo itself is not drawn.
|
||||
*/
|
||||
public final class OctagonView extends View {
|
||||
|
||||
private static final float START_DEG = 135f, SWEEP_DEG = 270f;
|
||||
private static final long FRAME_MS = 33;
|
||||
|
||||
private final Paint face = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint chrome = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint tick = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint needle = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint zone = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int accent, light, cream, chromeLight, chromeDark, faceColour, green, red;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
/** The needles as shown, easing to their values; NaN until the first reading. */
|
||||
private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN;
|
||||
|
||||
public OctagonView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
accent = AccentColor.resolve(context, R.attr.evAccent);
|
||||
light = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
cream = context.getColor(R.color.mg_cream);
|
||||
chromeLight = context.getColor(R.color.mg_chrome_light);
|
||||
chromeDark = context.getColor(R.color.mg_chrome_dark);
|
||||
faceColour = context.getColor(R.color.mg_face);
|
||||
green = context.getColor(R.color.ev_ok);
|
||||
red = context.getColor(R.color.ev_error);
|
||||
face.setColor(faceColour);
|
||||
chrome.setStyle(Paint.Style.STROKE);
|
||||
chrome.setStrokeJoin(Paint.Join.ROUND);
|
||||
tick.setColor(cream);
|
||||
tick.setStrokeCap(Paint.Cap.ROUND);
|
||||
needle.setStrokeCap(Paint.Cap.ROUND);
|
||||
zone.setStyle(Paint.Style.STROKE);
|
||||
text.setColor(cream);
|
||||
text.setTextAlign(Paint.Align.CENTER);
|
||||
text.setFontFeatureSettings("tnum");
|
||||
text.setTypeface(Typeface.create("serif", Typeface.NORMAL));
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
|
||||
speedShown = ease(speedShown, v.speedKmh, 180f);
|
||||
socShown = ease(socShown, v.socPercent, 100f);
|
||||
powerShown = ease(powerShown, v.powerKw, 200f);
|
||||
boolean moving = (v.speedKmh != null && speedShown != v.speedKmh)
|
||||
|| (v.socPercent != null && socShown != v.socPercent)
|
||||
|| (v.powerKw != null && powerShown != v.powerKw);
|
||||
|
||||
float dialsBottom = h * 0.84f;
|
||||
float big = Math.min(dialsBottom * 0.5f, w * 0.20f);
|
||||
float small = big * 0.68f;
|
||||
float cy = dialsBottom / 2f + 4f * d;
|
||||
float cx = w / 2f;
|
||||
float sideX = Math.min(w * 0.21f, cx - big - small - 20f * d);
|
||||
|
||||
// Battery, left.
|
||||
bezel(c, sideX + small * 0.05f, cy + big - small, small, d);
|
||||
dial(c, sideX + small * 0.05f, cy + big - small, small, 0f, 100f, 25f, 5,
|
||||
v.socPercent == null ? null : socShown, 20f, false, new String[] { "E", "½", "F" }, d);
|
||||
label(c, sideX + small * 0.05f, cy + big - small, small, "BATTERY",
|
||||
v.socPercent == null ? "—" : String.format(Locale.US, "%.0f%%", v.socPercent),
|
||||
v.rangeKm == null ? "— km" : String.format(Locale.US, "%.0f km", v.rangeKm), 0.20f);
|
||||
|
||||
// Power, right.
|
||||
float rx = w - sideX - small * 0.05f;
|
||||
bezel(c, rx, cy + big - small, small, d);
|
||||
dial(c, rx, cy + big - small, small, -50f, 150f, 50f, 5,
|
||||
v.powerKw == null ? null : powerShown, null, true, null, d);
|
||||
label(c, rx, cy + big - small, small, "kW",
|
||||
v.powerKw == null ? "—" : String.format(Locale.US, "%.1f", v.powerKw),
|
||||
v.powerKw != null && v.powerKw < 0f ? "CHARGE" : "POWER", 0.20f);
|
||||
|
||||
// Speed, centre.
|
||||
bezel(c, cx, cy, big, d);
|
||||
dial(c, cx, cy, big, 0f, 180f, 20f, 4, v.speedKmh == null ? null : speedShown, null, false, null, d);
|
||||
label(c, cx, cy, big, "km/h",
|
||||
v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), null, 0.24f);
|
||||
gears(c, cx, cy + big * 0.62f, big, d);
|
||||
|
||||
drawLine(c, w, h, d);
|
||||
|
||||
if (moving && isShown()) postInvalidateDelayed(FRAME_MS);
|
||||
}
|
||||
|
||||
/** One step of a needle towards its value; jumps there when close or on the first value. */
|
||||
private static float ease(float shown, Float target, float span) {
|
||||
if (target == null || !Float.isFinite(target)) return shown;
|
||||
if (Float.isNaN(shown)) return target;
|
||||
float next = shown + (target - shown) * 0.25f;
|
||||
return Math.abs(target - next) < span * 0.002f ? target : next;
|
||||
}
|
||||
|
||||
/** A regular octagon with flat sides at top, bottom and both sides. */
|
||||
private void octagon(float cx, float cy, float r) {
|
||||
path.reset();
|
||||
for (int i = 0; i < 8; i++) {
|
||||
double a = Math.toRadians(22.5 + 45 * i);
|
||||
float x = cx + r * (float) Math.cos(a), y = cy + r * (float) Math.sin(a);
|
||||
if (i == 0) path.moveTo(x, y); else path.lineTo(x, y);
|
||||
}
|
||||
path.close();
|
||||
}
|
||||
|
||||
/** The bezel: a dark face inside a chrome octagon, lit from above (light outer, dark inner line). */
|
||||
private void bezel(Canvas c, float cx, float cy, float r, float d) {
|
||||
octagon(cx, cy, r);
|
||||
c.drawPath(path, face);
|
||||
chrome.setColor(chromeLight);
|
||||
chrome.setStrokeWidth(Math.max(3f * d, r * 0.035f));
|
||||
c.drawPath(path, chrome);
|
||||
octagon(cx, cy, r * 0.93f);
|
||||
chrome.setColor(chromeDark);
|
||||
chrome.setStrokeWidth(Math.max(2f * d, r * 0.02f));
|
||||
c.drawPath(path, chrome);
|
||||
octagon(cx, cy, r * 1.035f);
|
||||
chrome.setColor(AccentColor.withAlpha(chromeLight, 0x40));
|
||||
chrome.setStrokeWidth(d);
|
||||
c.drawPath(path, chrome);
|
||||
}
|
||||
|
||||
/**
|
||||
* A scale inside the octagon, its numbers or end labels, an optional warning zone below
|
||||
* {@code warnBelow} (red) or charge zone below zero (green), and the needle when known.
|
||||
*/
|
||||
private void dial(Canvas c, float cx, float cy, float r, float min, float max, float major,
|
||||
int minorPer, Float value, Float warnBelow, boolean chargeZone, String[] labels, float d) {
|
||||
float sr = r * 0.74f;
|
||||
rect.set(cx - sr, cy - sr, cx + sr, cy + sr);
|
||||
zone.setStrokeWidth(r * 0.05f);
|
||||
if (warnBelow != null) {
|
||||
zone.setColor(AccentColor.withAlpha(red, 0xC0));
|
||||
c.drawArc(rect, START_DEG, SWEEP_DEG * (warnBelow - min) / (max - min), false, zone);
|
||||
}
|
||||
if (chargeZone && min < 0f) {
|
||||
zone.setColor(AccentColor.withAlpha(green, 0xC0));
|
||||
c.drawArc(rect, START_DEG, SWEEP_DEG * (0f - min) / (max - min), false, zone);
|
||||
}
|
||||
float minor = major / minorPer;
|
||||
int steps = Math.round((max - min) / minor);
|
||||
text.setTextSize(r * 0.13f);
|
||||
for (int i = 0; i <= steps; i++) {
|
||||
float val = min + i * minor;
|
||||
boolean isMajor = i % minorPer == 0;
|
||||
double a = Math.toRadians(START_DEG + SWEEP_DEG * (val - min) / (max - min));
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
float outer = r * 0.82f, inner = isMajor ? r * 0.68f : r * 0.75f;
|
||||
tick.setStrokeWidth(isMajor ? r * 0.025f : r * 0.012f);
|
||||
c.drawLine(cx + outer * cos, cy + outer * sin, cx + inner * cos, cy + inner * sin, tick);
|
||||
if (isMajor && labels == null) {
|
||||
float lr = r * 0.54f;
|
||||
c.drawText(String.format(Locale.US, "%.0f", val), cx + lr * cos,
|
||||
cy + lr * sin + text.getTextSize() * 0.36f, text);
|
||||
}
|
||||
}
|
||||
if (labels != null) {
|
||||
text.setTextSize(r * 0.16f);
|
||||
for (int i = 0; i < labels.length; i++) {
|
||||
double a = Math.toRadians(START_DEG + SWEEP_DEG * i / (labels.length - 1f));
|
||||
float lr = r * 0.52f;
|
||||
c.drawText(labels[i], cx + lr * (float) Math.cos(a), cy + lr * (float) Math.sin(a) + text.getTextSize() * 0.36f, text);
|
||||
}
|
||||
}
|
||||
if (value != null) {
|
||||
double a = Math.toRadians(START_DEG + SWEEP_DEG * (Math.max(min, Math.min(max, value)) - min) / (max - min));
|
||||
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
|
||||
needle.setColor(AccentColor.withAlpha(accent, 0x50));
|
||||
needle.setStrokeWidth(r * 0.06f);
|
||||
c.drawLine(cx - r * 0.12f * cos, cy - r * 0.12f * sin, cx + r * 0.80f * cos, cy + r * 0.80f * sin, needle);
|
||||
needle.setColor(accent);
|
||||
needle.setStrokeWidth(r * 0.03f);
|
||||
c.drawLine(cx - r * 0.12f * cos, cy - r * 0.12f * sin, cx + r * 0.80f * cos, cy + r * 0.80f * sin, needle);
|
||||
}
|
||||
// The hub: a small chrome octagon.
|
||||
octagon(cx, cy, r * 0.08f);
|
||||
fill.setColor(chromeLight);
|
||||
c.drawPath(path, fill);
|
||||
octagon(cx, cy, r * 0.045f);
|
||||
fill.setColor(faceColour);
|
||||
c.drawPath(path, fill);
|
||||
}
|
||||
|
||||
/** Under the hub: a small caption, the value large, and a line below it. */
|
||||
private void label(Canvas c, float cx, float cy, float r, String caption, String value, String below,
|
||||
float valueSize) {
|
||||
text.setTextSize(r * 0.10f);
|
||||
text.setColor(AccentColor.withAlpha(cream, 0xB0));
|
||||
text.setLetterSpacing(0.15f);
|
||||
c.drawText(caption, cx, cy + r * 0.25f, text);
|
||||
text.setLetterSpacing(0f);
|
||||
text.setColor(cream);
|
||||
text.setTextSize(r * valueSize);
|
||||
c.drawText(value, cx, cy + r * 0.50f, text);
|
||||
if (below != null) {
|
||||
text.setTextSize(r * 0.11f);
|
||||
text.setColor(light);
|
||||
c.drawText(below, cx, cy + r * 0.67f, text);
|
||||
text.setColor(cream);
|
||||
}
|
||||
}
|
||||
|
||||
/** P R N D under the speed; the engaged one in the accent colour. */
|
||||
private void gears(Canvas c, float cx, float y, float r, float d) {
|
||||
String[] letters = { "P", "R", "N", "D" };
|
||||
text.setTextSize(r * 0.10f);
|
||||
float pitch = r * 0.13f;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
boolean on = v.gear != null && v.gear == i + 1;
|
||||
text.setColor(on ? light : AccentColor.withAlpha(cream, 0x55));
|
||||
text.setFakeBoldText(on);
|
||||
c.drawText(letters[i], cx + (i - 1.5f) * pitch, y + r * 0.10f, text);
|
||||
}
|
||||
text.setFakeBoldText(false);
|
||||
text.setColor(cream);
|
||||
}
|
||||
|
||||
/** The cream line along the bottom, items separated by small octagons. */
|
||||
private void drawLine(Canvas c, float w, float h, float d) {
|
||||
String[] items = {
|
||||
v.odometerKm == null ? "ODO —" : String.format(Locale.US, "ODO %,.0f km", v.odometerKm),
|
||||
v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm),
|
||||
v.usage5Min == null ? "— kWh/100 km" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min),
|
||||
v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius),
|
||||
v.clock.isEmpty() ? "—" : v.clock,
|
||||
};
|
||||
float y = h * 0.95f;
|
||||
text.setTextSize(20f * d);
|
||||
text.setLetterSpacing(0.08f);
|
||||
float pitch = w / items.length;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
c.drawText(items[i], pitch * (i + 0.5f), y, text);
|
||||
if (i > 0) {
|
||||
octagon(pitch * i, y - 7f * d, 5f * d);
|
||||
fill.setColor(accent);
|
||||
c.drawPath(path, fill);
|
||||
}
|
||||
}
|
||||
text.setLetterSpacing(0f);
|
||||
chrome.setColor(chromeDark);
|
||||
chrome.setStrokeWidth(d);
|
||||
c.drawLine(w * 0.06f, h * 0.885f, w * 0.94f, h * 0.885f, chrome);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* The Overview styles in the order of the Settings drop-down, the value each is stored as, and
|
||||
* how it takes colour:
|
||||
*
|
||||
* - {@link #usesAccent}: drawn in the chosen accent colour, so Settings offers the accent
|
||||
* colour for it. Styles with only their own fixed palette (the starship console, the cartoon,
|
||||
* the blueprint, the flight display, the nixie tubes) ignore it and Settings greys it out.
|
||||
* - {@link #hasOriginal}: has an original palette of its own as well (Retro's turquoise
|
||||
* display, the terminal's green phosphor); Settings then offers "Original colours" above the
|
||||
* accent colours, remembered per style under {@link #originalKey()}.
|
||||
*
|
||||
* Pure Java, unit-tested.
|
||||
*/
|
||||
enum OverviewStyle {
|
||||
TILES(true, false),
|
||||
GAUGES(true, false),
|
||||
COCKPIT(true, false),
|
||||
RETRO(true, true),
|
||||
FUTURISTIC(true, false),
|
||||
SCIFI(false, false),
|
||||
CARTOON(false, false),
|
||||
BLUEPRINT(false, false),
|
||||
MG(true, false),
|
||||
MINIMAL(true, false),
|
||||
TERMINAL(true, true),
|
||||
AVIATION(false, false),
|
||||
NIXIE(false, false),
|
||||
CHRONOGRAPH(true, false),
|
||||
MASCOT(true, false);
|
||||
|
||||
static final String PREF_KEY = "overview_style";
|
||||
|
||||
final boolean usesAccent;
|
||||
final boolean hasOriginal;
|
||||
|
||||
OverviewStyle(boolean usesAccent, boolean hasOriginal) {
|
||||
this.usesAccent = usesAccent;
|
||||
this.hasOriginal = hasOriginal;
|
||||
}
|
||||
|
||||
/** Where the choice between the original palette and the accent colour is kept. */
|
||||
String originalKey() {
|
||||
return "original_colours_" + name();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored style. Older values: "DASHBOARD" (Gauges before 2.22.3), and "RETRO_ACCENT" /
|
||||
* "TERMINAL_ACCENT" (separate entries before 2.27.2, now the style in the accent colour —
|
||||
* see {@link #wasAccentEntry}). Anything unknown is Tiles.
|
||||
*/
|
||||
static OverviewStyle fromPref(String stored) {
|
||||
if ("DASHBOARD".equals(stored)) return GAUGES;
|
||||
if ("RETRO_ACCENT".equals(stored)) return RETRO;
|
||||
if ("TERMINAL_ACCENT".equals(stored)) return TERMINAL;
|
||||
for (OverviewStyle style : values()) {
|
||||
if (style.name().equals(stored)) return style;
|
||||
}
|
||||
return TILES;
|
||||
}
|
||||
|
||||
/** A stored value from when the accent-coloured variant was its own entry. */
|
||||
static boolean wasAccentEntry(String stored) {
|
||||
return "RETRO_ACCENT".equals(stored) || "TERMINAL_ACCENT".equals(stored);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Retro Overview style: an 80s digital cluster, a vacuum fluorescent display behind smoked
|
||||
* glass. Slanted seven-segment digits ({@link SevenSegment}) with the unlit segments faintly
|
||||
* visible, bar graphs, amber labels and a lit P R N D selector.
|
||||
*
|
||||
* Centre: the speed, and power as a bar from −50 to 150 kW (regeneration left of zero, green).
|
||||
* Left: charge as a bar, the percentage and range. Right: 5-minute usage, the average since
|
||||
* start and the distance. Bottom: climate, clock, odometer. Unread values show dashes and light
|
||||
* no bar segments. The whole pane is one Canvas, redrawn only when a reading arrives.
|
||||
*
|
||||
* Two palettes: the classic one (turquoise display, amber labels), or the chosen accent colour
|
||||
* for the display and its light shade for the labels ({@link #setAccentPalette}). Regeneration
|
||||
* stays green in both; a low battery is red in the classic palette and amber in the accent one,
|
||||
* so it never looks like the accent itself.
|
||||
*/
|
||||
public final class RetroView extends View {
|
||||
|
||||
private static final float SKEW = 0.09f;
|
||||
|
||||
private final Paint ghost = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint litPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint label = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint panel = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint bezel = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int classicLit, classicLabel, classicWarn, accentLit, accentLabel, regen;
|
||||
private int lit, amber, warn;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
|
||||
public RetroView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
classicLit = context.getColor(R.color.retro_lit);
|
||||
classicLabel = context.getColor(R.color.retro_label);
|
||||
classicWarn = context.getColor(R.color.retro_warn);
|
||||
accentLit = AccentColor.resolve(context, R.attr.evAccent);
|
||||
accentLabel = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
regen = context.getColor(R.color.retro_regen);
|
||||
glow.setStyle(Paint.Style.STROKE);
|
||||
glow.setStrokeJoin(Paint.Join.ROUND);
|
||||
label.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.BOLD));
|
||||
label.setLetterSpacing(0.12f);
|
||||
panel.setColor(context.getColor(R.color.retro_panel));
|
||||
bezel.setStyle(Paint.Style.STROKE);
|
||||
bezel.setColor(context.getColor(R.color.retro_bezel));
|
||||
setAccentPalette(false);
|
||||
}
|
||||
|
||||
/** The chosen accent colour instead of the classic turquoise and amber. */
|
||||
void setAccentPalette(boolean accent) {
|
||||
lit = accent ? accentLit : classicLit;
|
||||
amber = accent ? accentLabel : classicLabel;
|
||||
warn = accent ? classicLabel : classicWarn;
|
||||
ghost.setColor(AccentColor.withAlpha(lit, 0x14));
|
||||
label.setColor(amber);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
|
||||
bezel.setStrokeWidth(2f * d);
|
||||
rect.set(d, d, w - d, h - d);
|
||||
c.drawRoundRect(rect, 18f * d, 18f * d, panel);
|
||||
c.drawRoundRect(rect, 18f * d, 18f * d, bezel);
|
||||
|
||||
drawCentre(c, w, h);
|
||||
drawLeft(c, w, h);
|
||||
drawRight(c, w, h);
|
||||
drawBottom(c, w, h, d);
|
||||
}
|
||||
|
||||
// ---------- Blocks ----------
|
||||
|
||||
private void drawCentre(Canvas c, float w, float h) {
|
||||
float cx = w / 2f;
|
||||
// The gear selector, the lit letter boxed.
|
||||
drawGears(c, cx, h * 0.06f, h * 0.085f);
|
||||
|
||||
float dh = h * 0.38f;
|
||||
String speed = v.speedKmh == null ? "---" : String.format(Locale.US, "%.0f", v.speedKmh);
|
||||
float right = cx + cellWidth(dh) * 1.5f;
|
||||
drawDigits(c, speed, 3, right, h * 0.19f, dh, lit);
|
||||
label(c, "KM/H", right + h * 0.02f, h * 0.19f + dh, h * 0.045f, Paint.Align.LEFT);
|
||||
|
||||
// Power: a bar from −50 to 150 kW in 5 kW segments, zero after the tenth.
|
||||
float left = w * 0.29f, barRight = w * 0.71f, top = h * 0.70f, bottom = h * 0.77f;
|
||||
label(c, "POWER", left, h * 0.67f, h * 0.04f, Paint.Align.LEFT);
|
||||
String kw = v.powerKw == null ? "---" : String.format(Locale.US, "%.1f", v.powerKw);
|
||||
label(c, "KW", barRight, h * 0.67f, h * 0.04f, Paint.Align.RIGHT);
|
||||
drawDigits(c, kw, 4, barRight - h * 0.075f, h * 0.605f, h * 0.065f,
|
||||
v.powerKw != null && v.powerKw < 0f ? regen : lit);
|
||||
int segments = 40, zero = 10;
|
||||
float pitch = (barRight - left) / segments;
|
||||
int litTo = v.powerKw == null ? zero : zero + Math.round(Math.max(-50f, Math.min(150f, v.powerKw)) / 5f);
|
||||
for (int i = 0; i < segments; i++) {
|
||||
boolean on = v.powerKw != null && (litTo > zero ? i >= zero && i < litTo : i >= litTo && i < zero);
|
||||
float x = left + i * pitch;
|
||||
float segTop = i == zero ? top - h * 0.015f : top;
|
||||
bar(c, x + pitch * 0.18f, segTop, x + pitch * 0.82f, bottom, on, i < zero ? regen : lit);
|
||||
}
|
||||
float[] marks = { -50f, 0f, 50f, 100f, 150f };
|
||||
for (float m : marks) {
|
||||
float x = left + (zero + m / 5f) * pitch + pitch / 2f;
|
||||
label(c, String.format(Locale.US, "%.0f", m), x, h * 0.83f, h * 0.035f, Paint.Align.CENTER);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawLeft(Canvas c, float w, float h) {
|
||||
float x0 = w * 0.035f;
|
||||
label(c, "CHARGE", x0, h * 0.10f, h * 0.045f, Paint.Align.LEFT);
|
||||
// A vertical bar of 20 segments, red below 20 %.
|
||||
float top = h * 0.16f, bottom = h * 0.82f, bw = w * 0.035f;
|
||||
int segments = 20;
|
||||
float pitch = (bottom - top) / segments;
|
||||
int on = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 5f);
|
||||
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float y = bottom - (i + 1) * pitch;
|
||||
bar(c, x0, y + pitch * 0.2f, x0 + bw, y + pitch * 0.8f, i < on, low ? warn : lit);
|
||||
}
|
||||
|
||||
float right = w * 0.235f;
|
||||
String soc = v.socPercent == null ? "---" : String.format(Locale.US, "%.0f", v.socPercent);
|
||||
drawDigits(c, soc, 3, right, h * 0.16f, h * 0.17f, low ? warn : lit);
|
||||
label(c, "%", right + h * 0.015f, h * 0.33f, h * 0.05f, Paint.Align.LEFT);
|
||||
label(c, "RANGE", x0 + bw + w * 0.03f, h * 0.47f, h * 0.04f, Paint.Align.LEFT);
|
||||
String range = v.rangeKm == null ? "---" : String.format(Locale.US, "%.0f", v.rangeKm);
|
||||
drawDigits(c, range, 3, right, h * 0.51f, h * 0.12f, lit);
|
||||
label(c, "KM", right + h * 0.015f, h * 0.63f, h * 0.04f, Paint.Align.LEFT);
|
||||
}
|
||||
|
||||
private void drawRight(Canvas c, float w, float h) {
|
||||
float right = w * 0.925f, x0 = w * 0.77f;
|
||||
label(c, "ECONOMY", x0, h * 0.10f, h * 0.045f, Paint.Align.LEFT);
|
||||
drawDigits(c, oneDecimal(v.usage5Min), 4, right, h * 0.16f, h * 0.17f, lit);
|
||||
label(c, "KWH/100KM 5 MIN", x0, h * 0.40f, h * 0.035f, Paint.Align.LEFT);
|
||||
label(c, "AVG", x0, h * 0.535f, h * 0.04f, Paint.Align.LEFT);
|
||||
drawDigits(c, oneDecimal(v.usageTrip), 4, right, h * 0.47f, h * 0.085f, lit);
|
||||
label(c, "TRIP", x0, h * 0.675f, h * 0.04f, Paint.Align.LEFT);
|
||||
drawDigits(c, oneDecimal(v.tripKm), 4, right, h * 0.61f, h * 0.085f, lit);
|
||||
label(c, "KM", right + h * 0.015f, h * 0.695f, h * 0.035f, Paint.Align.LEFT);
|
||||
}
|
||||
|
||||
private void drawBottom(Canvas c, float w, float h, float d) {
|
||||
bezel.setStrokeWidth(d);
|
||||
c.drawLine(w * 0.03f, h * 0.875f, w * 0.97f, h * 0.875f, bezel);
|
||||
float top = h * 0.90f, dh = h * 0.07f, base = top + dh;
|
||||
|
||||
label(c, "TEMP", w * 0.035f, base, h * 0.04f, Paint.Align.LEFT);
|
||||
String temp = v.climateCelsius == null ? "--" : String.format(Locale.US, "%.0f", v.climateCelsius);
|
||||
drawDigits(c, temp, 2, w * 0.035f + h * 0.30f, top, dh, lit);
|
||||
label(c, "°C", w * 0.035f + h * 0.315f, base, h * 0.04f, Paint.Align.LEFT);
|
||||
|
||||
// The clock: two pairs of digits and a colon.
|
||||
String clock = v.clock.length() == 5 ? v.clock : "--:--";
|
||||
float cx = w / 2f, cell = cellWidth(dh), colon = dh * 0.35f;
|
||||
drawDigits(c, clock.substring(0, 2), 2, cx - colon / 2f, top, dh, lit);
|
||||
drawDigits(c, clock.substring(3, 5), 2, cx + colon / 2f + 2 * cell, top, dh, lit);
|
||||
float t = dh * 0.13f;
|
||||
glowRect(c, cx - t / 2f + SKEW * dh * 0.3f, top + dh * 0.28f, t, lit);
|
||||
glowRect(c, cx - t / 2f - SKEW * dh * 0.1f, top + dh * 0.66f, t, lit);
|
||||
|
||||
String odo = v.odometerKm == null ? "------" : String.format(Locale.US, "%06.0f", v.odometerKm);
|
||||
float right = w * 0.925f;
|
||||
label(c, "ODO", right - 6 * cell - h * 0.02f, base, h * 0.04f, Paint.Align.RIGHT);
|
||||
drawDigits(c, odo, 6, right, top, dh, lit);
|
||||
label(c, "KM", right + h * 0.015f, base, h * 0.035f, Paint.Align.LEFT);
|
||||
}
|
||||
|
||||
private void drawGears(Canvas c, float cx, float top, float size) {
|
||||
String[] letters = { "P", "R", "N", "D" };
|
||||
float pitch = size * 1.6f, x0 = cx - pitch * 1.5f;
|
||||
label.setTextSize(size * 0.8f);
|
||||
label.setTextAlign(Paint.Align.CENTER);
|
||||
for (int i = 0; i < letters.length; i++) {
|
||||
float x = x0 + i * pitch;
|
||||
boolean on = v.gear != null && v.gear == i + 1;
|
||||
rect.set(x - size * 0.6f, top, x + size * 0.6f, top + size * 1.1f);
|
||||
if (on) {
|
||||
glow.setColor(AccentColor.withAlpha(lit, 0x40));
|
||||
glow.setStrokeWidth(size * 0.25f);
|
||||
c.drawRoundRect(rect, size * 0.12f, size * 0.12f, glow);
|
||||
litPaint.setColor(lit);
|
||||
c.drawRoundRect(rect, size * 0.12f, size * 0.12f, litPaint);
|
||||
label.setColor(0xFF000000);
|
||||
} else {
|
||||
c.drawRoundRect(rect, size * 0.12f, size * 0.12f, ghost);
|
||||
label.setColor(AccentColor.withAlpha(lit, 0x40));
|
||||
}
|
||||
c.drawText(letters[i], x, top + size * 0.85f, label);
|
||||
}
|
||||
label.setColor(amber);
|
||||
}
|
||||
|
||||
// ---------- Primitives ----------
|
||||
|
||||
private static String oneDecimal(Double value) {
|
||||
return value == null || value.isNaN() ? "---" : String.format(Locale.US, "%.1f", value);
|
||||
}
|
||||
|
||||
private void label(Canvas c, String text, float x, float baseline, float size, Paint.Align align) {
|
||||
label.setTextSize(size);
|
||||
label.setTextAlign(align);
|
||||
c.drawText(text, x, baseline, label);
|
||||
}
|
||||
|
||||
private static float cellWidth(float dh) {
|
||||
return dh * 0.72f;
|
||||
}
|
||||
|
||||
/** Text as seven-segment digits in {@code cells} cells ending at {@code right}. */
|
||||
private void drawDigits(Canvas c, String text, int cells, float right, float top, float dh, int colour) {
|
||||
char[] chars = SevenSegment.cells(text, cells);
|
||||
boolean[] dots = SevenSegment.dots(text, cells);
|
||||
float cw = cellWidth(dh);
|
||||
for (int i = 0; i < cells; i++) {
|
||||
float x = right - (cells - i) * cw;
|
||||
drawDigit(c, x, top, dh, SevenSegment.mask(chars[i]), dots[i], colour);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawDigit(Canvas c, float x, float y, float dh, int mask, boolean dot, int colour) {
|
||||
float wd = dh * 0.5f, t = dh * 0.12f, g = t * 0.18f;
|
||||
float yMid = y + dh / 2f, yBottom = y + dh;
|
||||
segment(c, true, x + g, x + wd - g, y + t / 2f, t, y, dh, (mask & SevenSegment.A) != 0, colour);
|
||||
segment(c, true, x + g, x + wd - g, yMid, t, y, dh, (mask & SevenSegment.G) != 0, colour);
|
||||
segment(c, true, x + g, x + wd - g, yBottom - t / 2f, t, y, dh, (mask & SevenSegment.D) != 0, colour);
|
||||
segment(c, false, y + t / 2f + g, yMid - g, x + t / 2f, t, y, dh, (mask & SevenSegment.F) != 0, colour);
|
||||
segment(c, false, yMid + g, yBottom - t / 2f - g, x + t / 2f, t, y, dh, (mask & SevenSegment.E) != 0, colour);
|
||||
segment(c, false, y + t / 2f + g, yMid - g, x + wd - t / 2f, t, y, dh, (mask & SevenSegment.B) != 0, colour);
|
||||
segment(c, false, yMid + g, yBottom - t / 2f - g, x + wd - t / 2f, t, y, dh, (mask & SevenSegment.C) != 0, colour);
|
||||
float dx = x + wd + dh * 0.06f + SKEW * t;
|
||||
if (dot) glowRect(c, dx, yBottom - t, t, colour);
|
||||
else { rect.set(dx, yBottom - t, dx + t, yBottom); c.drawRect(rect, ghost); }
|
||||
}
|
||||
|
||||
/**
|
||||
* One segment, a hexagon from {@code a} to {@code b} along its axis at {@code across}; slanted
|
||||
* to the right with the digit's height (top {@code y}, height {@code dh}).
|
||||
*/
|
||||
private void segment(Canvas c, boolean horizontal, float a, float b, float across, float t,
|
||||
float y, float dh, boolean on, int colour) {
|
||||
float h2 = t / 2f;
|
||||
path.reset();
|
||||
if (horizontal) {
|
||||
point(true, a, across, y, dh);
|
||||
point(false, a + h2, across - h2, y, dh);
|
||||
point(false, b - h2, across - h2, y, dh);
|
||||
point(false, b, across, y, dh);
|
||||
point(false, b - h2, across + h2, y, dh);
|
||||
point(false, a + h2, across + h2, y, dh);
|
||||
} else {
|
||||
point(true, across, a, y, dh);
|
||||
point(false, across + h2, a + h2, y, dh);
|
||||
point(false, across + h2, b - h2, y, dh);
|
||||
point(false, across, b, y, dh);
|
||||
point(false, across - h2, b - h2, y, dh);
|
||||
point(false, across - h2, a + h2, y, dh);
|
||||
}
|
||||
path.close();
|
||||
if (!on) {
|
||||
c.drawPath(path, ghost);
|
||||
return;
|
||||
}
|
||||
glow.setColor(AccentColor.withAlpha(colour, 0x38));
|
||||
glow.setStrokeWidth(t * 0.9f);
|
||||
c.drawPath(path, glow);
|
||||
litPaint.setColor(colour);
|
||||
c.drawPath(path, litPaint);
|
||||
}
|
||||
|
||||
private void point(boolean first, float px, float py, float y, float dh) {
|
||||
float x = px + SKEW * (y + dh - py);
|
||||
if (first) path.moveTo(x, py); else path.lineTo(x, py);
|
||||
}
|
||||
|
||||
private void bar(Canvas c, float l, float t, float r, float b, boolean on, int colour) {
|
||||
rect.set(l, t, r, b);
|
||||
if (!on) {
|
||||
c.drawRect(rect, ghost);
|
||||
return;
|
||||
}
|
||||
glow.setColor(AccentColor.withAlpha(colour, 0x38));
|
||||
glow.setStrokeWidth(Math.min(r - l, b - t) * 0.5f);
|
||||
c.drawRect(rect, glow);
|
||||
litPaint.setColor(colour);
|
||||
c.drawRect(rect, litPaint);
|
||||
}
|
||||
|
||||
private void glowRect(Canvas c, float x, float y, float size, int colour) {
|
||||
bar(c, x, y, x + size, y + size, true, colour);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Path;
|
||||
import android.graphics.RectF;
|
||||
import android.graphics.Typeface;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Sci-fi Overview style: a starship console. Rounded "elbow" frames in orange, lilac and
|
||||
* blue, pill-shaped labels, condensed capitals and blocky bar graphs on black.
|
||||
*
|
||||
* Left: velocity (the speed) and the power transfer as a bar of blocks, regeneration in blue to
|
||||
* the left of zero. Middle: labelled rows — reserves, range, efficiency, trip, climate. Right:
|
||||
* the reserves as a stack of blocks. The gears are blocks in the left frame, the odometer and
|
||||
* clock run along the bottom frame. Below 20 % charge the frames turn red ("red alert").
|
||||
* Unread values show "—" and light no blocks. Redrawn only when a reading arrives.
|
||||
*/
|
||||
public final class SciFiView extends View {
|
||||
|
||||
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Path path = new Path();
|
||||
private final RectF rect = new RectF();
|
||||
private final int orange, peach, lilac, blue, alert, off;
|
||||
private ClusterValues v = new ClusterValues();
|
||||
|
||||
public SciFiView(Context context, AttributeSet attrs) {
|
||||
super(context, attrs);
|
||||
orange = context.getColor(R.color.scifi_orange);
|
||||
peach = context.getColor(R.color.scifi_peach);
|
||||
lilac = context.getColor(R.color.scifi_lilac);
|
||||
blue = context.getColor(R.color.scifi_blue);
|
||||
alert = context.getColor(R.color.scifi_alert);
|
||||
off = context.getColor(R.color.scifi_off);
|
||||
text.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD));
|
||||
text.setFontFeatureSettings("tnum");
|
||||
}
|
||||
|
||||
void setValues(ClusterValues values) {
|
||||
v = values == null ? new ClusterValues() : values;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
float d = getResources().getDisplayMetrics().density;
|
||||
boolean redAlert = v.socPercent != null && v.socPercent < 20f;
|
||||
int frame = redAlert ? alert : orange, frame2 = redAlert ? alert : lilac;
|
||||
|
||||
// Frames: an elbow at the top left and one at the bottom left.
|
||||
float side = w * 0.085f, bar = h * 0.055f, gap = 6f * d;
|
||||
float split = h * 0.50f;
|
||||
// Each elbow is only its corner; blocks fill the side between them.
|
||||
float corner = bar + side * 0.9f + 12f * d;
|
||||
elbow(c, 0, 0, w * 0.60f, corner, side, bar, true, frame);
|
||||
elbow(c, 0, h - corner, w * 0.70f, h, side, bar, false, frame2);
|
||||
// The top bar's end: the title, then a pill.
|
||||
label(c, redAlert ? "RED ALERT · LOW RESERVES" : "PROPULSION STATUS", w * 0.61f + 10f * d,
|
||||
bar * 0.86f, bar * 1.05f, redAlert ? alert : orange, Paint.Align.LEFT);
|
||||
pill(c, w * 0.94f, 0, w, bar, frame2);
|
||||
// The bottom bar's end: odometer and time.
|
||||
String odo = v.odometerKm == null ? "—" : String.format(Locale.US, "%06.0f", v.odometerKm);
|
||||
label(c, "ODO " + odo + " · " + (v.clock.isEmpty() ? "—" : v.clock),
|
||||
w - 4f * d, h - bar * 0.14f, bar * 0.95f, peach, Paint.Align.RIGHT);
|
||||
|
||||
// The gears as blocks down the lower elbow.
|
||||
String[] gears = { "P", "R", "N", "D" };
|
||||
float gy = split + gap / 2f, gh = (h - corner - gap - gy - 3 * gap) / 4f;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
boolean on = v.gear != null && v.gear == i + 1;
|
||||
float top = gy + i * (gh + gap);
|
||||
fill.setColor(on ? peach : off);
|
||||
c.drawRect(0, top, side, top + gh, fill);
|
||||
label(c, gears[i], side - 8f * d, top + gh - 6f * d, gh * 0.7f, on ? 0xFF000000 : lilac, Paint.Align.RIGHT);
|
||||
}
|
||||
// Upper elbow: three numbered blocks, the console's decoration.
|
||||
float by = corner + gap, bh = (split - gap / 2f - by - 2 * gap) / 3f;
|
||||
int[] colours = { lilac, blue, peach };
|
||||
String[] codes = { "01-4471", "02-0815", "03-2026" };
|
||||
for (int i = 0; i < 3; i++) {
|
||||
float top = by + i * (bh + gap);
|
||||
fill.setColor(redAlert ? alert : colours[i]);
|
||||
c.drawRect(0, top, side, top + bh, fill);
|
||||
label(c, codes[i], side - 8f * d, top + bh - 6f * d, Math.min(bh * 0.4f, 16f * d), 0xFF000000, Paint.Align.RIGHT);
|
||||
}
|
||||
|
||||
float x0 = side + w * 0.035f;
|
||||
// Velocity, large.
|
||||
label(c, "VELOCITY · KM/H", x0, h * 0.17f, h * 0.045f, lilac, Paint.Align.LEFT);
|
||||
label(c, v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), x0 - 4f * d,
|
||||
h * 0.45f, h * 0.30f, orange, Paint.Align.LEFT);
|
||||
|
||||
// Power transfer: blocks from −50 to 150 kW in 10 kW steps.
|
||||
label(c, "POWER TRANSFER · KW", x0, h * 0.62f, h * 0.045f, lilac, Paint.Align.LEFT);
|
||||
String kw = v.powerKw == null ? "—" : String.format(Locale.US, "%.1f", v.powerKw);
|
||||
label(c, kw, w * 0.42f, h * 0.62f, h * 0.06f, v.powerKw != null && v.powerKw < 0f ? blue : peach, Paint.Align.RIGHT);
|
||||
int blocks = 20, zero = 5;
|
||||
float bx0 = x0, bx1 = w * 0.42f, pitch = (bx1 - bx0) / blocks;
|
||||
int litTo = v.powerKw == null ? zero : zero + Math.round(Math.max(-50f, Math.min(150f, v.powerKw)) / 10f);
|
||||
for (int i = 0; i < blocks; i++) {
|
||||
boolean on = v.powerKw != null && (litTo > zero ? i >= zero && i < litTo : i >= litTo && i < zero);
|
||||
fill.setColor(on ? (i < zero ? blue : i >= 15 ? alert : orange) : off);
|
||||
float x = bx0 + i * pitch;
|
||||
rect.set(x, h * 0.65f, x + pitch - 4f * d, h * 0.74f);
|
||||
c.drawRoundRect(rect, i == 0 || i == blocks - 1 ? 10f * d : 0f, 10f * d, fill);
|
||||
}
|
||||
label(c, "−50", bx0, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT);
|
||||
label(c, "0", bx0 + zero * pitch, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT);
|
||||
label(c, "150", bx1, h * 0.79f, h * 0.035f, lilac, Paint.Align.RIGHT);
|
||||
|
||||
// Middle: labelled rows.
|
||||
String[][] rows = {
|
||||
{ "RESERVES", v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent) },
|
||||
{ "RANGE", v.rangeKm == null ? "—" : String.format(Locale.US, "%.0f KM", v.rangeKm) },
|
||||
{ "EFFICIENCY 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f", v.usage5Min) },
|
||||
{ "SINCE START", v.usageTrip == null ? "—" : String.format(Locale.US, "%.1f", v.usageTrip) },
|
||||
{ "TRIP", v.tripKm == null ? "—" : String.format(Locale.US, "%.1f KM", v.tripKm) },
|
||||
{ "CLIMATE", v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius) },
|
||||
};
|
||||
float rx = w * 0.47f, rw = w * 0.15f, ry = h * 0.11f, rh = h * 0.085f, rg = h * 0.035f;
|
||||
for (int i = 0; i < rows.length; i++) {
|
||||
float top = ry + i * (rh + rg);
|
||||
int colour = redAlert && i == 0 ? alert : i % 2 == 0 ? lilac : blue;
|
||||
pill(c, rx, top, rx + rw, top + rh, colour);
|
||||
label(c, rows[i][0], rx + rw - 14f * d, top + rh * 0.72f, rh * 0.48f, 0xFF000000, Paint.Align.RIGHT);
|
||||
label(c, rows[i][1], rx + rw + 16f * d, top + rh * 0.82f, rh * 0.85f, i == 0 && redAlert ? alert : peach, Paint.Align.LEFT);
|
||||
}
|
||||
|
||||
// Right: the reserves as ten blocks, filled from the bottom.
|
||||
float sx0 = w * 0.84f, sx1 = w * 0.93f, sTop = h * 0.11f, sBottom = h * 0.80f;
|
||||
label(c, "RESERVES", (sx0 + sx1) / 2f, sTop - 8f * d, h * 0.04f, lilac, Paint.Align.CENTER);
|
||||
int on = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 10f);
|
||||
float sh = (sBottom - sTop) / 10f;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
float bottom = sBottom - i * sh;
|
||||
fill.setColor(i < on ? (redAlert ? alert : i % 2 == 0 ? blue : lilac) : off);
|
||||
rect.set(sx0, bottom - sh + 3f * d, sx1, bottom);
|
||||
c.drawRoundRect(rect, i == 9 || i == 0 ? sh * 0.4f : 0f, sh * 0.4f, fill);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame: a vertical bar down the left joined by a rounded outer corner to a horizontal
|
||||
* bar along the top (or the bottom), with a smaller rounded inner corner.
|
||||
*/
|
||||
private void elbow(Canvas c, float l, float t, float r, float b, float side, float bar, boolean top, int colour) {
|
||||
float outer = side * 0.9f, inner = bar * 1.2f;
|
||||
path.reset();
|
||||
if (top) {
|
||||
path.moveTo(l, b);
|
||||
path.lineTo(l, t + outer);
|
||||
path.quadTo(l, t, l + outer, t);
|
||||
path.lineTo(r, t);
|
||||
path.lineTo(r, t + bar);
|
||||
path.lineTo(l + side + inner, t + bar);
|
||||
path.quadTo(l + side, t + bar, l + side, t + bar + inner);
|
||||
path.lineTo(l + side, b);
|
||||
} else {
|
||||
path.moveTo(l, t);
|
||||
path.lineTo(l, b - outer);
|
||||
path.quadTo(l, b, l + outer, b);
|
||||
path.lineTo(r, b);
|
||||
path.lineTo(r, b - bar);
|
||||
path.lineTo(l + side + inner, b - bar);
|
||||
path.quadTo(l + side, b - bar, l + side, b - bar - inner);
|
||||
path.lineTo(l + side, t);
|
||||
}
|
||||
path.close();
|
||||
fill.setColor(colour);
|
||||
c.drawPath(path, fill);
|
||||
}
|
||||
|
||||
private void pill(Canvas c, float l, float t, float r, float b, int colour) {
|
||||
fill.setColor(colour);
|
||||
rect.set(l, t, r, b);
|
||||
c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill);
|
||||
}
|
||||
|
||||
private void label(Canvas c, String s, float x, float baseline, float size, int colour, Paint.Align align) {
|
||||
text.setTextSize(size);
|
||||
text.setColor(colour);
|
||||
text.setTextAlign(align);
|
||||
c.drawText(s, x, baseline, text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
/**
|
||||
* Seven-segment digits for the Retro style: which segments a character lights, and how text
|
||||
* is laid out in cells. Pure Java so it can be tested off the car.
|
||||
*
|
||||
* Segments as bits: a (top) 1, b (upper right) 2, c (lower right) 4, d (bottom) 8, e (lower
|
||||
* left) 16, f (upper left) 32, g (middle) 64.
|
||||
*/
|
||||
final class SevenSegment {
|
||||
|
||||
static final int A = 1, B = 2, C = 4, D = 8, E = 16, F = 32, G = 64;
|
||||
static final int ALL = 127;
|
||||
|
||||
private static final int[] DIGITS = {
|
||||
A | B | C | D | E | F, // 0
|
||||
B | C, // 1
|
||||
A | B | D | E | G, // 2
|
||||
A | B | C | D | G, // 3
|
||||
B | C | F | G, // 4
|
||||
A | C | D | F | G, // 5
|
||||
A | C | D | E | F | G, // 6
|
||||
A | B | C, // 7
|
||||
ALL, // 8
|
||||
A | B | C | D | F | G, // 9
|
||||
};
|
||||
|
||||
private SevenSegment() {}
|
||||
|
||||
/** The segments a character lights: digits and '-'; anything else lights none. */
|
||||
static int mask(char c) {
|
||||
if (c >= '0' && c <= '9') return DIGITS[c - '0'];
|
||||
if (c == '-') return G;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text as digit cells, right-aligned in {@code cells}: each cell's character, with
|
||||
* '.' folded into the cell before it (see {@link #dots}). Longer text keeps its right end.
|
||||
*/
|
||||
static char[] cells(String text, int cells) {
|
||||
char[] out = new char[cells];
|
||||
java.util.Arrays.fill(out, ' ');
|
||||
String digits = text == null ? "" : text.replace(".", "");
|
||||
int start = Math.max(0, digits.length() - cells);
|
||||
for (int i = start; i < digits.length(); i++) out[cells - (digits.length() - i)] = digits.charAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** For each cell of {@link #cells}, whether a decimal point follows it. */
|
||||
static boolean[] dots(String text, int cells) {
|
||||
boolean[] out = new boolean[cells];
|
||||
if (text == null) return out;
|
||||
// From the right: a '.' marks the digit to its left.
|
||||
int cell = cells;
|
||||
boolean pending = false;
|
||||
for (int i = text.length() - 1; i >= 0; i--) {
|
||||
if (text.charAt(i) == '.') { pending = true; continue; }
|
||||
if (--cell < 0) break;
|
||||
if (pending) { out[cell] = true; pending = false; }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
|
||||
import com.evsuite.hardware.probe.VendorSurfaceProbe;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The signal recorder on the Logs tab: for {@link #DURATION_MS} it reads every value the
|
||||
* head unit offers (`VendorSurfaceProbe.capture`: every CarPropertyManager property and every
|
||||
* value getter of the vendor objects, read only) again and again, and every steering-wheel key
|
||||
* the head unit reports, into a {@link SignalRecording}. The driver notes the times they do
|
||||
* what is to be found; the values that change then are the leads. (Built to find the adaptive
|
||||
* cruise control, 2.32.0: on SWI68 the head unit gets none of it, see SPECS.md.)
|
||||
*
|
||||
* A full capture is far heavier than the Overview's reading, so the next one waits at least
|
||||
* {@link #MIN_INTERVAL_MS} and at least as long as the last one took: the recorder uses at most
|
||||
* half the time. It runs on its own thread, on after the app leaves the screen (the service
|
||||
* keeps the process), and stops by itself. At the end the recording goes to the app's storage
|
||||
* and to the app's folder on every USB stick plugged in.
|
||||
*/
|
||||
final class SignalRecorder {
|
||||
|
||||
static final long DURATION_MS = 3 * 60_000L;
|
||||
static final long MIN_INTERVAL_MS = 2_000L;
|
||||
static final String FILE = "signal_recording.txt";
|
||||
|
||||
private static SignalRecording recording;
|
||||
private static volatile boolean running;
|
||||
private static long startedMs;
|
||||
private static String result;
|
||||
private static Thread thread;
|
||||
|
||||
private SignalRecorder() {}
|
||||
|
||||
static synchronized boolean running() {
|
||||
return running;
|
||||
}
|
||||
|
||||
static synchronized void start(Context context) {
|
||||
if (running) return;
|
||||
Context app = context.getApplicationContext();
|
||||
recording = new SignalRecording();
|
||||
result = null;
|
||||
startedMs = System.currentTimeMillis();
|
||||
running = true;
|
||||
thread = new Thread(() -> record(app), "signal-recorder");
|
||||
thread.start();
|
||||
}
|
||||
|
||||
static synchronized void stop() {
|
||||
running = false;
|
||||
if (thread != null) thread.interrupt();
|
||||
}
|
||||
|
||||
private static void record(Context app) {
|
||||
SignalRecording rec = recording;
|
||||
BroadcastReceiver keys = new BroadcastReceiver() {
|
||||
@Override public void onReceive(Context c, Intent intent) {
|
||||
rec.note(System.currentTimeMillis(), "steering-wheel key: " + extras(intent.getExtras()));
|
||||
}
|
||||
};
|
||||
boolean keysRegistered = false;
|
||||
try {
|
||||
androidx.core.content.ContextCompat.registerReceiver(app, keys,
|
||||
new IntentFilter(SteeringKeyListener.ACTION), androidx.core.content.ContextCompat.RECEIVER_EXPORTED);
|
||||
keysRegistered = true;
|
||||
} catch (RuntimeException ignored) {
|
||||
// No key reports: the properties are recorded all the same.
|
||||
}
|
||||
try {
|
||||
long end = startedMs + DURATION_MS;
|
||||
while (running && System.currentTimeMillis() < end) {
|
||||
long t0 = System.currentTimeMillis();
|
||||
Map<String, String> capture = VendorSurfaceProbe.INSTANCE.capture(Collections.emptyList());
|
||||
rec.add(t0, capture);
|
||||
long took = System.currentTimeMillis() - t0;
|
||||
Thread.sleep(Math.max(MIN_INTERVAL_MS - took, took));
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
// Stopped by the driver.
|
||||
} catch (Throwable t) {
|
||||
rec.note(System.currentTimeMillis(), "recorder stopped: " + t.getClass().getSimpleName());
|
||||
} finally {
|
||||
if (keysRegistered) {
|
||||
try {
|
||||
app.unregisterReceiver(keys);
|
||||
} catch (RuntimeException ignored) {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
String saved = save(app, rec);
|
||||
synchronized (SignalRecorder.class) {
|
||||
running = false;
|
||||
result = saved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String extras(Bundle extras) {
|
||||
if (extras == null) return "(no extras)";
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String k : new java.util.TreeSet<>(extras.keySet())) parts.add(k + "=" + extras.get(k));
|
||||
return String.join(", ", parts);
|
||||
}
|
||||
|
||||
/** Writes the recording; says where it went. */
|
||||
private static String save(Context app, SignalRecording rec) {
|
||||
List<String> text = new ArrayList<>();
|
||||
text.add("Signal recording, " + rec.captures() + " captures from "
|
||||
+ SignalRecording.time(startedMs) + ", app " + BuildConfig.VERSION_NAME);
|
||||
text.add("");
|
||||
text.add("Steering-wheel keys and notes:");
|
||||
text.addAll(rec.notes());
|
||||
text.add("");
|
||||
text.add("Changed values, the rarest first:");
|
||||
text.addAll(rec.summary());
|
||||
text.add("");
|
||||
text.add("Everything, in order:");
|
||||
text.addAll(rec.lines());
|
||||
List<String> places = new ArrayList<>();
|
||||
if (write(new File(app.getFilesDir(), FILE), text)) places.add("app");
|
||||
String name = "signal-recording-" + new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US)
|
||||
.format(new Date(startedMs)) + ".txt";
|
||||
File[] dirs = app.getExternalFilesDirs(null);
|
||||
for (File dir : dirs == null ? new File[0] : dirs) {
|
||||
try {
|
||||
if (dir == null || !Environment.isExternalStorageRemovable(dir)) continue;
|
||||
File file = new File(dir, name);
|
||||
if (write(file, text)) places.add(file.getPath());
|
||||
} catch (RuntimeException ignored) {
|
||||
// A volume that cannot say what it is gets nothing.
|
||||
}
|
||||
}
|
||||
return places.isEmpty() ? "not saved" : "saved: " + String.join(", ", places);
|
||||
}
|
||||
|
||||
private static boolean write(File file, List<String> text) {
|
||||
try (Writer out = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) {
|
||||
for (String line : text) out.write(line + "\n");
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the Logs tab shows: the state, then the changed values, the rarest first. */
|
||||
static synchronized List<String> status(Context context) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (recording == null) return out;
|
||||
long elapsed = (running ? System.currentTimeMillis() : startedMs + DURATION_MS) - startedMs;
|
||||
if (running) {
|
||||
out.add(context.getString(R.string.signal_recorder_running, elapsed / 60_000, (elapsed / 1000) % 60,
|
||||
recording.captures()));
|
||||
} else {
|
||||
out.add(context.getString(R.string.signal_recorder_done, SignalRecording.time(startedMs),
|
||||
recording.captures(), result == null ? "…" : result));
|
||||
}
|
||||
// Steering-wheel keys and the like first: few, and each one a lead.
|
||||
List<String> notes = recording.notes();
|
||||
out.addAll(notes.subList(Math.max(0, notes.size() - 10), notes.size()));
|
||||
out.addAll(recording.summary());
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeSet;
|
||||
|
||||
/**
|
||||
* One recording of the signal recorder ({@link SignalRecorder}): every value the head unit
|
||||
* offers, captured again and again while the driver does what is to be found (switches a
|
||||
* feature on and off, changes a setting). It keeps what changed, when, and per value how
|
||||
* often and through which values, so the value that follows the action stands out: it
|
||||
* changes a few times, at the moments the driver noted.
|
||||
* Pure Java, unit-tested.
|
||||
*/
|
||||
final class SignalRecording {
|
||||
|
||||
/** Distinct values kept per key for the summary. */
|
||||
static final int VALUES_KEPT = 6;
|
||||
/** Longest value text in the summary. */
|
||||
static final int VALUE_CHARS = 40;
|
||||
|
||||
private Map<String, String> last;
|
||||
private int captures;
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final List<String> notes = new ArrayList<>();
|
||||
private final Map<String, Integer> changes = new HashMap<>();
|
||||
private final Map<String, LinkedHashSet<String>> seen = new HashMap<>();
|
||||
|
||||
static String time(long ms) {
|
||||
return new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(ms));
|
||||
}
|
||||
|
||||
/** A capture: the first is kept whole, each later one as what changed against the one before. */
|
||||
synchronized void add(long ms, Map<String, String> capture) {
|
||||
captures++;
|
||||
if (last == null) {
|
||||
lines.add(time(ms) + " first capture, " + capture.size() + " values:");
|
||||
for (Map.Entry<String, String> e : capture.entrySet()) {
|
||||
lines.add(" " + e.getKey() + " = " + e.getValue());
|
||||
remember(e.getKey(), e.getValue());
|
||||
}
|
||||
} else {
|
||||
TreeSet<String> keys = new TreeSet<>(last.keySet());
|
||||
keys.addAll(capture.keySet());
|
||||
for (String key : keys) {
|
||||
String before = last.get(key), after = capture.get(key);
|
||||
if (before == null ? after == null : before.equals(after)) continue;
|
||||
lines.add(time(ms) + " " + key + ": " + before + " → " + after);
|
||||
changes.merge(key, 1, Integer::sum);
|
||||
remember(key, after);
|
||||
}
|
||||
}
|
||||
last = capture;
|
||||
}
|
||||
|
||||
/** Something else that happened, such as a steering-wheel key the head unit reported. */
|
||||
synchronized void note(long ms, String what) {
|
||||
lines.add(time(ms) + " " + what);
|
||||
notes.add(time(ms) + " " + what);
|
||||
}
|
||||
|
||||
/** The notes alone, in order. */
|
||||
synchronized List<String> notes() {
|
||||
return new ArrayList<>(notes);
|
||||
}
|
||||
|
||||
private void remember(String key, String value) {
|
||||
LinkedHashSet<String> values = seen.computeIfAbsent(key, k -> new LinkedHashSet<>());
|
||||
if (values.size() < VALUES_KEPT) values.add(shorten(String.valueOf(value)));
|
||||
}
|
||||
|
||||
static String shorten(String value) {
|
||||
return value.length() <= VALUE_CHARS ? value : value.substring(0, VALUE_CHARS - 1) + "…";
|
||||
}
|
||||
|
||||
synchronized int captures() {
|
||||
return captures;
|
||||
}
|
||||
|
||||
/** Everything, in order: the first capture, then each change and note. */
|
||||
synchronized List<String> lines() {
|
||||
return new ArrayList<>(lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every value that changed, the rarest first (a switch the driver flips changes a handful
|
||||
* of times; speed and power all the time): key, how often, the values.
|
||||
*/
|
||||
synchronized List<String> summary() {
|
||||
List<String> keys = new ArrayList<>(changes.keySet());
|
||||
keys.sort((a, b) -> {
|
||||
int c = Integer.compare(changes.get(a), changes.get(b));
|
||||
return c != 0 ? c : a.compareTo(b);
|
||||
});
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String key : keys) {
|
||||
out.add(key + " " + changes.get(key) + "× " + String.join(" → ", seen.get(key))
|
||||
+ (seen.get(key).size() >= VALUES_KEPT ? " …" : ""));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Canvas;
|
||||
import android.graphics.Paint;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.SystemClock;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The Terminal Overview style: text only, green phosphor on black, faint scan lines, as if a
|
||||
* shell printed the car's state — each value on a row with an ASCII bar, the gear as [D], and
|
||||
* a prompt with a blinking cursor (it blinks because readings redraw the view; it does not
|
||||
* animate on its own). A low battery turns its row amber. Unread values show "—" and an empty
|
||||
* bar.
|
||||
*
|
||||
* Two palettes: green phosphor, or the chosen accent colour ({@link #setAccentPalette}) — the
|
||||
* text in its light shade, the command line in the accent itself, on plain black. The low
|
||||
* battery stays amber in both.
|
||||
*/
|
||||
final class TerminalView extends ClusterCanvasView {
|
||||
|
||||
private static final int BAR = 24;
|
||||
|
||||
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||
private final Paint scan = new Paint();
|
||||
private final int accentText, accentDim;
|
||||
private final int amber = 0xFFFFB000;
|
||||
private int green, dim, background;
|
||||
|
||||
TerminalView(Context context) {
|
||||
super(context);
|
||||
accentText = AccentColor.resolve(context, R.attr.evAccentLight);
|
||||
accentDim = AccentColor.resolve(context, R.attr.evAccent);
|
||||
setAccentPalette(false);
|
||||
text.setTypeface(Typeface.MONOSPACE);
|
||||
scan.setColor(0x0CFFFFFF);
|
||||
}
|
||||
|
||||
/** The chosen accent colour instead of green phosphor. */
|
||||
void setAccentPalette(boolean accent) {
|
||||
green = accent ? accentText : 0xFF33FF66;
|
||||
dim = accent ? accentDim : 0xFF1C8A3A;
|
||||
background = accent ? 0xFF050505 : 0xFF020A04;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDraw(Canvas c) {
|
||||
super.onDraw(c);
|
||||
float w = getWidth(), h = getHeight();
|
||||
if (w <= 0 || h <= 0) return;
|
||||
c.drawColor(background);
|
||||
|
||||
// About 14 rows at 1.32 em each plus margins; monospace is about 0.6 em wide and the
|
||||
// longest row about 58 characters.
|
||||
float x = 16f * dp;
|
||||
float size = Math.min(h / 19.5f, (w - x) / 38f);
|
||||
text.setTextSize(size);
|
||||
float line = size * 1.32f, y = 12f * dp + line;
|
||||
String gear = gearRow();
|
||||
|
||||
print(c, "nils@mg4:~$ watch -n 0.2 vehicle-status", x, y, dim);
|
||||
y += line * 1.4f;
|
||||
print(c, row("SPEED", fmt(v.speedKmh, "%.0f km/h", "—")) + bar(v.speedKmh, 0f, 180f), x, y, green);
|
||||
y += line;
|
||||
print(c, row("POWER", fmt(v.powerKw, "%.1f kW", "—")) + powerBar(), x, y, green);
|
||||
y += line;
|
||||
boolean low = v.socPercent != null && v.socPercent < 20f;
|
||||
print(c, row("BATTERY", fmt(v.socPercent, "%.0f %%", "—")) + bar(v.socPercent, 0f, 100f)
|
||||
+ (low ? " ! LOW" : ""), x, y, low ? amber : green);
|
||||
y += line;
|
||||
print(c, row("RANGE", fmt(v.rangeKm, "%.0f km", "—")), x, y, green);
|
||||
y += line;
|
||||
print(c, row("GEAR", gear), x, y, green);
|
||||
y += line * 1.4f;
|
||||
print(c, row("USAGE", fmt(v.usage5Min, "%.1f kWh/100km", "—")) + " # last 5 min", x, y, green);
|
||||
y += line;
|
||||
print(c, row("SINCE", fmt(v.usageTrip, "%.1f kWh/100km", "—")) + " # trip "
|
||||
+ fmt(v.tripKm, "%.1f km", "—"), x, y, green);
|
||||
y += line;
|
||||
print(c, row("CLIMATE", fmt(v.climateCelsius, "%.0f C", "—")) + " ODO " + fmt(v.odometerKm, "%06.0f km", "—"), x, y, green);
|
||||
y += line;
|
||||
print(c, row("TYRES", "FL " + v.tyreFl + " FR " + v.tyreFr + " RL " + v.tyreRl + " RR " + v.tyreRr), x, y, green);
|
||||
y += line * 1.4f;
|
||||
boolean cursor = (SystemClock.uptimeMillis() / 500) % 2 == 0;
|
||||
print(c, "[" + (v.clock.isEmpty() ? "--:--" : v.clock) + "] nils@mg4:~$ " + (cursor ? "█" : ""), x, y, green);
|
||||
|
||||
// Scan lines over everything.
|
||||
for (float sy = 0; sy < h; sy += 3f * dp) c.drawLine(0, sy, w, sy, scan);
|
||||
}
|
||||
|
||||
private void print(Canvas c, String s, float x, float y, int colour) {
|
||||
text.setColor(colour);
|
||||
c.drawText(s, x, y, text);
|
||||
}
|
||||
|
||||
private static String row(String label, String value) {
|
||||
return String.format(Locale.US, "%-9s%-16s", label, value);
|
||||
}
|
||||
|
||||
/** [#####.......] from min to max; empty when unread. */
|
||||
private static String bar(Float value, float min, float max) {
|
||||
int n = value == null ? 0 : Math.round(BAR * (Math.max(min, Math.min(max, value)) - min) / (max - min));
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < BAR; i++) sb.append(i < n ? '#' : '.');
|
||||
return sb.append(']').toString();
|
||||
}
|
||||
|
||||
/** −50…150 kW with a | at zero: < for regeneration to its left, # for drawing to its right. */
|
||||
private String powerBar() {
|
||||
int zero = BAR / 4;
|
||||
int to = v.powerKw == null ? zero : zero + Math.round(BAR * Math.max(-50f, Math.min(150f, v.powerKw)) / 200f);
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < BAR; i++) {
|
||||
if (i == zero) sb.append('|');
|
||||
else if (v.powerKw != null && i < zero && i >= to) sb.append('<');
|
||||
else if (v.powerKw != null && i > zero && i <= to) sb.append('#');
|
||||
else sb.append('.');
|
||||
}
|
||||
return sb.append(']').toString();
|
||||
}
|
||||
|
||||
private String gearRow() {
|
||||
String[] letters = { "P", "R", "N", "D" };
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < 4; i++) {
|
||||
boolean on = v.gear != null && v.gear == i + 1;
|
||||
sb.append(on ? "[" + letters[i] + "]" : " " + letters[i] + " ");
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A choice's outline: the accent when it is the one in effect, the plain outline otherwise. -->
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android"><item android:state_selected="true" android:color="?attr/evAccentStroke"/><item android:color="@color/ev_outline"/></selector>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The handle at the left edge in full screen: where the navigation swipes back in. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/ev_text_secondary" />
|
||||
<corners android:radius="3dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The tab at the left edge of the Overview when the car's own bars are shown: a small dark
|
||||
tongue, rounded on the inside, inside its larger touch area. -->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android" android:insetRight="12dp">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#CC1B1D1E" />
|
||||
<stroke android:width="1dp" android:color="@color/ev_outline" />
|
||||
<corners android:topRightRadius="12dp" android:bottomRightRadius="12dp" />
|
||||
</shape>
|
||||
</inset>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The full-screen button in the navigation: a small dark disc inside its touch area. -->
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android" android:inset="16dp">
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#99121212" />
|
||||
<stroke android:width="1dp" android:color="@color/ev_outline" />
|
||||
</shape>
|
||||
</inset>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Points into the screen: the Overview's tab that brings the navigation back. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:pathData="M9,6 L15,12 L9,18" android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="@color/ev_text_secondary" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Expand, for the Overview's full-screen button (hides the navigation). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/ev_text_primary"
|
||||
android:pathData="M7,14H5v5h5v-2H7v-3zM5,10h2V7h3V5H5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2V5h-5z" />
|
||||
</vector>
|
||||
@@ -2,10 +2,16 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/app_root" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:background="@color/ev_background">
|
||||
<com.evsuite.abrp.PremiumBackdropView android:layout_width="match_parent" android:layout_height="match_parent" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="horizontal">
|
||||
<ScrollView android:layout_width="192dp" android:layout_height="match_parent" android:layout_margin="16dp" android:fillViewport="true" android:background="@drawable/bg_sidebar" android:scrollbars="none">
|
||||
<ScrollView android:id="@+id/nav_bar" android:layout_width="192dp" android:layout_height="match_parent" android:layout_margin="16dp" android:fillViewport="true" android:background="@drawable/bg_sidebar" android:scrollbars="none">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent" android:paddingStart="14dp" android:paddingTop="8dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Secondary" android:paddingStart="14dp" android:layout_marginBottom="24dp" />
|
||||
<!-- The brand, and beside it the button that hides this panel on the Overview. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:layout_marginBottom="24dp">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand" android:textAppearance="@style/Text.EV.Hero" android:textColor="?attr/evAccent" android:paddingStart="14dp" android:paddingTop="8dp" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_brand_caption" android:textAppearance="@style/Text.EV.Secondary" android:paddingStart="14dp" />
|
||||
</LinearLayout>
|
||||
<ImageButton android:id="@+id/overview_fullscreen" android:layout_width="64dp" android:layout_height="72dp" android:background="@drawable/bg_fullscreen_button" android:src="@drawable/ic_fullscreen" android:padding="22dp" android:scaleType="fitCenter" android:contentDescription="@string/overview_fullscreen_enter" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab" android:layout_width="match_parent" android:layout_height="64dp" android:layout_marginBottom="6dp" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_charging" />
|
||||
@@ -16,13 +22,13 @@
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
|
||||
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
|
||||
layouts). Version, update check and About are on the Settings tab. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="true" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="10dp" android:paddingBottom="2dp">
|
||||
<TextView android:id="@+id/header_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:textSize="24sp" android:maxLines="1" />
|
||||
<TextView android:id="@+id/header_subtitle" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" />
|
||||
<!-- The pages, and over them in the Overview's full screen the way back: a faint handle at the edge
|
||||
in the app's full screen, a tab when the car's own sidebar takes the edge. -->
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
|
||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="match_parent" />
|
||||
<View android:id="@+id/edge_handle" android:layout_width="5dp" android:layout_height="96dp" android:layout_gravity="start|center_vertical" android:layout_marginStart="4dp" android:background="@drawable/bg_edge_handle" android:alpha="0.35" android:visibility="gone" />
|
||||
<ImageButton android:id="@+id/edge_tab" android:layout_width="48dp" android:layout_height="96dp" android:layout_gravity="start|center_vertical" android:background="@drawable/bg_edge_tab" android:src="@drawable/ic_chevron_right" android:paddingEnd="16dp" android:paddingStart="4dp" android:scaleType="center" android:visibility="gone" android:contentDescription="@string/overview_fullscreen_exit" />
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -10,29 +10,57 @@
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<!-- The Overview style, and next to it the accent colour, which only styles drawn in it offer. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="2" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_overview_style" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/overview_style_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:maxLines="1" android:ellipsize="end" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/accent_field" style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:maxLines="1" android:ellipsize="end" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:baselineAligned="false">
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginEnd="12dp" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_weight="3" android:layout_height="wrap_content" android:orientation="vertical">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<!-- Only for the Mascot style: who sits in it. -->
|
||||
<LinearLayout android:id="@+id/mascot_field" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:orientation="vertical" android:visibility="invisible">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_mascot_character" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/mascot_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:maxLines="1" android:ellipsize="end" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_overview_style" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/overview_style_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:layout_marginEnd="16dp">
|
||||
<!-- The drive mode: the one vehicle setting the app writes, only in P (DriveModeWrite). -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginBottom="16dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
|
||||
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/drive_mode_at_start_switch" style="@style/Widget.EV.Switch" android:layout_width="wrap_content" android:layout_height="48dp" android:minHeight="48dp" android:layout_marginStart="16dp" android:text="@string/drive_mode_at_start" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="8dp">
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
|
||||
</LinearLayout>
|
||||
<TextView android:id="@+id/drive_mode_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:maxLines="2" android:ellipsize="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/consumption_soh_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_manual_soh" app:suffixText="@string/unit_percent">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/consumption_soh_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="5" android:minHeight="72dp"/>
|
||||
@@ -47,9 +75,6 @@
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/usable_capacity_save" style="@style/Widget.EV.Button" android:layout_width="wrap_content" android:layout_height="72dp" android:layout_marginStart="12dp" android:text="@string/btn_save_capacity"/>
|
||||
</LinearLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:text="@string/helper_usable_capacity" android:textAppearance="@style/Text.EV.Secondary"/>
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass" android:layout_marginTop="12dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_price_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="false">
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/charge_price_layout" style="@style/Widget.Material3.TextInputLayout.OutlinedBox" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:hint="@string/settings_unit_price" app:suffixText="@string/unit_eur_per_kwh">
|
||||
<com.google.android.material.textfield.TextInputEditText android:id="@+id/charge_price_input" android:layout_width="match_parent" android:layout_height="wrap_content" android:inputType="numberDecimal" android:maxLength="10" android:minHeight="72dp"/>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/app_root" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" android:background="@color/ev_background">
|
||||
<com.evsuite.abrp.PremiumBackdropView android:layout_width="match_parent" android:layout_height="match_parent"/>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical">
|
||||
<!-- One compact line: the Overview has to fit the car's 1778×720 px window (about 640 dp for
|
||||
layouts). Version, update check and About are on the Settings tab. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:baselineAligned="true" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="10dp" android:paddingBottom="2dp">
|
||||
<TextView android:id="@+id/header_title" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/design_overview_title" android:textAppearance="@style/Text.EV.Title" android:textSize="24sp" android:maxLines="1" />
|
||||
<TextView android:id="@+id/header_subtitle" android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:text="@string/design_overview_subtitle" android:textAppearance="@style/Text.EV.Secondary" android:maxLines="1" android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1"/>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="8dp" android:background="@drawable/bg_sidebar">
|
||||
<!-- The pages, and over them in the Overview's full screen the way back: a faint handle at the edge
|
||||
in the app's full screen, a tab when the car's own sidebar takes the edge. -->
|
||||
<FrameLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1">
|
||||
<androidx.viewpager2.widget.ViewPager2 android:id="@+id/content" android:layout_width="match_parent" android:layout_height="match_parent"/>
|
||||
<View android:id="@+id/edge_handle" android:layout_width="5dp" android:layout_height="96dp" android:layout_gravity="start|center_vertical" android:layout_marginStart="4dp" android:background="@drawable/bg_edge_handle" android:alpha="0.35" android:visibility="gone"/>
|
||||
<ImageButton android:id="@+id/edge_tab" android:layout_width="48dp" android:layout_height="96dp" android:layout_gravity="start|center_vertical" android:background="@drawable/bg_edge_tab" android:src="@drawable/ic_chevron_right" android:paddingEnd="16dp" android:paddingStart="4dp" android:scaleType="center" android:visibility="gone" android:contentDescription="@string/overview_fullscreen_exit"/>
|
||||
</FrameLayout>
|
||||
<LinearLayout android:id="@+id/nav_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:padding="8dp" android:background="@drawable/bg_sidebar">
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabVehicle" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_vehicle" app:icon="@drawable/ic_tab_vehicle"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabConsumption" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_consumption" app:icon="@drawable/ic_tab_vehicle"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabCharging" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_charging" app:icon="@drawable/ic_tab_charging"/>
|
||||
@@ -17,6 +17,7 @@
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabServer" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_server" app:icon="@drawable/ic_tab_server"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabSettings" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_settings" app:icon="@drawable/ic_tab_service"/>
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/tabLog" style="@style/Widget.EV.NavTab.Compact" android:layout_width="0dp" android:layout_height="76dp" android:layout_weight="1" android:text="@string/tab_log" app:icon="@drawable/ic_tab_log"/>
|
||||
<ImageButton android:id="@+id/overview_fullscreen" android:layout_width="64dp" android:layout_height="76dp" android:background="@drawable/bg_fullscreen_button" android:src="@drawable/ic_fullscreen" android:padding="22dp" android:scaleType="fitCenter" android:contentDescription="@string/overview_fullscreen_enter"/>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Overview as a holographic HUD (Settings → Overview style → Futuristic), drawn by FlowView. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
|
||||
<com.evsuite.abrp.FlowView android:id="@+id/flow_view" android:layout_width="match_parent" android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -1,6 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.evsuite.abrp.StableScrollView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fillViewport="true" android:clipToPadding="false">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
|
||||
<!-- The signal recorder (SignalRecorder): every value the head unit offers, recorded over 3 minutes. -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:layout_marginBottom="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_signal_recorder" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/signal_recorder_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/signal_record_button" android:layout_width="match_parent" android:layout_height="72dp" android:layout_marginTop="16dp" android:text="@string/btn_signal_record" style="@style/Widget.EV.Button.Outlined" />
|
||||
<TextView android:id="@+id/signal_record_text" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:textAppearance="@style/Text.EV.Body" android:fontFamily="monospace" android:textSize="17sp" android:lineSpacingExtra="8dp" android:textIsSelectable="true" android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="16dp" android:paddingBottom="16dp" android:background="@drawable/bg_glass">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_call_log" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/flush_now_button" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/btn_flush_now" app:icon="@drawable/ic_refresh" app:iconGravity="textStart" style="@style/Widget.EV.Button.Outlined" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Overview as an 80s digital cluster (Settings → Overview style → Retro), drawn by RetroView. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
|
||||
<com.evsuite.abrp.RetroView android:id="@+id/retro_view" android:layout_width="match_parent" android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
@@ -7,18 +7,42 @@
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_full_screen" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/voice_key_switch" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:text="@string/label_voice_key" android:textAppearance="@style/Text.EV.Body" android:paddingTop="8dp" android:paddingBottom="8dp" app:useMaterialThemeColors="true" app:thumbTint="@color/switch_thumb" app:trackTint="@color/switch_track" android:textColor="@color/ev_text_primary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/hint_voice_key" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_overview_style" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/overview_style_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<!-- Only styles drawn in the accent colour offer it. -->
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout android:id="@+id/accent_field" style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:id="@+id/accent_note" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textAppearance="@style/Text.EV.Secondary" android:visibility="gone" />
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_tire_unit" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/tire_unit_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_accent" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/accent_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_overview_style" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="8dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/overview_style_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
<!-- Only for the Mascot style: who sits in it. -->
|
||||
<LinearLayout android:id="@+id/mascot_field" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:visibility="gone">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/label_mascot_character" android:textAppearance="@style/Text.EV.Body" android:layout_marginTop="20dp" />
|
||||
<com.google.android.material.textfield.TextInputLayout style="@style/Widget.EV.Dropdown" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="12dp">
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView android:id="@+id/mascot_choices" android:layout_width="match_parent" android:layout_height="wrap_content" android:minHeight="72dp" android:inputType="none" android:maxLines="1" android:ellipsize="end" android:textSize="20sp" app:simpleItemLayout="@layout/dropdown_item" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
<!-- The drive mode: the one vehicle setting the app writes, only in P (DriveModeWrite). -->
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical">
|
||||
<TextView android:layout_width="0dp" android:layout_weight="1" android:layout_height="wrap_content" android:text="@string/label_drive_mode" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/drive_mode_at_start_switch" style="@style/Widget.EV.Switch" android:layout_width="wrap_content" android:layout_height="48dp" android:minHeight="48dp" android:layout_marginStart="16dp" android:text="@string/drive_mode_at_start" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:layout_marginTop="8dp">
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_eco" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_eco" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_normal" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_normal" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_sport" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_sport" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_snow" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="6dp" android:text="@string/drive_mode_snow" />
|
||||
<com.google.android.material.button.MaterialButton android:id="@+id/drive_mode_custom" style="@style/Widget.EV.Choice" android:layout_width="0dp" android:layout_height="56dp" android:layout_weight="1" android:text="@string/drive_mode_custom" />
|
||||
</LinearLayout>
|
||||
<TextView android:id="@+id/drive_mode_status" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:maxLines="2" android:ellipsize="end" android:text="@string/drive_mode_hint" android:textAppearance="@style/Text.EV.Secondary" />
|
||||
</LinearLayout>
|
||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="24dp" android:background="@drawable/bg_glass" android:layout_marginTop="16dp">
|
||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/design_battery_settings_title" android:textAppearance="@style/Text.EV.Eyebrow" />
|
||||
|
||||
@@ -21,4 +21,33 @@
|
||||
<color name="ev_pressed_strong">#28FFFFFF</color>
|
||||
<color name="ev_icon_tile">#121212</color>
|
||||
<color name="ev_transparent">#00000000</color>
|
||||
<!-- The Retro Overview style: a vacuum fluorescent display behind smoked glass. -->
|
||||
<color name="retro_lit">#4DF5D0</color>
|
||||
<color name="retro_regen">#8CFF6A</color>
|
||||
<color name="retro_label">#FFB13D</color>
|
||||
<color name="retro_warn">#FF5C4D</color>
|
||||
<color name="retro_panel">#060B0A</color>
|
||||
<color name="retro_bezel">#1E2B28</color>
|
||||
<!-- The Sci-fi Overview style: a starship console. -->
|
||||
<color name="scifi_orange">#FF9966</color>
|
||||
<color name="scifi_peach">#FFCC99</color>
|
||||
<color name="scifi_lilac">#CC99CC</color>
|
||||
<color name="scifi_blue">#9999FF</color>
|
||||
<color name="scifi_alert">#FF4D4D</color>
|
||||
<color name="scifi_off">#2A2530</color>
|
||||
<!-- The Cartoon Overview style: flat colours, thick outlines, hard shadows. -->
|
||||
<color name="cartoon_night">#1C1F3B</color>
|
||||
<color name="cartoon_yellow">#FFD23F</color>
|
||||
<color name="cartoon_cyan">#3BCEAC</color>
|
||||
<color name="cartoon_pink">#EE4266</color>
|
||||
<color name="cartoon_cream">#FFF5D6</color>
|
||||
<color name="cartoon_ink">#111111</color>
|
||||
<!-- The Blueprint Overview style: white lines on a navy drawing sheet. -->
|
||||
<color name="blueprint_sheet">#0A2240</color>
|
||||
<color name="blueprint_line">#E3EEFF</color>
|
||||
<!-- The MG Overview style: octagonal dials after the classic MG dashboards. -->
|
||||
<color name="mg_face">#0D0D0F</color>
|
||||
<color name="mg_cream">#F2E8D5</color>
|
||||
<color name="mg_chrome_light">#D5DADF</color>
|
||||
<color name="mg_chrome_dark">#5A6066</color>
|
||||
</resources>
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
<string name="tab_vehicle">Overview</string>
|
||||
<string name="tab_consumption">Consumption</string>
|
||||
<string name="tab_charging">Charging</string>
|
||||
<string name="consumption_title">Consumption</string>
|
||||
<string name="consumption_subtitle">Since start, motor, lifetime and trip counters</string>
|
||||
<string name="charging_title">Charging</string>
|
||||
<string name="charging_subtitle">Live battery values and SOC/power curve</string>
|
||||
<string name="tab_server">Server</string>
|
||||
<string name="tab_settings">Settings</string>
|
||||
<string name="tab_log">Logs</string>
|
||||
@@ -43,7 +39,7 @@
|
||||
<string name="usage_last_5_min">over the last 5 minutes</string>
|
||||
<string name="hint_usable_capacity">Usable battery capacity</string>
|
||||
<string name="btn_save_capacity">Save capacity</string>
|
||||
<string name="helper_usable_capacity">Used for the range estimates on the Overview. The 51 kWh MG4 battery has 50.8 kWh usable, the 64 kWh one 61.7, the 77 kWh one 74.4.</string>
|
||||
<string name="helper_usable_capacity">For range estimates. Usable kWh: 50.8 (51 kWh), 61.7 (64), 74.4 (77).</string>
|
||||
<string name="usable_capacity_saved">Usable capacity saved</string>
|
||||
<string name="usable_capacity_invalid">Enter a value between 10 and 150 kWh</string>
|
||||
<string name="vehicle_row_speed">Speed</string>
|
||||
@@ -128,6 +124,128 @@
|
||||
<string name="dash_total">TOTAL %1$s</string>
|
||||
<string name="dash_trip">TRIP %1$s</string>
|
||||
<string name="overview_style_cockpit">Cockpit</string>
|
||||
<string name="overview_style_retro">Retro</string>
|
||||
<string name="overview_style_futuristic">Futuristic</string>
|
||||
<string name="overview_style_scifi">Sci-fi</string>
|
||||
<string name="overview_style_cartoon">Cartoon</string>
|
||||
<string name="overview_style_blueprint">Blueprint</string>
|
||||
<string name="overview_style_mg">MG</string>
|
||||
<string name="accent_not_used">%1$s has its own colours. To change the accent colour, pick a style that uses it.</string>
|
||||
<string name="overview_fullscreen_enter">Overview full screen: hide the navigation</string>
|
||||
<string name="overview_fullscreen_exit">Show the navigation again</string>
|
||||
<string name="overview_style_minimal">Minimal</string>
|
||||
<string name="overview_style_terminal">Terminal</string>
|
||||
<string name="accent_original">Original</string>
|
||||
<string name="overview_style_aviation">Aviation</string>
|
||||
<string name="overview_style_nixie">Nixie tubes</string>
|
||||
<string name="overview_style_chronograph">Chronograph</string>
|
||||
<string name="overview_style_mascot">Mascot</string>
|
||||
<string name="label_mascot_character">Mascot</string>
|
||||
<string name="mascot_character_classic">Classic</string>
|
||||
<string name="mascot_character_chibi">Chibi</string>
|
||||
<string name="mascot_character_fox">Fox</string>
|
||||
<!-- The fox's lines, per mood like mascot_*. -->
|
||||
<string-array name="fox_waiting">
|
||||
<item>Kon? Anyone there?</item>
|
||||
<item>Sniff sniff… no car yet.</item>
|
||||
</string-array>
|
||||
<string-array name="fox_reverse">
|
||||
<item>Backing up, ears up!</item>
|
||||
<item>Mind my tail!</item>
|
||||
</string-array>
|
||||
<string-array name="fox_worried">
|
||||
<item>Battery\'s low, kon…</item>
|
||||
<item>Find a charger, please!</item>
|
||||
</string-array>
|
||||
<string-array name="fox_sleeping">
|
||||
<item>Curled up in P… zzz</item>
|
||||
<item>Fox nap time.</item>
|
||||
</string-array>
|
||||
<string-array name="fox_cheering">
|
||||
<item>Kon kon! Free energy!</item>
|
||||
<item>Tail-wagging regen!</item>
|
||||
</string-array>
|
||||
<string-array name="fox_determined">
|
||||
<item>Pounce!</item>
|
||||
<item>Full speed, kon!</item>
|
||||
</string-array>
|
||||
<string-array name="fox_fast">
|
||||
<item>Ears back, here we go!</item>
|
||||
<item>Zoomies!</item>
|
||||
</string-array>
|
||||
<string-array name="fox_content">
|
||||
<item>Kon! Nice drive.</item>
|
||||
<item>Happy tail, happy fox.</item>
|
||||
<item>Sniffing the road ahead.</item>
|
||||
</string-array>
|
||||
<!-- The chibi's lines, per mood like mascot_*. -->
|
||||
<string-array name="chibi_waiting">
|
||||
<item>Hellooo? Car?</item>
|
||||
<item>Is the car awake yet?</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_reverse">
|
||||
<item>Beep beep, backing up!</item>
|
||||
<item>Eyes on the back!</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_worried">
|
||||
<item>The battery\'s hungry!</item>
|
||||
<item>Snack time for the car?</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_sleeping">
|
||||
<item>Zzz… five more minutes…</item>
|
||||
<item>Napping in P…</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_cheering">
|
||||
<item>Free energy! Yay!</item>
|
||||
<item>Regen power!</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_determined">
|
||||
<item>Zoom zoom!</item>
|
||||
<item>Full throttle!</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_fast">
|
||||
<item>Weeee, so fast!</item>
|
||||
<item>Wind in my hair!</item>
|
||||
</string-array>
|
||||
<string-array name="chibi_content">
|
||||
<item>La la la, driving~</item>
|
||||
<item>Comfy cruising!</item>
|
||||
<item>Vroom vroom!</item>
|
||||
</string-array>
|
||||
<!-- The Mascot style's co-pilot: a line per mood (MascotMood), one picked every 30 s. -->
|
||||
<string-array name="mascot_waiting">
|
||||
<item>Waiting for the car…</item>
|
||||
<item>Hello! Is anyone there?</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_reverse">
|
||||
<item>Careful behind us…</item>
|
||||
<item>I\'ll watch the back!</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_worried">
|
||||
<item>Battery\'s getting low…</item>
|
||||
<item>Maybe a charger soon?</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_sleeping">
|
||||
<item>Zz… wake me when we drive.</item>
|
||||
<item>Parked. Nap time.</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_cheering">
|
||||
<item>Free energy, yay!</item>
|
||||
<item>Charging on the go!</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_determined">
|
||||
<item>Full power!</item>
|
||||
<item>Let\'s go!</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_fast">
|
||||
<item>Wheee, motorway!</item>
|
||||
<item>I love this speed!</item>
|
||||
</string-array>
|
||||
<string-array name="mascot_content">
|
||||
<item>Nice and easy.</item>
|
||||
<item>Enjoying the drive.</item>
|
||||
<item>Smooth driving!</item>
|
||||
</string-array>
|
||||
<string name="cockpit_battery">BATTERY</string>
|
||||
<string name="cockpit_usage">USAGE · 5 MIN</string>
|
||||
<string name="cockpit_usage_unit">kWh/100 km</string>
|
||||
@@ -185,6 +303,34 @@
|
||||
|
||||
<!-- Call log -->
|
||||
<string name="label_call_log">Recent uploads</string>
|
||||
<string name="label_drive_mode">DRIVE MODE</string>
|
||||
<string name="drive_mode_eco">Eco</string>
|
||||
<string name="drive_mode_normal">Normal</string>
|
||||
<string name="drive_mode_sport">Sport</string>
|
||||
<string name="drive_mode_snow">Snow</string>
|
||||
<string name="drive_mode_custom">Custom</string>
|
||||
<string name="drive_mode_hint">Only in P at a standstill. At start: your last confirmed mode.</string>
|
||||
<string name="drive_mode_at_start">Set at start</string>
|
||||
<string name="drive_mode_start_applied">At start (%1$s): %2$s set.</string>
|
||||
<string name="drive_mode_start_already">At start (%1$s): %2$s was already set.</string>
|
||||
<string name="drive_mode_start_not_applied">At start (%1$s): %2$s not applied, the car reports %3$s.</string>
|
||||
<string name="drive_mode_start_unread">At start (%1$s): %2$s sent, not read back.</string>
|
||||
<string name="drive_mode_start_refused">At start (%1$s): %2$s not accepted by the car.</string>
|
||||
<string name="drive_mode_setting">Setting %1$s…</string>
|
||||
<string name="drive_mode_applied">%1$s set: the car reports it now.</string>
|
||||
<string name="drive_mode_not_applied">%1$s not applied: the car still reports %2$s.</string>
|
||||
<string name="drive_mode_unread_back">%1$s sent, but the car\'s mode could not be read back.</string>
|
||||
<string name="drive_mode_write_failed">%1$s not set: the car did not accept the change.</string>
|
||||
<string name="drive_mode_not_in_p">Not changed: shift to P first.</string>
|
||||
<string name="drive_mode_moving">Not changed: only at a standstill.</string>
|
||||
<string name="drive_mode_unreadable">Not changed: speed or gear unreadable.</string>
|
||||
<string name="drive_mode_unknown">an unknown mode (%1$s)</string>
|
||||
<string name="label_signal_recorder">Signal recorder</string>
|
||||
<string name="signal_recorder_hint">Records every value the head unit offers for 3 minutes, plus the steering-wheel keys it reports, and lists what changed, the rarest first. To find a signal: start it, note the times you switch the feature on and off or change it, and look for the values that changed then. With a USB stick plugged in, the whole recording is saved on it too (Android/data/…/files).</string>
|
||||
<string name="btn_signal_record">Record 3 minutes</string>
|
||||
<string name="btn_signal_stop">Stop recording</string>
|
||||
<string name="signal_recorder_running">Recording: %1$d:%2$02d of 3:00, %3$d captures. Keys reported, then values changed so far, the rarest first:</string>
|
||||
<string name="signal_recorder_done">Recording from %1$s: %2$d captures, %3$s. Keys reported, then values changed, the rarest first:</string>
|
||||
<string name="call_log_empty">No upload attempted yet.</string>
|
||||
<string name="update_log_title">Updates, newest first:</string>
|
||||
<string name="btn_flush_now">Upload now</string>
|
||||
|
||||
@@ -5,16 +5,6 @@
|
||||
<string name="design_brand">MG⁴</string>
|
||||
<string name="design_brand_caption">Nils’ MG4</string>
|
||||
<string name="design_brand_footer">ON THE ROAD, ELECTRIC</string>
|
||||
<string name="design_overview_title">Your drive at a glance.</string>
|
||||
<string name="design_overview_subtitle">Data from your car, all in one place.</string>
|
||||
<string name="design_server_title">Where your data goes.</string>
|
||||
<string name="design_server_subtitle">Your own server stores it; the car sends live data to ABRP itself.</string>
|
||||
<string name="design_service_title">Live data.</string>
|
||||
<string name="design_service_subtitle">Where your car\'s live data goes, and how often.</string>
|
||||
<string name="design_settings_title">Settings.</string>
|
||||
<string name="design_settings_subtitle">Display, battery, charging price and updates.</string>
|
||||
<string name="design_log_title">Every upload, right here.</string>
|
||||
<string name="design_log_subtitle">Status and details of recent uploads.</string>
|
||||
<string name="design_battery">BATTERY</string>
|
||||
<string name="design_energy">VEHICLE INFO</string>
|
||||
<string name="design_tires">TIRE PRESSURES</string>
|
||||
@@ -26,6 +16,7 @@
|
||||
<string name="design_battery_settings_title">BATTERY</string>
|
||||
<string name="design_app_title">APP</string>
|
||||
<string name="design_price_title">CHARGING PRICE</string>
|
||||
<string name="design_battery_price_title">BATTERY AND CHARGING PRICE</string>
|
||||
<string name="design_phone_title">ON YOUR PHONE</string>
|
||||
<string name="design_service_timing">UPDATE FREQUENCY</string>
|
||||
<string name="design_car_desc">Tire pressure positions on the vehicle</string>
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
<item name="cornerRadius">18dp</item>
|
||||
<item name="elevation">0dp</item>
|
||||
</style>
|
||||
<!-- One of a row of choices, highlighted (isSelected) like the navigation's current page. -->
|
||||
<style name="Widget.EV.Choice" parent="Widget.EV.NavTab">
|
||||
<item name="android:paddingStart">4dp</item>
|
||||
<item name="android:paddingEnd">4dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:minHeight">56dp</item>
|
||||
<item name="strokeColor">@color/choice_stroke</item>
|
||||
</style>
|
||||
<style name="Widget.EV.NavTab.Compact">
|
||||
<item name="android:paddingStart">2dp</item>
|
||||
<item name="android:paddingEnd">2dp</item>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The drive mode write's rules — pure JVM. */
|
||||
public class DriveModeWriteTest {
|
||||
|
||||
@Test
|
||||
public void onlyInPAtAStandstill() {
|
||||
assertEquals(DriveModeWrite.Verdict.ALLOWED, DriveModeWrite.check(0f, 1));
|
||||
assertEquals(DriveModeWrite.Verdict.NOT_IN_P, DriveModeWrite.check(0f, 4));
|
||||
assertEquals(DriveModeWrite.Verdict.NOT_IN_P, DriveModeWrite.check(0f, 3));
|
||||
assertEquals(DriveModeWrite.Verdict.NOT_IN_P, DriveModeWrite.check(30f, 2));
|
||||
assertEquals(DriveModeWrite.Verdict.MOVING, DriveModeWrite.check(0.5f, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadableIsRefused() {
|
||||
assertEquals(DriveModeWrite.Verdict.UNREADABLE, DriveModeWrite.check(null, 1));
|
||||
assertEquals(DriveModeWrite.Verdict.UNREADABLE, DriveModeWrite.check(0f, null));
|
||||
assertEquals(DriveModeWrite.Verdict.UNREADABLE, DriveModeWrite.check(Float.NaN, 1));
|
||||
assertEquals(DriveModeWrite.Verdict.UNREADABLE, DriveModeWrite.check(-1f, 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resultIsWhatTheCarReports() {
|
||||
assertEquals(DriveModeWrite.Outcome.APPLIED, DriveModeWrite.outcome(4, 4));
|
||||
assertEquals(DriveModeWrite.Outcome.NOT_APPLIED, DriveModeWrite.outcome(4, 2));
|
||||
assertEquals(DriveModeWrite.Outcome.UNREAD, DriveModeWrite.outcome(4, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atStartWritesOnceTheChosenModeInP() {
|
||||
assertEquals(DriveModeWrite.StartStep.WRITE, DriveModeWrite.atStart(4, true, 1, 0f, 2, 5_000));
|
||||
assertEquals(DriveModeWrite.StartStep.ALREADY, DriveModeWrite.atStart(4, true, 1, 0f, 4, 5_000));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atStartWaitsForReadings() {
|
||||
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, null, 0f, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, 1, null, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.WAIT, DriveModeWrite.atStart(4, true, 1, 0f, null, 1_000));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atStartGivesUp() {
|
||||
// Nothing chosen (or a code not offered), switched off, too late.
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(null, true, 1, 0f, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(5, true, 1, 0f, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, false, 1, 0f, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP,
|
||||
DriveModeWrite.atStart(4, true, 1, 0f, 2, DriveModeWrite.START_WINDOW_MS + 1));
|
||||
// Driven off: out of P, or moving.
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, true, 4, 0f, 2, 1_000));
|
||||
assertEquals(DriveModeWrite.StartStep.GIVE_UP, DriveModeWrite.atStart(4, true, 1, 3f, 2, 1_000));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buttonsByCode() {
|
||||
assertEquals(0, DriveModeWrite.indexOf(2));
|
||||
assertEquals(2, DriveModeWrite.indexOf(4));
|
||||
assertEquals(4, DriveModeWrite.indexOf(7));
|
||||
assertEquals(-1, DriveModeWrite.indexOf(5));
|
||||
assertEquals(-1, DriveModeWrite.indexOf(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The swipe from the left edge that brings the navigation back — pure JVM. */
|
||||
public class EdgeSwipeTest {
|
||||
|
||||
@Test
|
||||
public void swipeFromTheEdgeCounts() {
|
||||
EdgeSwipe swipe = new EdgeSwipe(48f, 80f);
|
||||
swipe.down(10f, 300f);
|
||||
assertFalse(swipe.move(50f, 305f));
|
||||
assertTrue(swipe.move(95f, 310f));
|
||||
// Only once per touch.
|
||||
assertFalse(swipe.move(200f, 310f));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void touchAwayFromTheEdgeIsIgnored() {
|
||||
EdgeSwipe swipe = new EdgeSwipe(48f, 80f);
|
||||
swipe.down(200f, 300f);
|
||||
assertFalse(swipe.move(400f, 300f));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void verticalScrollIsNotTheSwipe() {
|
||||
EdgeSwipe swipe = new EdgeSwipe(48f, 80f);
|
||||
swipe.down(10f, 100f);
|
||||
assertFalse(swipe.move(30f, 200f));
|
||||
// Gone sideways afterwards: still the scroll.
|
||||
assertFalse(swipe.move(150f, 210f));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void leftwardOrLiftedIsNotTheSwipe() {
|
||||
EdgeSwipe swipe = new EdgeSwipe(48f, 80f);
|
||||
swipe.down(40f, 300f);
|
||||
assertFalse(swipe.move(0f, 300f));
|
||||
swipe.up();
|
||||
assertFalse(swipe.move(200f, 300f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The Mascot style's moods — pure JVM. */
|
||||
public class MascotMoodTest {
|
||||
|
||||
private static ClusterValues values(Integer gear, Float speed, Float soc, Float power) {
|
||||
ClusterValues v = new ClusterValues();
|
||||
v.gear = gear;
|
||||
v.speedKmh = speed;
|
||||
v.socPercent = soc;
|
||||
v.powerKw = power;
|
||||
return v;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nothingReadIsWaiting() {
|
||||
assertEquals(MascotMood.WAITING, MascotMood.of(null));
|
||||
assertEquals(MascotMood.WAITING, MascotMood.of(new ClusterValues()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void moodsByPriority() {
|
||||
assertEquals(MascotMood.REVERSE, MascotMood.of(values(2, 3f, 10f, 5f)));
|
||||
assertEquals(MascotMood.WORRIED, MascotMood.of(values(4, 50f, 15f, 10f)));
|
||||
assertEquals(MascotMood.WORRIED, MascotMood.of(values(1, 0f, 15f, 0f)));
|
||||
assertEquals(MascotMood.SLEEPING, MascotMood.of(values(1, 0f, 80f, 0f)));
|
||||
assertEquals(MascotMood.CHEERING, MascotMood.of(values(4, 120f, 80f, -20f)));
|
||||
assertEquals(MascotMood.DETERMINED, MascotMood.of(values(4, 60f, 80f, 90f)));
|
||||
assertEquals(MascotMood.FAST, MascotMood.of(values(4, 110f, 80f, 25f)));
|
||||
assertEquals(MascotMood.CONTENT, MascotMood.of(values(4, 50f, 80f, 10f)));
|
||||
assertEquals(MascotMood.CONTENT, MascotMood.of(values(3, 0f, 80f, 0f)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unreadValuesAreNoReason() {
|
||||
// Speed unread, battery unread: in D she is content, not worried or fast.
|
||||
assertEquals(MascotMood.CONTENT, MascotMood.of(values(4, null, null, 10f)));
|
||||
assertEquals(MascotMood.CONTENT, MascotMood.of(values(null, 50f, null, null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void holderWaitsOutShortChanges() {
|
||||
MascotMood.Holder holder = new MascotMood.Holder();
|
||||
assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CONTENT, 0));
|
||||
assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 100));
|
||||
assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CONTENT, 300));
|
||||
assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 400));
|
||||
assertEquals(MascotMood.CONTENT, holder.update(MascotMood.CHEERING, 1800));
|
||||
assertEquals(MascotMood.CHEERING, holder.update(MascotMood.CHEERING, 1900));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reverseAndWaitingShowAtOnce() {
|
||||
MascotMood.Holder holder = new MascotMood.Holder();
|
||||
holder.update(MascotMood.SLEEPING, 0);
|
||||
assertEquals(MascotMood.REVERSE, holder.update(MascotMood.REVERSE, 10));
|
||||
assertEquals(MascotMood.WAITING, holder.update(MascotMood.WAITING, 20));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void characterFromPref() {
|
||||
assertEquals(MascotCharacter.CHIBI, MascotCharacter.fromPref("CHIBI"));
|
||||
assertEquals(MascotCharacter.FOX, MascotCharacter.fromPref("FOX"));
|
||||
assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref("CLASSIC"));
|
||||
assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref(null));
|
||||
assertEquals(MascotCharacter.CLASSIC, MascotCharacter.fromPref("NOPE"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The Overview styles — pure JVM. */
|
||||
public class OverviewStyleTest {
|
||||
|
||||
@Test
|
||||
public void storedValuesRoundTrip() {
|
||||
for (OverviewStyle style : OverviewStyle.values()) {
|
||||
assertEquals(style, OverviewStyle.fromPref(style.name()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void oldAndUnknownValues() {
|
||||
assertEquals(OverviewStyle.GAUGES, OverviewStyle.fromPref("DASHBOARD"));
|
||||
assertEquals(OverviewStyle.RETRO, OverviewStyle.fromPref("RETRO_ACCENT"));
|
||||
assertEquals(OverviewStyle.TERMINAL, OverviewStyle.fromPref("TERMINAL_ACCENT"));
|
||||
assertTrue(OverviewStyle.wasAccentEntry("RETRO_ACCENT"));
|
||||
assertFalse(OverviewStyle.wasAccentEntry("RETRO"));
|
||||
assertEquals(OverviewStyle.TILES, OverviewStyle.fromPref(null));
|
||||
assertEquals(OverviewStyle.TILES, OverviewStyle.fromPref("NOPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void colourOptions() {
|
||||
assertTrue(OverviewStyle.TILES.usesAccent);
|
||||
assertFalse(OverviewStyle.TILES.hasOriginal);
|
||||
assertTrue(OverviewStyle.RETRO.usesAccent);
|
||||
assertTrue(OverviewStyle.RETRO.hasOriginal);
|
||||
assertTrue(OverviewStyle.TERMINAL.hasOriginal);
|
||||
assertFalse(OverviewStyle.CARTOON.usesAccent);
|
||||
assertFalse(OverviewStyle.NIXIE.usesAccent);
|
||||
assertTrue(OverviewStyle.MASCOT.usesAccent);
|
||||
assertEquals("original_colours_RETRO", OverviewStyle.RETRO.originalKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** Seven-segment digits for the Retro style — pure JVM. */
|
||||
public class SevenSegmentTest {
|
||||
|
||||
@Test
|
||||
public void digitsLightTheRightSegments() {
|
||||
assertEquals(SevenSegment.ALL, SevenSegment.mask('8'));
|
||||
assertEquals(SevenSegment.B | SevenSegment.C, SevenSegment.mask('1'));
|
||||
assertEquals(SevenSegment.ALL & ~SevenSegment.G, SevenSegment.mask('0'));
|
||||
assertEquals(SevenSegment.G, SevenSegment.mask('-'));
|
||||
assertEquals(0, SevenSegment.mask(' '));
|
||||
assertEquals(0, SevenSegment.mask('x'));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void textIsRightAligned() {
|
||||
assertArrayEquals(new char[] { ' ', '8', '7' }, SevenSegment.cells("87", 3));
|
||||
assertArrayEquals(new char[] { '1', '2', '3' }, SevenSegment.cells("9123", 3));
|
||||
assertArrayEquals(new char[] { ' ', ' ', ' ' }, SevenSegment.cells(null, 3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decimalPointsFoldIntoTheDigitBefore() {
|
||||
assertArrayEquals(new char[] { '1', '5', '8' }, SevenSegment.cells("15.8", 3));
|
||||
assertArrayEquals(new boolean[] { false, true, false }, SevenSegment.dots("15.8", 3));
|
||||
assertArrayEquals(new boolean[] { false, false, true, false }, SevenSegment.dots("5.8", 4));
|
||||
assertArrayEquals(new boolean[] { false, false }, SevenSegment.dots("42", 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.evsuite.abrp;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
/** The signal recorder's bookkeeping — pure JVM. */
|
||||
public class SignalRecordingTest {
|
||||
|
||||
private static Map<String, String> capture(String speed, String feature) {
|
||||
Map<String, String> m = new TreeMap<>();
|
||||
m.put("speed", speed);
|
||||
m.put("feature", feature);
|
||||
m.put("steady", "1");
|
||||
return m;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rarestChangeFirst() {
|
||||
SignalRecording r = new SignalRecording();
|
||||
r.add(0, capture("10", "0"));
|
||||
r.add(2000, capture("20", "0"));
|
||||
r.add(4000, capture("30", "120"));
|
||||
r.add(6000, capture("40", "120"));
|
||||
r.add(8000, capture("50", "0"));
|
||||
List<String> summary = r.summary();
|
||||
assertEquals(2, summary.size());
|
||||
assertEquals("feature 2× 0 → 120", summary.get(0));
|
||||
assertTrue(summary.get(1).startsWith("speed 4× 10 → 20 → 30 → 40 → 50"));
|
||||
assertEquals(5, r.captures());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appearingAndVanishingKeysAreChanges() {
|
||||
SignalRecording r = new SignalRecording();
|
||||
Map<String, String> a = capture("1", "0");
|
||||
Map<String, String> b = capture("1", "0");
|
||||
b.put("new", "5");
|
||||
r.add(0, a);
|
||||
r.add(1000, b);
|
||||
r.add(2000, a);
|
||||
assertEquals("new 2× 5 → null", r.summary().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void longValuesAreShortened() {
|
||||
String v = "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]";
|
||||
assertEquals(SignalRecording.VALUE_CHARS, SignalRecording.shorten(v).length());
|
||||
assertEquals("short", SignalRecording.shorten("short"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstCaptureIsKeptWhole() {
|
||||
SignalRecording r = new SignalRecording();
|
||||
r.add(0, capture("1", "0"));
|
||||
r.note(500, "key 287 down");
|
||||
assertEquals(5, r.lines().size());
|
||||
assertTrue(r.lines().get(4).endsWith("key 287 down"));
|
||||
assertEquals(1, r.notes().size());
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 162 KiB |