diff --git a/AGENTS.md b/AGENTS.md index 786414c..42eaa83 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.5 s on a background thread. Three styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace) | +| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.2 s on a background thread. Five styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD), Futuristic (`FlowView`, animated energy flow) | | 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 65e59a6..bad9809 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 every half second | +| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Five looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro and Futuristic | | **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 16f3a51..273edef 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,22 @@ 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.23.0 (branch `styles-retro-future`): two more Overview styles, each one + Canvas view fed a `ClusterValues` per reading. **Retro** (`RetroView`): an 80s digital cluster + as a vacuum fluorescent display — slanted seven-segment digits (`SevenSegment`, unit-tested) + with the unlit segments faintly visible, amber labels, a lit P R N D selector, the speed large, + power as a −50…150 kW bar (regeneration green), charge as a vertical bar (red below 20 %), + range, 5-minute and since-start usage, trip, climate, clock and odometer; its fixed palette is + `retro_*` in colors.xml (a style's own colours, not the accent). **Futuristic** (`FlowView`): a + hairline HUD in the accent colour — a battery capsule of scan lines, an energy channel whose + particles flow to the wheel while drawing and back in green while regenerating (faster with + more power), the speed inside a ring of dashes that turns with the speed and a power arc, + P R N D in a column, and a readout line. It animates at about 30 frames a second only while + power or speed is non-zero and the view is shown. Unread values show dashes and light or move + nothing in both. The Overview now reads the car every 0.2 s instead of 0.5 s (a read still + running skips the tick, as before). Checked on the emulator with sample values in a throwaway + build (`screenshots/nilsmg4-2.23.0-retro.png`, `…-futuristic.png`) and without data through + the real setting; no crash. Not tried on the car, including the 0.2 s reading rate. - 2026-10-24 — App 2.22.3: the Overview style "Dashboard" is now called "Gauges" (stored as `GAUGES`; the old value `DASHBOARD` still selects it, so the choice survives the update; `pane_dashboard.xml` is `pane_gauges.xml`). The branches `dashboard-gauges` and diff --git a/app/build.gradle b/app/build.gradle index b95ac77..ad19ae0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 114 - versionName "2.22.3" + versionCode 115 + versionName "2.23.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/ClusterValues.java b/app/src/main/java/com/evsuite/abrp/ClusterValues.java new file mode 100644 index 0000000..d5a8a1c --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ClusterValues.java @@ -0,0 +1,22 @@ +package com.evsuite.abrp; + +/** + * One reading for the drawn Overview styles (Retro, Futuristic): what they show, already in + * display units. Every field is null when unread, and the styles show that as dashes, never 0. + */ +final class ClusterValues { + Float socPercent; + Float rangeKm; + Float powerKw; + Float speedKmh; + /** The car's raw gear code ({@link Gear}). */ + Integer gear; + /** kWh/100 km over the last 5 minutes and since start; km since start. */ + Double usage5Min; + Double usageTrip; + Double tripKm; + Float climateCelsius; + Float odometerKm; + /** "HH:mm", never null. */ + String clock = ""; +} diff --git a/app/src/main/java/com/evsuite/abrp/FlowView.java b/app/src/main/java/com/evsuite/abrp/FlowView.java new file mode 100644 index 0000000..85f942f --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/FlowView.java @@ -0,0 +1,277 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Locale; + +/** + * The Futuristic Overview style: a holographic HUD in hairlines and the accent colour. + * + * Left, the battery as a capsule filled to the charge. Right, the "wheel": the speed inside a + * ring of dashes that turns with the speed, and an arc for the power. Between them, an energy + * channel in which particles flow from the battery to the wheel while drawing power, and back + * in green while regenerating, faster with more power. A column of P R N D, and a line of + * readouts at the bottom. Unread values show "—", fill nothing and move nothing. + * + * It animates only while something moves (power or speed) and the view is on screen, at about + * 30 frames a second; parked and idle it draws once per reading like the other styles. + */ +public final class FlowView extends View { + + private static final long FRAME_MS = 33; + + private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dim = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint thin = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint small = new Paint(Paint.ANTI_ALIAS_FLAG); + private final RectF rect = new RectF(); + private final int accent, light, regen, warn, white, grey; + private ClusterValues v = new ClusterValues(); + /** Particle offset along the channel (px) and the wheel's turn (degrees). */ + private float flow, turn; + private long lastFrame; + + public FlowView(Context context, AttributeSet attrs) { + super(context, attrs); + accent = AccentColor.resolve(context, R.attr.evAccent); + light = AccentColor.resolve(context, R.attr.evAccentLight); + regen = context.getColor(R.color.ev_ok); + warn = context.getColor(R.color.ev_error); + white = context.getColor(R.color.ev_text_primary); + grey = context.getColor(R.color.ev_text_secondary); + line.setStyle(Paint.Style.STROKE); + line.setStrokeCap(Paint.Cap.ROUND); + dim.setStyle(Paint.Style.STROKE); + dim.setColor(AccentColor.withAlpha(accent, 0x40)); + glow.setStyle(Paint.Style.STROKE); + glow.setStrokeCap(Paint.Cap.ROUND); + Typeface thinFace = Typeface.create("sans-serif-thin", Typeface.NORMAL); + thin.setTypeface(thinFace); + thin.setColor(white); + thin.setFontFeatureSettings("tnum"); + small.setTypeface(Typeface.create("sans-serif-medium", Typeface.NORMAL)); + small.setLetterSpacing(0.2f); + small.setFontFeatureSettings("tnum"); + } + + void setValues(ClusterValues values) { + v = values == null ? new ClusterValues() : values; + invalidate(); + } + + @Override + protected void onDraw(Canvas c) { + super.onDraw(c); + float w = getWidth(), h = getHeight(); + if (w <= 0 || h <= 0) return; + float d = getResources().getDisplayMetrics().density; + + long now = SystemClock.uptimeMillis(); + float dt = lastFrame == 0 ? 0f : Math.min(100, now - lastFrame) / 1000f; + lastFrame = now; + float power = v.powerKw == null ? 0f : v.powerKw; + float speed = v.speedKmh == null ? 0f : v.speedKmh; + boolean flowing = Math.abs(power) >= 0.3f; + if (flowing) flow += Math.signum(power) * (60f + Math.min(150f, Math.abs(power)) * 7f) * d * dt; + if (speed >= 0.5f) turn += Math.min(180f, speed) * 1.2f * dt; + + drawBrackets(c, w, h, d); + float cy = h * 0.44f; + float bl = w * 0.05f, br = w * 0.115f; + drawBattery(c, bl, h * 0.12f, br, h * 0.78f, d); + float wheelR = h * 0.34f, wheelX = w * 0.68f; + drawWheel(c, wheelX, cy, wheelR, d); + drawChannel(c, br + 10f * d, wheelX - wheelR - 14f * d, cy, d, power, flowing); + drawGears(c, w * 0.935f, h, d); + drawReadouts(c, w, h, d); + + if ((flowing || speed >= 0.5f) && isShown()) postInvalidateDelayed(FRAME_MS); + else lastFrame = 0; + } + + private void drawBrackets(Canvas c, float w, float h, float d) { + line.setColor(AccentColor.withAlpha(accent, 0x90)); + line.setStrokeWidth(1.5f * d); + float l = 18f * d, m = 4f * d; + float[][] corners = { { m, m, 1, 1 }, { w - m, m, -1, 1 }, { m, h - m, 1, -1 }, { w - m, h - m, -1, -1 } }; + for (float[] k : corners) { + c.drawLine(k[0], k[1], k[0] + k[2] * l, k[1], line); + c.drawLine(k[0], k[1], k[0], k[1] + k[3] * l, line); + } + } + + private void drawBattery(Canvas c, float l, float t, float r, float b, float d) { + float radius = (r - l) * 0.35f; + boolean low = v.socPercent != null && v.socPercent < 20f; + int colour = low ? warn : accent; + // The terminal, then the outline. + fill.setColor(AccentColor.withAlpha(colour, 0xB0)); + rect.set(l + (r - l) * 0.32f, t - 8f * d, r - (r - l) * 0.32f, t - 2f * d); + c.drawRoundRect(rect, 2f * d, 2f * d, fill); + line.setColor(colour); + line.setStrokeWidth(1.5f * d); + rect.set(l, t, r, b); + c.drawRoundRect(rect, radius, radius, line); + // The charge as scan lines from the bottom. + if (v.socPercent != null) { + float inset = 6f * d, top = b - inset - (b - t - 2 * inset) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f; + line.setStrokeWidth(2f * d); + for (float y = b - inset; y >= top; y -= 5f * d) { + float k = (y - top) / Math.max(1f, b - inset - top); + line.setColor(AccentColor.withAlpha(colour, (int) (0x50 + 0xAF * (1f - k * 0.6f)))); + c.drawLine(l + inset, y, r - inset, y, line); + } + glow.setColor(AccentColor.withAlpha(colour, 0x50)); + glow.setStrokeWidth(5f * d); + c.drawLine(l + inset, top, r - inset, top, glow); + } + + float x = r + 28f * d; + caption(c, "BATTERY", x, t + 10f * d, Paint.Align.LEFT); + thin.setTextAlign(Paint.Align.LEFT); + thin.setTextSize((b - t) * 0.30f); + thin.setColor(low ? warn : white); + String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f", v.socPercent); + c.drawText(soc, x - 4f * d, t + (b - t) * 0.40f, thin); + float sw = thin.measureText(soc); + thin.setTextSize((b - t) * 0.10f); + thin.setColor(light); + c.drawText("%", x + sw + 2f * d, t + (b - t) * 0.40f, thin); + thin.setColor(white); + thin.setTextSize((b - t) * 0.10f); + c.drawText(v.rangeKm == null ? "— km" : String.format(Locale.US, "%.0f km", v.rangeKm), x, b - (b - t) * 0.10f, thin); + caption(c, "RANGE", x, b - (b - t) * 0.25f, Paint.Align.LEFT); + } + + private void drawChannel(Canvas c, float x0, float x1, float cy, float d, float power, boolean flowing) { + if (x1 <= x0) return; + boolean regenerating = v.powerKw != null && v.powerKw < 0f; + int colour = regenerating ? regen : accent; + float half = 9f * d; + dim.setStrokeWidth(d); + c.drawLine(x0, cy - half, x1, cy - half, dim); + c.drawLine(x0, cy + half, x1, cy + half, dim); + + if (flowing) { + // Particles every 34 dp, fading in and out at the ends. + float spacing = 34f * d, len = 12f * d; + float offset = ((flow % spacing) + spacing) % spacing; + line.setStrokeWidth(4f * d); + glow.setStrokeWidth(10f * d); + for (float x = x0 + offset - spacing; x < x1; x += spacing) { + float a = Math.max(x0, x), b = Math.min(x1, x + len); + if (b <= a) continue; + float edge = Math.min(a - x0, x1 - b) / (spacing * 2f); + int alpha = (int) (0xFF * Math.max(0.15f, Math.min(1f, edge))); + glow.setColor(AccentColor.withAlpha(colour, alpha / 5)); + c.drawLine(a, cy, b, cy, glow); + line.setColor(AccentColor.withAlpha(colour, alpha)); + c.drawLine(a, cy, b, cy, line); + } + } + + float mid = (x0 + x1) / 2f; + thin.setTextAlign(Paint.Align.CENTER); + thin.setTextSize(30f * d); + thin.setColor(regenerating ? regen : white); + c.drawText(v.powerKw == null ? "— kW" : String.format(Locale.US, "%.1f kW", v.powerKw), mid, cy - half - 16f * d, thin); + thin.setColor(white); + String state = v.powerKw == null ? "POWER" : regenerating ? "REGENERATING" : flowing ? "DRAWING" : "IDLE"; + caption(c, state, mid, cy + half + 26f * d, Paint.Align.CENTER); + } + + private void drawWheel(Canvas c, float cx, float cy, float r, float d) { + // An outer ring of dashes that turns with the speed. + dim.setStrokeWidth(d); + c.drawCircle(cx, cy, r * 0.84f, dim); + line.setColor(AccentColor.withAlpha(accent, 0xA0)); + line.setStrokeWidth(3f * d); + for (int i = 0; i < 72; i++) { + double a = Math.toRadians(turn + i * 5f); + float len = i % 6 == 0 ? r * 0.07f : r * 0.035f; + float cos = (float) Math.cos(a), sin = (float) Math.sin(a); + c.drawLine(cx + r * cos, cy + r * sin, cx + (r - len) * cos, cy + (r - len) * sin, line); + } + // Power as an arc from the top: clockwise drawing (150 kW = 300°), anticlockwise regen. + if (v.powerKw != null && Math.abs(v.powerKw) >= 0.1f) { + float sweep = Math.max(-100f, Math.min(300f, v.powerKw * 2f)); + int colour = sweep < 0 ? regen : accent; + rect.set(cx - r * 0.92f, cy - r * 0.92f, cx + r * 0.92f, cy + r * 0.92f); + glow.setColor(AccentColor.withAlpha(colour, 0x40)); + glow.setStrokeWidth(12f * d); + c.drawArc(rect, -90f, sweep, false, glow); + line.setColor(colour); + line.setStrokeWidth(4f * d); + c.drawArc(rect, -90f, sweep, false, line); + } + + thin.setTextAlign(Paint.Align.CENTER); + thin.setColor(white); + thin.setTextSize(r * 0.62f); + c.drawText(v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), cx, cy + r * 0.18f, thin); + caption(c, "KM/H", cx, cy + r * 0.42f, Paint.Align.CENTER); + } + + private void drawGears(Canvas c, float x, float h, float d) { + String[] letters = { "P", "R", "N", "D" }; + thin.setTextAlign(Paint.Align.CENTER); + thin.setTextSize(h * 0.075f); + float y0 = h * 0.20f, pitch = h * 0.13f; + for (int i = 0; i < letters.length; i++) { + boolean on = v.gear != null && v.gear == i + 1; + float y = y0 + i * pitch; + thin.setColor(on ? white : AccentColor.withAlpha(grey, 0x50)); + thin.setFakeBoldText(on); + c.drawText(letters[i], x, y, thin); + if (on) { + line.setColor(accent); + line.setStrokeWidth(2f * d); + float s = h * 0.05f, top = y - h * 0.075f; + c.drawLine(x - s - 6f * d, top, x - s - 6f * d, y + 6f * d, line); + c.drawLine(x + s + 6f * d, top, x + s + 6f * d, y + 6f * d, line); + } + } + thin.setFakeBoldText(false); + } + + private void drawReadouts(Canvas c, float w, float h, float d) { + float top = h * 0.84f; + dim.setStrokeWidth(d); + c.drawLine(w * 0.04f, top, w * 0.96f, top, dim); + String[] labels = { "USAGE 5 MIN", "SINCE START", "TRIP", "CLIMATE", "ODOMETER", "TIME" }; + String[] values = { + v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f", v.usage5Min), + v.usageTrip == null ? "—" : String.format(Locale.US, "%.1f", v.usageTrip), + v.tripKm == null ? "— km" : String.format(Locale.US, "%.1f km", v.tripKm), + v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius), + v.odometerKm == null ? "— km" : String.format(Locale.US, "%,.0f km", v.odometerKm), + v.clock.isEmpty() ? "—" : v.clock, + }; + float pitch = (w * 0.92f) / labels.length; + for (int i = 0; i < labels.length; i++) { + float x = w * 0.04f + pitch * i + pitch / 2f; + caption(c, labels[i], x, top + 22f * d, Paint.Align.CENTER); + thin.setTextAlign(Paint.Align.CENTER); + thin.setTextSize(i == labels.length - 1 ? 34f * d : 28f * d); + thin.setColor(white); + c.drawText(values[i], x, top + 58f * d, thin); + if (i > 0) c.drawLine(w * 0.04f + pitch * i, top + 10f * d, w * 0.04f + pitch * i, top + 60f * d, dim); + } + } + + private void caption(Canvas c, String text, float x, float baseline, Paint.Align align) { + small.setTextSize(12f * getResources().getDisplayMetrics().density); + small.setTextAlign(align); + small.setColor(light); + c.drawText(text, x, baseline, small); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index f6b2ceb..1411ecf 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -68,6 +68,7 @@ public class MainActivity extends AppCompatActivity { private View vehiclePane; private View gaugesPane; private View cockpitPane; + private View retroPane, futuristicPane; private View consumptionPane; private View chargingPane; /** Display, battery, charging price and Wi-Fi. */ @@ -246,6 +247,8 @@ public class MainActivity extends AppCompatActivity { vehiclePane = inflatePane(R.layout.pane_vehicle); gaugesPane = inflatePane(R.layout.pane_gauges); cockpitPane = inflatePane(R.layout.pane_cockpit); + retroPane = inflatePane(R.layout.pane_retro); + futuristicPane = inflatePane(R.layout.pane_futuristic); // The Overview page holds both styles; Settings → Overview style picks the one shown. android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( @@ -254,6 +257,8 @@ public class MainActivity extends AppCompatActivity { overviewPage.addView(vehiclePane); overviewPage.addView(gaugesPane); overviewPage.addView(cockpitPane); + overviewPage.addView(retroPane); + overviewPage.addView(futuristicPane); consumptionPane = inflatePane(R.layout.pane_consumption); chargingPane = inflatePane(R.layout.pane_charging); settingsPane = inflatePane(R.layout.pane_settings); @@ -1343,10 +1348,13 @@ public class MainActivity extends AppCompatActivity { * is not work to repeat every two seconds. A read that throws leaves the cells as they * were rather than blanking the page — a transient miss is not news worth showing. */ - // ---------- Overview: read every half second, off the main thread ---------- + // ---------- Overview: read five times a second, off the main thread ---------- - /** How often the Overview reads the car while it is on screen. */ - private static final long OVERVIEW_REFRESH_MS = 500L; + /** + * How often the Overview reads the car while it is on screen: 0.2 s, so needles, rings and + * the speed follow the car closely. A read still running makes the next tick skip. + */ + private static final long OVERVIEW_REFRESH_MS = 200L; /** One reader thread; a read that is still running makes the next tick skip, not queue. */ private final java.util.concurrent.ExecutorService carReader = @@ -1434,7 +1442,7 @@ public class MainActivity extends AppCompatActivity { private Double dashTripKm; /** The stored values of the Overview styles, in the order of the drop-down. */ - private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT" }; + private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "FUTURISTIC" }; /** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */ private int overviewStyle() { @@ -1449,12 +1457,15 @@ public class MainActivity extends AppCompatActivity { vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE); gaugesPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE); cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE); + retroPane.setVisibility(style == 3 ? View.VISIBLE : View.GONE); + futuristicPane.setVisibility(style == 4 ? View.VISIBLE : View.GONE); } - /** Tiles (the cards), Gauges (needles) or Cockpit (rings); all are fed on every reading. */ + /** Tiles, Gauges, Cockpit, Retro or Futuristic; all are fed on every reading. */ private void bindOverviewStyle() { String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges), - getString(R.string.overview_style_cockpit) }; + getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro), + getString(R.string.overview_style_futuristic) }; bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(), position -> { prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply(); @@ -1478,6 +1489,30 @@ public class MainActivity extends AppCompatActivity { dashBattery.setWarnBelow(20f); } + // ---------- Retro and Futuristic (Overview styles, each one drawn view) ---------- + + private final java.text.SimpleDateFormat clockFormat = + new java.text.SimpleDateFormat("HH:mm", java.util.Locale.US); + + private void showDrawnStyles(EnergySnapshot s) { + RetroView retro = retroPane.findViewById(R.id.retro_view); + FlowView flow = futuristicPane.findViewById(R.id.flow_view); + ClusterValues v = new ClusterValues(); + v.socPercent = s.getSocPercent(); + v.rangeKm = s.getRangeKm(); + v.powerKw = s.getBatteryPowerKw(); + v.speedKmh = s.getSpeedKmh(); + v.gear = s.getGearRaw(); + v.usage5Min = cockpitAverage == null ? null : cockpitAverage.kwhPer100Km; + v.usageTrip = cockpitTrip == null ? null : cockpitTrip.kwhPer100Km; + v.tripKm = cockpitTrip == null ? null : cockpitTrip.distanceKm; + v.climateCelsius = s.getClimate().getDriverTargetCelsius(); + v.odometerKm = s.getOdometerKm(); + v.clock = clockFormat.format(new java.util.Date()); + retro.setValues(v); + flow.setValues(v); + } + // ---------- Cockpit (Overview style) ---------- /** Power over the last two minutes, a slot per second. */ @@ -1578,6 +1613,7 @@ public class MainActivity extends AppCompatActivity { vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa())); showGauges(s); showCockpit(s); + showDrawnStyles(s); } catch (Throwable ignored) { // Leave the last good reading on screen. } diff --git a/app/src/main/java/com/evsuite/abrp/RetroView.java b/app/src/main/java/com/evsuite/abrp/RetroView.java new file mode 100644 index 0000000..b14743f --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/RetroView.java @@ -0,0 +1,300 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Locale; + +/** + * The Retro Overview style: an 80s digital cluster, a vacuum fluorescent display behind smoked + * glass. Slanted seven-segment digits ({@link SevenSegment}) with the unlit segments faintly + * visible, bar graphs, amber labels and a lit P R N D selector. + * + * Centre: the speed, and power as a bar from −50 to 150 kW (regeneration left of zero, green). + * Left: charge as a bar, the percentage and range. Right: 5-minute usage, the average since + * start and the distance. Bottom: climate, clock, odometer. Unread values show dashes and light + * no bar segments. The whole pane is one Canvas, redrawn only when a reading arrives. + */ +public final class RetroView extends View { + + private static final float SKEW = 0.09f; + + private final Paint ghost = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint litPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint label = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint panel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint bezel = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final int lit, regen, amber, warn; + private ClusterValues v = new ClusterValues(); + + public RetroView(Context context, AttributeSet attrs) { + super(context, attrs); + lit = context.getColor(R.color.retro_lit); + regen = context.getColor(R.color.retro_regen); + amber = context.getColor(R.color.retro_label); + warn = context.getColor(R.color.retro_warn); + ghost.setColor(AccentColor.withAlpha(lit, 0x14)); + glow.setStyle(Paint.Style.STROKE); + glow.setStrokeJoin(Paint.Join.ROUND); + label.setColor(amber); + label.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)); + label.setLetterSpacing(0.12f); + panel.setColor(context.getColor(R.color.retro_panel)); + bezel.setStyle(Paint.Style.STROKE); + bezel.setColor(context.getColor(R.color.retro_bezel)); + } + + void setValues(ClusterValues values) { + v = values == null ? new ClusterValues() : values; + invalidate(); + } + + @Override + protected void onDraw(Canvas c) { + super.onDraw(c); + float w = getWidth(), h = getHeight(); + if (w <= 0 || h <= 0) return; + float d = getResources().getDisplayMetrics().density; + + bezel.setStrokeWidth(2f * d); + rect.set(d, d, w - d, h - d); + c.drawRoundRect(rect, 18f * d, 18f * d, panel); + c.drawRoundRect(rect, 18f * d, 18f * d, bezel); + + drawCentre(c, w, h); + drawLeft(c, w, h); + drawRight(c, w, h); + drawBottom(c, w, h, d); + } + + // ---------- Blocks ---------- + + private void drawCentre(Canvas c, float w, float h) { + float cx = w / 2f; + // The gear selector, the lit letter boxed. + drawGears(c, cx, h * 0.06f, h * 0.085f); + + float dh = h * 0.38f; + String speed = v.speedKmh == null ? "---" : String.format(Locale.US, "%.0f", v.speedKmh); + float right = cx + cellWidth(dh) * 1.5f; + drawDigits(c, speed, 3, right, h * 0.19f, dh, lit); + label(c, "KM/H", right + h * 0.02f, h * 0.19f + dh, h * 0.045f, Paint.Align.LEFT); + + // Power: a bar from −50 to 150 kW in 5 kW segments, zero after the tenth. + float left = w * 0.29f, barRight = w * 0.71f, top = h * 0.70f, bottom = h * 0.77f; + label(c, "POWER", left, h * 0.67f, h * 0.04f, Paint.Align.LEFT); + String kw = v.powerKw == null ? "---" : String.format(Locale.US, "%.1f", v.powerKw); + label(c, "KW", barRight, h * 0.67f, h * 0.04f, Paint.Align.RIGHT); + drawDigits(c, kw, 4, barRight - h * 0.075f, h * 0.605f, h * 0.065f, + v.powerKw != null && v.powerKw < 0f ? regen : lit); + int segments = 40, zero = 10; + float pitch = (barRight - left) / segments; + int litTo = v.powerKw == null ? zero : zero + Math.round(Math.max(-50f, Math.min(150f, v.powerKw)) / 5f); + for (int i = 0; i < segments; i++) { + boolean on = v.powerKw != null && (litTo > zero ? i >= zero && i < litTo : i >= litTo && i < zero); + float x = left + i * pitch; + float segTop = i == zero ? top - h * 0.015f : top; + bar(c, x + pitch * 0.18f, segTop, x + pitch * 0.82f, bottom, on, i < zero ? regen : lit); + } + float[] marks = { -50f, 0f, 50f, 100f, 150f }; + for (float m : marks) { + float x = left + (zero + m / 5f) * pitch + pitch / 2f; + label(c, String.format(Locale.US, "%.0f", m), x, h * 0.83f, h * 0.035f, Paint.Align.CENTER); + } + } + + private void drawLeft(Canvas c, float w, float h) { + float x0 = w * 0.035f; + label(c, "CHARGE", x0, h * 0.10f, h * 0.045f, Paint.Align.LEFT); + // A vertical bar of 20 segments, red below 20 %. + float top = h * 0.16f, bottom = h * 0.82f, bw = w * 0.035f; + int segments = 20; + float pitch = (bottom - top) / segments; + int on = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 5f); + boolean low = v.socPercent != null && v.socPercent < 20f; + for (int i = 0; i < segments; i++) { + float y = bottom - (i + 1) * pitch; + bar(c, x0, y + pitch * 0.2f, x0 + bw, y + pitch * 0.8f, i < on, low ? warn : lit); + } + + float right = w * 0.235f; + String soc = v.socPercent == null ? "---" : String.format(Locale.US, "%.0f", v.socPercent); + drawDigits(c, soc, 3, right, h * 0.16f, h * 0.17f, low ? warn : lit); + label(c, "%", right + h * 0.015f, h * 0.33f, h * 0.05f, Paint.Align.LEFT); + label(c, "RANGE", x0 + bw + w * 0.03f, h * 0.47f, h * 0.04f, Paint.Align.LEFT); + String range = v.rangeKm == null ? "---" : String.format(Locale.US, "%.0f", v.rangeKm); + drawDigits(c, range, 3, right, h * 0.51f, h * 0.12f, lit); + label(c, "KM", right + h * 0.015f, h * 0.63f, h * 0.04f, Paint.Align.LEFT); + } + + private void drawRight(Canvas c, float w, float h) { + float right = w * 0.925f, x0 = w * 0.77f; + label(c, "ECONOMY", x0, h * 0.10f, h * 0.045f, Paint.Align.LEFT); + drawDigits(c, oneDecimal(v.usage5Min), 4, right, h * 0.16f, h * 0.17f, lit); + label(c, "KWH/100KM 5 MIN", x0, h * 0.40f, h * 0.035f, Paint.Align.LEFT); + label(c, "AVG", x0, h * 0.535f, h * 0.04f, Paint.Align.LEFT); + drawDigits(c, oneDecimal(v.usageTrip), 4, right, h * 0.47f, h * 0.085f, lit); + label(c, "TRIP", x0, h * 0.675f, h * 0.04f, Paint.Align.LEFT); + drawDigits(c, oneDecimal(v.tripKm), 4, right, h * 0.61f, h * 0.085f, lit); + label(c, "KM", right + h * 0.015f, h * 0.695f, h * 0.035f, Paint.Align.LEFT); + } + + private void drawBottom(Canvas c, float w, float h, float d) { + bezel.setStrokeWidth(d); + c.drawLine(w * 0.03f, h * 0.875f, w * 0.97f, h * 0.875f, bezel); + float top = h * 0.90f, dh = h * 0.07f, base = top + dh; + + label(c, "TEMP", w * 0.035f, base, h * 0.04f, Paint.Align.LEFT); + String temp = v.climateCelsius == null ? "--" : String.format(Locale.US, "%.0f", v.climateCelsius); + drawDigits(c, temp, 2, w * 0.035f + h * 0.30f, top, dh, lit); + label(c, "°C", w * 0.035f + h * 0.315f, base, h * 0.04f, Paint.Align.LEFT); + + // The clock: two pairs of digits and a colon. + String clock = v.clock.length() == 5 ? v.clock : "--:--"; + float cx = w / 2f, cell = cellWidth(dh), colon = dh * 0.35f; + drawDigits(c, clock.substring(0, 2), 2, cx - colon / 2f, top, dh, lit); + drawDigits(c, clock.substring(3, 5), 2, cx + colon / 2f + 2 * cell, top, dh, lit); + float t = dh * 0.13f; + glowRect(c, cx - t / 2f + SKEW * dh * 0.3f, top + dh * 0.28f, t, lit); + glowRect(c, cx - t / 2f - SKEW * dh * 0.1f, top + dh * 0.66f, t, lit); + + String odo = v.odometerKm == null ? "------" : String.format(Locale.US, "%06.0f", v.odometerKm); + float right = w * 0.925f; + label(c, "ODO", right - 6 * cell - h * 0.02f, base, h * 0.04f, Paint.Align.RIGHT); + drawDigits(c, odo, 6, right, top, dh, lit); + label(c, "KM", right + h * 0.015f, base, h * 0.035f, Paint.Align.LEFT); + } + + private void drawGears(Canvas c, float cx, float top, float size) { + String[] letters = { "P", "R", "N", "D" }; + float pitch = size * 1.6f, x0 = cx - pitch * 1.5f; + label.setTextSize(size * 0.8f); + label.setTextAlign(Paint.Align.CENTER); + for (int i = 0; i < letters.length; i++) { + float x = x0 + i * pitch; + boolean on = v.gear != null && v.gear == i + 1; + rect.set(x - size * 0.6f, top, x + size * 0.6f, top + size * 1.1f); + if (on) { + glow.setColor(AccentColor.withAlpha(lit, 0x40)); + glow.setStrokeWidth(size * 0.25f); + c.drawRoundRect(rect, size * 0.12f, size * 0.12f, glow); + litPaint.setColor(lit); + c.drawRoundRect(rect, size * 0.12f, size * 0.12f, litPaint); + label.setColor(0xFF000000); + } else { + c.drawRoundRect(rect, size * 0.12f, size * 0.12f, ghost); + label.setColor(AccentColor.withAlpha(lit, 0x40)); + } + c.drawText(letters[i], x, top + size * 0.85f, label); + } + label.setColor(amber); + } + + // ---------- Primitives ---------- + + private static String oneDecimal(Double value) { + return value == null || value.isNaN() ? "---" : String.format(Locale.US, "%.1f", value); + } + + private void label(Canvas c, String text, float x, float baseline, float size, Paint.Align align) { + label.setTextSize(size); + label.setTextAlign(align); + c.drawText(text, x, baseline, label); + } + + private static float cellWidth(float dh) { + return dh * 0.72f; + } + + /** Text as seven-segment digits in {@code cells} cells ending at {@code right}. */ + private void drawDigits(Canvas c, String text, int cells, float right, float top, float dh, int colour) { + char[] chars = SevenSegment.cells(text, cells); + boolean[] dots = SevenSegment.dots(text, cells); + float cw = cellWidth(dh); + for (int i = 0; i < cells; i++) { + float x = right - (cells - i) * cw; + drawDigit(c, x, top, dh, SevenSegment.mask(chars[i]), dots[i], colour); + } + } + + private void drawDigit(Canvas c, float x, float y, float dh, int mask, boolean dot, int colour) { + float wd = dh * 0.5f, t = dh * 0.12f, g = t * 0.18f; + float yMid = y + dh / 2f, yBottom = y + dh; + segment(c, true, x + g, x + wd - g, y + t / 2f, t, y, dh, (mask & SevenSegment.A) != 0, colour); + segment(c, true, x + g, x + wd - g, yMid, t, y, dh, (mask & SevenSegment.G) != 0, colour); + segment(c, true, x + g, x + wd - g, yBottom - t / 2f, t, y, dh, (mask & SevenSegment.D) != 0, colour); + segment(c, false, y + t / 2f + g, yMid - g, x + t / 2f, t, y, dh, (mask & SevenSegment.F) != 0, colour); + segment(c, false, yMid + g, yBottom - t / 2f - g, x + t / 2f, t, y, dh, (mask & SevenSegment.E) != 0, colour); + segment(c, false, y + t / 2f + g, yMid - g, x + wd - t / 2f, t, y, dh, (mask & SevenSegment.B) != 0, colour); + segment(c, false, yMid + g, yBottom - t / 2f - g, x + wd - t / 2f, t, y, dh, (mask & SevenSegment.C) != 0, colour); + float dx = x + wd + dh * 0.06f + SKEW * t; + if (dot) glowRect(c, dx, yBottom - t, t, colour); + else { rect.set(dx, yBottom - t, dx + t, yBottom); c.drawRect(rect, ghost); } + } + + /** + * One segment, a hexagon from {@code a} to {@code b} along its axis at {@code across}; slanted + * to the right with the digit's height (top {@code y}, height {@code dh}). + */ + private void segment(Canvas c, boolean horizontal, float a, float b, float across, float t, + float y, float dh, boolean on, int colour) { + float h2 = t / 2f; + path.reset(); + if (horizontal) { + point(true, a, across, y, dh); + point(false, a + h2, across - h2, y, dh); + point(false, b - h2, across - h2, y, dh); + point(false, b, across, y, dh); + point(false, b - h2, across + h2, y, dh); + point(false, a + h2, across + h2, y, dh); + } else { + point(true, across, a, y, dh); + point(false, across + h2, a + h2, y, dh); + point(false, across + h2, b - h2, y, dh); + point(false, across, b, y, dh); + point(false, across - h2, b - h2, y, dh); + point(false, across - h2, a + h2, y, dh); + } + path.close(); + if (!on) { + c.drawPath(path, ghost); + return; + } + glow.setColor(AccentColor.withAlpha(colour, 0x38)); + glow.setStrokeWidth(t * 0.9f); + c.drawPath(path, glow); + litPaint.setColor(colour); + c.drawPath(path, litPaint); + } + + private void point(boolean first, float px, float py, float y, float dh) { + float x = px + SKEW * (y + dh - py); + if (first) path.moveTo(x, py); else path.lineTo(x, py); + } + + private void bar(Canvas c, float l, float t, float r, float b, boolean on, int colour) { + rect.set(l, t, r, b); + if (!on) { + c.drawRect(rect, ghost); + return; + } + glow.setColor(AccentColor.withAlpha(colour, 0x38)); + glow.setStrokeWidth(Math.min(r - l, b - t) * 0.5f); + c.drawRect(rect, glow); + litPaint.setColor(colour); + c.drawRect(rect, litPaint); + } + + private void glowRect(Canvas c, float x, float y, float size, int colour) { + bar(c, x, y, x + size, y + size, true, colour); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/SevenSegment.java b/app/src/main/java/com/evsuite/abrp/SevenSegment.java new file mode 100644 index 0000000..084de7e --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SevenSegment.java @@ -0,0 +1,64 @@ +package com.evsuite.abrp; + +/** + * Seven-segment digits for the Retro style: which segments a character lights, and how text + * is laid out in cells. Pure Java so it can be tested off the car. + * + * Segments as bits: a (top) 1, b (upper right) 2, c (lower right) 4, d (bottom) 8, e (lower + * left) 16, f (upper left) 32, g (middle) 64. + */ +final class SevenSegment { + + static final int A = 1, B = 2, C = 4, D = 8, E = 16, F = 32, G = 64; + static final int ALL = 127; + + private static final int[] DIGITS = { + A | B | C | D | E | F, // 0 + B | C, // 1 + A | B | D | E | G, // 2 + A | B | C | D | G, // 3 + B | C | F | G, // 4 + A | C | D | F | G, // 5 + A | C | D | E | F | G, // 6 + A | B | C, // 7 + ALL, // 8 + A | B | C | D | F | G, // 9 + }; + + private SevenSegment() {} + + /** The segments a character lights: digits and '-'; anything else lights none. */ + static int mask(char c) { + if (c >= '0' && c <= '9') return DIGITS[c - '0']; + if (c == '-') return G; + return 0; + } + + /** + * The text as digit cells, right-aligned in {@code cells}: each cell's character, with + * '.' folded into the cell before it (see {@link #dots}). Longer text keeps its right end. + */ + static char[] cells(String text, int cells) { + char[] out = new char[cells]; + java.util.Arrays.fill(out, ' '); + String digits = text == null ? "" : text.replace(".", ""); + int start = Math.max(0, digits.length() - cells); + for (int i = start; i < digits.length(); i++) out[cells - (digits.length() - i)] = digits.charAt(i); + return out; + } + + /** For each cell of {@link #cells}, whether a decimal point follows it. */ + static boolean[] dots(String text, int cells) { + boolean[] out = new boolean[cells]; + if (text == null) return out; + // From the right: a '.' marks the digit to its left. + int cell = cells; + boolean pending = false; + for (int i = text.length() - 1; i >= 0; i--) { + if (text.charAt(i) == '.') { pending = true; continue; } + if (--cell < 0) break; + if (pending) { out[cell] = true; pending = false; } + } + return out; + } +} diff --git a/app/src/main/res/layout/pane_futuristic.xml b/app/src/main/res/layout/pane_futuristic.xml new file mode 100644 index 0000000..062a2df --- /dev/null +++ b/app/src/main/res/layout/pane_futuristic.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/pane_retro.xml b/app/src/main/res/layout/pane_retro.xml new file mode 100644 index 0000000..74d06c1 --- /dev/null +++ b/app/src/main/res/layout/pane_retro.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 6fdc37f..5b2e4b6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -21,4 +21,11 @@ #28FFFFFF #121212 #00000000 + + #4DF5D0 + #8CFF6A + #FFB13D + #FF5C4D + #060B0A + #1E2B28 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0ea84a3..6b199b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,6 +128,8 @@ TOTAL %1$s TRIP %1$s Cockpit + Retro + Futuristic BATTERY USAGE · 5 MIN kWh/100 km diff --git a/app/src/test/java/com/evsuite/abrp/SevenSegmentTest.java b/app/src/test/java/com/evsuite/abrp/SevenSegmentTest.java new file mode 100644 index 0000000..92e7fb5 --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/SevenSegmentTest.java @@ -0,0 +1,35 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +/** Seven-segment digits for the Retro style — pure JVM. */ +public class SevenSegmentTest { + + @Test + public void digitsLightTheRightSegments() { + assertEquals(SevenSegment.ALL, SevenSegment.mask('8')); + assertEquals(SevenSegment.B | SevenSegment.C, SevenSegment.mask('1')); + assertEquals(SevenSegment.ALL & ~SevenSegment.G, SevenSegment.mask('0')); + assertEquals(SevenSegment.G, SevenSegment.mask('-')); + assertEquals(0, SevenSegment.mask(' ')); + assertEquals(0, SevenSegment.mask('x')); + } + + @Test + public void textIsRightAligned() { + assertArrayEquals(new char[] { ' ', '8', '7' }, SevenSegment.cells("87", 3)); + assertArrayEquals(new char[] { '1', '2', '3' }, SevenSegment.cells("9123", 3)); + assertArrayEquals(new char[] { ' ', ' ', ' ' }, SevenSegment.cells(null, 3)); + } + + @Test + public void decimalPointsFoldIntoTheDigitBefore() { + assertArrayEquals(new char[] { '1', '5', '8' }, SevenSegment.cells("15.8", 3)); + assertArrayEquals(new boolean[] { false, true, false }, SevenSegment.dots("15.8", 3)); + assertArrayEquals(new boolean[] { false, false, true, false }, SevenSegment.dots("5.8", 4)); + assertArrayEquals(new boolean[] { false, false }, SevenSegment.dots("42", 2)); + } +} diff --git a/screenshots/nilsmg4-2.23.0-futuristic.png b/screenshots/nilsmg4-2.23.0-futuristic.png new file mode 100644 index 0000000..bac1274 Binary files /dev/null and b/screenshots/nilsmg4-2.23.0-futuristic.png differ diff --git a/screenshots/nilsmg4-2.23.0-retro.png b/screenshots/nilsmg4-2.23.0-retro.png new file mode 100644 index 0000000..3d3179b Binary files /dev/null and b/screenshots/nilsmg4-2.23.0-retro.png differ