App 2.23.0: Overview styles Retro and Futuristic; read every 0.2 s

Retro: an 80s vacuum fluorescent cluster with seven-segment digits,
bar graphs and a lit P R N D selector. Futuristic: a hairline HUD with
an animated energy flow between battery and wheel, a turning speed ring
and a power arc. Both show unread values as dashes. The Overview now
reads the car five times a second. Emulator only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-24 19:32:07 +02:00
co-authored by Claude Opus 5.5
parent c649ff6e28
commit d9f6e368cf
16 changed files with 779 additions and 10 deletions
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -79,7 +79,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo
| Tab | What it shows |
|---|---|
| **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 |
+16
View File
@@ -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
+2 -2
View File
@@ -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.
@@ -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 = "";
}
@@ -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);
}
}
@@ -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.
}
@@ -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);
}
}
@@ -0,0 +1,64 @@
package com.evsuite.abrp;
/**
* Seven-segment digits for the Retro style: which segments a character lights, and how text
* is laid out in cells. Pure Java so it can be tested off the car.
*
* Segments as bits: a (top) 1, b (upper right) 2, c (lower right) 4, d (bottom) 8, e (lower
* left) 16, f (upper left) 32, g (middle) 64.
*/
final class SevenSegment {
static final int A = 1, B = 2, C = 4, D = 8, E = 16, F = 32, G = 64;
static final int ALL = 127;
private static final int[] DIGITS = {
A | B | C | D | E | F, // 0
B | C, // 1
A | B | D | E | G, // 2
A | B | C | D | G, // 3
B | C | F | G, // 4
A | C | D | F | G, // 5
A | C | D | E | F | G, // 6
A | B | C, // 7
ALL, // 8
A | B | C | D | F | G, // 9
};
private SevenSegment() {}
/** The segments a character lights: digits and '-'; anything else lights none. */
static int mask(char c) {
if (c >= '0' && c <= '9') return DIGITS[c - '0'];
if (c == '-') return G;
return 0;
}
/**
* The text as digit cells, right-aligned in {@code cells}: each cell's character, with
* '.' folded into the cell before it (see {@link #dots}). Longer text keeps its right end.
*/
static char[] cells(String text, int cells) {
char[] out = new char[cells];
java.util.Arrays.fill(out, ' ');
String digits = text == null ? "" : text.replace(".", "");
int start = Math.max(0, digits.length() - cells);
for (int i = start; i < digits.length(); i++) out[cells - (digits.length() - i)] = digits.charAt(i);
return out;
}
/** For each cell of {@link #cells}, whether a decimal point follows it. */
static boolean[] dots(String text, int cells) {
boolean[] out = new boolean[cells];
if (text == null) return out;
// From the right: a '.' marks the digit to its left.
int cell = cells;
boolean pending = false;
for (int i = text.length() - 1; i >= 0; i--) {
if (text.charAt(i) == '.') { pending = true; continue; }
if (--cell < 0) break;
if (pending) { out[cell] = true; pending = false; }
}
return out;
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Overview as a holographic HUD (Settings → Overview style → Futuristic), drawn by FlowView. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<com.evsuite.abrp.FlowView android:id="@+id/flow_view" android:layout_width="match_parent" android:layout_height="match_parent" />
</FrameLayout>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Overview as an 80s digital cluster (Settings → Overview style → Retro), drawn by RetroView. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<com.evsuite.abrp.RetroView android:id="@+id/retro_view" android:layout_width="match_parent" android:layout_height="match_parent" />
</FrameLayout>
+7
View File
@@ -21,4 +21,11 @@
<color name="ev_pressed_strong">#28FFFFFF</color>
<color name="ev_icon_tile">#121212</color>
<color name="ev_transparent">#00000000</color>
<!-- The Retro Overview style: a vacuum fluorescent display behind smoked glass. -->
<color name="retro_lit">#4DF5D0</color>
<color name="retro_regen">#8CFF6A</color>
<color name="retro_label">#FFB13D</color>
<color name="retro_warn">#FF5C4D</color>
<color name="retro_panel">#060B0A</color>
<color name="retro_bezel">#1E2B28</color>
</resources>
+2
View File
@@ -128,6 +128,8 @@
<string name="dash_total">TOTAL %1$s</string>
<string name="dash_trip">TRIP %1$s</string>
<string name="overview_style_cockpit">Cockpit</string>
<string name="overview_style_retro">Retro</string>
<string name="overview_style_futuristic">Futuristic</string>
<string name="cockpit_battery">BATTERY</string>
<string name="cockpit_usage">USAGE · 5 MIN</string>
<string name="cockpit_usage_unit">kWh/100 km</string>
@@ -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));
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB