diff --git a/AGENTS.md b/AGENTS.md index fb9fe76..9ad4005 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | Tab | Contents | |---|---| -| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, 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 | | 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 | diff --git a/README.md b/README.md index 5c5f52a..02fb4a6 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo | Tab | What it shows | |---|---| -| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (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 | | **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | diff --git a/SPECS.md b/SPECS.md index 9f7d907..685e6a5 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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 upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-24 — App 2.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, diff --git a/app/build.gradle b/app/build.gradle index 2d1bcd3..27ee57b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 119 - versionName "2.25.0" + versionCode 120 + versionName "2.26.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/AviationView.java b/app/src/main/java/com/evsuite/abrp/AviationView.java new file mode 100644 index 0000000..0234bda --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/AviationView.java @@ -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); + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ChronographView.java b/app/src/main/java/com/evsuite/abrp/ChronographView.java new file mode 100644 index 0000000..8897377 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChronographView.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ClusterCanvasView.java b/app/src/main/java/com/evsuite/abrp/ClusterCanvasView.java new file mode 100644 index 0000000..d3cf898 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ClusterCanvasView.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 45bbdf2..3075415 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -66,6 +66,8 @@ public class MainActivity extends AppCompatActivity { private CartoonView cartoonView; private BlueprintView blueprintView; private OctagonView octagonView; + /** The styles built on {@link ClusterCanvasView}, fed together. */ + private final java.util.List canvasStyles = new java.util.ArrayList<>(); /** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */ private View[] stylePanes; private View consumptionPane; @@ -254,8 +256,13 @@ public class MainActivity extends AppCompatActivity { octagonView = new OctagonView(this, null); View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), 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, - 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. android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( @@ -270,6 +277,7 @@ public class MainActivity extends AppCompatActivity { 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); @@ -1454,7 +1462,7 @@ public class MainActivity extends AppCompatActivity { /** 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", - "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. */ 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_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_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(), position -> { prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply(); @@ -1540,6 +1551,7 @@ public class MainActivity extends AppCompatActivity { cartoonView.setValues(v); blueprintView.setValues(v); octagonView.setValues(v); + for (ClusterCanvasView view : canvasStyles) view.setValues(v); } // ---------- Cockpit (Overview style) ---------- diff --git a/app/src/main/java/com/evsuite/abrp/MinimalView.java b/app/src/main/java/com/evsuite/abrp/MinimalView.java new file mode 100644 index 0000000..1133784 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/MinimalView.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/NixieView.java b/app/src/main/java/com/evsuite/abrp/NixieView.java new file mode 100644 index 0000000..3a0da24 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/NixieView.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/TerminalView.java b/app/src/main/java/com/evsuite/abrp/TerminalView.java new file mode 100644 index 0000000..dd88f1d --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/TerminalView.java @@ -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(); + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a7c746a..73fe13b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,11 @@ Cartoon Blueprint MG + Minimal + Terminal + Aviation + Nixie tubes + Chronograph BATTERY USAGE · 5 MIN kWh/100 km diff --git a/screenshots/nilsmg4-2.26.0-aviation.png b/screenshots/nilsmg4-2.26.0-aviation.png new file mode 100644 index 0000000..e55b351 Binary files /dev/null and b/screenshots/nilsmg4-2.26.0-aviation.png differ diff --git a/screenshots/nilsmg4-2.26.0-chronograph.png b/screenshots/nilsmg4-2.26.0-chronograph.png new file mode 100644 index 0000000..2e96f70 Binary files /dev/null and b/screenshots/nilsmg4-2.26.0-chronograph.png differ diff --git a/screenshots/nilsmg4-2.26.0-minimal.png b/screenshots/nilsmg4-2.26.0-minimal.png new file mode 100644 index 0000000..1935b8a Binary files /dev/null and b/screenshots/nilsmg4-2.26.0-minimal.png differ diff --git a/screenshots/nilsmg4-2.26.0-nixie.png b/screenshots/nilsmg4-2.26.0-nixie.png new file mode 100644 index 0000000..e94e18d Binary files /dev/null and b/screenshots/nilsmg4-2.26.0-nixie.png differ diff --git a/screenshots/nilsmg4-2.26.0-terminal.png b/screenshots/nilsmg4-2.26.0-terminal.png new file mode 100644 index 0000000..eeba19b Binary files /dev/null and b/screenshots/nilsmg4-2.26.0-terminal.png differ