App 2.26.0: Overview styles Minimal, Terminal, Aviation, Nixie, Chronograph

Five more styles that differ in arrangement as well as look: the speed
alone with hairlines; a green text terminal with ASCII bars; a flight
display with tapes and a horizon that pitches with power; nixie tubes on
walnut and brass; a watch face with sub-dials. They share a new base,
ClusterCanvasView. Emulator only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-24 20:47:43 +02:00
co-authored by Claude Opus 5.5
parent 1af5b0efe7
commit 7902d8f941
17 changed files with 946 additions and 7 deletions
+1 -1
View File
@@ -69,7 +69,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`),
| Tab | Contents | | Tab | Contents |
|---|---| |---|---|
| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, classic or in the accent colour), 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); the drawn styles share `ClusterValues` | | 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, classic or in the accent colour), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph; the drawn styles share `ClusterValues` |
| Consumption / Charging | period totals, daily log, charging sessions and curve | | Consumption / Charging | period totals, daily log, charging sessions and curve |
| Live data | on/off, ABRP API key and user token, upload frequency | | Live data | on/off, ABRP API key and user token, upload frequency |
| Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | | Server | URL, pairing by QR, ingest token, installation ID (read-only), config import |
+1 -1
View File
@@ -79,7 +79,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| Tab | What it shows | | Tab | What it shows |
|---|---| |---|---|
| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (classic turquoise or in your accent colour), Futuristic, Sci-fi, Cartoon, Blueprint and MG | | **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 (classic turquoise or in your accent colour), Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes and Chronograph |
| **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 | | **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 | | **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 | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency |
+20
View File
@@ -131,6 +131,26 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does *
confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces
upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator`
(Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked).
- 2026-10-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`), - 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 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, own driver display, and without MG's octagon logo. Three octagonal dials with chrome bezels,
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId "com.stinnesbeck.nilsmg4" applicationId "com.stinnesbeck.nilsmg4"
minSdk 27 minSdk 27
targetSdk 36 targetSdk 36
versionCode 119 versionCode 120
versionName "2.25.0" versionName "2.26.0"
// Nothing about the server ships in the APK: its URL and ingest token are entered in // Nothing about the server ships in the APK: its URL and ingest token are entered in
// the app's Server tab (ServerConfig), and the database is only reachable by the server. // the app's Server tab (ServerConfig), and the database is only reachable by the server.
@@ -0,0 +1,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,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);
}
}
@@ -66,6 +66,8 @@ public class MainActivity extends AppCompatActivity {
private CartoonView cartoonView; private CartoonView cartoonView;
private BlueprintView blueprintView; private BlueprintView blueprintView;
private OctagonView octagonView; private OctagonView octagonView;
/** The styles built on {@link ClusterCanvasView}, fed together. */
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
/** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */ /** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */
private View[] stylePanes; private View[] stylePanes;
private View consumptionPane; private View consumptionPane;
@@ -254,8 +256,13 @@ public class MainActivity extends AppCompatActivity {
octagonView = new OctagonView(this, null); octagonView = new OctagonView(this, null);
View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView),
blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView); blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView);
java.util.Collections.addAll(canvasStyles, new MinimalView(this), new TerminalView(this),
new AviationView(this), new NixieView(this), new ChronographView(this));
View[] canvasPanes = new View[canvasStyles.size()];
for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i));
stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, retroPane, stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, retroPane,
futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane }; futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane,
canvasPanes[0], canvasPanes[1], canvasPanes[2], canvasPanes[3], canvasPanes[4] };
// The Overview page holds every style; Settings → Overview style picks the one shown. // The Overview page holds every style; Settings → Overview style picks the one shown.
android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this);
overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams(
@@ -270,6 +277,7 @@ public class MainActivity extends AppCompatActivity {
overviewPage.addView(cartoonPane); overviewPage.addView(cartoonPane);
overviewPage.addView(blueprintPane); overviewPage.addView(blueprintPane);
overviewPage.addView(mgPane); overviewPage.addView(mgPane);
for (View pane : canvasPanes) overviewPage.addView(pane);
consumptionPane = inflatePane(R.layout.pane_consumption); consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging); chargingPane = inflatePane(R.layout.pane_charging);
settingsPane = inflatePane(R.layout.pane_settings); settingsPane = inflatePane(R.layout.pane_settings);
@@ -1454,7 +1462,7 @@ public class MainActivity extends AppCompatActivity {
/** The stored values of the Overview styles, in the order of the drop-down. */ /** The stored values of the Overview styles, in the order of the drop-down. */
private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC", private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC",
"SCIFI", "CARTOON", "BLUEPRINT", "MG" }; "SCIFI", "CARTOON", "BLUEPRINT", "MG", "MINIMAL", "TERMINAL", "AVIATION", "NIXIE", "CHRONOGRAPH" };
/** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */ /** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */
private int overviewStyle() { private int overviewStyle() {
@@ -1477,7 +1485,10 @@ public class MainActivity extends AppCompatActivity {
getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro), getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro),
getString(R.string.overview_style_retro_accent), getString(R.string.overview_style_futuristic), getString(R.string.overview_style_retro_accent), getString(R.string.overview_style_futuristic),
getString(R.string.overview_style_scifi), getString(R.string.overview_style_cartoon), 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_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) };
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(), bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(),
position -> { position -> {
prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply(); prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply();
@@ -1540,6 +1551,7 @@ public class MainActivity extends AppCompatActivity {
cartoonView.setValues(v); cartoonView.setValues(v);
blueprintView.setValues(v); blueprintView.setValues(v);
octagonView.setValues(v); octagonView.setValues(v);
for (ClusterCanvasView view : canvasStyles) view.setValues(v);
} }
// ---------- Cockpit (Overview style) ---------- // ---------- Cockpit (Overview style) ----------
@@ -0,0 +1,92 @@
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);
text(c, v.clock.isEmpty() ? "—" : v.clock, w - 8f * 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,119 @@
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.
*/
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 green, dim, amber;
TerminalView(Context context) {
super(context);
green = 0xFF33FF66;
dim = 0xFF1C8A3A;
amber = 0xFFFFB000;
text.setTypeface(Typeface.MONOSPACE);
scan.setColor(0x0CFFFFFF);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
c.drawColor(0xFF020A04);
// 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 size = Math.min(h / 19.5f, w / 38f);
text.setTextSize(size);
float line = size * 1.32f, x = 16f * dp, 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();
}
}
+5
View File
@@ -131,6 +131,11 @@
<string name="overview_style_cartoon">Cartoon</string> <string name="overview_style_cartoon">Cartoon</string>
<string name="overview_style_blueprint">Blueprint</string> <string name="overview_style_blueprint">Blueprint</string>
<string name="overview_style_mg">MG</string> <string name="overview_style_mg">MG</string>
<string name="overview_style_minimal">Minimal</string>
<string name="overview_style_terminal">Terminal</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="cockpit_battery">BATTERY</string> <string name="cockpit_battery">BATTERY</string>
<string name="cockpit_usage">USAGE · 5 MIN</string> <string name="cockpit_usage">USAGE · 5 MIN</string>
<string name="cockpit_usage_unit">kWh/100 km</string> <string name="cockpit_usage_unit">kWh/100 km</string>
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB