App 2.22.0: Overview style Cockpit, segmented rings and a power trace

A third Overview style beside Tiles and Dashboard: rings for battery,
power (zero at the top, regeneration anticlockwise in green) with the
speed and gear inside, and 5-minute usage with a tick for the average
since start; below, the power of the last two minutes, the clock,
climate and odometer. Unread values light nothing and show "—"; the
trace leaves gaps (PowerTrace, unit-tested). 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 16:48:49 +02:00
co-authored by Claude Opus 5.5
parent 234a7e1b60
commit cd0dfcd612
11 changed files with 634 additions and 13 deletions
@@ -0,0 +1,276 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.View;
/**
* The Cockpit's instrument: a ring of segments open at the bottom, lit up to the value, brighter
* towards the leading segment, with text inside — a small label on top, the value large, its
* unit, and up to two caption lines.
*
* Two kinds of scale: from {@code min} at the lower left round to {@code max} at the lower right,
* or centred ({@link #setCentred}): zero at the top, positive values clockwise in the accent
* colour, negative ones (regeneration) anticlockwise in green. An optional marker shows a second
* value on the same scale (an average). Null draws no lit segments and the text "—": an unread
* value is never shown as zero. Canvas only, no gradient drawables (Android 9 crashes on those
* with theme colours).
*/
public final class RingView extends View {
private static final float START_DEG = 135f;
private static final float SWEEP_DEG = 270f;
private static final float TOP_DEG = 270f;
private final Paint track = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint lit = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint halo = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint hairline = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint markerPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint topPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint pill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint big = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint unitPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint caption = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint scale = new Paint(Paint.ANTI_ALIAS_FLAG);
private final RectF oval = new RectF();
private final RectF pillRect = new RectF();
private final int accent, regen, warn;
private float min = 0f, max = 100f;
private boolean centred;
private int segments = 48;
private Float warnBelow;
private String minLabel, maxLabel, zeroLabel;
private float bigScale = 0.40f;
private boolean topPill;
private String top, valueText = "—", unit = "", caption1, caption2;
private Float target, marker;
private float shown = Float.NaN;
public RingView(Context context, AttributeSet attrs) {
super(context, attrs);
accent = AccentColor.resolve(context, R.attr.evAccent);
int light = AccentColor.resolve(context, R.attr.evAccentLight);
regen = context.getColor(R.color.ev_ok);
warn = context.getColor(R.color.ev_error);
track.setStyle(Paint.Style.STROKE);
track.setColor(AccentColor.withAlpha(accent, 0x26));
lit.setStyle(Paint.Style.STROKE);
halo.setStyle(Paint.Style.STROKE);
hairline.setStyle(Paint.Style.STROKE);
hairline.setColor(context.getColor(R.color.ev_outline));
markerPaint.setColor(context.getColor(R.color.ev_text_primary));
markerPaint.setStrokeCap(Paint.Cap.ROUND);
topPaint.setColor(light);
topPaint.setTextAlign(Paint.Align.CENTER);
topPaint.setFakeBoldText(true);
pill.setStyle(Paint.Style.STROKE);
pill.setColor(AccentColor.withAlpha(accent, 0x99));
big.setColor(context.getColor(R.color.ev_text_primary));
big.setTextAlign(Paint.Align.CENTER);
big.setFontFeatureSettings("tnum");
big.setTypeface(android.graphics.Typeface.create("sans-serif-light", android.graphics.Typeface.NORMAL));
unitPaint.setColor(light);
unitPaint.setTextAlign(Paint.Align.CENTER);
unitPaint.setLetterSpacing(0.12f);
caption.setColor(context.getColor(R.color.ev_text_secondary));
caption.setTextAlign(Paint.Align.CENTER);
caption.setFontFeatureSettings("tnum");
scale.setColor(context.getColor(R.color.ev_text_secondary));
scale.setTextAlign(Paint.Align.CENTER);
scale.setFontFeatureSettings("tnum");
}
/** A scale from min (lower left) to max (lower right), in this many segments. */
void configure(float min, float max, int segments) {
this.min = min;
this.max = max;
this.segments = Math.max(4, segments);
this.centred = false;
invalidate();
}
/** A scale with zero at the top: max clockwise, min (negative) anticlockwise. */
void setCentred(float min, float max, int segments) {
configure(min, max, segments);
this.centred = true;
}
/** Labels at the two ends of the scale, and at the top (centred scales). */
void setScaleLabels(String minLabel, String zeroLabel, String maxLabel) {
this.minLabel = minLabel;
this.zeroLabel = zeroLabel;
this.maxLabel = maxLabel;
invalidate();
}
/** Lit segments in the warning colour while the value is below this (a low battery). */
void setWarnBelow(Float value) {
warnBelow = value;
invalidate();
}
/** The size of the value text, as a fraction of the radius. */
void setBigScale(float fraction) {
bigScale = fraction;
invalidate();
}
/** The label on top in an outlined pill (the gear) instead of as a plain title. */
void setTopPill(boolean pill) {
topPill = pill;
invalidate();
}
/**
* The value (null when unread) and the texts: the label on top (null for none), the value's
* digits ("—" when null), its unit and two caption lines (null for none).
*/
void setValue(Float value, String top, String valueText, String unit, String caption1, String caption2) {
target = value == null || !Float.isFinite(value) ? null : value;
this.top = top;
this.valueText = valueText == null ? "—" : valueText;
this.unit = unit == null ? "" : unit;
this.caption1 = caption1;
this.caption2 = caption2;
if (target != null && Float.isNaN(shown)) shown = target;
invalidate();
}
/** A second value on the same scale, as a tick on the ring; null for none. */
void setMarker(Float value) {
marker = value == null || !Float.isFinite(value) ? null : value;
invalidate();
}
/** The angle of a value; on a centred scale each side has half the sweep. */
private float angleOf(float v) {
v = Math.max(min, Math.min(max, v));
if (!centred) return START_DEG + SWEEP_DEG * (v - min) / (max - min);
float half = SWEEP_DEG / 2f;
if (v >= 0f) return TOP_DEG + (max > 0f ? half * v / max : 0f);
return TOP_DEG - (min < 0f ? half * v / min : 0f);
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
// The ring is open at the bottom, so the circle may hang below the view by the part of
// the gap that holds only the end labels.
float r = Math.min(w / 2f, h / 1.86f) * 0.96f;
if (r <= 0f) return;
float cx = w / 2f, cy = h / 2f + r * 0.06f;
float thick = r * 0.13f;
float rr = r - thick / 2f;
oval.set(cx - rr, cy - rr, cx + rr, cy + rr);
track.setStrokeWidth(thick);
lit.setStrokeWidth(thick);
halo.setStrokeWidth(thick * 1.7f);
// The value, eased towards its target.
boolean animating = false;
if (target != null) {
shown += (target - shown) * 0.25f;
if (Math.abs(target - shown) < (max - min) * 0.001f) shown = target;
animating = shown != target;
}
float seg = SWEEP_DEG / segments;
float gap = Math.min(seg * 0.32f, 2.2f);
float zeroAngle = centred ? TOP_DEG : START_DEG;
float valueAngle = target == null ? zeroAngle : angleOf(shown);
float from = Math.min(zeroAngle, valueAngle), to = Math.max(zeroAngle, valueAngle);
boolean negative = valueAngle < zeroAngle;
int colour = negative ? regen
: warnBelow != null && target != null && shown < warnBelow ? warn : accent;
float span = Math.max(seg, to - from);
for (int i = 0; i < segments; i++) {
float a0 = START_DEG + i * seg;
float mid = a0 + seg / 2f;
boolean on = target != null && mid >= from && mid <= to;
if (!on) {
c.drawArc(oval, a0 + gap / 2f, seg - gap, false, track);
continue;
}
// Brighter away from zero, towards the leading segment.
float f = Math.max(0f, Math.min(1f, (negative ? to - mid : mid - from) / span));
int alpha = (int) (0x55 + (0xFF - 0x55) * f);
lit.setColor(AccentColor.withAlpha(colour, alpha));
c.drawArc(oval, a0 + gap / 2f, seg - gap, false, lit);
}
if (target != null && to - from >= seg * 0.5f) {
// A soft halo on the leading segment.
float lead = negative ? from : to;
halo.setColor(AccentColor.withAlpha(colour, 0x33));
c.drawArc(oval, lead - (negative ? 0f : seg), seg, false, halo);
}
// A fine inner ring, and the scale's zero at the top.
hairline.setStrokeWidth(Math.max(1f, r * 0.006f));
float ir = r - thick - r * 0.05f;
oval.set(cx - ir, cy - ir, cx + ir, cy + ir);
c.drawArc(oval, START_DEG, SWEEP_DEG, false, hairline);
if (centred) {
markerPaint.setStrokeWidth(r * 0.012f);
c.drawLine(cx, cy - ir, cx, cy - ir + r * 0.05f, markerPaint);
}
if (marker != null) {
double a = Math.toRadians(angleOf(marker));
float cos = (float) Math.cos(a), sin = (float) Math.sin(a);
markerPaint.setStrokeWidth(r * 0.022f);
float o = r + r * 0.015f, in = r - thick - r * 0.03f;
c.drawLine(cx + o * cos, cy + o * sin, cx + in * cos, cy + in * sin, markerPaint);
}
// End labels, tucked into the gap under the ends of the ring.
scale.setTextSize(r * 0.075f);
drawScaleLabel(c, cx, cy, rr, START_DEG - 9f, minLabel, negative && target != null ? regen : 0);
drawScaleLabel(c, cx, cy, rr, START_DEG + SWEEP_DEG + 9f, maxLabel, 0);
if (centred && zeroLabel != null) {
c.drawText(zeroLabel, cx, cy - ir + r * 0.05f + scale.getTextSize() * 1.15f, scale);
}
// Inside: the label on top in a pill, the value, the unit, the captions.
if (top != null) {
topPaint.setTextSize(r * (topPill ? 0.11f : 0.075f));
topPaint.setLetterSpacing(topPill ? 0f : 0.16f);
float ty = cy - r * 0.40f;
if (topPill) {
float tw = Math.max(topPaint.measureText(top), topPaint.getTextSize() * 0.7f);
float ph = topPaint.getTextSize() * 1.45f;
pillRect.set(cx - tw / 2f - ph * 0.45f, ty - ph * 0.72f, cx + tw / 2f + ph * 0.45f, ty + ph * 0.28f);
pill.setStrokeWidth(Math.max(1.5f, r * 0.008f));
c.drawRoundRect(pillRect, ph / 2f, ph / 2f, pill);
}
c.drawText(top, cx, ty, topPaint);
}
big.setTextSize(r * bigScale);
c.drawText(valueText, cx, cy + big.getTextSize() * 0.30f, big);
unitPaint.setTextSize(r * 0.085f);
c.drawText(unit, cx, cy + big.getTextSize() * 0.30f + r * 0.15f, unitPaint);
caption.setTextSize(r * 0.085f);
float y = cy + big.getTextSize() * 0.30f + r * 0.34f;
if (caption1 != null) c.drawText(caption1, cx, y, caption);
if (caption2 != null) c.drawText(caption2, cx, y + caption.getTextSize() * 1.3f, caption);
if (animating) postInvalidateOnAnimation();
}
private void drawScaleLabel(Canvas c, float cx, float cy, float radius, float deg, String label, int colour) {
if (label == null) return;
double a = Math.toRadians(deg);
int old = scale.getColor();
if (colour != 0) scale.setColor(colour);
c.drawText(label, cx + radius * (float) Math.cos(a),
cy + radius * (float) Math.sin(a) + scale.getTextSize() * 0.36f, scale);
scale.setColor(old);
}
}