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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
This commit is contained in:
2026-10-24 20:47:43 +02:00
co-authored by Claude Opus 5.5
parent 1af5b0efe7
commit 7902d8f941
17 changed files with 946 additions and 7 deletions
@@ -0,0 +1,233 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.RectF;
import android.graphics.Typeface;
/**
* The Aviation Overview style: a pilot's primary flight display.
*
* The speed on a scrolling tape at the left and the battery on one at the right (where the
* altitude would be), each with a readout box. In the middle, a horizon that "pitches" with the
* power: drawing power raises the nose (the horizon drops), regenerating lowers it; a ladder of
* kW marks and the yellow aircraft symbol over it. Green annunciators at the top (gear, power,
* clock), boxes of range, trip, usage, climate and odometer along the bottom. Unread values
* show "—" and leave the horizon level.
*
* The tapes and the horizon ease to new values and redraw only until settled, only on screen.
*/
final class AviationView extends ClusterCanvasView {
private static final long FRAME_MS = 33;
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path = new Path();
private final RectF rect = new RectF();
private final int sky = 0xFF1E4C80, ground = 0xFF5A3A1E, white = 0xFFF2F4F7, green = 0xFF3CE07A,
cyan = 0xFF4FD8F0, magenta = 0xFFE05CE0, yellow = 0xFFFFD21F, tape = 0xCC1A1F26, amber = 0xFFFFB000;
private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN;
AviationView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
text.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD));
text.setFontFeatureSettings("tnum");
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
speedShown = ease(speedShown, v.speedKmh, 180f);
socShown = ease(socShown, v.socPercent, 100f);
powerShown = ease(powerShown, v.powerKw, 200f);
boolean moving = (v.speedKmh != null && speedShown != v.speedKmh)
|| (v.socPercent != null && socShown != v.socPercent)
|| (v.powerKw != null && powerShown != v.powerKw);
c.drawColor(0xFF05070A);
float top = h * 0.10f, bottom = h * 0.84f;
float tapeW = w * 0.10f;
float leftTape = w * 0.04f, rightTape = w - w * 0.04f - tapeW;
drawHorizon(c, leftTape + tapeW + 16f * dp, top, rightTape - 16f * dp, bottom);
drawTape(c, leftTape, top, tapeW, bottom, v.speedKmh == null ? null : speedShown, 0f, 250f, 10f, 20f,
fmt(v.speedKmh, "%.0f", "—"), "KM/H", true);
drawTape(c, rightTape, top, tapeW, bottom, v.socPercent == null ? null : socShown, 0f, 100f, 5f, 10f,
fmt(v.socPercent, "%.0f", "—"), "BATT %", false);
drawAnnunciators(c, w, top);
drawBoxes(c, w, h, bottom);
keepAnimating(moving, FRAME_MS);
}
/** Sky and ground split by a horizon that moves with the power, a kW ladder, the aircraft. */
private void drawHorizon(Canvas c, float l, float t, float r, float b) {
float cx = (l + r) / 2f, cy = (t + b) / 2f, half = (b - t) / 2f;
// 150 kW lifts the nose by 0.8 of the half height; regeneration lowers it, 50 kW as far.
float pitch = v.powerKw == null ? 0f
: powerShown >= 0f ? Math.min(150f, powerShown) / 150f : Math.max(-50f, powerShown) / 50f * 0.6f;
float horizon = cy + pitch * half * 0.8f;
float perKw = half * 0.8f / 150f;
c.save();
rect.set(l, t, r, b);
c.clipRect(rect);
fill.setColor(sky);
c.drawRect(l, t, r, horizon, fill);
fill.setColor(ground);
c.drawRect(l, horizon, r, b, fill);
line.setColor(white);
line.setStrokeWidth(2.5f * dp);
c.drawLine(l, horizon, r, horizon, line);
// The ladder: a mark every 25 kW above the horizon (drawing), below it (regenerating).
text.setTextSize(13f * dp);
for (int kw = -50; kw <= 150; kw += 25) {
if (kw == 0) continue;
float y = horizon - kw * (kw > 0 ? perKw : perKw * 150f / 50f * 0.6f);
float half2 = kw % 50 == 0 ? (r - l) * 0.10f : (r - l) * 0.05f;
line.setStrokeWidth(1.5f * dp);
c.drawLine(cx - half2, y, cx + half2, y, line);
if (kw % 50 == 0) {
text.setColor(white);
text.setTextAlign(Paint.Align.LEFT);
c.drawText(String.valueOf(kw), cx + half2 + 6f * dp, y + 5f * dp, text);
text.setTextAlign(Paint.Align.RIGHT);
c.drawText(String.valueOf(kw), cx - half2 - 6f * dp, y + 5f * dp, text);
}
}
c.restore();
// The aircraft symbol, fixed: two wings and a dot, yellow outlined in black.
float wing = (r - l) * 0.14f;
for (int pass = 0; pass < 2; pass++) {
line.setColor(pass == 0 ? 0xFF000000 : yellow);
line.setStrokeWidth(pass == 0 ? 9f * dp : 5f * dp);
c.drawLine(cx - wing * 2f, cy, cx - wing, cy, line);
c.drawLine(cx - wing, cy, cx - wing, cy + wing * 0.25f, line);
c.drawLine(cx + wing * 2f, cy, cx + wing, cy, line);
c.drawLine(cx + wing, cy, cx + wing, cy + wing * 0.25f, line);
}
fill.setColor(yellow);
c.drawCircle(cx, cy, 5f * dp, fill);
// The power readout in magenta under the symbol.
text.setTextAlign(Paint.Align.CENTER);
text.setTextSize(22f * dp);
text.setColor(v.powerKw != null && v.powerKw < 0f ? cyan : magenta);
c.drawText(fmt(v.powerKw, "%.1f kW", "— kW"), cx, b - 14f * dp, text);
line.setColor(0xFF2A3038);
line.setStrokeWidth(2f * dp);
c.drawRect(l, t, r, b, line);
}
/**
* A vertical tape scrolling with its value: ticks every {@code minor}, numbers every
* {@code major}, and a readout box at the middle pointing in (right for the left tape).
*/
private void drawTape(Canvas c, float l, float t, float tw, float b, Float value, float min, float max,
float minor, float major, String readout, String title, boolean left) {
float cy = (t + b) / 2f, perUnit = (b - t) / (max - min) * (max > 150f ? 3.2f : 2.2f);
fill.setColor(tape);
c.drawRect(l, t, l + tw, b, fill);
c.save();
c.clipRect(l, t, l + tw, b);
float centre = value == null ? min : value;
text.setTextSize(16f * dp);
text.setColor(white);
line.setColor(white);
// No value, no scale: a tape centred on zero would read as 0.
for (float u = min; value != null && u <= max; u += minor) {
float y = cy - (u - centre) * perUnit;
if (y < t - 20f * dp || y > b + 20f * dp) continue;
boolean isMajor = Math.round(u) % Math.round(major) == 0;
float len = isMajor ? tw * 0.22f : tw * 0.12f;
line.setStrokeWidth(2f * dp);
float edge = left ? l + tw : l;
c.drawLine(edge, y, edge + (left ? -len : len), y, line);
if (isMajor) {
text.setTextAlign(left ? Paint.Align.RIGHT : Paint.Align.LEFT);
c.drawText(String.valueOf(Math.round(u)), left ? edge - len - 6f * dp : edge + len + 6f * dp, y + 6f * dp, text);
}
}
c.restore();
// The readout box with its pointer.
boolean low = !left && v.socPercent != null && v.socPercent < 20f;
float bh = 44f * dp;
path.reset();
if (left) {
path.moveTo(l + 2f * dp, cy - bh / 2f);
path.lineTo(l + tw - 12f * dp, cy - bh / 2f);
path.lineTo(l + tw + 2f * dp, cy);
path.lineTo(l + tw - 12f * dp, cy + bh / 2f);
path.lineTo(l + 2f * dp, cy + bh / 2f);
} else {
path.moveTo(l + tw - 2f * dp, cy - bh / 2f);
path.lineTo(l + 12f * dp, cy - bh / 2f);
path.lineTo(l - 2f * dp, cy);
path.lineTo(l + 12f * dp, cy + bh / 2f);
path.lineTo(l + tw - 2f * dp, cy + bh / 2f);
}
path.close();
fill.setColor(0xFF000000);
c.drawPath(path, fill);
line.setColor(low ? amber : white);
line.setStrokeWidth(2f * dp);
c.drawPath(path, line);
text.setTextAlign(Paint.Align.CENTER);
text.setTextSize(30f * dp);
text.setColor(low ? amber : green);
c.drawText(readout, l + tw / 2f + (left ? -4f * dp : 4f * dp), cy + 11f * dp, text);
text.setTextSize(14f * dp);
text.setColor(cyan);
c.drawText(title, l + tw / 2f, t - 8f * dp, text);
}
/** The annunciators: gear, drive state and the clock, green on black in boxes. */
private void drawAnnunciators(Canvas c, float w, float top) {
String gear = Gear.label(v.gear);
String state = v.powerKw == null ? "—" : v.powerKw < -0.3f ? "REGEN" : v.powerKw > 0.3f ? "DRIVE PWR" : "IDLE";
String[] items = { "GEAR " + (gear.isEmpty() ? "—" : gear), state, v.clock.isEmpty() ? "—" : v.clock };
float bw = w * 0.16f, gap = w * 0.02f, x = (w - 3 * bw - 2 * gap) / 2f, bt = 4f * dp, bb = top - 26f * dp;
if (bb - bt < 18f * dp) bb = bt + 22f * dp;
text.setTextSize((bb - bt) * 0.6f);
text.setTextAlign(Paint.Align.CENTER);
for (String item : items) {
line.setColor(0xFF2A3038);
line.setStrokeWidth(1.5f * dp);
c.drawRect(x, bt, x + bw, bb, line);
text.setColor(green);
c.drawText(item, x + bw / 2f, bb - (bb - bt) * 0.25f, text);
x += bw + gap;
}
}
/** Data boxes along the bottom, cyan labels over white values. */
private void drawBoxes(Canvas c, float w, float h, float top) {
String[][] boxes = {
{ "RANGE", fmt(v.rangeKm, "%.0f km", "—") },
{ "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "USAGE 5 MIN", fmt(v.usage5Min, "%.1f", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "—") },
{ "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f", "—") },
};
float gap = 8f * dp, bt = top + 14f * dp, bb = h - 4f * dp;
float bw = (w - gap * (boxes.length - 1)) / boxes.length;
for (int i = 0; i < boxes.length; i++) {
float x = i * (bw + gap);
line.setColor(0xFF2A3038);
line.setStrokeWidth(1.5f * dp);
c.drawRect(x, bt, x + bw, bb, line);
text.setTextAlign(Paint.Align.CENTER);
text.setTextSize(12f * dp);
text.setColor(cyan);
c.drawText(boxes[i][0], x + bw / 2f, bt + 17f * dp, text);
text.setTextSize(Math.min(24f * dp, (bb - bt) * 0.42f));
text.setColor(white);
c.drawText(boxes[i][1], x + bw / 2f, bb - 10f * dp, text);
}
}
}
@@ -0,0 +1,231 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RadialGradient;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
/**
* The Chronograph Overview style: a watch face.
*
* One large dial, deep blue with a steel bezel carrying the speed scale; the long main hand is
* the speed. Three sub-dials inside it: battery at nine o'clock, power at three (charge zone
* below zero in green), 5-minute usage at six. A window under twelve shows the gear. Either side
* of the watch, engraved columns: range and trip on the left, time, climate and odometer on the
* right. Unread values draw no hand and "—".
*
* Hands ease to new values and redraw only until settled, only on screen. The gradient is a
* Canvas shader with fixed colours (not a drawable with theme colours, which Android 9 rejects).
*/
final class ChronographView extends ClusterCanvasView {
private static final long FRAME_MS = 33;
private static final float START = 135f, SWEEP = 270f, MAX_KMH = 200f;
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dial = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF rect = new RectF();
private final int accent, steel = 0xFFC9CED3, steelDark = 0xFF6E757C, ivory = 0xFFF1EBDD,
green, red;
private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN, usageShown = Float.NaN;
ChronographView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
green = context.getColor(R.color.ev_ok);
red = context.getColor(R.color.ev_error);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
text.setTypeface(Typeface.create("serif", Typeface.NORMAL));
text.setFontFeatureSettings("tnum");
text.setTextAlign(Paint.Align.CENTER);
}
@Override
protected void onSizeChanged(int w, int h, int oldW, int oldH) {
super.onSizeChanged(w, h, oldW, oldH);
float r = h * 0.47f;
if (r > 0f) dial.setShader(new RadialGradient(0f, 0f, r * 0.88f, 0xFF20365A, 0xFF0A1424, Shader.TileMode.CLAMP));
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
speedShown = ease(speedShown, v.speedKmh, MAX_KMH);
socShown = ease(socShown, v.socPercent, 100f);
powerShown = ease(powerShown, v.powerKw, 200f);
usageShown = ease(usageShown, v.usage5Min == null ? null : v.usage5Min.floatValue(), 30f);
boolean moving = (v.speedKmh != null && speedShown != v.speedKmh)
|| (v.socPercent != null && socShown != v.socPercent)
|| (v.powerKw != null && powerShown != v.powerKw)
|| (v.usage5Min != null && usageShown != v.usage5Min.floatValue());
float r = h * 0.47f, cx = w / 2f, cy = h / 2f;
drawCase(c, cx, cy, r);
drawScale(c, cx, cy, r);
// Sub-dials.
float sr = r * 0.24f;
subDial(c, cx - r * 0.45f, cy, sr, 0f, 100f, 25f, v.socPercent == null ? null : socShown,
"BATTERY", fmt(v.socPercent, "%.0f%%", "—"), 20f, false);
subDial(c, cx + r * 0.45f, cy, sr, -50f, 150f, 50f, v.powerKw == null ? null : powerShown,
"kW", fmt(v.powerKw, "%.1f", "—"), null, true);
subDial(c, cx, cy + r * 0.45f, sr, 0f, 30f, 10f, v.usage5Min == null ? null : usageShown,
"kWh/100", fmt(v.usage5Min, "%.1f", "—"), null, false);
// The gear window under twelve, the maker's line above it.
text.setTextSize(r * 0.065f);
text.setColor(ivory);
text.setLetterSpacing(0.25f);
c.drawText("NILS' MG4", cx, cy - r * 0.50f, text);
text.setTextSize(r * 0.04f);
c.drawText("ELECTRIC · CHRONOGRAPH", cx, cy - r * 0.43f, text);
text.setLetterSpacing(0f);
String gear = Gear.label(v.gear);
rect.set(cx - r * 0.07f, cy - r * 0.36f, cx + r * 0.07f, cy - r * 0.22f);
fill.setColor(ivory);
c.drawRect(rect, fill);
line.setColor(steelDark);
line.setStrokeWidth(1.5f * dp);
c.drawRect(rect, line);
text.setColor(0xFF111111);
text.setTextSize(r * 0.10f);
c.drawText(gear.isEmpty() ? " " : gear, cx, cy - r * 0.255f, text);
// The speed: the long main hand, and the value small above the hub.
text.setColor(ivory);
text.setTextSize(r * 0.075f);
c.drawText(fmt(v.speedKmh, "%.0f km/h", "— km/h"), cx, cy - r * 0.10f, text);
if (v.speedKmh != null) hand(c, cx, cy, r * 0.86f, r * 0.15f, angle(speedShown, 0f, MAX_KMH), r * 0.022f, steel, accent);
fill.setColor(steel);
c.drawCircle(cx, cy, r * 0.035f, fill);
fill.setColor(accent);
c.drawCircle(cx, cy, r * 0.015f, fill);
drawColumns(c, w, h, cx, r);
keepAnimating(moving, FRAME_MS);
}
private static float angle(float value, float min, float max) {
return START + SWEEP * (Math.max(min, Math.min(max, value)) - min) / (max - min);
}
/** The case and bezel in steel, the dial deep blue with a soft light in the middle. */
private void drawCase(Canvas c, float cx, float cy, float r) {
fill.setShader(null);
fill.setColor(steelDark);
c.drawCircle(cx, cy, r * 1.04f, fill);
fill.setColor(steel);
c.drawCircle(cx, cy, r, fill);
fill.setColor(0xFF2A2F35);
c.drawCircle(cx, cy, r * 0.90f, fill);
c.save();
c.translate(cx, cy);
c.drawCircle(0f, 0f, r * 0.88f, dial);
c.restore();
}
/** The speed scale on the bezel ring: numbers every 20, ticks every 5; applied indices inside. */
private void drawScale(Canvas c, float cx, float cy, float r) {
text.setTextSize(r * 0.06f);
text.setColor(0xFF1A1D21);
for (int kmh = 0; kmh <= MAX_KMH; kmh += 5) {
double a = Math.toRadians(angle(kmh, 0f, MAX_KMH));
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
boolean major = kmh % 20 == 0;
line.setColor(major ? ivory : 0xAAF1EBDD);
line.setStrokeWidth(major ? r * 0.012f : r * 0.006f);
c.drawLine(cx + r * 0.87f * cos, cy + r * 0.87f * sin, cx + r * (major ? 0.79f : 0.83f) * cos,
cy + r * (major ? 0.79f : 0.83f) * sin, line);
if (major) {
c.drawText(String.valueOf(kmh), cx + r * 0.95f * cos, cy + r * 0.95f * sin + text.getTextSize() * 0.36f, text);
}
}
}
/** A small dial: ticks, end labels, a zone, the hand, its caption and value. */
private void subDial(Canvas c, float cx, float cy, float r, float min, float max, float major,
Float value, String caption, String readout, Float warnBelow, boolean chargeZone) {
fill.setColor(0xFF0C1626);
c.drawCircle(cx, cy, r, fill);
line.setColor(steelDark);
line.setStrokeWidth(1.5f * dp);
c.drawCircle(cx, cy, r, line);
rect.set(cx - r * 0.82f, cy - r * 0.82f, cx + r * 0.82f, cy + r * 0.82f);
line.setStrokeWidth(r * 0.06f);
line.setStrokeCap(Paint.Cap.BUTT);
if (warnBelow != null) {
line.setColor(red);
c.drawArc(rect, START, SWEEP * (warnBelow - min) / (max - min), false, line);
}
if (chargeZone) {
line.setColor(green);
c.drawArc(rect, START, SWEEP * (0f - min) / (max - min), false, line);
}
line.setStrokeCap(Paint.Cap.ROUND);
line.setColor(ivory);
for (float u = min; u <= max + 0.01f; u += major / 2f) {
double a = Math.toRadians(angle(u, min, max));
boolean isMajor = Math.round(u * 10) % Math.round(major * 10) == 0;
line.setStrokeWidth(isMajor ? r * 0.04f : r * 0.02f);
float outer = r * 0.92f, inner = isMajor ? r * 0.72f : r * 0.80f;
c.drawLine(cx + outer * (float) Math.cos(a), cy + outer * (float) Math.sin(a),
cx + inner * (float) Math.cos(a), cy + inner * (float) Math.sin(a), line);
}
text.setColor(ivory);
text.setTextSize(r * 0.16f);
c.drawText(caption, cx, cy - r * 0.32f, text);
text.setTextSize(r * 0.24f);
c.drawText(readout, cx, cy + r * 0.55f, text);
if (value != null) hand(c, cx, cy, r * 0.78f, r * 0.12f, angle(value, min, max), r * 0.05f, ivory, accent);
fill.setColor(ivory);
c.drawCircle(cx, cy, r * 0.07f, fill);
}
/** A hand: a tail behind the hub, a shaft, and a coloured tip. */
private void hand(Canvas c, float cx, float cy, float length, float tail, float deg, float width, int colour, int tip) {
double a = Math.toRadians(deg);
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
line.setColor(0x66000000);
line.setStrokeWidth(width * 1.6f);
c.drawLine(cx - tail * cos + 2f * dp, cy - tail * sin + 3f * dp, cx + length * cos + 2f * dp, cy + length * sin + 3f * dp, line);
line.setColor(colour);
line.setStrokeWidth(width);
c.drawLine(cx - tail * cos, cy - tail * sin, cx + length * cos, cy + length * sin, line);
line.setColor(tip);
c.drawLine(cx + length * 0.78f * cos, cy + length * 0.78f * sin, cx + length * cos, cy + length * sin, line);
}
/** Engraved columns either side of the watch. */
private void drawColumns(Canvas c, float w, float h, float cx, float r) {
float leftX = (cx - r * 1.04f) / 2f, rightX = w - leftX;
String[][] left = { { "RANGE", fmt(v.rangeKm, "%.0f km", "—") }, { "TRIP", fmt(v.tripKm, "%.1f km", "—") },
{ "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") } };
String[][] right = { { "TIME", v.clock.isEmpty() ? "—" : v.clock }, { "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") },
{ "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") } };
column(c, leftX, h, left);
column(c, rightX, h, right);
}
private void column(Canvas c, float x, float h, String[][] rows) {
float y = h * 0.26f;
for (String[] row : rows) {
text.setTextSize(12f * dp);
text.setLetterSpacing(0.25f);
text.setColor(0xFF9AA3AD);
c.drawText(row[0], x, y, text);
text.setLetterSpacing(0f);
text.setTextSize(30f * dp);
text.setColor(ivory);
c.drawText(row[1], x, y + 38f * dp, text);
y += h * 0.27f;
}
}
}
@@ -0,0 +1,45 @@
package com.evsuite.abrp;
import android.content.Context;
import android.view.View;
import java.util.Locale;
/**
* The plumbing the drawn Overview styles share: the latest {@link ClusterValues}, the density,
* needle easing and null-safe formatting ("—" for an unread value, never 0).
*/
abstract class ClusterCanvasView extends View {
protected ClusterValues v = new ClusterValues();
protected final float dp;
ClusterCanvasView(Context context) {
super(context);
dp = context.getResources().getDisplayMetrics().density;
}
void setValues(ClusterValues values) {
v = values == null ? new ClusterValues() : values;
invalidate();
}
/** One step of a needle towards its value; jumps there when close or on the first value. */
static float ease(float shown, Float target, float span) {
if (target == null || !Float.isFinite(target)) return shown;
if (Float.isNaN(shown)) return target;
float next = shown + (target - shown) * 0.25f;
return Math.abs(target - next) < span * 0.002f ? target : next;
}
/** The value in the pattern, or {@code none} when unread. */
static String fmt(Number value, String pattern, String none) {
if (value == null || Double.isNaN(value.doubleValue())) return none;
return String.format(Locale.US, pattern, value);
}
/** Keeps drawing while something moves, but only while the view is on screen. */
protected void keepAnimating(boolean moving, long frameMs) {
if (moving && isShown()) postInvalidateDelayed(frameMs);
}
}
@@ -66,6 +66,8 @@ public class MainActivity extends AppCompatActivity {
private CartoonView cartoonView;
private BlueprintView blueprintView;
private OctagonView octagonView;
/** The styles built on {@link ClusterCanvasView}, fed together. */
private final java.util.List<ClusterCanvasView> canvasStyles = new java.util.ArrayList<>();
/** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */
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) ----------
@@ -0,0 +1,92 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Typeface;
/**
* The Minimal Overview style: one number and almost nothing else.
*
* The speed, very large and thin, in the middle, the gear above it. Under it, power as a short
* line growing from the centre — right in the accent colour while drawing, left in green while
* regenerating. Along the bottom edge, the battery as a hairline filled to the charge, with the
* percentage and range at its ends. Power and clock sit small in the top corners, usage at the
* bottom. Unread values show "—" and draw no line.
*/
final class MinimalView extends ClusterCanvasView {
private final Paint big = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint small = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint bar = new Paint(Paint.ANTI_ALIAS_FLAG);
private final int accent, light, regen, warn, white, grey, track;
MinimalView(Context context) {
super(context);
accent = AccentColor.resolve(context, R.attr.evAccent);
light = AccentColor.resolve(context, R.attr.evAccentLight);
regen = context.getColor(R.color.ev_ok);
warn = context.getColor(R.color.ev_error);
white = context.getColor(R.color.ev_text_primary);
grey = context.getColor(R.color.ev_text_secondary);
track = context.getColor(R.color.ev_outline);
big.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL));
big.setTextAlign(Paint.Align.CENTER);
big.setFontFeatureSettings("tnum");
big.setColor(white);
small.setTypeface(Typeface.create("sans-serif-light", Typeface.NORMAL));
small.setFontFeatureSettings("tnum");
small.setLetterSpacing(0.06f);
bar.setStrokeCap(Paint.Cap.ROUND);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
// The gear, the speed, its unit.
String gear = Gear.label(v.gear);
text(c, gear.isEmpty() ? " " : gear, w / 2f, h * 0.17f, 26f * dp, light, Paint.Align.CENTER);
big.setTextSize(h * 0.50f);
c.drawText(fmt(v.speedKmh, "%.0f", "—"), w / 2f, h * 0.58f, big);
text(c, "km/h", w / 2f, h * 0.67f, 18f * dp, grey, Paint.Align.CENTER);
// Power: a line from the centre, its length up to a quarter of the width at 150 kW.
float y = h * 0.75f;
bar.setStrokeWidth(4f * dp);
bar.setColor(track);
c.drawLine(w / 2f - 1f * dp, y, w / 2f + 1f * dp, y, bar);
if (v.powerKw != null && Math.abs(v.powerKw) >= 0.1f) {
float len = w * 0.25f * Math.min(150f, Math.abs(v.powerKw)) / 150f * (v.powerKw < 0f ? 3f : 1f);
len = Math.min(len, w * 0.25f);
bar.setColor(v.powerKw < 0f ? regen : accent);
c.drawLine(w / 2f, y, w / 2f + (v.powerKw < 0f ? -len : len), y, bar);
}
// Corners: power and the clock; bottom middle: usage.
text(c, fmt(v.powerKw, "%.1f kW", "— kW"), 8f * dp, 30f * dp, 20f * dp, grey, Paint.Align.LEFT);
text(c, v.clock.isEmpty() ? "—" : v.clock, w - 8f * dp, 30f * dp, 20f * dp, grey, Paint.Align.RIGHT);
text(c, fmt(v.usage5Min, "%.1f kWh/100 km", "— kWh/100 km"), w / 2f, h * 0.86f, 16f * dp, grey, Paint.Align.CENTER);
// The battery: a hairline along the bottom, filled to the charge.
float by = h - 6f * dp;
bar.setStrokeWidth(3f * dp);
bar.setColor(track);
c.drawLine(8f * dp, by, w - 8f * dp, by, bar);
if (v.socPercent != null) {
bar.setColor(v.socPercent < 20f ? warn : accent);
c.drawLine(8f * dp, by, 8f * dp + (w - 16f * dp) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f, by, bar);
}
text(c, fmt(v.socPercent, "%.0f %%", "— %"), 8f * dp, by - 12f * dp, 20f * dp, white, Paint.Align.LEFT);
text(c, fmt(v.rangeKm, "%.0f km", "— km"), w - 8f * dp, by - 12f * dp, 20f * dp, white, Paint.Align.RIGHT);
}
private void text(Canvas c, String s, float x, float y, float size, int colour, Paint.Align align) {
small.setTextSize(size);
small.setColor(colour);
small.setTextAlign(align);
c.drawText(s, x, y, small);
}
}
@@ -0,0 +1,182 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Typeface;
/**
* The Nixie Overview style: glowing orange digits in glass tubes, on a dark walnut panel with
* brass name plates.
*
* Two rows of tube groups. Top, large: speed, the gear in a single tube, battery. Bottom,
* smaller: power (with its sign), range, 5-minute usage and the clock (with a neon colon).
* Each tube shows the unlit numerals faintly behind the lit one, and a fine mesh. Decimal
* points are small neon dots ({@link SevenSegment} lays the text out in tubes). Unread values
* light dashes. Redrawn only when a reading arrives.
*/
final class NixieView extends ClusterCanvasView {
private final Paint glass = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint rim = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint mesh = new Paint();
private final Paint ghost = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint digit = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint plate = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint engraving = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint bg = new Paint();
private final Paint grain = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint shine = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF rect = new RectF();
private final int neon = 0xFFFF8A1F, panel = 0xFF140C07;
NixieView(Context context) {
super(context);
glass.setColor(0xFF1C130D);
rim.setStyle(Paint.Style.STROKE);
rim.setColor(0xFF4A3524);
mesh.setColor(0x16FFB070);
Typeface face = Typeface.create("sans-serif-light", Typeface.NORMAL);
ghost.setTypeface(face);
ghost.setTextAlign(Paint.Align.CENTER);
ghost.setColor(0x22FF8A1F);
glow.setTypeface(face);
glow.setTextAlign(Paint.Align.CENTER);
glow.setStyle(Paint.Style.STROKE);
glow.setColor(0x55FF6A00);
digit.setTypeface(face);
digit.setTextAlign(Paint.Align.CENTER);
digit.setColor(neon);
plate.setColor(0xFF6B5230);
engraving.setColor(0xFFF0DDB2);
engraving.setTextAlign(Paint.Align.CENTER);
engraving.setTypeface(Typeface.create("serif", Typeface.BOLD));
engraving.setLetterSpacing(0.18f);
bg.setColor(panel);
grain.setColor(0x0EFFC890);
shine.setColor(0x18FFFFFF);
shine.setStyle(Paint.Style.STROKE);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
rect.set(0, 0, w, h);
c.drawRoundRect(rect, 20f * dp, 20f * dp, bg);
// Walnut grain: a few faint horizontal strokes.
grain.setStrokeWidth(dp);
for (int i = 0; i < 14; i++) {
float y = h * (0.04f + i * 0.07f);
c.drawLine(w * 0.02f, y + (i % 3) * 2f * dp, w * 0.98f, y - (i % 2) * 3f * dp, grain);
}
// Top row, large: speed, gear, battery.
float th = h * 0.42f, tw = th * 0.50f, gap = tw * 0.12f, ty = h * 0.06f;
String speed = fmt(v.speedKmh, "%.0f", "---");
String soc = fmt(v.socPercent, "%.0f", "---");
String gear = Gear.label(v.gear);
float groupGap = tw * 0.9f;
float total = 3 * tw + 2 * gap + groupGap + tw + groupGap + 3 * tw + 2 * gap;
float x = (w - total) / 2f;
x = tubes(c, speed, 3, x, ty, tw, th, gap, "KM/H");
x += groupGap;
x = tubes(c, gear.isEmpty() ? " " : gear.equals("—") ? "-" : gear, 1, x, ty, tw, th, gap, "GEAR");
x += groupGap;
tubes(c, soc, 3, x, ty, tw, th, gap, "BATTERY %");
// Bottom row, smaller: power, range, usage, clock.
// 14 tubes, 14 gaps, a colon and three group gaps: about 18.6 tube widths, fitted to the width.
float bw = Math.min(h * 0.24f * 0.52f, w * 0.96f / 18.6f), bh = bw / 0.52f, bgap = bw * 0.12f, by = h * 0.62f;
float bGroup = bw * 0.8f;
String power = v.powerKw == null ? "---" : String.format(java.util.Locale.US, "%.1f", v.powerKw);
String range = fmt(v.rangeKm, "%.0f", "---");
String usage = fmt(v.usage5Min, "%.1f", "--.-");
String clock = v.clock.length() == 5 ? v.clock : "--:--";
float colon = bw * 0.5f;
float bTotal = 14 * bw + 14 * bgap + colon + 3 * bGroup;
x = (w - bTotal) / 2f;
x = tubes(c, power, 4, x, by, bw, bh, bgap, "POWER KW");
x += bGroup;
x = tubes(c, range, 3, x, by, bw, bh, bgap, "RANGE KM");
x += bGroup;
x = tubes(c, usage, 3, x, by, bw, bh, bgap, "KWH/100");
x += bGroup;
float clockStart = x;
x = tubes(c, clock.substring(0, 2), 2, x, by, bw, bh, bgap, null);
// The colon: two neon dots.
float cx = x - bgap + colon / 2f + bgap / 2f;
neonDot(c, cx, by + bh * 0.38f, bw * 0.07f);
neonDot(c, cx, by + bh * 0.62f, bw * 0.07f);
x += colon;
x = tubes(c, clock.substring(3, 5), 2, x, by, bw, bh, bgap, null);
nameplate(c, (clockStart + x - bgap) / 2f, by + bh + 8f * dp, "TIME");
}
/**
* Tubes for the text, right-aligned in {@code cells}, from {@code x}; a name plate under
* them unless null. Returns the x after the last tube and its gap.
*/
private float tubes(Canvas c, String text, int cells, float x, float y, float tw, float th, float gap, String name) {
char[] chars = SevenSegment.cells(text, cells);
boolean[] dots = SevenSegment.dots(text, cells);
float start = x;
for (int i = 0; i < cells; i++) {
tube(c, x, y, tw, th, chars[i], dots[i]);
x += tw + gap;
}
if (name != null) nameplate(c, (start + x - gap) / 2f, y + th + 8f * dp, name);
return x;
}
private void tube(Canvas c, float x, float y, float tw, float th, char ch, boolean dot) {
rect.set(x, y, x + tw, y + th);
float radius = tw * 0.45f;
c.drawRoundRect(rect, radius, radius, glass);
// The mesh in front of the digits.
c.save();
c.clipRect(x + tw * 0.08f, y + th * 0.06f, x + tw * 0.92f, y + th * 0.94f);
float step = Math.max(3f * dp, tw * 0.07f);
for (float mx = x; mx < x + tw; mx += step) c.drawLine(mx, y, mx, y + th, mesh);
for (float my = y; my < y + th; my += step) c.drawLine(x, my, x + tw, my, mesh);
c.restore();
// The stacked cathodes, faint, then the lit one with its glow.
float size = th * 0.66f, base = y + th * 0.5f + size * 0.36f;
ghost.setTextSize(size);
c.drawText("8", x + tw / 2f, base, ghost);
c.drawText("0", x + tw / 2f, base, ghost);
if (ch != ' ') {
glow.setTextSize(size);
glow.setStrokeWidth(size * 0.08f);
c.drawText(String.valueOf(ch), x + tw / 2f, base, glow);
digit.setTextSize(size);
c.drawText(String.valueOf(ch), x + tw / 2f, base, digit);
}
if (dot) neonDot(c, x + tw * 0.85f, y + th * 0.84f, tw * 0.06f);
rim.setStrokeWidth(Math.max(1.5f * dp, tw * 0.025f));
c.drawRoundRect(rect, radius, radius, rim);
// A highlight on the glass.
shine.setStrokeWidth(tw * 0.05f);
c.drawLine(x + tw * 0.2f, y + th * 0.12f, x + tw * 0.2f, y + th * 0.45f, shine);
}
private void neonDot(Canvas c, float x, float y, float r) {
dotPaint.setColor(0x55FF6A00);
c.drawCircle(x, y, r * 2f, dotPaint);
dotPaint.setColor(neon);
c.drawCircle(x, y, r, dotPaint);
}
/** A brass plate with engraved capitals, centred at x. */
private void nameplate(Canvas c, float x, float y, String name) {
engraving.setTextSize(12f * dp);
float tw = engraving.measureText(name) + 20f * dp;
rect.set(x - tw / 2f, y, x + tw / 2f, y + 22f * dp);
c.drawRoundRect(rect, 4f * dp, 4f * dp, plate);
c.drawText(name, x, y + 16f * dp, engraving);
}
}
@@ -0,0 +1,119 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Locale;
/**
* The Terminal Overview style: text only, green phosphor on black, faint scan lines, as if a
* shell printed the car's state — each value on a row with an ASCII bar, the gear as [D], and
* a prompt with a blinking cursor (it blinks because readings redraw the view; it does not
* animate on its own). A low battery turns its row amber. Unread values show "—" and an empty
* bar.
*/
final class TerminalView extends ClusterCanvasView {
private static final int BAR = 24;
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint scan = new Paint();
private final int green, dim, amber;
TerminalView(Context context) {
super(context);
green = 0xFF33FF66;
dim = 0xFF1C8A3A;
amber = 0xFFFFB000;
text.setTypeface(Typeface.MONOSPACE);
scan.setColor(0x0CFFFFFF);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
c.drawColor(0xFF020A04);
// About 14 rows at 1.32 em each plus margins; monospace is about 0.6 em wide and the
// longest row about 58 characters.
float size = Math.min(h / 19.5f, w / 38f);
text.setTextSize(size);
float line = size * 1.32f, x = 16f * dp, y = 12f * dp + line;
String gear = gearRow();
print(c, "nils@mg4:~$ watch -n 0.2 vehicle-status", x, y, dim);
y += line * 1.4f;
print(c, row("SPEED", fmt(v.speedKmh, "%.0f km/h", "—")) + bar(v.speedKmh, 0f, 180f), x, y, green);
y += line;
print(c, row("POWER", fmt(v.powerKw, "%.1f kW", "—")) + powerBar(), x, y, green);
y += line;
boolean low = v.socPercent != null && v.socPercent < 20f;
print(c, row("BATTERY", fmt(v.socPercent, "%.0f %%", "—")) + bar(v.socPercent, 0f, 100f)
+ (low ? " ! LOW" : ""), x, y, low ? amber : green);
y += line;
print(c, row("RANGE", fmt(v.rangeKm, "%.0f km", "—")), x, y, green);
y += line;
print(c, row("GEAR", gear), x, y, green);
y += line * 1.4f;
print(c, row("USAGE", fmt(v.usage5Min, "%.1f kWh/100km", "—")) + " # last 5 min", x, y, green);
y += line;
print(c, row("SINCE", fmt(v.usageTrip, "%.1f kWh/100km", "—")) + " # trip "
+ fmt(v.tripKm, "%.1f km", "—"), x, y, green);
y += line;
print(c, row("CLIMATE", fmt(v.climateCelsius, "%.0f C", "—")) + " ODO " + fmt(v.odometerKm, "%06.0f km", "—"), x, y, green);
y += line;
print(c, row("TYRES", "FL " + v.tyreFl + " FR " + v.tyreFr + " RL " + v.tyreRl + " RR " + v.tyreRr), x, y, green);
y += line * 1.4f;
boolean cursor = (SystemClock.uptimeMillis() / 500) % 2 == 0;
print(c, "[" + (v.clock.isEmpty() ? "--:--" : v.clock) + "] nils@mg4:~$ " + (cursor ? "█" : ""), x, y, green);
// Scan lines over everything.
for (float sy = 0; sy < h; sy += 3f * dp) c.drawLine(0, sy, w, sy, scan);
}
private void print(Canvas c, String s, float x, float y, int colour) {
text.setColor(colour);
c.drawText(s, x, y, text);
}
private static String row(String label, String value) {
return String.format(Locale.US, "%-9s%-16s", label, value);
}
/** [#####.......] from min to max; empty when unread. */
private static String bar(Float value, float min, float max) {
int n = value == null ? 0 : Math.round(BAR * (Math.max(min, Math.min(max, value)) - min) / (max - min));
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < BAR; i++) sb.append(i < n ? '#' : '.');
return sb.append(']').toString();
}
/** −50…150 kW with a | at zero: < for regeneration to its left, # for drawing to its right. */
private String powerBar() {
int zero = BAR / 4;
int to = v.powerKw == null ? zero : zero + Math.round(BAR * Math.max(-50f, Math.min(150f, v.powerKw)) / 200f);
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < BAR; i++) {
if (i == zero) sb.append('|');
else if (v.powerKw != null && i < zero && i >= to) sb.append('<');
else if (v.powerKw != null && i > zero && i <= to) sb.append('#');
else sb.append('.');
}
return sb.append(']').toString();
}
private String gearRow() {
String[] letters = { "P", "R", "N", "D" };
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 4; i++) {
boolean on = v.gear != null && v.gear == i + 1;
sb.append(on ? "[" + letters[i] + "]" : " " + letters[i] + " ");
}
return sb.toString();
}
}