diff --git a/AGENTS.md b/AGENTS.md index d45ea11..05adf13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,11 +68,11 @@ Repository: `ssh://git@git.stinnesbeck.com:2222/nils/mg4.git` (remote `origin`), | Tab | Contents | |---|---| -| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread | +| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed), vehicle info (odometer, lease allowance and the odometer against it), tyre pressures; read every 0.5 s on a background thread. Three styles (Settings → Overview style): Tiles, Dashboard (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace) | | Consumption / Charging | period totals, daily log, charging sessions and curve | | Live data | on/off, ABRP API key and user token, upload frequency | | Server | URL, pairing by QR, ingest token, installation ID (read-only), config import | -| Settings | full screen, voice key, tyre unit, accent colour, battery, charging price, version, update check, install from USB, About | +| Settings | full screen, voice key, tyre unit, accent colour, Overview style, battery, charging price, version, update check, install from USB, About | | Logs | update log (every install step, with the platform's words), upload log | Key classes (`app/src/main/java/com/evsuite/abrp/`): `AbrpUploadService` (foreground service, diff --git a/SPECS.md b/SPECS.md index 89c8a8e..ed44d45 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,17 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-24 — App 2.22.0 (branch `dashboard-cockpit`, from `dashboard-gauges`): a third Overview + style, "Cockpit", designed without a reference: three segmented rings (`RingView`, Canvas) — + battery (0…100 %, range inside, below 20 % in the warning colour), power with the speed and the + gear inside (zero at the top: drawing power clockwise to 150 kW in the accent colour, + regeneration anticlockwise to −50 kW in green) and 5-minute usage (0…30 kWh/100 km, a white tick + for the average since start). Segments brighten towards the leading one. Below, the power of the + last two minutes as a trace (`TraceView`; `PowerTrace`, one slot per second, unit-tested; gaps + for unread or missed readings, never zeros), the clock, climate setpoint and odometer. Settings + → Overview style: Tiles / Dashboard / Cockpit. Checked on the emulator with sample values in a + throwaway build (`screenshots/nilsmg4-2.22.0-cockpit.png`) and without data through the real + setting (nothing lit, "—", the page fits 1778×640). Not tried on the car. - 2026-10-24 — App 2.21.0 (branch `dashboard-gauges`): Overview style "Dashboard", an instrument cluster after a reference picture of the owner's: power (−50…150 kW), speed (0…180 km/h, with TOTAL odometer and TRIP km since start) and battery (E…F, below 20 % in the warning colour, diff --git a/app/build.gradle b/app/build.gradle index 8e9e4f1..9ce9509 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 110 - versionName "2.21.0" + versionCode 111 + versionName "2.22.0" // Nothing about the server ships in the APK: its URL and ingest token are entered in // the app's Server tab (ServerConfig), and the database is only reachable by the server. diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 3e8cf85..787354a 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -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. } diff --git a/app/src/main/java/com/evsuite/abrp/PowerTrace.java b/app/src/main/java/com/evsuite/abrp/PowerTrace.java new file mode 100644 index 0000000..c961e13 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/PowerTrace.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/RingView.java b/app/src/main/java/com/evsuite/abrp/RingView.java new file mode 100644 index 0000000..6d5ec2b --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/RingView.java @@ -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); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/TraceView.java b/app/src/main/java/com/evsuite/abrp/TraceView.java new file mode 100644 index 0000000..0e2f55f --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/TraceView.java @@ -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(); + } + } +} diff --git a/app/src/main/res/layout/pane_cockpit.xml b/app/src/main/res/layout/pane_cockpit.xml new file mode 100644 index 0000000..86bcf20 --- /dev/null +++ b/app/src/main/res/layout/pane_cockpit.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 033972d..07b3c08 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,6 +127,12 @@ SOC TOTAL %1$s TRIP %1$s + Cockpit + BATTERY + USAGE · 5 MIN + kWh/100 km + since start %1$s + POWER · LAST 2 MIN Double-press the voice key to open this app Press the voice key on the steering wheel twice quickly. The voice assistant may still react to the presses. Send live data diff --git a/app/src/test/java/com/evsuite/abrp/PowerTraceTest.java b/app/src/test/java/com/evsuite/abrp/PowerTraceTest.java new file mode 100644 index 0000000..cefc03a --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/PowerTraceTest.java @@ -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); + } +} diff --git a/screenshots/nilsmg4-2.22.0-cockpit.png b/screenshots/nilsmg4-2.22.0-cockpit.png new file mode 100644 index 0000000..d4614ac Binary files /dev/null and b/screenshots/nilsmg4-2.22.0-cockpit.png differ