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
@@ -67,6 +67,7 @@ public class MainActivity extends AppCompatActivity {
private View logPane;
private View vehiclePane;
private View dashboardPane;
private View cockpitPane;
private View consumptionPane;
private View chargingPane;
/** Display, battery, charging price and Wi-Fi. */
@@ -244,6 +245,7 @@ public class MainActivity extends AppCompatActivity {
serverPane = inflatePane(R.layout.pane_server);
vehiclePane = inflatePane(R.layout.pane_vehicle);
dashboardPane = inflatePane(R.layout.pane_dashboard);
cockpitPane = inflatePane(R.layout.pane_cockpit);
// 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(
@@ -251,6 +253,7 @@ public class MainActivity extends AppCompatActivity {
android.view.ViewGroup.LayoutParams.MATCH_PARENT));
overviewPage.addView(vehiclePane);
overviewPage.addView(dashboardPane);
overviewPage.addView(cockpitPane);
consumptionPane = inflatePane(R.layout.pane_consumption);
chargingPane = inflatePane(R.layout.pane_charging);
settingsPane = inflatePane(R.layout.pane_settings);
@@ -261,6 +264,7 @@ public class MainActivity extends AppCompatActivity {
UpdateLog.noteVersion(this, BuildConfig.VERSION_NAME);
bindVehiclePane();
bindDashboard();
bindCockpit();
bindConsumptionPane();
bindChargingPane();
bindServerPane();
@@ -1307,6 +1311,8 @@ public class MainActivity extends AppCompatActivity {
showEstimate(vehRangeAverage, vehRangeAverageNote, vehUsageAverage, vehUsageAverageNote,
average, getString(R.string.usage_last_5_min), R.string.range_waiting);
dashTripKm = trip == null ? null : trip.distanceKm;
cockpitTrip = trip;
cockpitAverage = average;
if (dashUsage != null) dashUsage.setText(average == null ? "—"
: String.format(java.util.Locale.US, "%.1f kWh/100 km", average.kwhPer100Km));
}
@@ -1427,22 +1433,30 @@ public class MainActivity extends AppCompatActivity {
/** Kilometres since start, from the range estimator; null until it has a distance. */
private Double dashTripKm;
private boolean dashboardStyle() {
return "DASHBOARD".equals(prefs.getString(PREF_OVERVIEW_STYLE, "TILES"));
/** The stored values of the Overview styles, in the order of the drop-down. */
private static final String[] OVERVIEW_STYLES = { "TILES", "DASHBOARD", "COCKPIT" };
/** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */
private int overviewStyle() {
String style = prefs.getString(PREF_OVERVIEW_STYLE, "TILES");
for (int i = 0; i < OVERVIEW_STYLES.length; i++) if (OVERVIEW_STYLES[i].equals(style)) return i;
return 0;
}
private void applyOverviewStyle() {
boolean dash = dashboardStyle();
dashboardPane.setVisibility(dash ? View.VISIBLE : View.GONE);
vehiclePane.setVisibility(dash ? View.GONE : View.VISIBLE);
int style = overviewStyle();
vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE);
dashboardPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE);
cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE);
}
/** Tiles (the cards) or Dashboard (gauges); both are fed on every reading. */
/** Tiles (the cards), Dashboard (gauges) or Cockpit (rings); all are fed on every reading. */
private void bindOverviewStyle() {
String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_dashboard) };
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, dashboardStyle() ? 1 : 0,
String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_dashboard),
getString(R.string.overview_style_cockpit) };
bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(),
position -> {
prefs.edit().putString(PREF_OVERVIEW_STYLE, position == 1 ? "DASHBOARD" : "TILES").apply();
prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply();
applyOverviewStyle();
});
}
@@ -1461,9 +1475,69 @@ public class MainActivity extends AppCompatActivity {
dashBattery.configure(0f, 100f, 25f, 5, getString(R.string.dash_unit_battery));
dashBattery.setEndLabels("E", "F");
dashBattery.setWarnBelow(20f);
}
// ---------- Cockpit (Overview style) ----------
/** Power over the last two minutes, a slot per second. */
private final PowerTrace cockpitTrace = new PowerTrace(120_000, 120);
private RingView cockpitBattery, cockpitPower, cockpitUsage;
private TraceView cockpitTraceView;
private TextView cockpitClimate, cockpitOdometer;
/** From the range estimator: since start and the last 5 minutes; null until known. */
private RangeEstimator.Estimate cockpitTrip, cockpitAverage;
private void bindCockpit() {
cockpitBattery = cockpitPane.findViewById(R.id.cockpit_battery);
cockpitPower = cockpitPane.findViewById(R.id.cockpit_power);
cockpitUsage = cockpitPane.findViewById(R.id.cockpit_usage);
cockpitTraceView = cockpitPane.findViewById(R.id.cockpit_trace);
cockpitClimate = cockpitPane.findViewById(R.id.cockpit_climate);
cockpitOdometer = cockpitPane.findViewById(R.id.cockpit_odometer);
cockpitBattery.configure(0f, 100f, 40);
cockpitBattery.setWarnBelow(20f);
cockpitBattery.setBigScale(0.36f);
cockpitBattery.setScaleLabels("0", null, "100");
cockpitPower.setCentred(-50f, 150f, 60);
cockpitPower.setTopPill(true);
cockpitPower.setBigScale(0.44f);
cockpitPower.setScaleLabels("−50", "0", "150 kW");
cockpitUsage.configure(0f, 30f, 40);
cockpitUsage.setBigScale(0.36f);
cockpitUsage.setScaleLabels("0", null, "30");
applyOverviewStyle();
}
/** The rings and the strip; every value null-safe ("—", nothing lit, gaps in the trace). */
private void showCockpit(EnergySnapshot s) {
if (cockpitPower == null) return;
java.util.Locale us = java.util.Locale.US;
Float soc = s.getSocPercent();
cockpitBattery.setValue(soc, getString(R.string.cockpit_battery),
soc == null ? null : String.format(us, "%.0f", soc), "%",
fmt(s.getRangeKm(), "%.0f km"), null);
Float power = s.getBatteryPowerKw();
Float speed = s.getSpeedKmh();
Boolean parked = s.getParked();
cockpitPower.setValue(power, parked == null ? "—" : parked ? "P" : "D",
speed == null ? null : String.format(us, "%.0f", speed), getString(R.string.dash_unit_kmh),
power == null ? "—" : String.format(us, "%.1f kW", power), null);
cockpitTrace.add(android.os.SystemClock.elapsedRealtime(), power);
cockpitTraceView.setValues(cockpitTrace.snapshot());
RangeEstimator.Estimate now = cockpitAverage, trip = cockpitTrip;
cockpitUsage.setValue(now == null ? null : (float) now.kwhPer100Km, getString(R.string.cockpit_usage),
now == null ? null : String.format(us, "%.1f", now.kwhPer100Km), getString(R.string.cockpit_usage_unit),
trip == null ? getString(R.string.cockpit_usage_trip, "—")
: getString(R.string.cockpit_usage_trip, String.format(us, "%.1f", trip.kwhPer100Km)),
trip == null ? null : String.format(us, "%.1f km", trip.distanceKm));
cockpitUsage.setMarker(trip == null ? null : (float) trip.kwhPer100Km);
cockpitClimate.setText(fmt(s.getClimate().getDriverTargetCelsius(), "%.0f °C"));
cockpitOdometer.setText(fmt(s.getOdometerKm(), "%,.0f km"));
}
/** The gauges and the strip; every value null-safe ("—", no needle). */
private void showDashboard(EnergySnapshot s) {
if (dashPower == null) return;
@@ -1504,6 +1578,7 @@ public class MainActivity extends AppCompatActivity {
vehTireRl.setText(unit.format(s.getTirePressures().getRearLeftKpa()));
vehTireRr.setText(unit.format(s.getTirePressures().getRearRightKpa()));
showDashboard(s);
showCockpit(s);
} catch (Throwable ignored) {
// Leave the last good reading on screen.
}
@@ -0,0 +1,63 @@
package com.evsuite.abrp;
/**
* The last few minutes of battery power for the Cockpit's trace, by monotonic time. Pure Java so
* it can be tested off the car.
*
* Each reading lands in a fixed slot of the window ({@code windowMs / slots} wide); a slot keeps
* the last reading that fell into it. A slot no reading reached, or one whose reading was
* unread, is NaN — the trace leaves a gap there instead of drawing a zero (an unread value is
* not 0 kW).
*/
final class PowerTrace {
private final long windowMs;
private final float[] values;
private final long slotMs;
/** The slot index (time / slotMs) of the newest reading; Long.MIN_VALUE before the first. */
private long newest = Long.MIN_VALUE;
PowerTrace(long windowMs, int slots) {
if (windowMs <= 0 || slots < 2) throw new IllegalArgumentException();
this.windowMs = windowMs;
this.values = new float[slots];
this.slotMs = Math.max(1, windowMs / slots);
java.util.Arrays.fill(values, Float.NaN);
}
long windowMs() {
return windowMs;
}
/** A reading at {@code monotonicMs}; null or not finite for one that could not be read. */
void add(long monotonicMs, Float kw) {
long slot = monotonicMs / slotMs;
if (newest != Long.MIN_VALUE && slot < newest) return; // the clock never runs back
if (newest != Long.MIN_VALUE) {
// Slots skipped since the newest reading had no reading: gaps.
long gap = Math.min(values.length, slot - newest);
for (long s = newest + 1; s < newest + 1 + gap; s++) values[index(s)] = Float.NaN;
}
newest = slot;
values[index(slot)] = kw == null || !Float.isFinite(kw) ? Float.NaN : kw;
}
/**
* Oldest first, as many values as there are slots; NaN where there is nothing. Before the
* first reading every value is NaN.
*/
float[] snapshot() {
float[] out = new float[values.length];
if (newest == Long.MIN_VALUE) {
java.util.Arrays.fill(out, Float.NaN);
return out;
}
long first = newest - values.length + 1;
for (int i = 0; i < out.length; i++) out[i] = values[index(first + i)];
return out;
}
private int index(long slot) {
return (int) Math.floorMod(slot, (long) values.length);
}
}
@@ -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);
}
}
@@ -0,0 +1,106 @@
package com.evsuite.abrp;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
import java.util.Locale;
/**
* The Cockpit's strip: battery power over the last minutes ({@link PowerTrace}), newest on the
* right. Drawing power above the zero line in the accent colour, regeneration below it in green,
* each as a line over a faint fill. Gaps (unread or missed readings) stay gaps. The scale grows
* in steps of 10 kW to fit what is shown, at least ±10 kW.
*/
public final class TraceView extends View {
private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint zero = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Paint label = new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path linePath = new Path();
private final Path fillPath = new Path();
private final int accent, regen;
private float[] values = new float[0];
public TraceView(Context context, AttributeSet attrs) {
super(context, attrs);
accent = AccentColor.resolve(context, R.attr.evAccent);
regen = context.getColor(R.color.ev_ok);
line.setStyle(Paint.Style.STROKE);
line.setStrokeJoin(Paint.Join.ROUND);
line.setStrokeCap(Paint.Cap.ROUND);
fill.setStyle(Paint.Style.FILL);
zero.setStyle(Paint.Style.STROKE);
zero.setColor(context.getColor(R.color.ev_outline));
label.setColor(context.getColor(R.color.ev_text_secondary));
label.setFontFeatureSettings("tnum");
}
/** The values oldest first, NaN for gaps. */
void setValues(float[] values) {
this.values = values == null ? new float[0] : values;
invalidate();
}
@Override
protected void onDraw(Canvas c) {
super.onDraw(c);
float w = getWidth(), h = getHeight();
float density = getResources().getDisplayMetrics().density;
label.setTextSize(15f * density);
float left = label.measureText("−150 kW") + 12f * density, right = w, top = 6f * density, bottom = h - 6f * density;
if (right - left <= 0f || bottom - top <= 0f || values.length < 2) return;
float hi = 0f, lo = 0f;
for (float v : values) {
if (Float.isNaN(v)) continue;
hi = Math.max(hi, v);
lo = Math.min(lo, v);
}
hi = Math.max(10f, (float) Math.ceil(hi / 10f) * 10f);
lo = Math.min(-10f, (float) Math.floor(lo / 10f) * 10f);
float zy = top + (bottom - top) * hi / (hi - lo);
zero.setStrokeWidth(Math.max(1f, density));
c.drawLine(left, zy, right, zy, zero);
label.setTextAlign(Paint.Align.LEFT);
c.drawText(String.format(Locale.US, "%.0f kW", hi), 0f, top + label.getTextSize(), label);
c.drawText(String.format(Locale.US, "%.0f kW", lo).replace('-', '−'), 0f, bottom, label);
float step = (right - left) / (values.length - 1);
line.setStrokeWidth(2.5f * density);
// Draw every run of readings twice, clipped to each side of the zero line.
for (int side = 0; side < 2; side++) {
int colour = side == 0 ? accent : regen;
line.setColor(colour);
fill.setColor(AccentColor.withAlpha(colour, 0x38));
c.save();
if (side == 0) c.clipRect(left, top - density * 3f, right, zy);
else c.clipRect(left, zy, right, bottom + density * 3f);
int i = 0;
while (i < values.length) {
if (Float.isNaN(values[i])) { i++; continue; }
int start = i;
linePath.reset();
fillPath.reset();
fillPath.moveTo(left + start * step, zy);
for (; i < values.length && !Float.isNaN(values[i]); i++) {
float x = left + i * step;
float y = top + (bottom - top) * (hi - values[i]) / (hi - lo);
if (i == start) linePath.moveTo(x, y); else linePath.lineTo(x, y);
fillPath.lineTo(x, y);
}
fillPath.lineTo(left + (i - 1) * step, zy);
fillPath.close();
c.drawPath(fillPath, fill);
if (i - start == 1) c.drawCircle(left + start * step, top + (bottom - top) * (hi - values[start]) / (hi - lo), line.getStrokeWidth(), fill);
c.drawPath(linePath, line);
}
c.restore();
}
}
}
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Overview as a cockpit (Settings → Overview style → Cockpit): segmented rings for battery,
power with the speed inside, and efficiency; below them the power of the last two minutes,
the clock, climate and odometer. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="24dp" android:paddingEnd="24dp" android:paddingTop="12dp" android:paddingBottom="12dp">
<LinearLayout android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:orientation="horizontal" android:baselineAligned="false">
<com.evsuite.abrp.RingView android:id="@+id/cockpit_battery" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginTop="44dp" android:layout_marginBottom="20dp" />
<com.evsuite.abrp.RingView android:id="@+id/cockpit_power" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1.3" android:layout_marginStart="12dp" android:layout_marginEnd="12dp" />
<com.evsuite.abrp.RingView android:id="@+id/cockpit_usage" android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:layout_marginTop="44dp" android:layout_marginBottom="20dp" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="128dp" android:layout_marginTop="10dp" android:orientation="horizontal" android:gravity="center_vertical" android:paddingStart="24dp" android:paddingEnd="28dp" android:paddingTop="10dp" android:paddingBottom="10dp" android:background="@drawable/bg_glass" android:baselineAligned="false">
<LinearLayout android:layout_width="0dp" android:layout_weight="1" android:layout_height="match_parent" android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/cockpit_trace_title" android:textColor="?attr/evAccentLight" android:textSize="14sp" android:letterSpacing="0.16" />
<com.evsuite.abrp.TraceView android:id="@+id/cockpit_trace" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" android:layout_marginTop="2dp" />
</LinearLayout>
<View android:layout_width="1dp" android:layout_height="match_parent" android:layout_marginStart="28dp" android:layout_marginEnd="28dp" android:background="@color/ev_outline" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="center_horizontal">
<TextClock android:layout_width="wrap_content" android:layout_height="wrap_content" android:format24Hour="HH:mm" android:format12Hour="HH:mm" android:textColor="@color/ev_text_primary" android:textSize="44sp" android:fontFamily="sans-serif-light" android:fontFeatureSettings="tnum" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal">
<TextView android:id="@+id/cockpit_climate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/no_value" android:textColor="?attr/evAccentLight" android:textSize="18sp" android:fontFeatureSettings="tnum" />
<TextView android:id="@+id/cockpit_odometer" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="18dp" android:text="@string/no_value" android:textColor="@color/ev_text_secondary" android:textSize="18sp" android:fontFeatureSettings="tnum" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
+6
View File
@@ -127,6 +127,12 @@
<string name="dash_unit_battery">SOC</string>
<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="cockpit_battery">BATTERY</string>
<string name="cockpit_usage">USAGE · 5 MIN</string>
<string name="cockpit_usage_unit">kWh/100 km</string>
<string name="cockpit_usage_trip">since start %1$s</string>
<string name="cockpit_trace_title">POWER · LAST 2 MIN</string>
<string name="label_voice_key">Double-press the voice key to open this app</string>
<string name="hint_voice_key">Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses.</string>
<string name="label_abrp_upload_enabled">Send live data</string>
@@ -0,0 +1,59 @@
package com.evsuite.abrp;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
/** The Cockpit's power trace buffer — pure JVM. */
public class PowerTraceTest {
private static final float NaN = Float.NaN;
@Test
public void emptyIsAllGaps() {
float[] v = new PowerTrace(4000, 4).snapshot();
assertEquals(4, v.length);
for (float f : v) assertTrue(Float.isNaN(f));
}
@Test
public void newestIsLastAndOlderSlide() {
PowerTrace t = new PowerTrace(4000, 4); // 1 s slots
t.add(10_000, 5f);
t.add(11_000, 10f);
t.add(12_000, -3f);
assertArrayEquals(new float[] { NaN, 5f, 10f, -3f }, t.snapshot(), 0f);
t.add(13_000, 1f);
t.add(14_000, 2f);
assertArrayEquals(new float[] { 10f, -3f, 1f, 2f }, t.snapshot(), 0f);
}
@Test
public void unreadAndMissedSlotsAreGapsNotZero() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(11_000, null);
t.add(13_000, 7f); // nothing at 12 s
assertArrayEquals(new float[] { 5f, NaN, NaN, 7f }, t.snapshot(), 0f);
}
@Test
public void aLongPauseClearsEverything() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(11_000, 6f);
t.add(60_000, 8f);
assertArrayEquals(new float[] { NaN, NaN, NaN, 8f }, t.snapshot(), 0f);
}
@Test
public void laterReadingInTheSameSlotWinsAndTheClockNeverRunsBack() {
PowerTrace t = new PowerTrace(4000, 4);
t.add(10_000, 5f);
t.add(10_500, 6f);
t.add(9_000, 99f);
assertArrayEquals(new float[] { NaN, NaN, NaN, 6f }, t.snapshot(), 0f);
}
}