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