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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user