App 2.37.0: Overview styles Time circuits and Quest

Time circuits, after Back to the Future: three LED rows (the car as the
destination, the present time, the last departure from P), the speed in
red digits and a flux capacitor that flows with the power; at 88 km/h
it all flashes. Letters on fourteen segments (Segments14, tested).
Quest, after World of Warcraft's interface: player frame, minimap, gold
speed, cast bar, quest tracker, chat, action bar and the odometer as
experience. Both in their own colours, the app with them. In THEMES.md
with screenshots. Emulator only; not tried on the car.

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-26 18:54:48 +01:00
co-authored by Claude Opus 5.5
parent cba46504a6
commit 96b9659bdd
18 changed files with 1035 additions and 12 deletions
@@ -0,0 +1,404 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.Shader;
import android.graphics.Typeface;
import android.os.SystemClock;
import java.util.Calendar;
import java.util.Locale;
/**
* The Time circuits Overview style, after the time machine in Back to the Future: three rows of
* LED read-outs in a metal panel, labelled with embossed tape, a red speed display and a flux
* capacitor.
*
* The rows keep the film's look, each in its colour: red "DESTINATION" — the gear as a word
* (DRV, REV, NTL, PRK), the charge, the range (where you can still get to), power or
* regeneration on the lamps, and the usage over 5 minutes; green "PRESENT TIME" — today's date
* and time; amber "LAST TIME DEPARTED" — when the car last left P, dashes until it has been seen
* to. Right, the speed in large red digits and the flux capacitor, whose light runs to its
* centre faster with more power and outwards while regenerating; at 88 km/h it flashes. Along
* the bottom, trip, usage since start, climate and odometer. Unread values show dashes and light
* nothing. It animates (about 30 frames a second) only while there is power to show or at 88,
* and only on screen.
*/
final class TimeCircuitsView extends ClusterCanvasView {
private static final int RED = 0xFFFF3A26, GREEN = 0xFF3DFF5A, AMBER = 0xFFFFA726, WHITE_HOT = 0xFFFFF4D6;
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint metal = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF r = new RectF();
private final Typeface tape = Typeface.create("sans-serif-condensed", Typeface.BOLD);
private int metalFor = -1;
/** When the car last left P, wall clock; 0 until that has been seen. */
private long departedAt;
private Integer lastGear;
private float fluxPhase;
private long lastFrame;
TimeCircuitsView(Context context) {
super(context);
line.setStyle(Paint.Style.STROKE);
line.setStrokeCap(Paint.Cap.ROUND);
}
@Override
void setValues(ClusterValues values) {
// Readings arrive whether or not the style is shown, so the departure is never missed.
Integer gear = values == null ? null : values.gear;
if (gear != null && lastGear != null && lastGear == 1 && gear != 1) {
departedAt = System.currentTimeMillis();
}
if (gear != null) lastGear = gear;
super.setValues(values);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
if (w <= 0 || h <= 0) return;
float u = Math.min(h / 640f, w / 1100f);
advance();
c.drawColor(0xFF050505);
float split = w * 0.64f;
drawPanel(c, 12f * u, 10f * u, split - 6f * u, h - 10f * u, u);
float rowLeft = 34f * u, rowRight = split - 24f * u;
drawCarRow(c, rowLeft, rowRight, 22f * u, u);
drawTimeRow(c, rowLeft, rowRight, 170f * u, u, GREEN, "PRESENT TIME", System.currentTimeMillis());
drawTimeRow(c, rowLeft, rowRight, 318f * u, u, AMBER, "LAST TIME DEPARTED", departedAt);
drawBottom(c, rowLeft, rowRight, 470f * u, u);
drawPanel(c, split + 6f * u, 10f * u, w - 12f * u, h - 10f * u, u);
drawSpeed(c, split + 28f * u, w - 28f * u, 26f * u, u);
drawFlux(c, split + 28f * u, w - 28f * u, 262f * u, h - 26f * u, u);
boolean moving = (v.powerKw != null && Math.abs(v.powerKw) > 0.5f) || at88();
keepAnimating(moving, 33);
}
private boolean at88() {
return v.speedKmh != null && v.speedKmh >= 88f;
}
private void advance() {
long now = SystemClock.uptimeMillis();
float dt = lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f);
lastFrame = now;
if (v.powerKw != null) fluxPhase = (fluxPhase + v.powerKw * 0.025f * dt + 1000f) % 1f;
}
// ---------- Panels and tape ----------
private void drawPanel(Canvas c, float l, float t, float rr, float b, float u) {
if (metalFor != (int) (b - t)) {
metal.setShader(new LinearGradient(0, t, 0, b, 0xFF2C2D31, 0xFF18191B, Shader.TileMode.CLAMP));
metalFor = (int) (b - t);
}
r.set(l, t, rr, b);
c.drawRoundRect(r, 10f * u, 10f * u, metal);
line.setColor(0xFF45464B);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 10f * u, 10f * u, line);
fill.setColor(0xFF5A5B60);
for (float x : new float[] { l + 14f * u, rr - 14f * u }) {
for (float y : new float[] { t + 14f * u, b - 14f * u }) c.drawCircle(x, y, 4f * u, fill);
}
}
/** Embossed label tape: black, raised white capitals, centred on x. */
private void tape(Canvas c, String s, float x, float y, float u, int tint) {
text.setTypeface(tape);
text.setTextSize(18f * u);
text.setLetterSpacing(0.18f);
text.setTextAlign(Paint.Align.CENTER);
float tw = text.measureText(s);
r.set(x - tw / 2f - 14f * u, y - 18f * u, x + tw / 2f + 14f * u, y + 7f * u);
fill.setColor(tint);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
fill.setColor(0x22FFFFFF);
c.drawRect(r.left + 4f * u, r.top + 3f * u, r.right - 4f * u, r.top + 5f * u, fill);
text.setColor(0xFFF2F2F2);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
/** A small caption above a field. */
private void caption(Canvas c, String s, float x, float y, float u) {
text.setTypeface(tape);
text.setTextSize(13f * u);
text.setLetterSpacing(0.12f);
text.setTextAlign(Paint.Align.CENTER);
text.setColor(0xFFBFC1C6);
c.drawText(s, x, y, text);
text.setLetterSpacing(0f);
}
// ---------- Rows ----------
/** The cells of one field: 'a' alphanumeric (fourteen segments), 'n' digits, 'l' lamps, ':' colon. */
private static final class Field {
final char kind;
final int cells;
final String caption;
Field(char kind, int cells, String caption) {
this.kind = kind;
this.cells = cells;
this.caption = caption;
}
}
private static final Field[] CAR = {
new Field('a', 3, "GEAR"), new Field('n', 3, "CHARGE %"), new Field('n', 4, "RANGE KM"),
new Field('l', 0, ""), new Field('n', 3, "USAGE"),
};
private static final Field[] TIME = {
new Field('a', 3, "MONTH"), new Field('n', 2, "DAY"), new Field('n', 4, "YEAR"),
new Field('l', 0, ""), new Field('n', 2, "HOUR"), new Field(':', 0, ""), new Field('n', 2, "MIN"),
};
private void drawCarRow(Canvas c, float left, float right, float top, float u) {
String charge = v.socPercent == null ? "---" : String.format(Locale.US, "%3.0f", v.socPercent).replace(' ', '0');
String range = v.rangeKm == null ? "----" : String.format(Locale.US, "%04.0f", Math.min(9999f, v.rangeKm));
String usage = v.usage5Min == null ? "---" : String.format(Locale.US, "%.1f", Math.min(99.9, Math.abs(v.usage5Min)));
boolean regen = v.powerKw != null && v.powerKw < -0.5f, power = v.powerKw != null && v.powerKw > 0.5f;
drawRow(c, left, right, top, u, RED, "DESTINATION", CAR,
new String[] { Segments14.gearWord(v.gear), charge, range, null, usage },
new String[] { "POWER", "REGEN" }, new boolean[] { power, regen });
}
private void drawTimeRow(Canvas c, float left, float right, float top, float u, int colour, String title, long when) {
String[] values;
boolean[] lamps = { false, false };
if (when <= 0) {
values = new String[] { "---", "--", "----", null, "--", null, "--" };
} else {
Calendar cal = Calendar.getInstance();
cal.setTimeInMillis(when);
int hour = cal.get(Calendar.HOUR_OF_DAY);
values = new String[] { Segments14.month(cal.get(Calendar.MONTH)),
String.format(Locale.US, "%02d", cal.get(Calendar.DAY_OF_MONTH)),
String.format(Locale.US, "%04d", cal.get(Calendar.YEAR)), null,
String.format(Locale.US, "%02d", hour), null,
String.format(Locale.US, "%02d", cal.get(Calendar.MINUTE)) };
lamps = new boolean[] { hour < 12, hour >= 12 };
}
drawRow(c, left, right, top, u, colour, title, TIME, values, new String[] { "AM", "PM" }, lamps);
}
private void drawRow(Canvas c, float left, float right, float top, float u, int colour, String title,
Field[] fields, String[] values, String[] lampNames, boolean[] lampsOn) {
float boxTop = top + 22f * u, boxH = 78f * u, gap = 14f * u, pad = 8f * u;
float lampW = 74f * u, colonW = 16f * u;
int cells = 0;
float fixed = 0;
for (Field f : fields) {
cells += f.cells;
if (f.kind == 'l') fixed += lampW;
else if (f.kind == ':') fixed += colonW;
else fixed += 2 * pad;
}
fixed += gap * (fields.length - 1);
float cw = Math.min(boxH * 0.58f, (right - left - fixed) / cells);
float total = cells * cw + fixed, x = left + (right - left - total) / 2f;
for (int i = 0; i < fields.length; i++) {
Field f = fields[i];
if (f.kind == 'l') {
drawLamps(c, x, boxTop, lampW, boxH, u, colour, lampNames, lampsOn);
x += lampW + gap;
continue;
}
if (f.kind == ':') {
fill.setColor(colour);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.36f, 3.5f * u, fill);
c.drawCircle(x + colonW / 2f, boxTop + boxH * 0.64f, 3.5f * u, fill);
x += colonW + gap;
continue;
}
float bw = f.cells * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, f.caption, x + bw / 2f, top + 14f * u, u);
drawText(c, values[i], f.kind == 'a', x + pad, boxTop + 8f * u, cw, boxH - 16f * u, f.cells, colour, u);
x += bw + gap;
}
tape(c, title, (left + right) / 2f, boxTop + boxH + 34f * u, u, 0xFF0E0E0F);
}
private void drawLamps(Canvas c, float x, float top, float w, float h, float u, int colour,
String[] names, boolean[] on) {
for (int k = 0; k < 2; k++) {
float cy = top + h * (k == 0 ? 0.3f : 0.72f), cx = x + w - 16f * u;
caption(c, names[k], x + 22f * u, cy + 5f * u, u);
fill.setColor(on[k] ? AccentColor.withAlpha(colour, 0x55) : 0x00000000);
c.drawCircle(cx, cy, 11f * u, fill);
fill.setColor(on[k] ? colour : AccentColor.withAlpha(colour, 0x30));
c.drawCircle(cx, cy, 6f * u, fill);
}
}
// ---------- Segments ----------
/** Right-aligned text in cells; a '.' joins the cell before it. */
private void drawText(Canvas c, String s, boolean alpha, float x, float y, float cw, float ch,
int cells, int colour, float u) {
char[] chars = SevenSegment.cells(s == null ? "" : s, cells);
boolean[] dots = SevenSegment.dots(s == null ? "" : s, cells);
for (int i = 0; i < cells; i++) {
int mask = alpha ? Segments14.mask(chars[i]) : from7(SevenSegment.mask(chars[i]));
drawCell(c, x + i * cw, y, cw, ch, mask, alpha, colour, u);
if (dots[i]) {
fill.setColor(colour);
c.drawCircle(x + (i + 1) * cw - cw * 0.06f, y + ch - cw * 0.1f, cw * 0.07f, fill);
}
}
}
/** Seven-segment bits as fourteen-segment ones (the middle bar is both halves). */
private static int from7(int m) {
int out = m & 63; // a–f share their bits
if ((m & SevenSegment.G) != 0) out |= Segments14.G1 | Segments14.G2;
return out;
}
private void drawCell(Canvas c, float x, float y, float cw, float ch, int mask, boolean alpha, int colour, float u) {
float m = cw * 0.16f, t = cw * 0.12f;
float x0 = x + m, x1 = x + cw - m, xc = (x0 + x1) / 2f, y0 = y + t, y1 = y + ch - t, ym = (y0 + y1) / 2f;
float[][] segs = {
{ x0, y0, x1, y0, Segments14.A }, { x1, y0, x1, ym, Segments14.B }, { x1, ym, x1, y1, Segments14.C },
{ x0, y1, x1, y1, Segments14.D }, { x0, ym, x0, y1, Segments14.E }, { x0, y0, x0, ym, Segments14.F },
{ x0, ym, xc, ym, Segments14.G1 }, { xc, ym, x1, ym, Segments14.G2 },
{ x0, y0, xc, ym, Segments14.H }, { xc, y0, xc, ym, Segments14.I }, { x1, y0, xc, ym, Segments14.J },
{ x0, y1, xc, ym, Segments14.K }, { xc, ym, xc, y1, Segments14.L }, { x1, y1, xc, ym, Segments14.M },
};
for (float[] s : segs) {
int bit = (int) s[4];
if (!alpha && bit >= Segments14.H) continue; // digits have no inner segments
boolean on = (mask & bit) != 0;
float dx = s[2] - s[0], dy = s[3] - s[1], len = (float) Math.hypot(dx, dy);
float g = Math.min(t * 0.8f, len * 0.3f), ux = dx / len * g, uy = dy / len * g;
float ax = s[0] + ux, ay = s[1] + uy, bx = s[2] - ux, by = s[3] - uy;
if (on) {
line.setColor(AccentColor.withAlpha(colour, 0x38));
line.setStrokeWidth(t * 2.4f);
c.drawLine(ax, ay, bx, by, line);
}
line.setColor(on ? colour : AccentColor.withAlpha(colour, 0x1C));
line.setStrokeWidth(bit >= Segments14.H ? t * 0.8f : t);
c.drawLine(ax, ay, bx, by, line);
}
}
// ---------- Bottom read-outs ----------
private void drawBottom(Canvas c, float left, float right, float top, float u) {
String[][] cells = {
{ "TRIP KM", fmt(v.tripKm, "%.1f", "---"), "5" },
{ "SINCE START", fmt(v.usageTrip, "%.1f", "---"), "4" },
{ "CLIMATE °C", fmt(v.climateCelsius, "%.0f", "--"), "3" },
{ "ODOMETER KM", fmt(v.odometerKm, "%.0f", "------"), "6" },
};
float gap = 18f * u, boxH = 62f * u, boxTop = top + 22f * u, pad = 8f * u;
int total = 0;
for (String[] cell : cells) total += Integer.parseInt(cell[2]);
float cw = Math.min(boxH * 0.55f, (right - left - gap * (cells.length - 1) - cells.length * 2 * pad) / total);
float width = total * cw + gap * (cells.length - 1) + cells.length * 2 * pad;
float x = left + (right - left - width) / 2f;
for (String[] cell : cells) {
int n = Integer.parseInt(cell[2]);
float bw = n * cw + 2 * pad;
r.set(x, boxTop, x + bw, boxTop + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 4f * u, 4f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2f * u);
c.drawRoundRect(r, 4f * u, 4f * u, line);
caption(c, cell[0], x + bw / 2f, top + 14f * u, u);
drawText(c, cell[1], false, x + pad, boxTop + 7f * u, cw, boxH - 14f * u, n, AMBER, u);
x += bw + gap;
}
}
// ---------- Speed and flux capacitor ----------
private void drawSpeed(Canvas c, float left, float right, float top, float u) {
float boxH = 168f * u, cells = 3;
float cw = Math.min(boxH * 0.56f, (right - left - 40f * u) / cells);
float bw = cells * cw + 40f * u, x = (left + right - bw) / 2f;
r.set(x, top, x + bw, top + boxH);
fill.setColor(0xFF020202);
c.drawRoundRect(r, 6f * u, 6f * u, fill);
line.setColor(0xFF5C5D62);
line.setStrokeWidth(2.5f * u);
c.drawRoundRect(r, 6f * u, 6f * u, line);
String speed = v.speedKmh == null ? "---" : String.format(Locale.US, "%.0f", Math.min(999f, v.speedKmh));
// At 88 the digits burn white-hot, and flicker.
boolean hot = at88() && (SystemClock.uptimeMillis() / 90) % 3 != 0;
drawText(c, speed, false, x + 20f * u, top + 14f * u, cw, boxH - 28f * u, 3, hot ? WHITE_HOT : RED, u);
tape(c, at88() ? "88 KM/H — HOLD ON" : "KM/H", (left + right) / 2f, top + boxH + 34f * u, u, 0xFF0E0E0F);
}
/** Three glass tubes in a Y, light running to the centre with the power (outwards in regen). */
private void drawFlux(Canvas c, float left, float right, float top, float bottom, float u) {
float labelSpace = 70f * u, boxBottom = bottom - labelSpace;
float size = Math.min(right - left, boxBottom - top);
float cx = (left + right) / 2f, bx = cx - size / 2f;
r.set(bx, top, bx + size, boxBottom);
fill.setColor(0xFF0B0C0E);
c.drawRoundRect(r, 8f * u, 8f * u, fill);
line.setColor(0xFF6A6B70);
line.setStrokeWidth(3f * u);
c.drawRoundRect(r, 8f * u, 8f * u, line);
float cy = top + (boxBottom - top) * 0.52f, inset = size * 0.14f;
float[][] ends = { { bx + inset, top + inset }, { bx + size - inset, top + inset }, { cx, boxBottom - inset } };
boolean flash = at88();
boolean live = v.powerKw != null;
float strength = live ? Math.min(1f, Math.abs(v.powerKw) / 60f) : 0f;
for (float[] e : ends) {
line.setColor(0xFF2A2C30);
line.setStrokeWidth(16f * u);
c.drawLine(e[0], e[1], cx, cy, line);
line.setColor(flash ? WHITE_HOT : AccentColor.withAlpha(WHITE_HOT, live ? 0x40 + (int) (0x60 * strength) : 0x18));
line.setStrokeWidth(5f * u);
c.drawLine(e[0], e[1], cx, cy, line);
if (live && Math.abs(v.powerKw) > 0.5f) {
// Pulses along the tube: phase 0 at the end, 1 at the centre.
for (int k = 0; k < 4; k++) {
float p = (fluxPhase + k / 4f) % 1f;
float px = e[0] + (cx - e[0]) * p, py = e[1] + (cy - e[1]) * p;
fill.setColor(AccentColor.withAlpha(WHITE_HOT, 0x50));
c.drawCircle(px, py, 12f * u, fill);
fill.setColor(WHITE_HOT);
c.drawCircle(px, py, 5f * u, fill);
}
}
fill.setColor(0xFF6A6B70);
c.drawCircle(e[0], e[1], 12f * u, fill);
}
fill.setColor(AccentColor.withAlpha(WHITE_HOT, flash ? 0xA0 : live ? 0x30 + (int) (0x70 * strength) : 0x14));
c.drawCircle(cx, cy, 34f * u, fill);
fill.setColor(flash || (live && strength > 0.05f) ? WHITE_HOT : 0xFF3A3B40);
c.drawCircle(cx, cy, 13f * u, fill);
tape(c, "FLUX CAPACITOR", cx, boxBottom + 30f * u, u, 0xFF0E0E0F);
String power = !live ? "— KILOWATTS"
: v.powerKw < -0.5f ? String.format(Locale.US, "REGEN %.1f KILOWATTS", -v.powerKw)
: String.format(Locale.US, "%.1f KILOWATTS", v.powerKw);
tape(c, power, cx, boxBottom + 62f * u, u, 0xFF5A1410);
}
}