App 2.25.0: Overview style MG, octagonal dials

After the classic MG dashboards: three octagonal dials with chrome
bezels, cream scales and accent-coloured needles for speed, battery and
power, and a cream line of odometer, trip, usage, climate and clock.
No MG logo. 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 20:31:47 +02:00
co-authored by Claude Opus 5.5
parent ef296683aa
commit 1af5b0efe7
9 changed files with 308 additions and 8 deletions
@@ -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) ----------
@@ -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);
}
}
+5
View File
@@ -45,4 +45,9 @@
<!-- The Blueprint Overview style: white lines on a navy drawing sheet. -->
<color name="blueprint_sheet">#0A2240</color>
<color name="blueprint_line">#E3EEFF</color>
<!-- The MG Overview style: octagonal dials after the classic MG dashboards. -->
<color name="mg_face">#0D0D0F</color>
<color name="mg_cream">#F2E8D5</color>
<color name="mg_chrome_light">#D5DADF</color>
<color name="mg_chrome_dark">#5A6066</color>
</resources>
+1
View File
@@ -130,6 +130,7 @@
<string name="overview_style_scifi">Sci-fi</string>
<string name="overview_style_cartoon">Cartoon</string>
<string name="overview_style_blueprint">Blueprint</string>
<string name="overview_style_mg">MG</string>
<string name="cockpit_battery">BATTERY</string>
<string name="cockpit_usage">USAGE · 5 MIN</string>
<string name="cockpit_usage_unit">kWh/100 km</string>