diff --git a/AGENTS.md b/AGENTS.md index 697b8e7..b1742ec 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) | +| 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` | | 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 291826d..ca6b1c2 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) and Futuristic | +| **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 | | **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 a65574f..103f524 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,25 @@ 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.24.0 (branch `styles-retro-future`): three more Overview styles, each one + Canvas view fed the shared `ClusterValues` (now with the tyre pressures, formatted in the + chosen unit). **Sci-fi** (`SciFiView`): a starship console — rounded elbow frames in orange + and lilac, pill labels, condensed capitals; velocity large, power as blocks (regeneration + blue), rows for reserves, range, efficiency, since start, trip and climate, the reserves as a + stack of blocks, gears as blocks in the frame; below 20 % the frames turn red ("red alert"). + **Cartoon** (`CartoonView`): comic panels with ink outlines and hard shadows on night blue; a + battery with a face (happy, unsure, worried and sweating), a speedometer whose needle springs + into place (animated only until it settles, only on screen), the speed in a speech bubble, + the gear on a sticker, a "zap-o-meter" with a sound word (PSST, PURR, VROOM, ZOOM!, + RECHARGE!), stats on pills. **Blueprint** (`BlueprintView`): a top-view drawing of the car on a + gridded navy sheet — the battery hatched as far as it is charged, leader lines to each tyre's + pressure, power as a force arrow (dashed and reversed while regenerating), range and trip as + dimension lines, the cabin setpoint as a note, and a title block with speed, power, gear, + usage, odometer and time. Palettes `scifi_*`, `cartoon_*`, `blueprint_*` in colors.xml. + `MainActivity` now maps each style to its pane in a table (`stylePanes`); the new panes are + built in code (`drawnPane`). Checked on the emulator with sample values in a throwaway build + (`screenshots/nilsmg4-2.24.0-scifi.png`, `-cartoon.png`, `-blueprint.png`) and without data + through the real setting; no crash. Not tried on the car. - 2026-10-24 — App 2.23.1 (branch `styles-retro-future`): "Retro (accent colour)", the Retro style in the chosen paint colour — display in `evAccent`, labels in `evAccentLight`; regeneration stays green, a low battery turns amber (not red, so it never looks like Dynamic diff --git a/app/build.gradle b/app/build.gradle index 55868d9..4bacad9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 116 - versionName "2.23.1" + versionCode 117 + versionName "2.24.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/BlueprintView.java b/app/src/main/java/com/evsuite/abrp/BlueprintView.java new file mode 100644 index 0000000..0816839 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/BlueprintView.java @@ -0,0 +1,283 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.DashPathEffect; +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 Blueprint Overview style: a technical drawing of the car from above, white lines on a navy + * sheet with a grid. + * + * The battery pack between the axles is hatched as far as it is charged. Each tyre has a leader + * line to its pressure. Power is a force arrow at the front — out of the car while drawing, + * into it (dashed) while regenerating — its length scaled to the power. Dimension lines give the + * range and the trip, the cabin is annotated with the climate setpoint, and a title block in the + * corner holds speed, power, gear, usage, odometer and time. Unread values show "—", no hatch and + * no arrow. Redrawn only when a reading arrives. + */ +public final class BlueprintView extends View { + + /** The MG4's length over its width, 4.287 m by 1.836 m. */ + private static final float ASPECT = 4.287f / 1.836f; + + private final Paint sheet = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint grid = new Paint(); + private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint faint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint dashed = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final int ink; + private ClusterValues v = new ClusterValues(); + + public BlueprintView(Context context, AttributeSet attrs) { + super(context, attrs); + float d = context.getResources().getDisplayMetrics().density; + ink = context.getColor(R.color.blueprint_line); + sheet.setColor(context.getColor(R.color.blueprint_sheet)); + grid.setStyle(Paint.Style.STROKE); + line.setStyle(Paint.Style.STROKE); + line.setColor(ink); + line.setStrokeJoin(Paint.Join.ROUND); + line.setStrokeCap(Paint.Cap.ROUND); + faint.setStyle(Paint.Style.STROKE); + faint.setColor(AccentColor.withAlpha(ink, 0x99)); + dashed.setStyle(Paint.Style.STROKE); + dashed.setColor(AccentColor.withAlpha(ink, 0xB0)); + dashed.setPathEffect(new DashPathEffect(new float[] { 8f * d, 6f * d }, 0f)); + text.setColor(ink); + text.setTypeface(Typeface.create(Typeface.MONOSPACE, Typeface.NORMAL)); + text.setFontFeatureSettings("tnum"); + } + + 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; + + // The sheet and its grid: fine every 20 dp, bolder every 100 dp; a border inset. + rect.set(0, 0, w, h); + c.drawRect(rect, sheet); + for (float step : new float[] { 20f * d, 100f * d }) { + grid.setColor(AccentColor.withAlpha(ink, step > 50f * d ? 0x22 : 0x10)); + grid.setStrokeWidth(Math.max(1f, d * 0.75f)); + for (float x = 0; x <= w; x += step) c.drawLine(x, 0, x, h, grid); + for (float y = 0; y <= h; y += step) c.drawLine(0, y, w, y, grid); + } + line.setStrokeWidth(2f * d); + c.drawRect(8f * d, 8f * d, w - 8f * d, h - 8f * d, line); + + // The car, front to the right, its size from the space left of the title block. + // Centred, with its power arrow, in the space left of the title block. + float carH = Math.min(h * 0.40f, w * 0.44f / ASPECT), carW = carH * ASPECT; + float l = w * 0.04f + (w * 0.60f - carW - w * 0.12f) / 2f, t = (h - carH) / 2f, r = l + carW, b = t + carH; + drawCar(c, l, t, r, b, d); + drawTyres(c, l, t, r, b, d); + drawPower(c, r, (t + b) / 2f, w * 0.10f, d); + drawDimensions(c, l, t, r, b, d); + drawTitleBlock(c, w * 0.66f, h * 0.10f, w - 22f * d, h - 22f * d, d); + } + + private void drawCar(Canvas c, float l, float t, float r, float b, float d) { + float len = r - l, wid = b - t; + // Body: rounded, fuller at the front. + path.reset(); + path.moveTo(l + len * 0.10f, t); + path.lineTo(r - len * 0.14f, t); + path.cubicTo(r - len * 0.02f, t, r, t + wid * 0.12f, r, t + wid * 0.30f); + path.lineTo(r, b - wid * 0.30f); + path.cubicTo(r, b - wid * 0.12f, r - len * 0.02f, b, r - len * 0.14f, b); + path.lineTo(l + len * 0.10f, b); + path.cubicTo(l + len * 0.02f, b, l, b - wid * 0.14f, l, b - wid * 0.32f); + path.lineTo(l, t + wid * 0.32f); + path.cubicTo(l, t + wid * 0.14f, l + len * 0.02f, t, l + len * 0.10f, t); + line.setStrokeWidth(2.5f * d); + c.drawPath(path, line); + // Glass: windscreen, roof and rear window, dashed. + rect.set(l + len * 0.24f, t + wid * 0.12f, l + len * 0.74f, b - wid * 0.12f); + c.drawRoundRect(rect, wid * 0.12f, wid * 0.12f, dashed); + c.drawLine(l + len * 0.62f, t + wid * 0.13f, l + len * 0.62f, b - wid * 0.13f, dashed); + c.drawLine(l + len * 0.33f, t + wid * 0.13f, l + len * 0.33f, b - wid * 0.13f, dashed); + // Mirrors. + float mx = l + len * 0.66f; + line.setStrokeWidth(2f * d); + c.drawLine(mx, t, mx - len * 0.03f, t - wid * 0.09f, line); + c.drawLine(mx - len * 0.03f, t - wid * 0.09f, mx - len * 0.07f, t - wid * 0.02f, line); + c.drawLine(mx, b, mx - len * 0.03f, b + wid * 0.09f, line); + c.drawLine(mx - len * 0.03f, b + wid * 0.09f, mx - len * 0.07f, b + wid * 0.02f, line); + + // The battery between the axles, hatched as far as it is charged (from the rear). + float pl = l + len * 0.25f, pr = l + len * 0.73f, pt = t + wid * 0.24f, pb = b - wid * 0.24f; + faint.setStrokeWidth(1.5f * d); + c.drawRect(pl, pt, pr, pb, faint); + if (v.socPercent != null) { + float fx = pl + (pr - pl) * Math.max(0f, Math.min(100f, v.socPercent)) / 100f; + c.save(); + c.clipRect(pl, pt, fx, pb); + float step = 9f * d; + for (float x = pl - (pb - pt); x < fx; x += step) c.drawLine(x, pb, x + (pb - pt), pt, faint); + c.restore(); + line.setStrokeWidth(2f * d); + c.drawLine(fx, pt - 6f * d, fx, pb + 6f * d, line); + } + text.setTextAlign(Paint.Align.CENTER); + text.setTextSize(14f * d); + String soc = v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent); + float labelY = (pt + pb) / 2f + 5f * d; + rect.set((pl + pr) / 2f - 70f * d, labelY - 18f * d, (pl + pr) / 2f + 70f * d, labelY + 8f * d); + c.drawRect(rect, sheet); + c.drawText("BATTERY " + soc, (pl + pr) / 2f, labelY, text); + + // The cabin's climate setpoint, as a note. + String climate = v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius); + text.setTextSize(12f * d); + c.drawText("CABIN SET " + climate, l + len * 0.49f, t + wid * 0.19f, text); + } + + private void drawTyres(Canvas c, float l, float t, float r, float b, float d) { + float len = r - l, wid = b - t, tw = len * 0.13f, th = wid * 0.13f; + float rear = l + len * 0.20f, front = l + len * 0.80f; + // Driving to the right, the car's left side is at the top. + Object[][] tyres = { + { front, t, v.tyreFl, true, "FL" }, { front, b, v.tyreFr, false, "FR" }, + { rear, t, v.tyreRl, true, "RL" }, { rear, b, v.tyreRr, false, "RR" }, + }; + line.setStrokeWidth(2f * d); + text.setTextSize(15f * d); + for (Object[] tyre : tyres) { + float x = (Float) tyre[0], y = (Float) tyre[1]; + boolean top = (Boolean) tyre[3]; + rect.set(x - tw / 2f, y - th / 2f, x + tw / 2f, y + th / 2f); + c.drawRect(rect, sheet); + c.drawRoundRect(rect, 4f * d, 4f * d, line); + // A leader line out to the pressure. + float ly = top ? y - th / 2f - wid * 0.24f : y + th / 2f + wid * 0.24f; + float lx = x + len * 0.06f; + faint.setStrokeWidth(1.5f * d); + c.drawLine(x, top ? y - th / 2f : y + th / 2f, lx, ly, faint); + c.drawLine(lx, ly, lx + 110f * d, ly, faint); + c.drawCircle(x, top ? y - th / 2f : y + th / 2f, 3f * d, line); + text.setTextAlign(Paint.Align.LEFT); + c.drawText(tyre[4] + " " + tyre[2], lx + 4f * d, ly - 6f * d, text); + } + } + + /** Power as a force arrow at the front; length scaled to 150 kW; regeneration points back. */ + private void drawPower(Canvas c, float x, float y, float maxLen, float d) { + text.setTextAlign(Paint.Align.LEFT); + text.setTextSize(15f * d); + if (v.powerKw == null || Math.abs(v.powerKw) < 0.1f) { + c.drawText(v.powerKw == null ? "P = —" : "P = 0 kW", x + 16f * d, y + 5f * d, text); + return; + } + float kw = v.powerKw; + float len = Math.max(24f * d, maxLen * Math.min(150f, Math.abs(kw)) / 150f); + float x0 = x + 10f * d, x1 = x0 + len; + Paint p = kw >= 0f ? line : dashed; + p.setStrokeWidth(2.5f * d); + c.drawLine(x0, y, x1, y, p); + // The head: at the far end drawing, at the car regenerating. + float hx = kw >= 0f ? x1 : x0, dir = kw >= 0f ? 1f : -1f, hs = 10f * d; + line.setStrokeWidth(2.5f * d); + c.drawLine(hx, y, hx - dir * hs, y - hs * 0.6f, line); + c.drawLine(hx, y, hx - dir * hs, y + hs * 0.6f, line); + c.drawText(String.format(Locale.US, "P = %.1f kW", kw), x0, y - 10f * d, text); + if (kw < 0f) c.drawText("(REGEN)", x0, y + 22f * d, text); + } + + /** Dimension lines: the range under the car, the trip above it. */ + private void drawDimensions(Canvas c, float l, float t, float r, float b, float d) { + float wid = b - t; + String range = v.rangeKm == null ? "RANGE —" : String.format(Locale.US, "RANGE %.0f km", v.rangeKm); + dimension(c, l, r, b + wid * 0.50f, range, d); + String trip = v.tripKm == null ? "TRIP —" : String.format(Locale.US, "TRIP %.1f km", v.tripKm); + if (v.usageTrip != null) trip += String.format(Locale.US, " @ %.1f kWh/100 km", v.usageTrip); + dimension(c, l, r, t - wid * 0.50f, trip, d); + } + + private void dimension(Canvas c, float x0, float x1, float y, String label, float d) { + faint.setStrokeWidth(1.5f * d); + c.drawLine(x0, y - 10f * d, x0, y + 10f * d, faint); + c.drawLine(x1, y - 10f * d, x1, y + 10f * d, faint); + c.drawLine(x0, y, x1, y, faint); + float hs = 9f * d; + c.drawLine(x0, y, x0 + hs, y - hs * 0.45f, faint); + c.drawLine(x0, y, x0 + hs, y + hs * 0.45f, faint); + c.drawLine(x1, y, x1 - hs, y - hs * 0.45f, faint); + c.drawLine(x1, y, x1 - hs, y + hs * 0.45f, faint); + text.setTextAlign(Paint.Align.CENTER); + text.setTextSize(15f * d); + float tw = text.measureText(label); + float mid = (x0 + x1) / 2f; + rect.set(mid - tw / 2f - 8f * d, y - 12f * d, mid + tw / 2f + 8f * d, y + 12f * d); + c.drawRect(rect, sheet); + c.drawText(label, mid, y + 5f * d, text); + } + + /** The drawing's title block: rows of fields, the speed large. */ + private void drawTitleBlock(Canvas c, float l, float t, float r, float b, float d) { + line.setStrokeWidth(2f * d); + rect.set(l, t, r, b); + c.drawRect(rect, sheet); + c.drawRect(rect, line); + // Eight rows: the speed takes two, the last holds scale and time. + float rowH = (b - t) / 8f; + String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh); + String[][] rows = { + { "DRAWING", "NILS' MG4 · TOP VIEW" }, + { "SPEED", null }, + { "POWER", v.powerKw == null ? "—" : String.format(Locale.US, "%.1f kW", v.powerKw) }, + { "GEAR", Gear.label(v.gear).isEmpty() ? "—" : Gear.label(v.gear) }, + { "USAGE 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f kWh/100 km", v.usage5Min) }, + { "ODOMETER", v.odometerKm == null ? "—" : String.format(Locale.US, "%,.0f km", v.odometerKm) }, + }; + float split = l + (r - l) * 0.34f; + float y = t; + for (int i = 0; i < rows.length; i++) { + float rh = i == 1 ? rowH * 2f : rowH; + if (i > 0) c.drawLine(l, y, r, y, faint); + text.setTextAlign(Paint.Align.LEFT); + text.setTextSize(11f * d); + c.drawText(rows[i][0], l + 10f * d, y + 18f * d, text); + if (i == 1) { + text.setTextSize(rh * 0.62f); + text.setTextAlign(Paint.Align.RIGHT); + c.drawText(speed, r - (r - l) * 0.22f, y + rh * 0.80f, text); + text.setTextSize(14f * d); + text.setTextAlign(Paint.Align.LEFT); + c.drawText("km/h", r - (r - l) * 0.20f, y + rh * 0.80f, text); + } else { + text.setTextSize(Math.min(rh * 0.42f, 18f * d)); + c.drawText(rows[i][1], i == 0 ? l + 10f * d : split + 12f * d, y + rh * 0.72f, text); + } + y += rh; + } + // The last row: scale and time, split in two. + c.drawLine(l, y, r, y, faint); + c.drawLine(split, y, split, b, faint); + text.setTextSize(11f * d); + text.setTextAlign(Paint.Align.LEFT); + c.drawText("SCALE 1:20", l + 10f * d, y + rowH * 0.62f, text); + c.drawText("TIME", split + 10f * d, y + 18f * d, text); + text.setTextSize(Math.min(rowH * 0.5f, 22f * d)); + text.setTextAlign(Paint.Align.RIGHT); + c.drawText(v.clock.isEmpty() ? "—" : v.clock, r - 12f * d, y + rowH * 0.72f, text); + c.drawLine(split, t + rowH * 3f, split, t + rowH * 7f, faint); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/CartoonView.java b/app/src/main/java/com/evsuite/abrp/CartoonView.java new file mode 100644 index 0000000..7dfe6a6 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/CartoonView.java @@ -0,0 +1,353 @@ +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.os.SystemClock; +import android.util.AttributeSet; +import android.view.View; + +import java.util.Locale; + +/** + * The Cartoon Overview style: comic panels in flat colours with thick ink outlines and hard + * shadows, on a night-blue page (no white page at night). + * + * Left, a battery with a face: happy above 50 %, unsure from 20 %, worried and sweating below. + * Centre, a chunky speedometer whose needle springs and wobbles into place, the speed in a + * speech bubble and the gear on a sticker. Right, the "zap-o-meter" for power with a sound word + * in a burst — PURR, VROOM, ZOOM! or RECHARGE! while regenerating. Below, stats on pills. + * Unread values show "—", a face with no expression and no needle. + * + * The needle animates (about 30 frames a second) only until it settles, and only on screen. + */ +public final class CartoonView extends View { + + private static final float START_DEG = 150f, SWEEP_DEG = 240f, MAX_KMH = 180f; + private static final long FRAME_MS = 33; + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint ink = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint textInk = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final int night, yellow, cyan, pink, cream, inkColour, green, orange, red; + private ClusterValues v = new ClusterValues(); + /** The needle in km/h and its speed, for the spring. */ + private float needle = Float.NaN, needleVelocity; + private long lastFrame; + + public CartoonView(Context context, AttributeSet attrs) { + super(context, attrs); + night = context.getColor(R.color.cartoon_night); + yellow = context.getColor(R.color.cartoon_yellow); + cyan = context.getColor(R.color.cartoon_cyan); + pink = context.getColor(R.color.cartoon_pink); + cream = context.getColor(R.color.cartoon_cream); + inkColour = context.getColor(R.color.cartoon_ink); + green = 0xFF5BD35B; + orange = 0xFFFF9F1C; + red = 0xFFE63946; + ink.setStyle(Paint.Style.STROKE); + ink.setStrokeJoin(Paint.Join.ROUND); + ink.setStrokeCap(Paint.Cap.ROUND); + ink.setColor(inkColour); + Typeface black = Typeface.create("sans-serif-black", Typeface.NORMAL); + text.setTypeface(black); + text.setFontFeatureSettings("tnum"); + textInk.setTypeface(black); + textInk.setFontFeatureSettings("tnum"); + textInk.setStyle(Paint.Style.STROKE); + textInk.setStrokeJoin(Paint.Join.ROUND); + textInk.setColor(inkColour); + } + + void setValues(ClusterValues values) { + v = values == null ? new ClusterValues() : values; + if (v.speedKmh != null && Float.isNaN(needle)) needle = v.speedKmh; + 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; + rect.set(0, 0, w, h); + fill.setColor(night); + c.drawRoundRect(rect, 24f * d, 24f * d, fill); + + boolean moving = stepNeedle(); + + drawBattery(c, w * 0.02f, h * 0.05f, w * 0.27f, h * 0.76f, d); + drawSpeedometer(c, w * 0.50f, h * 0.43f, h * 0.38f, d); + drawZap(c, w * 0.73f, h * 0.05f, w * 0.98f, h * 0.76f, d); + drawPills(c, w, h, d); + + if (moving && isShown()) postInvalidateDelayed(FRAME_MS); + else lastFrame = 0; + } + + /** A spring towards the speed: overshoots a little and wobbles to rest. True while moving. */ + private boolean stepNeedle() { + if (v.speedKmh == null || Float.isNaN(needle)) return false; + long now = SystemClock.uptimeMillis(); + float dt = lastFrame == 0 ? FRAME_MS / 1000f : Math.min(100, now - lastFrame) / 1000f; + lastFrame = now; + float target = Math.max(0f, Math.min(MAX_KMH, v.speedKmh)); + needleVelocity += (target - needle) * 60f * dt; + needleVelocity *= (float) Math.pow(0.02, dt); + needle += needleVelocity * dt; + if (Math.abs(target - needle) < 0.2f && Math.abs(needleVelocity) < 0.5f) { + needle = target; + needleVelocity = 0f; + return false; + } + return true; + } + + // ---------- Panels ---------- + + /** A comic panel: a hard shadow, the colour, a thick outline. */ + private void panel(Canvas c, float l, float t, float r, float b, int colour, float d) { + float off = 6f * d, radius = 18f * d; + fill.setColor(inkColour); + rect.set(l + off, t + off, r + off, b + off); + c.drawRoundRect(rect, radius, radius, fill); + fill.setColor(colour); + rect.set(l, t, r, b); + c.drawRoundRect(rect, radius, radius, fill); + ink.setStrokeWidth(4f * d); + c.drawRoundRect(rect, radius, radius, ink); + } + + private void drawBattery(Canvas c, float l, float t, float r, float b, float d) { + panel(c, l, t, r, b, yellow, d); + Float soc = v.socPercent; + // The body: a battery standing up, filled to the charge. + float bw = (r - l) * 0.46f, bl = (l + r) / 2f - bw / 2f, bt = t + (b - t) * 0.14f, bb = t + (b - t) * 0.66f; + fill.setColor(inkColour); + rect.set(bl + bw * 0.32f, bt - 12f * d, bl + bw * 0.68f, bt + 2f * d); + c.drawRoundRect(rect, 4f * d, 4f * d, fill); + fill.setColor(cream); + rect.set(bl, bt, bl + bw, bb); + c.drawRoundRect(rect, 16f * d, 16f * d, fill); + if (soc != null) { + float level = bb - (bb - bt) * Math.max(0f, Math.min(100f, soc)) / 100f; + fill.setColor(soc < 20f ? red : soc < 50f ? orange : green); + c.save(); + c.clipRect(bl, level, bl + bw, bb); + c.drawRoundRect(rect, 16f * d, 16f * d, fill); + c.restore(); + } + ink.setStrokeWidth(4f * d); + c.drawRoundRect(rect, 16f * d, 16f * d, ink); + + // The face. + float fx = bl + bw / 2f, fy = bt + (bb - bt) * 0.42f, eye = bw * 0.09f; + fill.setColor(inkColour); + c.drawOval(fx - bw * 0.22f - eye, fy - eye * 1.4f, fx - bw * 0.22f + eye, fy + eye * 1.4f, fill); + c.drawOval(fx + bw * 0.22f - eye, fy - eye * 1.4f, fx + bw * 0.22f + eye, fy + eye * 1.4f, fill); + fill.setColor(0xFFFFFFFF); + c.drawCircle(fx - bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill); + c.drawCircle(fx + bw * 0.22f + eye * 0.35f, fy - eye * 0.6f, eye * 0.38f, fill); + float my = fy + bw * 0.30f, mw = bw * 0.26f; + ink.setStrokeWidth(4f * d); + path.reset(); + path.moveTo(fx - mw, my); + if (soc == null) path.lineTo(fx + mw, my); + else if (soc >= 50f) path.quadTo(fx, my + mw * 0.9f, fx + mw, my); // smile + else if (soc >= 20f) path.quadTo(fx, my + mw * 0.15f, fx + mw, my); // unsure + else path.quadTo(fx, my - mw * 0.8f, fx + mw, my); // worried + c.drawPath(path, ink); + if (soc != null && soc < 20f) { + // A drop of sweat. + float sx = bl + bw + 10f * d, sy = bt + 14f * d; + path.reset(); + path.moveTo(sx, sy); + path.quadTo(sx + 12f * d, sy + 18f * d, sx, sy + 24f * d); + path.quadTo(sx - 12f * d, sy + 18f * d, sx, sy); + fill.setColor(0xFF7FD3FF); + c.drawPath(path, fill); + ink.setStrokeWidth(2.5f * d); + c.drawPath(path, ink); + } + + outlined(c, soc == null ? "—" : String.format(Locale.US, "%.0f%%", soc), (l + r) / 2f, + t + (b - t) * 0.84f, (b - t) * 0.15f, 0xFFFFFFFF, d); + outlined(c, v.rangeKm == null ? "— km to go" : String.format(Locale.US, "%.0f km to go!", v.rangeKm), + (l + r) / 2f, t + (b - t) * 0.95f, (b - t) * 0.065f, 0xFFFFFFFF, d); + } + + private void drawSpeedometer(Canvas c, float cx, float cy, float r, float d) { + // Shadow, face, coloured zones, outline. + float off = 6f * d; + fill.setColor(inkColour); + c.drawCircle(cx + off, cy + off, r, fill); + fill.setColor(cream); + c.drawCircle(cx, cy, r, fill); + float zr = r * 0.80f; + rect.set(cx - zr, cy - zr, cx + zr, cy + zr); + ink.setStrokeCap(Paint.Cap.BUTT); + int[] zones = { green, orange, red }; + for (int i = 0; i < 3; i++) { + ink.setColor(zones[i]); + ink.setStrokeWidth(r * 0.14f); + c.drawArc(rect, START_DEG + i * SWEEP_DEG / 3f, SWEEP_DEG / 3f, false, ink); + } + ink.setColor(inkColour); + ink.setStrokeCap(Paint.Cap.ROUND); + ink.setStrokeWidth(5f * d); + c.drawCircle(cx, cy, r, ink); + // Chunky ticks and numbers every 30 km/h. + text.setTextAlign(Paint.Align.CENTER); + text.setColor(inkColour); + text.setTextSize(r * 0.12f); + for (int kmh = 0; kmh <= MAX_KMH; kmh += 30) { + double a = Math.toRadians(START_DEG + SWEEP_DEG * kmh / MAX_KMH); + float cos = (float) Math.cos(a), sin = (float) Math.sin(a); + ink.setStrokeWidth(5f * d); + c.drawLine(cx + r * 0.88f * cos, cy + r * 0.88f * sin, cx + r * 0.72f * cos, cy + r * 0.72f * sin, ink); + c.drawText(String.valueOf(kmh), cx + r * 0.56f * cos, cy + r * 0.56f * sin + text.getTextSize() * 0.36f, text); + } + // The needle, springy. + if (v.speedKmh != null && !Float.isNaN(needle)) { + double a = Math.toRadians(START_DEG + SWEEP_DEG * Math.max(-4f, Math.min(MAX_KMH + 4f, needle)) / MAX_KMH); + float nx = cx + r * 0.78f * (float) Math.cos(a), ny = cy + r * 0.78f * (float) Math.sin(a); + ink.setStrokeWidth(12f * d); + c.drawLine(cx, cy, nx, ny, ink); + fill.setColor(red); + ink.setStrokeWidth(7f * d); + ink.setColor(red); + c.drawLine(cx, cy, nx, ny, ink); + ink.setColor(inkColour); + } + fill.setColor(pink); + c.drawCircle(cx, cy, r * 0.09f, fill); + ink.setStrokeWidth(4f * d); + c.drawCircle(cx, cy, r * 0.09f, ink); + + // The speed in a speech bubble below the hub. + String speed = v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh); + text.setTextSize(r * 0.30f); + float tw = Math.max(text.measureText(speed), r * 0.4f); + float bt = cy + r * 0.22f, bb = cy + r * 0.62f; + rect.set(cx - tw / 2f - r * 0.12f, bt, cx + tw / 2f + r * 0.12f, bb); + fill.setColor(0xFFFFFFFF); + c.drawRoundRect(rect, 14f * d, 14f * d, fill); + ink.setStrokeWidth(4f * d); + c.drawRoundRect(rect, 14f * d, 14f * d, ink); + text.setColor(inkColour); + c.drawText(speed, cx, bb - (bb - bt) * 0.22f, text); + text.setTextSize(r * 0.09f); + c.drawText("km/h", cx, cy + r * 0.76f, text); + + // The gear on a round sticker at the top right of the dial. + String gear = Gear.label(v.gear); + if (!gear.isEmpty()) { + float gx = cx + r * 0.80f, gy = cy - r * 0.78f, gr = r * 0.20f; + fill.setColor(inkColour); + c.drawCircle(gx + 4f * d, gy + 4f * d, gr, fill); + fill.setColor(pink); + c.drawCircle(gx, gy, gr, fill); + c.drawCircle(gx, gy, gr, ink); + outlined(c, gear, gx, gy + gr * 0.40f, gr * 1.1f, 0xFFFFFFFF, d); + } + } + + private void drawZap(Canvas c, float l, float t, float r, float b, float d) { + panel(c, l, t, r, b, cyan, d); + Float kw = v.powerKw; + outlined(c, "ZAP-O-METER", (l + r) / 2f, t + (b - t) * 0.13f, (b - t) * 0.07f, yellow, d); + + // The sound word in a burst. + String word = kw == null ? "…" : kw < -5f ? "RECHARGE!" : kw < 0.5f ? "PSST" : kw < 10f ? "PURR" + : kw < 40f ? "VROOM" : "ZOOM!"; + float bx = (l + r) / 2f, by = t + (b - t) * 0.40f, br = Math.min(r - l, b - t) * 0.30f; + path.reset(); + for (int i = 0; i < 24; i++) { + double a = Math.PI * 2 * i / 24; + float rr = i % 2 == 0 ? br : br * 0.74f; + float x = bx + rr * 1.25f * (float) Math.cos(a), y = by + rr * 0.85f * (float) Math.sin(a); + if (i == 0) path.moveTo(x, y); else path.lineTo(x, y); + } + path.close(); + fill.setColor(kw != null && kw < -5f ? green : pink); + c.drawPath(path, fill); + ink.setStrokeWidth(4f * d); + c.drawPath(path, ink); + text.setTextSize(br * 0.42f); + float size = br * 0.42f * Math.min(1f, (br * 2.1f) / Math.max(1f, text.measureText(word))); + c.save(); + c.rotate(-8f, bx, by); + outlined(c, word, bx, by + size * 0.36f, size, yellow, d); + c.restore(); + + // The tube: regeneration fills left of the middle mark in green, power right of it. + float tl = l + (r - l) * 0.10f, tr = r - (r - l) * 0.10f, tt = t + (b - t) * 0.70f, tb = t + (b - t) * 0.80f; + float zero = tl + (tr - tl) * 0.25f; + rect.set(tl, tt, tr, tb); + fill.setColor(cream); + c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill); + if (kw != null && Math.abs(kw) >= 0.1f) { + float x = kw >= 0f ? zero + (tr - zero) * Math.min(150f, kw) / 150f : zero - (zero - tl) * Math.min(50f, -kw) / 50f; + c.save(); + c.clipRect(Math.min(zero, x), tt, Math.max(zero, x), tb); + fill.setColor(kw >= 0f ? orange : green); + c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, fill); + c.restore(); + } + ink.setStrokeWidth(4f * d); + c.drawRoundRect(rect, (tb - tt) / 2f, (tb - tt) / 2f, ink); + ink.setStrokeWidth(3f * d); + c.drawLine(zero, tt - 6f * d, zero, tb + 6f * d, ink); + outlined(c, kw == null ? "— kW" : String.format(Locale.US, "%.1f kW", kw), (l + r) / 2f, + t + (b - t) * 0.94f, (b - t) * 0.10f, 0xFFFFFFFF, d); + } + + private void drawPills(Canvas c, float w, float h, float d) { + String[] items = { + v.usage5Min == null ? "— kWh/100" : String.format(Locale.US, "%.1f kWh/100", v.usage5Min), + v.tripKm == null ? "Trip —" : String.format(Locale.US, "Trip %.1f km", v.tripKm), + v.climateCelsius == null ? "— °C" : String.format(Locale.US, "%.0f °C", v.climateCelsius), + v.odometerKm == null ? "— km" : String.format(Locale.US, "%,.0f km", v.odometerKm), + v.clock.isEmpty() ? "—" : v.clock, + }; + int[] colours = { pink, yellow, cyan, yellow, pink }; + float t = h * 0.84f, b = h * 0.97f, gap = 16f * d, x = w * 0.02f; + float pw = (w * 0.96f - gap * (items.length - 1)) / items.length; + for (int i = 0; i < items.length; i++) { + float off = 4f * d; + fill.setColor(inkColour); + rect.set(x + off, t + off, x + pw + off, b + off); + c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill); + fill.setColor(colours[i]); + rect.set(x, t, x + pw, b); + c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill); + ink.setStrokeWidth(3.5f * d); + c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, ink); + text.setTextAlign(Paint.Align.CENTER); + text.setTextSize((b - t) * 0.46f); + text.setColor(inkColour); + c.drawText(items[i], x + pw / 2f, b - (b - t) * 0.30f, text); + x += pw + gap; + } + } + + /** Comic lettering: the text in ink, thick, then the colour on top. */ + private void outlined(Canvas c, String s, float x, float baseline, float size, int colour, float d) { + textInk.setTextSize(size); + textInk.setTextAlign(Paint.Align.CENTER); + textInk.setStrokeWidth(Math.max(3f * d, size * 0.12f)); + c.drawText(s, x, baseline, textInk); + text.setTextSize(size); + text.setTextAlign(Paint.Align.CENTER); + text.setColor(colour); + c.drawText(s, x, baseline, text); + } +} diff --git a/app/src/main/java/com/evsuite/abrp/ClusterValues.java b/app/src/main/java/com/evsuite/abrp/ClusterValues.java index d5a8a1c..e71c0ea 100644 --- a/app/src/main/java/com/evsuite/abrp/ClusterValues.java +++ b/app/src/main/java/com/evsuite/abrp/ClusterValues.java @@ -1,8 +1,8 @@ package com.evsuite.abrp; /** - * One reading for the drawn Overview styles (Retro, Futuristic): what they show, already in - * display units. Every field is null when unread, and the styles show that as dashes, never 0. + * One reading for the drawn Overview styles (Retro, Futuristic, Sci-fi, Cartoon, Blueprint): + * what they show, already in display units. Every field is null when unread, and the styles show that as dashes, never 0. */ final class ClusterValues { Float socPercent; @@ -19,4 +19,6 @@ final class ClusterValues { Float odometerKm; /** "HH:mm", never null. */ String clock = ""; + /** Tyre pressures formatted in the chosen unit, "—" when unread; never null. */ + String tyreFl = "—", tyreFr = "—", tyreRl = "—", tyreRr = "—"; } diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 871b480..2b9c92b 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -69,6 +69,14 @@ public class MainActivity extends AppCompatActivity { private View gaugesPane; private View cockpitPane; private View retroPane, futuristicPane; + /** The drawn styles' views, each fed the same {@link ClusterValues} per reading. */ + private RetroView retroView; + private FlowView flowView; + private SciFiView sciFiView; + private CartoonView cartoonView; + private BlueprintView blueprintView; + /** The pane of each Overview style, by its index in {@link #OVERVIEW_STYLES}. */ + private View[] stylePanes; private View consumptionPane; private View chargingPane; /** Display, battery, charging price and Wi-Fi. */ @@ -249,7 +257,16 @@ public class MainActivity extends AppCompatActivity { cockpitPane = inflatePane(R.layout.pane_cockpit); retroPane = inflatePane(R.layout.pane_retro); futuristicPane = inflatePane(R.layout.pane_futuristic); - // The Overview page holds both styles; Settings → Overview style picks the one shown. + retroView = retroPane.findViewById(R.id.retro_view); + flowView = futuristicPane.findViewById(R.id.flow_view); + sciFiView = new SciFiView(this, null); + cartoonView = new CartoonView(this, null); + blueprintView = new BlueprintView(this, null); + View sciFiPane = drawnPane(sciFiView), cartoonPane = drawnPane(cartoonView), + blueprintPane = drawnPane(blueprintView); + stylePanes = new View[] { vehiclePane, gaugesPane, cockpitPane, retroPane, retroPane, + futuristicPane, sciFiPane, cartoonPane, blueprintPane }; + // 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( android.view.ViewGroup.LayoutParams.MATCH_PARENT, @@ -259,6 +276,9 @@ public class MainActivity extends AppCompatActivity { overviewPage.addView(cockpitPane); overviewPage.addView(retroPane); overviewPage.addView(futuristicPane); + overviewPage.addView(sciFiPane); + overviewPage.addView(cartoonPane); + overviewPage.addView(blueprintPane); consumptionPane = inflatePane(R.layout.pane_consumption); chargingPane = inflatePane(R.layout.pane_charging); settingsPane = inflatePane(R.layout.pane_settings); @@ -1442,7 +1462,8 @@ public class MainActivity extends AppCompatActivity { private Double dashTripKm; /** 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" }; + private static final String[] OVERVIEW_STYLES = { "TILES", "GAUGES", "COCKPIT", "RETRO", "RETRO_ACCENT", "FUTURISTIC", + "SCIFI", "CARTOON", "BLUEPRINT" }; /** The chosen style's index in {@link #OVERVIEW_STYLES}; anything unknown is Tiles. */ private int overviewStyle() { @@ -1454,20 +1475,18 @@ public class MainActivity extends AppCompatActivity { private void applyOverviewStyle() { int style = overviewStyle(); - vehiclePane.setVisibility(style == 0 ? View.VISIBLE : View.GONE); - gaugesPane.setVisibility(style == 1 ? View.VISIBLE : View.GONE); - cockpitPane.setVisibility(style == 2 ? View.VISIBLE : View.GONE); + for (View pane : stylePanes) pane.setVisibility(pane == stylePanes[style] ? View.VISIBLE : View.GONE); // Retro in either palette: one pane, the view told which. - retroPane.setVisibility(style == 3 || style == 4 ? View.VISIBLE : View.GONE); - ((RetroView) retroPane.findViewById(R.id.retro_view)).setAccentPalette(style == 4); - futuristicPane.setVisibility(style == 5 ? View.VISIBLE : View.GONE); + retroView.setAccentPalette("RETRO_ACCENT".equals(OVERVIEW_STYLES[style])); } - /** Tiles, Gauges, Cockpit, Retro (classic or accent colour) or Futuristic; all are fed on every reading. */ + /** The style drop-down; every style is fed on every reading, only the chosen one is shown. */ private void bindOverviewStyle() { String[] labels = { getString(R.string.overview_style_tiles), getString(R.string.overview_style_gauges), 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_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) }; bindChoices(settingsPane.findViewById(R.id.overview_style_choices), labels, overviewStyle(), position -> { prefs.edit().putString(PREF_OVERVIEW_STYLE, OVERVIEW_STYLES[position]).apply(); @@ -1496,9 +1515,17 @@ public class MainActivity extends AppCompatActivity { private final java.text.SimpleDateFormat clockFormat = new java.text.SimpleDateFormat("HH:mm", java.util.Locale.US); + /** A drawn style's view in a pane with the Overview's margins. */ + private View drawnPane(View view) { + android.widget.FrameLayout pane = new android.widget.FrameLayout(this); + float dp = getResources().getDisplayMetrics().density; + pane.setPadding(Math.round(24 * dp), Math.round(12 * dp), Math.round(24 * dp), Math.round(12 * dp)); + pane.addView(view, new android.widget.FrameLayout.LayoutParams( + android.view.ViewGroup.LayoutParams.MATCH_PARENT, android.view.ViewGroup.LayoutParams.MATCH_PARENT)); + return pane; + } + private void showDrawnStyles(EnergySnapshot s) { - RetroView retro = retroPane.findViewById(R.id.retro_view); - FlowView flow = futuristicPane.findViewById(R.id.flow_view); ClusterValues v = new ClusterValues(); v.socPercent = s.getSocPercent(); v.rangeKm = s.getRangeKm(); @@ -1511,8 +1538,16 @@ public class MainActivity extends AppCompatActivity { v.climateCelsius = s.getClimate().getDriverTargetCelsius(); v.odometerKm = s.getOdometerKm(); v.clock = clockFormat.format(new java.util.Date()); - retro.setValues(v); - flow.setValues(v); + PressureUnit unit = PressureUnit.fromPref(prefs.getString(PressureUnit.PREF_KEY, null)); + v.tyreFl = unit.format(s.getTirePressures().getFrontLeftKpa()); + v.tyreFr = unit.format(s.getTirePressures().getFrontRightKpa()); + v.tyreRl = unit.format(s.getTirePressures().getRearLeftKpa()); + v.tyreRr = unit.format(s.getTirePressures().getRearRightKpa()); + retroView.setValues(v); + flowView.setValues(v); + sciFiView.setValues(v); + cartoonView.setValues(v); + blueprintView.setValues(v); } // ---------- Cockpit (Overview style) ---------- diff --git a/app/src/main/java/com/evsuite/abrp/SciFiView.java b/app/src/main/java/com/evsuite/abrp/SciFiView.java new file mode 100644 index 0000000..1591331 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/SciFiView.java @@ -0,0 +1,194 @@ +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 Sci-fi Overview style: a starship console. Rounded "elbow" frames in orange, lilac and + * blue, pill-shaped labels, condensed capitals and blocky bar graphs on black. + * + * Left: velocity (the speed) and the power transfer as a bar of blocks, regeneration in blue to + * the left of zero. Middle: labelled rows — reserves, range, efficiency, trip, climate. Right: + * the reserves as a stack of blocks. The gears are blocks in the left frame, the odometer and + * clock run along the bottom frame. Below 20 % charge the frames turn red ("red alert"). + * Unread values show "—" and light no blocks. Redrawn only when a reading arrives. + */ +public final class SciFiView extends View { + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final RectF rect = new RectF(); + private final int orange, peach, lilac, blue, alert, off; + private ClusterValues v = new ClusterValues(); + + public SciFiView(Context context, AttributeSet attrs) { + super(context, attrs); + orange = context.getColor(R.color.scifi_orange); + peach = context.getColor(R.color.scifi_peach); + lilac = context.getColor(R.color.scifi_lilac); + blue = context.getColor(R.color.scifi_blue); + alert = context.getColor(R.color.scifi_alert); + off = context.getColor(R.color.scifi_off); + text.setTypeface(Typeface.create("sans-serif-condensed", Typeface.BOLD)); + text.setFontFeatureSettings("tnum"); + } + + 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; + boolean redAlert = v.socPercent != null && v.socPercent < 20f; + int frame = redAlert ? alert : orange, frame2 = redAlert ? alert : lilac; + + // Frames: an elbow at the top left and one at the bottom left. + float side = w * 0.085f, bar = h * 0.055f, gap = 6f * d; + float split = h * 0.50f; + // Each elbow is only its corner; blocks fill the side between them. + float corner = bar + side * 0.9f + 12f * d; + elbow(c, 0, 0, w * 0.60f, corner, side, bar, true, frame); + elbow(c, 0, h - corner, w * 0.70f, h, side, bar, false, frame2); + // The top bar's end: the title, then a pill. + label(c, redAlert ? "RED ALERT · LOW RESERVES" : "PROPULSION STATUS", w * 0.61f + 10f * d, + bar * 0.86f, bar * 1.05f, redAlert ? alert : orange, Paint.Align.LEFT); + pill(c, w * 0.94f, 0, w, bar, frame2); + // The bottom bar's end: odometer and time. + String odo = v.odometerKm == null ? "—" : String.format(Locale.US, "%06.0f", v.odometerKm); + label(c, "ODO " + odo + " · " + (v.clock.isEmpty() ? "—" : v.clock), + w - 4f * d, h - bar * 0.14f, bar * 0.95f, peach, Paint.Align.RIGHT); + + // The gears as blocks down the lower elbow. + String[] gears = { "P", "R", "N", "D" }; + float gy = split + gap / 2f, gh = (h - corner - gap - gy - 3 * gap) / 4f; + for (int i = 0; i < 4; i++) { + boolean on = v.gear != null && v.gear == i + 1; + float top = gy + i * (gh + gap); + fill.setColor(on ? peach : off); + c.drawRect(0, top, side, top + gh, fill); + label(c, gears[i], side - 8f * d, top + gh - 6f * d, gh * 0.7f, on ? 0xFF000000 : lilac, Paint.Align.RIGHT); + } + // Upper elbow: three numbered blocks, the console's decoration. + float by = corner + gap, bh = (split - gap / 2f - by - 2 * gap) / 3f; + int[] colours = { lilac, blue, peach }; + String[] codes = { "01-4471", "02-0815", "03-2026" }; + for (int i = 0; i < 3; i++) { + float top = by + i * (bh + gap); + fill.setColor(redAlert ? alert : colours[i]); + c.drawRect(0, top, side, top + bh, fill); + label(c, codes[i], side - 8f * d, top + bh - 6f * d, Math.min(bh * 0.4f, 16f * d), 0xFF000000, Paint.Align.RIGHT); + } + + float x0 = side + w * 0.035f; + // Velocity, large. + label(c, "VELOCITY · KM/H", x0, h * 0.17f, h * 0.045f, lilac, Paint.Align.LEFT); + label(c, v.speedKmh == null ? "—" : String.format(Locale.US, "%.0f", v.speedKmh), x0 - 4f * d, + h * 0.45f, h * 0.30f, orange, Paint.Align.LEFT); + + // Power transfer: blocks from −50 to 150 kW in 10 kW steps. + label(c, "POWER TRANSFER · KW", x0, h * 0.62f, h * 0.045f, lilac, Paint.Align.LEFT); + String kw = v.powerKw == null ? "—" : String.format(Locale.US, "%.1f", v.powerKw); + label(c, kw, w * 0.42f, h * 0.62f, h * 0.06f, v.powerKw != null && v.powerKw < 0f ? blue : peach, Paint.Align.RIGHT); + int blocks = 20, zero = 5; + float bx0 = x0, bx1 = w * 0.42f, pitch = (bx1 - bx0) / blocks; + int litTo = v.powerKw == null ? zero : zero + Math.round(Math.max(-50f, Math.min(150f, v.powerKw)) / 10f); + for (int i = 0; i < blocks; i++) { + boolean on = v.powerKw != null && (litTo > zero ? i >= zero && i < litTo : i >= litTo && i < zero); + fill.setColor(on ? (i < zero ? blue : i >= 15 ? alert : orange) : off); + float x = bx0 + i * pitch; + rect.set(x, h * 0.65f, x + pitch - 4f * d, h * 0.74f); + c.drawRoundRect(rect, i == 0 || i == blocks - 1 ? 10f * d : 0f, 10f * d, fill); + } + label(c, "−50", bx0, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT); + label(c, "0", bx0 + zero * pitch, h * 0.79f, h * 0.035f, lilac, Paint.Align.LEFT); + label(c, "150", bx1, h * 0.79f, h * 0.035f, lilac, Paint.Align.RIGHT); + + // Middle: labelled rows. + String[][] rows = { + { "RESERVES", v.socPercent == null ? "—" : String.format(Locale.US, "%.0f %%", v.socPercent) }, + { "RANGE", v.rangeKm == null ? "—" : String.format(Locale.US, "%.0f KM", v.rangeKm) }, + { "EFFICIENCY 5 MIN", v.usage5Min == null ? "—" : String.format(Locale.US, "%.1f", v.usage5Min) }, + { "SINCE START", v.usageTrip == null ? "—" : String.format(Locale.US, "%.1f", v.usageTrip) }, + { "TRIP", v.tripKm == null ? "—" : String.format(Locale.US, "%.1f KM", v.tripKm) }, + { "CLIMATE", v.climateCelsius == null ? "—" : String.format(Locale.US, "%.0f °C", v.climateCelsius) }, + }; + float rx = w * 0.47f, rw = w * 0.15f, ry = h * 0.11f, rh = h * 0.085f, rg = h * 0.035f; + for (int i = 0; i < rows.length; i++) { + float top = ry + i * (rh + rg); + int colour = redAlert && i == 0 ? alert : i % 2 == 0 ? lilac : blue; + pill(c, rx, top, rx + rw, top + rh, colour); + label(c, rows[i][0], rx + rw - 14f * d, top + rh * 0.72f, rh * 0.48f, 0xFF000000, Paint.Align.RIGHT); + label(c, rows[i][1], rx + rw + 16f * d, top + rh * 0.82f, rh * 0.85f, i == 0 && redAlert ? alert : peach, Paint.Align.LEFT); + } + + // Right: the reserves as ten blocks, filled from the bottom. + float sx0 = w * 0.84f, sx1 = w * 0.93f, sTop = h * 0.11f, sBottom = h * 0.80f; + label(c, "RESERVES", (sx0 + sx1) / 2f, sTop - 8f * d, h * 0.04f, lilac, Paint.Align.CENTER); + int on = v.socPercent == null ? 0 : Math.round(Math.max(0f, Math.min(100f, v.socPercent)) / 10f); + float sh = (sBottom - sTop) / 10f; + for (int i = 0; i < 10; i++) { + float bottom = sBottom - i * sh; + fill.setColor(i < on ? (redAlert ? alert : i % 2 == 0 ? blue : lilac) : off); + rect.set(sx0, bottom - sh + 3f * d, sx1, bottom); + c.drawRoundRect(rect, i == 9 || i == 0 ? sh * 0.4f : 0f, sh * 0.4f, fill); + } + } + + /** + * A frame: a vertical bar down the left joined by a rounded outer corner to a horizontal + * bar along the top (or the bottom), with a smaller rounded inner corner. + */ + private void elbow(Canvas c, float l, float t, float r, float b, float side, float bar, boolean top, int colour) { + float outer = side * 0.9f, inner = bar * 1.2f; + path.reset(); + if (top) { + path.moveTo(l, b); + path.lineTo(l, t + outer); + path.quadTo(l, t, l + outer, t); + path.lineTo(r, t); + path.lineTo(r, t + bar); + path.lineTo(l + side + inner, t + bar); + path.quadTo(l + side, t + bar, l + side, t + bar + inner); + path.lineTo(l + side, b); + } else { + path.moveTo(l, t); + path.lineTo(l, b - outer); + path.quadTo(l, b, l + outer, b); + path.lineTo(r, b); + path.lineTo(r, b - bar); + path.lineTo(l + side + inner, b - bar); + path.quadTo(l + side, b - bar, l + side, b - bar - inner); + path.lineTo(l + side, t); + } + path.close(); + fill.setColor(colour); + c.drawPath(path, fill); + } + + private void pill(Canvas c, float l, float t, float r, float b, int colour) { + fill.setColor(colour); + rect.set(l, t, r, b); + c.drawRoundRect(rect, (b - t) / 2f, (b - t) / 2f, fill); + } + + private void label(Canvas c, String s, float x, float baseline, float size, int colour, Paint.Align align) { + text.setTextSize(size); + text.setColor(colour); + text.setTextAlign(align); + c.drawText(s, x, baseline, text); + } +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 5b2e4b6..d0b5072 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -28,4 +28,21 @@ #FF5C4D #060B0A #1E2B28 + + #FF9966 + #FFCC99 + #CC99CC + #9999FF + #FF4D4D + #2A2530 + + #1C1F3B + #FFD23F + #3BCEAC + #EE4266 + #FFF5D6 + #111111 + + #0A2240 + #E3EEFF diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2323bca..e55052a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -131,6 +131,9 @@ Retro Futuristic Retro (accent colour) + Sci-fi + Cartoon + Blueprint BATTERY USAGE · 5 MIN kWh/100 km diff --git a/screenshots/nilsmg4-2.24.0-blueprint.png b/screenshots/nilsmg4-2.24.0-blueprint.png new file mode 100644 index 0000000..c741c2c Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-blueprint.png differ diff --git a/screenshots/nilsmg4-2.24.0-cartoon.png b/screenshots/nilsmg4-2.24.0-cartoon.png new file mode 100644 index 0000000..d891de4 Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-cartoon.png differ diff --git a/screenshots/nilsmg4-2.24.0-scifi.png b/screenshots/nilsmg4-2.24.0-scifi.png new file mode 100644 index 0000000..5434fe4 Binary files /dev/null and b/screenshots/nilsmg4-2.24.0-scifi.png differ