diff --git a/AGENTS.md b/AGENTS.md index b1742ec..fb9fe76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ 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.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, classic or in the accent colour), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures); the drawn styles share `ClusterValues` | +| 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.2 s on a background thread. Styles (Settings → Overview style): Tiles, Gauges (`GaugeView` needles), Cockpit (`RingView` rings, `TraceView` power trace), Retro (`RetroView`, seven-segment VFD, classic or in the accent colour), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards); the drawn styles share `ClusterValues` | | 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 | diff --git a/README.md b/README.md index ca6b1c2..5c5f52a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ with the server using a token of its own, obtained by pairing with a phone (belo | Tab | What it shows | |---|---| -| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (classic turquoise or in your accent colour), Futuristic, Sci-fi, Cartoon and Blueprint | +| **Overview** | Battery, range, power and speed; range, usage and energy since start; 5-minute averages of range, usage, power and speed; vehicle info (odometer, climate setpoint) and tyre pressures. Read five times a second. Looks to choose from in Settings → Overview style: Tiles, Gauges, Cockpit, Retro (classic turquoise or in your accent colour), Futuristic, Sci-fi, Cartoon, Blueprint and MG | | **Consumption** | Lifetime, last 7 and 30 days, Trip A and B (distance, energy, average consumption and speed, SOC change), and a daily log of the last four months | | **Charging** | The current session (SOC, voltage, current, power, energy into the battery and from the grid, time, cost) and its SOC/power curve | | **Live data** | Live data on/off, ABRP credentials for sending directly from the car, upload frequency | diff --git a/SPECS.md b/SPECS.md index 0d3a72f..9f7d907 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,16 @@ 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.25.0 (branch `styles-retro-future`): Overview style "MG" (`OctagonView`), + after the classic MG dashboards whose dials sat in octagonal bezels — not a copy of the MG4's + own driver display, and without MG's octagon logo. Three octagonal dials with chrome bezels, + cream scales and needles in the accent colour: speed in the middle (digits and P R N D under + the hub), battery E…F on the left (red zone below 20 %, range), power −50…150 kW on the right + (green charge zone below zero). A cream line below: odometer, trip, 5-minute usage, climate, + clock, separated by small accent octagons. Needles ease and redraw only until settled, only + on screen; unread values draw no needle and "—". Palette `mg_*`. Checked on the emulator with + sample values (`screenshots/nilsmg4-2.25.0-mg.png`) and without data; no crash. Not tried on + the car. - 2026-10-24 — App 2.24.1 (branch `styles-retro-future`): the line of text above every page ("Your drive at a glance.", "Settings. Display, battery…" and so on) is gone, at the owner's request; the pages start at the top, level with the sidebar, and gain about 45 dp. Its 14 diff --git a/app/build.gradle b/app/build.gradle index 4dc7472..2d1bcd3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 118 - versionName "2.24.1" + versionCode 119 + versionName "2.25.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 31b53e0..45bbdf2 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -65,6 +65,7 @@ public class MainActivity extends AppCompatActivity { private SciFiView sciFiView; private CartoonView cartoonView; private BlueprintView blueprintView; + private OctagonView octagonView; /** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */ private View[] stylePanes; private View consumptionPane; @@ -250,10 +251,11 @@ public class MainActivity extends AppCompatActivity { sciFiView = new SciFiView(this, null); cartoonView = new CartoonView(this, null); blueprintView = new BlueprintView(this, null); + octagonView = new OctagonView(this, null); View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), - blueprintPane = drawnPane(blueprintView); + blueprintPane = drawnPane(blueprintView), mgPane = drawnPane(octagonView); stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, retroPane, - futuristicPane, sciFiPane, cartoonPane, blueprintPane }; + futuristicPane, sciFiPane, cartoonPane, blueprintPane, mgPane }; // The Overview page holds every style; Settings → Overview style picks the one shown. android.widget.FrameLayout overviewPage = new android.widget.FrameLayout(this); overviewPage.setLayoutParams(new android.view.ViewGroup.LayoutParams( @@ -267,6 +269,7 @@ public class MainActivity extends AppCompatActivity { overviewPage.addView(sciFiPane); overviewPage.addView(cartoonPane); overviewPage.addView(blueprintPane); + overviewPage.addView(mgPane); consumptionPane = inflatePane(R.layout.pane_consumption); chargingPane = inflatePane(R.layout.pane_charging); settingsPane = inflatePane(R.layout.pane_settings); @@ -1451,7 +1454,7 @@ public class MainActivity extends AppCompatActivity { /** The stored values of the Overview styles, in the order of the drop-down. */ private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC", - "SCIFI", "CARTOON", "BLUEPRINT" }; + "SCIFI", "CARTOON", "BLUEPRINT", "MG" }; /** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */ private int overviewStyle() { @@ -1474,7 +1477,7 @@ public class MainActivity extends AppCompatActivity { getString(R.string.overview_style_cockpit), getString(R.string.overview_style_retro), getString(R.string.overview_style_retro_accent), getString(R.string.overview_style_futuristic), getString(R.string.overview_style_scifi), getString(R.string.overview_style_cartoon), - getString(R.string.overview_style_blueprint) }; + getString(R.string.overview_style_blueprint), getString(R.string.overview_style_mg) }; bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(), position -> { prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply(); @@ -1536,6 +1539,7 @@ public class MainActivity extends AppCompatActivity { sciFiView.setValues(v); cartoonView.setValues(v); blueprintView.setValues(v); + octagonView.setValues(v); } // ---------- Cockpit (Overview style) ---------- diff --git a/app/src/main/java/com/evsuite/abrp/OctagonView.java b/app/src/main/java/com/evsuite/abrp/OctagonView.java new file mode 100644 index 0000000..ec9444b --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/OctagonView.java @@ -0,0 +1,280 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Locale; + +/** + * The MG Overview style, after the classic MG dashboards whose dials sat in octagonal bezels. + * + * Three octagonal dials with chrome bezels, cream scales and needles in the accent colour: the + * speedometer in the middle (with the speed as digits and P R N D underneath), the battery on + * the left (E to F, red below 20 %, range underneath) and power on the right (−50 to 150 kW, + * the charge zone below zero in green). Below, a cream line of odometer, trip, usage, climate + * and clock, separated by small octagons. Unread values draw no needle and "—". + * + * Needles ease to a new value and the view redraws only until they are there, only on screen. + * The MG octagon logo itself is not drawn. + */ +public final class OctagonView extends View { + + private static final float START_DEG = 135f, SWEEP_DEG = 270f; + private static final long FRAME_MS = 33; + + private final Paint face = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint chrome = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint tick = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint needle = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint zone = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final int accent, light, cream, chromeLight, chromeDark, faceColour, green, red; + private ClusterValues v = new ClusterValues(); + /** The needles as shown, easing to their values; NaN until the first reading. */ + private float speedShown = Float.NaN, socShown = Float.NaN, powerShown = Float.NaN; + + public OctagonView(Context context, AttributeSet attrs) { + super(context, attrs); + accent = AccentColor.resolve(context, R.attr.evAccent); + light = AccentColor.resolve(context, R.attr.evAccentLight); + cream = context.getColor(R.color.mg_cream); + chromeLight = context.getColor(R.color.mg_chrome_light); + chromeDark = context.getColor(R.color.mg_chrome_dark); + faceColour = context.getColor(R.color.mg_face); + green = context.getColor(R.color.ev_ok); + red = context.getColor(R.color.ev_error); + face.setColor(faceColour); + chrome.setStyle(Paint.Style.STROKE); + chrome.setStrokeJoin(Paint.Join.ROUND); + tick.setColor(cream); + tick.setStrokeCap(Paint.Cap.ROUND); + needle.setStrokeCap(Paint.Cap.ROUND); + zone.setStyle(Paint.Style.STROKE); + text.setColor(cream); + text.setTextAlign(Paint.Align.CENTER); + text.setFontFeatureSettings("tnum"); + text.setTypeface(Typeface.create("serif", Typeface.NORMAL)); + } + + void setValues(ClusterValues values) { + v = values == null ? new ClusterValues() : values; + invalidate(); + } + + @Override + protected void onDraw(Canvas c) { + super.onDraw(c); + float w = getWidth(), h = getHeight(); + if (w <= 0 || h <= 0) return; + float d = getResources().getDisplayMetrics().density; + + speedShown = ease(speedShown, v.speedKmh, 180f); + socShown = ease(socShown, v.socPercent, 100f); + powerShown = ease(powerShown, v.powerKw, 200f); + boolean moving = (v.speedKmh != null && speedShown != v.speedKmh) + || (v.socPercent != null && socShown != v.socPercent) + || (v.powerKw != null && powerShown != v.powerKw); + + float dialsBottom = h * 0.84f; + float big = Math.min(dialsBottom * 0.5f, w * 0.20f); + float small = big * 0.68f; + float cy = dialsBottom / 2f + 4f * d; + float cx = w / 2f; + float sideX = Math.min(w * 0.21f, cx - big - small - 20f * d); + + // Battery, left. + bezel(c, sideX + small * 0.05f, cy + big - small, small, d); + dial(c, sideX + small * 0.05f, cy + big - small, small, 0f, 100f, 25f, 5, + v.socPercent == null ? null : socShown, 20f, false, new String[] { "E", "½", "F" }, d); + label(c, sideX + small * 0.05f, cy + big - small, small, "BATTERY", + v.socPercent == null ? "—" : String.format(Locale.US, "%.0f%%", v.socPercent), + v.rangeKm == null ? "— km" : String.format(Locale.US, "%.0f km", v.rangeKm), 0.20f); + + // Power, right. + float rx = w - sideX - small * 0.05f; + bezel(c, rx, cy + big - small, small, d); + dial(c, rx, cy + big - small, small, -50f, 150f, 50f, 5, + v.powerKw == null ? null : powerShown, null, true, null, d); + label(c, rx, cy + big - small, small, "kW", + v.powerKw == null ? "—" : String.format(Locale.US, "%.1f", v.powerKw), + v.powerKw != null && v.powerKw < 0f ? "CHARGE" : "POWER", 0.20f); + + // Speed, centre. + bezel(c, cx, cy, big, d); + dial(c, cx, cy, big, 0f, 180f, 20f, 4, v.speedKmh == null ? null : speedShown, null, false, null, d); + label(c, cx, cy, big, "km/h", + v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), null, 0.24f); + gears(c, cx, cy + big * 0.62f, big, d); + + drawLine(c, w, h, d); + + if (moving && isShown()) postInvalidateDelayed(FRAME_MS); + } + + /** One step of a needle towards its value; jumps there when close or on the first value. */ + private static float ease(float shown, Float target, float span) { + if (target == null || !Float.isFinite(target)) return shown; + if (Float.isNaN(shown)) return target; + float next = shown + (target - shown) * 0.25f; + return Math.abs(target - next) < span * 0.002f ? target : next; + } + + /** A regular octagon with flat sides at top, bottom and both sides. */ + private void octagon(float cx, float cy, float r) { + path.reset(); + for (int i = 0; i < 8; i++) { + double a = Math.toRadians(22.5 + 45 * i); + float x = cx + r * (float) Math.cos(a), y = cy + r * (float) Math.sin(a); + if (i == 0) path.moveTo(x, y); else path.lineTo(x, y); + } + path.close(); + } + + /** The bezel: a dark face inside a chrome octagon, lit from above (light outer, dark inner line). */ + private void bezel(Canvas c, float cx, float cy, float r, float d) { + octagon(cx, cy, r); + c.drawPath(path, face); + chrome.setColor(chromeLight); + chrome.setStrokeWidth(Math.max(3f * d, r * 0.035f)); + c.drawPath(path, chrome); + octagon(cx, cy, r * 0.93f); + chrome.setColor(chromeDark); + chrome.setStrokeWidth(Math.max(2f * d, r * 0.02f)); + c.drawPath(path, chrome); + octagon(cx, cy, r * 1.035f); + chrome.setColor(AccentColor.withAlpha(chromeLight, 0x40)); + chrome.setStrokeWidth(d); + c.drawPath(path, chrome); + } + + /** + * A scale inside the octagon, its numbers or end labels, an optional warning zone below + * {@code warnBelow} (red) or charge zone below zero (green), and the needle when known. + */ + private void dial(Canvas c, float cx, float cy, float r, float min, float max, float major, + int minorPer, Float value, Float warnBelow, boolean chargeZone, String[] labels, float d) { + float sr = r * 0.74f; + rect.set(cx - sr, cy - sr, cx + sr, cy + sr); + zone.setStrokeWidth(r * 0.05f); + if (warnBelow != null) { + zone.setColor(AccentColor.withAlpha(red, 0xC0)); + c.drawArc(rect, START_DEG, SWEEP_DEG * (warnBelow - min) / (max - min), false, zone); + } + if (chargeZone && min < 0f) { + zone.setColor(AccentColor.withAlpha(green, 0xC0)); + c.drawArc(rect, START_DEG, SWEEP_DEG * (0f - min) / (max - min), false, zone); + } + float minor = major / minorPer; + int steps = Math.round((max - min) / minor); + text.setTextSize(r * 0.13f); + for (int i = 0; i <= steps; i++) { + float val = min + i * minor; + boolean isMajor = i % minorPer == 0; + double a = Math.toRadians(START_DEG + SWEEP_DEG * (val - min) / (max - min)); + float cos = (float) Math.cos(a), sin = (float) Math.sin(a); + float outer = r * 0.82f, inner = isMajor ? r * 0.68f : r * 0.75f; + tick.setStrokeWidth(isMajor ? r * 0.025f : r * 0.012f); + c.drawLine(cx + outer * cos, cy + outer * sin, cx + inner * cos, cy + inner * sin, tick); + if (isMajor && labels == null) { + float lr = r * 0.54f; + c.drawText(String.format(Locale.US, "%.0f", val), cx + lr * cos, + cy + lr * sin + text.getTextSize() * 0.36f, text); + } + } + if (labels != null) { + text.setTextSize(r * 0.16f); + for (int i = 0; i < labels.length; i++) { + double a = Math.toRadians(START_DEG + SWEEP_DEG * i / (labels.length - 1f)); + float lr = r * 0.52f; + c.drawText(labels[i], cx + lr * (float) Math.cos(a), cy + lr * (float) Math.sin(a) + text.getTextSize() * 0.36f, text); + } + } + if (value != null) { + double a = Math.toRadians(START_DEG + SWEEP_DEG * (Math.max(min, Math.min(max, value)) - min) / (max - min)); + float cos = (float) Math.cos(a), sin = (float) Math.sin(a); + needle.setColor(AccentColor.withAlpha(accent, 0x50)); + needle.setStrokeWidth(r * 0.06f); + c.drawLine(cx - r * 0.12f * cos, cy - r * 0.12f * sin, cx + r * 0.80f * cos, cy + r * 0.80f * sin, needle); + needle.setColor(accent); + needle.setStrokeWidth(r * 0.03f); + c.drawLine(cx - r * 0.12f * cos, cy - r * 0.12f * sin, cx + r * 0.80f * cos, cy + r * 0.80f * sin, needle); + } + // The hub: a small chrome octagon. + octagon(cx, cy, r * 0.08f); + fill.setColor(chromeLight); + c.drawPath(path, fill); + octagon(cx, cy, r * 0.045f); + fill.setColor(faceColour); + c.drawPath(path, fill); + } + + /** Under the hub: a small caption, the value large, and a line below it. */ + private void label(Canvas c, float cx, float cy, float r, String caption, String value, String below, + float valueSize) { + text.setTextSize(r * 0.10f); + text.setColor(AccentColor.withAlpha(cream, 0xB0)); + text.setLetterSpacing(0.15f); + c.drawText(caption, cx, cy + r * 0.25f, text); + text.setLetterSpacing(0f); + text.setColor(cream); + text.setTextSize(r * valueSize); + c.drawText(value, cx, cy + r * 0.50f, text); + if (below != null) { + text.setTextSize(r * 0.11f); + text.setColor(light); + c.drawText(below, cx, cy + r * 0.67f, text); + text.setColor(cream); + } + } + + /** P R N D under the speed; the engaged one in the accent colour. */ + private void gears(Canvas c, float cx, float y, float r, float d) { + String[] letters = { "P", "R", "N", "D" }; + text.setTextSize(r * 0.10f); + float pitch = r * 0.13f; + for (int i = 0; i < 4; i++) { + boolean on = v.gear != null && v.gear == i + 1; + text.setColor(on ? light : AccentColor.withAlpha(cream, 0x55)); + text.setFakeBoldText(on); + c.drawText(letters[i], cx + (i - 1.5f) * pitch, y + r * 0.10f, text); + } + text.setFakeBoldText(false); + text.setColor(cream); + } + + /** The cream line along the bottom, items separated by small octagons. */ + private void drawLine(Canvas c, float w, float h, float d) { + String[] items = { + v.odometerKm == null ? "ODO —" : String.format(Locale.US, "ODO %,.0f km", v.odometerKm), + v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm), + v.usage5Min == null ? "— kWh/100 km" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min), + v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius), + v.clock.isEmpty() ? "—" : v.clock, + }; + float y = h * 0.95f; + text.setTextSize(20f * d); + text.setLetterSpacing(0.08f); + float pitch = w / items.length; + for (int i = 0; i < items.length; i++) { + c.drawText(items[i], pitch * (i + 0.5f), y, text); + if (i > 0) { + octagon(pitch * i, y - 7f * d, 5f * d); + fill.setColor(accent); + c.drawPath(path, fill); + } + } + text.setLetterSpacing(0f); + chrome.setColor(chromeDark); + chrome.setStrokeWidth(d); + c.drawLine(w * 0.06f, h * 0.885f, w * 0.94f, h * 0.885f, chrome); + } +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d0b5072..c94c2cc 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -45,4 +45,9 @@ #0A2240 #E3EEFF + + #0D0D0F + #F2E8D5 + #D5DADF + #5A6066 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a32f9a5..a7c746a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -130,6 +130,7 @@ Sci-fi Cartoon Blueprint + MG BATTERY USAGE · 5 MIN kWh/100 km diff --git a/screenshots/nilsmg4-2.25.0-mg.png b/screenshots/nilsmg4-2.25.0-mg.png new file mode 100644 index 0000000..976fdc2 Binary files /dev/null and b/screenshots/nilsmg4-2.25.0-mg.png differ