diff --git a/AGENTS.md b/AGENTS.md index b1c10e4..4666723 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,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, and under the battery what plays: `NowPlaying` picks from the media sessions `NowPlayingWatcher` follows — listened to only, never connected to, nothing logged or sent; Tiles, Terminal, Sci-fi, Quest, Empire, Día de Muertos and Halloween, Settings → "Show what's playing", `PREF_NOW_PLAYING`, on by default), 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), both counting with `Tally`, the pumpkin's light flaring on the kick drum while on screen, when "Pumpkin light follows the music" is on (off by default, `PREF_MUSIC_LIGHT`; `MusicTap`, `MusicPulse`: Android's audio analyser, 40–120 Hz, jumps not levels; Bluetooth and CarPlay, not the radio); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) | +| Overview | value tiles (battery, range, range/usage/energy since start, 5-min averages of range, usage, power, speed, and under the battery what plays: `NowPlaying` picks from the media sessions `NowPlayingWatcher` follows — listened to only, never connected to, nothing logged or sent; Tiles, Terminal, Sci-fi, Quest, Empire, Día de Muertos, Halloween and Christmas, Settings → "Show what's playing", `PREF_NOW_PLAYING`, on by default), 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Nixie tubes, Chronograph, Grid (`GridView`, neon lines over a moving grid floor, power as a light-cycle trail from `ClusterValues.powerTrail`), Grid with map (the same `GridView` over the roads round the car, top-down: GPS while it is shown, tiles from the server's `/v1/map/roads` through `MapRoads`, cached in `files/map`; `RoadTile`, `MapGeometry`; the map is `MapLayerView`, a hardware layer drawn once and moved and turned by the GPU every display frame), Time circuits (`TimeCircuitsView`, the time machine's LED rows with `Segments14` letters and a flux capacitor), Quest (`QuestView`, a fantasy game interface), Visor (`VisorView`, a helmet display with a motion tracker of the power trail), Empire (`EmpireView`, a strategy game with an isometric world and ages by odometer, `EmpireAge`), Día de Muertos (`MuertosView`, an ofrenda: papel picado, candles, a calavera in a ring of marigolds), Halloween (`HalloweenView`, a jack-o'-lantern, a potion flask, a tombstone on a graveyard hill), Christmas (`ChristmasView`, a tree with a bauble, fairy lights, a mug of cocoa, a wreath clock and candy canes in the snow), all counting with `Tally`, the pumpkin's light and Christmas's tree lights flaring on the kick drum while on screen, when "Light follows the music" is on (off by default, `PREF_MUSIC_LIGHT`; `MusicTap`, `MusicPulse`: Android's audio analyser, 40–120 Hz, jumps not levels; Bluetooth and CarPlay, not the radio); the drawn styles share `ClusterValues`. A style shown in its own palette (Sci-fi, Cartoon, Nixie, Time circuits, Quest, Empire, Día de Muertos, Halloween, Christmas; Retro, Terminal, Grid and Visor with "Original") gives the whole app its colours (`StylePalette`, `res/values/style_palettes.xml`) instead of the accent colour. A button in the navigation's header hides the navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`); a swipe in from the left edge (`EdgeSwipe`) brings it back; without the app's own full screen the car's sidebar takes that edge, so a tab at the left edge (`edge_tab`) is tapped or swiped instead. Pages change only by the navigation (the pager's own swiping is off) | | 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/SPECS.md b/SPECS.md index fc1d137..4520c75 100644 --- a/SPECS.md +++ b/SPECS.md @@ -140,6 +140,7 @@ 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-11-07 — App 2.48.0: **the Christmas style** (owner: "let's also do a christmas based theme", with music and something that pulsates; design agreed first). `ChristmasView`: a tree with a big red bauble (speed), fairy lights arching over it (power: gold left to right while drawing, ice blue and right to left while regenerating), a glass mug of cocoa filling with the charge (cold blue and "BATTERY LOW" below 20 %), gift tags (trip, usage, climate, odometer), a wreath clock, a signboard (range) with a candy cane per 50 km, P R N D as baubles, and a snow drift of the last two minutes of power; snow falls and the lights twinkle while driving. Own palette (red); always in the drop-down; English only. The music light is now **"Light follows the music"** for Halloween and Christmas (same `PREF_MUSIC_LIGHT`, off by default): the tree's lights and the arch flare on the kick drum. What plays: Santa's sleigh with a reindeer and, on the next pass, an angel with a trumpet tow a strip of wrapping paper (colour fixed by the song); both in `SongCarriers`. Two screenshots in THEMES.md. Tested: unit tests, lint, emulator with sample values (panel and full screen, and with no data), with the throwaway fake song (both carriers), and the music-tap thread runs only with the switch on. Not tried on the emulator: the pulse itself with real sound (the code path is Halloween's `MusicTap`). Not tried on the car. - 2026-11-02 — App 2.47.0 published (`car-2.47.0`). - 2026-11-02 — App 2.47.0: **now playing in four more styles** (owner: "let's implement Now playing in more styles"; Spanish labels declined). Quest: a red dragon tows a waving scroll across the top of the sky, behind the frames. Empire: an eagle flies a scroll across the top of the world, behind the speed. Terminal: a PLAYING row above the prompt. Sci-fi: the song in black on the top bar. The creatures are in the new `SongCarriers` (dragon, eagle) beside `SongBanner`. Same switch as before (Settings → "Show what's playing"). Not yet in the rest: Gauges, Cockpit, Retro, Futuristic, MG, Minimal, Nixie, Chronograph, Grid, Grid with map, Time circuits, Visor, Cartoon. Tested: unit tests, lint, emulator with the throwaway fake session. Not tried on the car. - 2026-11-02 — App 2.46.0 published (`car-2.46.0`). diff --git a/THEMES.md b/THEMES.md index 0846c38..80db10f 100644 --- a/THEMES.md +++ b/THEMES.md @@ -11,7 +11,7 @@ emulator has no car; on the car the values are live. **Colours.** Most styles are drawn in the accent colour you pick from the MG4 paints (Settings → Accent colour; shown here in Dynamic Red). Retro, Terminal, Grid (with or without the map) and Visor can also show their original -colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de Muertos and Halloween always do; while one of those is shown, the whole app +colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de Muertos, Halloween and Christmas always do; while one of those is shown, the whole app (navigation, buttons, switches) takes on its colours too. | Style | Look | Colours | @@ -36,6 +36,7 @@ colours, and Sci-fi, Cartoon, Nixie tubes, Time circuits, Quest, Empire, Día de | [Empire](#empire) | a strategy game's interface | own | | [Día de Muertos](#día-de-muertos) | an ofrenda with papel picado and a calavera | own | | [Halloween](#halloween) | a jack-o'-lantern on a graveyard hill | own | +| [Christmas](#christmas) | a Christmas tree in a snowy village | own | ## Tiles @@ -281,3 +282,23 @@ pumpkin's orange with it. ![Halloween with the navigation](screenshots/themes/halloween-panel.png) ![Halloween, full screen](screenshots/themes/halloween.png) + +## Christmas + +A snowy village at night, warm and festive. In the middle a Christmas tree with a big red bauble +in front of it: the speed. Fairy lights arch over the tree: the power, left to right in warm gold +while drawing, ice blue and right to left while regenerating. On the left a glass mug of hot cocoa +fills with the charge, with marshmallows floating on it and steam rising; it turns cold blue with +"BATTERY LOW" blinking below 20 %. Above it, gift tags hang from a garland with trip, usage, +climate and odometer. On the right a wreath holds the clock, and a snowy signboard reads the range +with a candy cane for every 50 km in the snow below; P R N D are four baubles, the one engaged lit. +The snow drift along the bottom is the last two minutes of power, rising with it. While you drive the +snow falls and the tree's lights twinkle. With "Light follows the music" on (Settings, shown with +this style and Halloween; off by default) and Bluetooth or CarPlay music playing, the lights on the +tree and the fairy lights flare on each kick drum; the radio cannot be heard, so they twinkle as +usual then. What plays (Bluetooth, CarPlay or the radio) crosses the sky above the tree on a waving +strip of wrapping paper towed by Santa's sleigh and, on the next pass, a small angel with a +trumpet; the paper's colour is fixed by the song. The app turns red with it. + +![Christmas with the navigation](screenshots/themes/christmas-panel.png) +![Christmas, full screen](screenshots/themes/christmas.png) diff --git a/app/build.gradle b/app/build.gradle index 6f50119..866e4a6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 164 - versionName "2.47.0" + versionCode 165 + versionName "2.48.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/ChristmasView.java b/app/src/main/java/com/evsuite/abrp/ChristmasView.java new file mode 100644 index 0000000..6bf83b9 --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/ChristmasView.java @@ -0,0 +1,616 @@ +package com.evsuite.abrp; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.DashPathEffect; +import android.graphics.LinearGradient; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RadialGradient; +import android.graphics.RectF; +import android.graphics.Shader; +import android.graphics.Typeface; +import android.os.SystemClock; + +import java.util.Locale; + +/** + * The Christmas Overview style: a snowy village night, warm and festive. + * + * In the middle a Christmas tree with a big red bauble in front of it: the speed. Fairy lights + * arch over the tree: the power, left to right in warm gold while drawing, ice blue and right to + * left while regenerating. On the left a glass mug of hot cocoa filling with the charge, cold blue + * with "BATTERY LOW" blinking below 20 %, under gift tags with trip, usage, climate and odometer. + * On the right a wreath holds the clock, and a snowy signboard reads the range, with a candy cane + * per 50 km; P R N D are four baubles, the one engaged lit. The snow drift along the bottom is the + * last two minutes of power, rising with it, in gold or, where it regenerated, ice blue. Unread + * values show "—" and light nothing. With music playing, the tree's lights and the fairy lights + * flare on the beat ({@link MusicTap}); with no music they twinkle as before. What plays crosses + * the sky on a banner of wrapping paper, towed by Santa's sleigh and, on the next pass, an angel + * ({@link NowPlaying}). + * + * The night, the hills and the stars are drawn once into a bitmap per size; only the rest goes over + * it. Snow falls and the lights twinkle (about 30 frames a second) only while the car moves and the + * view is on screen; the lights' pulse and the song keep it drawing while there are any. + */ +final class ChristmasView extends ClusterCanvasView { + + private static final int BULBS = 25, FLAKES = 70, CANES = 8; + private static final int SKY_TOP = 0xFF06142B, SKY_LOW = 0xFF173A5E, SKY_END = 0xFF2A5A7A; + private static final int SNOW = 0xFFE9F2FF, PINE = 0xFF1F7A3C, PINE_DARK = 0xFF0F4A24; + private static final int RED = 0xFFE53946, GOLD = 0xFFFFD04A, BONE = 0xFFF6F0E4, INK = 0xFF1B0F12; + private static final int COCOA = 0xFF7B3F22, COLD = 0xFF4A8AD0, ICE = 0xFF8FD8FF, WOOD = 0xFF4A2E1A; + private static final int[] LIGHTS = { RED, GOLD, ICE, 0xFFFFFFFF, 0xFF5CE08A }; + private static final int[] PAPER = { 0xFFC62828, 0xFF1E7B3C, 0xFF2B5FA8, 0xFFD9A62E, 0xFFF1F1F1 }; + + private final Paint fill = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint line = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint text = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Paint image = new Paint(Paint.FILTER_BITMAP_FLAG); + private final Paint glow = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path path = new Path(); + private final SongBanner banner = new SongBanner(); + private final RectF r = new RectF(); + private final Typeface serif = Typeface.create("serif", Typeface.BOLD); + private final Typeface face = Typeface.create("sans-serif-condensed", Typeface.BOLD); + private final Typeface thin = Typeface.create("sans-serif-condensed", Typeface.NORMAL); + + private Bitmap sky; + private int skyW, skyH; + private float time; + private long lastFrame; + private MusicTap music; + + ChristmasView(Context context) { + super(context); + line.setStyle(Paint.Style.STROKE); + line.setStrokeCap(Paint.Cap.ROUND); + } + + /** Where the lights hear the music; without it, they do not follow any. */ + void setMusic(MusicTap tap) { + music = tap; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + sky = null; + skyW = 0; + } + + @Override + protected void onDraw(Canvas c) { + super.onDraw(c); + int w = getWidth(), h = getHeight(); + if (w <= 0 || h <= 0) return; + float u = Math.min(h / 640f, w / 1100f); + boolean moving = v.speedKmh != null && v.speedKmh > 0.5f; + if (moving) { + long now = SystemClock.uptimeMillis(); + time += lastFrame == 0 ? 0f : Math.min(0.2f, (now - lastFrame) / 1000f); + lastFrame = now; + } else { + lastFrame = 0; + } + if (sky == null || skyW != w || skyH != h) buildSky(w, h, u); + c.drawBitmap(sky, 0, 0, image); + boolean song = drawSong(c, w, u); + drawSnow(c, w, h, u); + drawDrift(c, w, h, u); + float pulse = music == null ? 0f : music.pulse(); + drawTree(c, w, u, pulse); + drawArch(c, w, u, pulse); + drawBauble(c, w, u, pulse); + drawWreath(c, w, u); + drawTags(c, u); + drawMug(c, u); + drawBoard(c, w, u); + drawGears(c, w, u); + keepAnimating(moving || pulse > 0f || song, 33); + } + + private void label(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a) { + text.setTypeface(tf); + text.setTextSize(size); + text.setTextAlign(a); + text.setColor(colour); + c.drawText(s, x, y, text); + } + + /** Text with a dark outline, to read over whatever is behind it. */ + private void outlined(Canvas c, String s, float x, float y, float size, int colour, Typeface tf, Paint.Align a, float u) { + text.setStyle(Paint.Style.STROKE); + text.setStrokeWidth(Math.max(3f * u, size * 0.12f)); + text.setStrokeJoin(Paint.Join.ROUND); + label(c, s, x, y, size, 0xDD0A0608, tf, a); + text.setStyle(Paint.Style.FILL); + label(c, s, x, y, size, colour, tf, a); + } + + private boolean regen() { + return v.powerKw != null && v.powerKw < -0.05f; + } + + // ---------- Night sky and hills ---------- + + private void buildSky(int w, int h, float u) { + sky = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888); + skyW = w; + skyH = h; + Canvas c = new Canvas(sky); + Paint p = new Paint(); + p.setShader(new LinearGradient(0, 0, 0, h, new int[] { SKY_TOP, SKY_LOW, SKY_END }, + new float[] { 0f, 0.7f, 1f }, Shader.TileMode.CLAMP)); + c.drawRect(0, 0, w, h, p); + Paint star = new Paint(Paint.ANTI_ALIAS_FLAG); + star.setColor(0x99FFFFFF); + for (int i = 0; i < 90; i++) { + float x = ((i * 191 + 17) % 1009) / 1009f * w, y = ((i * 83 + 5) % 421) * u; + c.drawCircle(x, y, (i % 4 == 0 ? 1.7f : 0.9f) * u, star); + } + // Two ranges of snowy hills, with pines on the far one. + hills(c, w, h, h - 150f * u, 0xFF28496B, 0.8f, u); + Paint pine = new Paint(Paint.ANTI_ALIAS_FLAG); + pine.setColor(0xFF0F3A2A); + for (int i = 0; i < 14; i++) { + float x = ((i * 233 + 41) % 997) / 997f * w, tall = (36f + (i * 17) % 30) * u; + float base = h - 150f * u + 8f * u; + Path tri = new Path(); + tri.moveTo(x, base - tall); + tri.lineTo(x + tall * 0.32f, base); + tri.lineTo(x - tall * 0.32f, base); + tri.close(); + c.drawPath(tri, pine); + } + hills(c, w, h, h - 100f * u, 0xFF3C6286, 1.5f, u); + } + + private void hills(Canvas c, int w, int h, float base, int colour, float wave, float u) { + Path hill = new Path(); + hill.moveTo(0, h); + for (int i = 0; i <= 24; i++) { + float x = w * i / 24f; + hill.lineTo(x, base - (float) (Math.sin(i * wave) * 0.5 + Math.sin(i * wave * 0.37 + 1) * 0.5) * 22f * u); + } + hill.lineTo(w, h); + hill.close(); + Paint p = new Paint(Paint.ANTI_ALIAS_FLAG); + p.setColor(colour); + c.drawPath(hill, p); + } + + private void drawSnow(Canvas c, int w, int h, float u) { + for (int i = 0; i < FLAKES; i++) { + float fall = (30f + (i % 5) * 12f) * u; + float y = (((i * 53) % 600) * u + time * fall) % h; + float x = ((i * 97 + 13) % 1000) / 1000f * w + (float) Math.sin(time * 0.8f + i) * 12f * u; + fill.setColor(i % 3 == 0 ? 0xE0FFFFFF : 0x90FFFFFF); + c.drawCircle(x, y, (1f + (i % 3) * 0.8f) * u, fill); + } + } + + // ---------- The drift: the last two minutes of power ---------- + + private void drawDrift(Canvas c, int w, int h, float u) { + float base = h - 44f * u, amp = 36f * u; + float[] trail = v.powerTrail; + path.reset(); + path.moveTo(0, h); + int n = trail == null ? 0 : trail.length; + if (n == 0) { + path.lineTo(0, base); + path.lineTo(w, base); + } else { + for (int i = 0; i < n; i++) { + float p = trail[i]; + float height = Float.isNaN(p) ? 0f : Math.min(1f, Math.abs(p) / (p < 0 ? 40f : 60f)); + path.lineTo(i / (n - 1f) * w, base - height * amp); + } + } + path.lineTo(w, h); + path.close(); + fill.setColor(SNOW); + c.drawPath(path, fill); + if (n > 1) { + line.setStrokeWidth(3f * u); + for (int i = 1; i < n; i++) { + float a = trail[i - 1], b = trail[i]; + if (Float.isNaN(a) || Float.isNaN(b)) continue; + float ha = Math.min(1f, Math.abs(a) / (a < 0 ? 40f : 60f)); + float hb = Math.min(1f, Math.abs(b) / (b < 0 ? 40f : 60f)); + line.setColor(b < 0 ? ICE : GOLD); + c.drawLine((i - 1f) / (n - 1f) * w, base - ha * amp, i / (n - 1f) * w, base - hb * amp, line); + } + } + } + + // ---------- The tree ---------- + + private void drawTree(Canvas c, int w, float u, float pulse) { + float cx = w / 2f; + // The trunk, then three tiers, each with a darker underside. + r.set(cx - 22f * u, 480f * u, cx + 22f * u, 526f * u); + fill.setColor(WOOD); + c.drawRect(r, fill); + tier(c, cx, 118f * u, 238f * u, 96f * u, u); + tier(c, cx, 190f * u, 358f * u, 136f * u, u); + tier(c, cx, 270f * u, 492f * u, 182f * u, u); + // The star on top, flaring with the music. + float sy = 104f * u, flare = 1f + pulse * 0.5f; + glow.setShader(new RadialGradient(cx, sy, 60f * u * flare, + new int[] { AccentColor.withAlpha(GOLD, 0x55 + (int) (pulse * 0x80)), AccentColor.withAlpha(GOLD, 0) }, null, + Shader.TileMode.CLAMP)); + c.drawCircle(cx, sy, 60f * u * flare, glow); + path.reset(); + for (int i = 0; i < 10; i++) { + double a = -Math.PI / 2 + Math.PI * i / 5; + float rad = (i % 2 == 0 ? 24f : 10f) * u; + float x = cx + (float) Math.cos(a) * rad, y = sy + (float) Math.sin(a) * rad; + if (i == 0) path.moveTo(x, y); + else path.lineTo(x, y); + } + path.close(); + fill.setColor(GOLD); + c.drawPath(path, fill); + // The lights: strung along each tier's lower edge, steady, twinkling a little while driving, + // flaring together on the beat. + float[][] strings = { { 238f, 96f, 5 }, { 358f, 136f, 7 }, { 492f, 182f, 10 } }; + int i = 0; + for (float[] st : strings) { + int n = (int) st[2]; + for (int k = 0; k < n; k++, i++) { + float x = cx - st[1] * 0.85f * u + k * (1.7f * st[1] * u) / (n - 1); + float y = (st[0] - 16f + (k % 2) * 9f) * u; + int colour = LIGHTS[i % LIGHTS.length]; + float twinkle = 0.5f + 0.5f * (float) Math.sin(time * 3f + i * 1.7f); + int alpha = Math.min(0xFF, 0x58 + (int) (twinkle * 0x38) + (int) (pulse * 0x90)); + fill.setColor(AccentColor.withAlpha(colour, alpha)); + c.drawCircle(x, y, (11f + pulse * 9f) * u, fill); + fill.setColor(brighter(colour, pulse * 0.4f)); + c.drawCircle(x, y, 4.8f * u * (1f + pulse * 0.35f), fill); + } + } + } + + private void tier(Canvas c, float cx, float apex, float base, float half, float u) { + for (int pass = 0; pass < 2; pass++) { + float dy = pass == 0 ? 7f * u : 0f; + path.reset(); + path.moveTo(cx, apex + dy); + path.lineTo(cx + half, base - 6f * u + dy); + int scallops = 5; + float sw = half * 2f / scallops; + for (int i = scallops; i > 0; i--) { + float x = cx - half + i * sw; + path.quadTo(x - sw / 2f, base + 12f * u + dy, x - sw, base - 6f * u + dy); + } + path.close(); + fill.setColor(pass == 0 ? PINE_DARK : PINE); + c.drawPath(path, fill); + } + } + + /** The colour moved {@code share} of the way to white. */ + private static int brighter(int colour, float share) { + int r = (colour >> 16) & 0xFF, g = (colour >> 8) & 0xFF, b = colour & 0xFF; + return 0xFF000000 | (r + (int) ((255 - r) * share)) << 16 | (g + (int) ((255 - g) * share)) << 8 + | (b + (int) ((255 - b) * share)); + } + + // ---------- The fairy lights over the tree: the power ---------- + + private void drawArch(Canvas c, int w, float u, float pulse) { + float cx = w / 2f, cy = 348f * u, radX = 272f * u, radY = 244f * u; + int lit = Tally.ofPower(v.powerKw, 150f, 50f, BULBS); + boolean regen = regen(); + // The wire. + line.setColor(PINE_DARK); + line.setStrokeWidth(3f * u); + path.reset(); + for (int i = 0; i <= 48; i++) { + double a = Math.PI + Math.PI * i / 48.0; + float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY; + if (i == 0) path.moveTo(x, y); + else path.lineTo(x, y); + } + c.drawPath(path, line); + for (int i = 0; i < BULBS; i++) { + // Left to right over the top; regeneration comes back from the right. + int k = regen ? BULBS - 1 - i : i; + double a = Math.PI + Math.PI * k / (BULBS - 1); + float x = cx + (float) Math.cos(a) * radX, y = cy + (float) Math.sin(a) * radY; + boolean on = i < lit; + if (on) { + fill.setColor(AccentColor.withAlpha(regen ? ICE : GOLD, 0x44 + (int) (pulse * 0x50))); + c.drawCircle(x, y, (15f + pulse * 6f) * u, fill); + } + fill.setColor(on ? brighter(regen ? ICE : GOLD, pulse * 0.4f) : 0x38FFFFFF); + c.drawCircle(x, y, 7.5f * u, fill); + } + } + + // ---------- The big bauble: the speed ---------- + + private void drawBauble(Canvas c, int w, float u, float pulse) { + float cx = w / 2f, cy = 372f * u, rad = 88f * u; + glow.setShader(new RadialGradient(cx, cy, rad * (1.6f + pulse * 0.2f), + new int[] { AccentColor.withAlpha(RED, 0x40 + (int) (pulse * 0x50)), AccentColor.withAlpha(RED, 0) }, null, + Shader.TileMode.CLAMP)); + c.drawCircle(cx, cy, rad * (1.6f + pulse * 0.2f), glow); + // The cap and its loop. + line.setColor(GOLD); + line.setStrokeWidth(3f * u); + r.set(cx - 12f * u, cy - rad - 30f * u, cx + 12f * u, cy - rad - 6f * u); + c.drawOval(r, line); + glow.setShader(new RadialGradient(cx - rad * 0.3f, cy - rad * 0.35f, rad * 1.3f, + new int[] { 0xFFFF7A82, RED, 0xFF8E1623 }, new float[] { 0f, 0.5f, 1f }, Shader.TileMode.CLAMP)); + c.drawCircle(cx, cy, rad, glow); + r.set(cx - 16f * u, cy - rad - 12f * u, cx + 16f * u, cy - rad + 8f * u); + fill.setColor(GOLD); + c.drawRect(r, fill); + // A gold band round the middle's lower half, and a highlight. + line.setColor(0x88FFD04A); + line.setStrokeWidth(3f * u); + r.set(cx - rad + 6f * u, cy + 34f * u, cx + rad - 6f * u, cy + rad - 4f * u); + c.drawArc(r, 20f, 140f, false, line); + fill.setColor(0x55FFFFFF); + r.set(cx - rad * 0.62f, cy - rad * 0.72f, cx - rad * 0.2f, cy - rad * 0.42f); + c.drawOval(r, fill); + label(c, fmt(v.speedKmh, "%.0f", "—"), cx, cy + 20f * u, 84f * u, BONE, serif, Paint.Align.CENTER); + label(c, "KM/H", cx, cy + 50f * u, 16f * u, 0xCCFFE9EA, face, Paint.Align.CENTER); + // The power under the tree, above the drift. + boolean regen = regen(); + String power = v.powerKw == null ? "— kW" : String.format(Locale.US, regen ? "+%.1f kW" : "%.1f kW", + Math.abs(v.powerKw)); + outlined(c, power, cx, 556f * u, 34f * u, regen ? ICE : BONE, face, Paint.Align.CENTER, u); + } + + // ---------- The wreath: the clock ---------- + + private void drawWreath(Canvas c, int w, float u) { + float rad = 74f * u, cx = w - 100f * u - rad * 0.2f, cy = 96f * u; + line.setColor(PINE); + line.setStrokeWidth(24f * u); + c.drawCircle(cx, cy, rad, line); + line.setColor(PINE_DARK); + line.setStrokeWidth(5f * u); + c.drawCircle(cx, cy, rad - 8f * u, line); + fill.setColor(0xFF2E9C52); + for (int i = 0; i < 16; i++) { + double a = Math.PI * 2 * i / 16; + c.drawCircle(cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, 8f * u, fill); + } + fill.setColor(RED); + for (int i = 0; i < 9; i++) { + double a = Math.PI * 2 * i / 9 + 0.3; + c.drawCircle(cx + (float) Math.cos(a) * rad, cy + (float) Math.sin(a) * rad, 4.5f * u, fill); + } + // The bow at the foot. + path.reset(); + float bx = cx, by = cy + rad; + path.moveTo(bx, by); + path.lineTo(bx - 28f * u, by - 14f * u); + path.lineTo(bx - 28f * u, by + 14f * u); + path.close(); + path.moveTo(bx, by); + path.lineTo(bx + 28f * u, by - 14f * u); + path.lineTo(bx + 28f * u, by + 14f * u); + path.close(); + fill.setColor(RED); + c.drawPath(path, fill); + c.drawCircle(bx, by, 7f * u, fill); + // The clock face inside. + fill.setColor(0xFFFFF6E0); + c.drawCircle(cx, cy, rad - 18f * u, fill); + line.setColor(GOLD); + line.setStrokeWidth(3f * u); + c.drawCircle(cx, cy, rad - 18f * u, line); + label(c, v.clock.isEmpty() ? "—" : v.clock, cx, cy + 14f * u, 40f * u, INK, serif, Paint.Align.CENTER); + } + + // ---------- Gift tags: trip, usage, climate, odometer ---------- + + private void drawTags(Canvas c, float u) { + float l = 28f * u, rr = l + 300f * u, t = 44f * u, tag = 34f * u, step = 38f * u; + // The garland they hang from. + line.setColor(PINE); + line.setStrokeWidth(8f * u); + c.drawLine(l + 10f * u, 20f * u, rr - 10f * u, 20f * u, line); + fill.setColor(RED); + for (int i = 0; i < 7; i++) c.drawCircle(l + 24f * u + i * 44f * u, 20f * u, 4f * u, fill); + String[][] rows = { + { "TRIP", fmt(v.tripKm, "%.1f km", "—") }, + { "5 MIN", fmt(v.usage5Min, "%.1f kWh/100", "—") }, + { "SINCE START", fmt(v.usageTrip, "%.1f kWh/100", "—") }, + { "CLIMATE", fmt(v.climateCelsius, "%.0f °C", "—") }, + { "ODOMETER", fmt(v.odometerKm, "%,.0f km", "—") }, + }; + for (int i = 0; i < rows.length; i++) { + float top = t + i * step; + // A tag with a clipped corner and a hole, hung on a thread from the one above. + path.reset(); + path.moveTo(l + 18f * u, top); + path.lineTo(rr, top); + path.lineTo(rr, top + tag); + path.lineTo(l + 18f * u, top + tag); + path.lineTo(l, top + tag / 2f); + path.close(); + fill.setColor(i % 2 == 0 ? 0xFFF6EBD0 : 0xFFEAD9B6); + c.drawPath(path, fill); + line.setColor(RED); + line.setStrokeWidth(2f * u); + c.drawPath(path, line); + line.setColor(0xCCFFFFFF); + line.setStrokeWidth(1.5f * u); + c.drawLine(l + 12f * u, top - (i == 0 ? 20f * u - 0f : step - tag), l + 12f * u, top + tag / 2f, line); + fill.setColor(0xFF2A1A10); + c.drawCircle(l + 12f * u, top + tag / 2f, 3f * u, fill); + label(c, rows[i][0], l + 28f * u, top + 23f * u, 17f * u, 0xFF8E1623, thin, Paint.Align.LEFT); + label(c, rows[i][1], rr - 12f * u, top + 25f * u, 22f * u, INK, face, Paint.Align.RIGHT); + } + } + + // ---------- The mug of cocoa: the battery ---------- + + private void drawMug(Canvas c, float u) { + float cx = 178f * u, l = cx - 92f * u, rr = cx + 92f * u, top = 330f * u, bottom = 560f * u; + boolean low = v.socPercent != null && v.socPercent < 20f; + boolean blinkOn = (SystemClock.uptimeMillis() / 500) % 2 == 0; + float corner = 22f * u, inner = 6f * u; + // The handle. + line.setColor(0xFFCFE6F0); + line.setStrokeWidth(14f * u); + r.set(rr - 26f * u, top + 50f * u, rr + 40f * u, top + 170f * u); + c.drawArc(r, -90f, 180f, false, line); + // The glass. + r.set(l, top, rr, bottom); + fill.setColor(0x30CFE6F0); + c.drawRoundRect(r, corner, corner, fill); + if (v.socPercent != null) { + float share = Math.max(0f, Math.min(1f, v.socPercent / 100f)); + float level = bottom - inner - share * (bottom - top - 2f * inner); + c.save(); + r.set(l + inner, top + inner, rr - inner, bottom - inner); + path.reset(); + path.addRoundRect(r, corner - inner, corner - inner, Path.Direction.CW); + c.clipPath(path); + fill.setColor(low ? COLD : COCOA); + c.drawRect(l, level, rr, bottom, fill); + fill.setColor(0x33FFFFFF); + c.drawRect(l, level, rr, level + 4f * u, fill); + c.restore(); + // Marshmallows float on top, bobbing while driving. + for (int i = 0; i < 3; i++) { + float mx = cx + (i - 1) * 50f * u, my = level - 8f * u + (float) Math.sin(time * 2f + i * 2f) * 2f * u; + r.set(mx - 14f * u, my - 12f * u, mx + 14f * u, my + 8f * u); + fill.setColor(BONE); + c.drawRoundRect(r, 6f * u, 6f * u, fill); + } + // Steam. + line.setColor(0x66FFFFFF); + line.setStrokeWidth(4f * u); + for (int i = 0; i < 3; i++) { + float sx = cx + (i - 1) * 44f * u; + path.reset(); + path.moveTo(sx, level - 24f * u); + for (int k = 1; k <= 6; k++) { + path.lineTo(sx + (float) Math.sin(time * 2.5f + i + k * 0.9f) * 9f * u, level - 24f * u - k * 11f * u); + } + c.drawPath(path, line); + } + } + line.setColor(0xFFCFE6F0); + line.setStrokeWidth(4f * u); + r.set(l, top, rr, bottom); + c.drawRoundRect(r, corner, corner, line); + String number = fmt(v.socPercent, "%.0f %%", "—"); + outlined(c, number, cx, 470f * u, 58f * u, low ? 0xFFFF9AA3 : BONE, serif, Paint.Align.CENTER, u); + if (low && blinkOn) outlined(c, "BATTERY LOW", cx, 508f * u, 21f * u, BONE, face, Paint.Align.CENTER, u); + else if (!low) outlined(c, "BATTERY", cx, 508f * u, 19f * u, GOLD, face, Paint.Align.CENTER, u); + } + + // ---------- The signboard and candy canes: the range; baubles: the gears ---------- + + private void drawBoard(Canvas c, int w, float u) { + float rr = w - 40f * u, l = rr - 250f * u, t = 214f * u, b = 394f * u, mid = (l + rr) / 2f; + r.set(l, t, rr, b); + fill.setColor(WOOD); + c.drawRoundRect(r, 10f * u, 10f * u, fill); + line.setColor(0xFF8A5A33); + line.setStrokeWidth(3f * u); + c.drawRoundRect(r, 10f * u, 10f * u, line); + // Snow on top. + path.reset(); + path.moveTo(l - 4f * u, t + 4f * u); + path.quadTo(l + 40f * u, t - 24f * u, l + 90f * u, t - 4f * u); + path.quadTo(l + 140f * u, t - 28f * u, l + 190f * u, t - 6f * u); + path.quadTo(l + 230f * u, t - 16f * u, rr + 4f * u, t + 4f * u); + path.lineTo(rr + 4f * u, t + 12f * u); + path.lineTo(l - 4f * u, t + 12f * u); + path.close(); + fill.setColor(SNOW); + c.drawPath(path, fill); + label(c, "RANGE", mid, t + 52f * u, 20f * u, GOLD, face, Paint.Align.CENTER); + label(c, fmt(v.rangeKm, "%.0f", "—"), mid, t + 128f * u, 80f * u, BONE, serif, Paint.Align.CENTER); + label(c, "KM", mid, t + 160f * u, 20f * u, GOLD, face, Paint.Align.CENTER); + // A candy cane per 50 km in the snow below. + int lit = Tally.lit(v.rangeKm, 50f, CANES); + for (int i = 0; i < CANES; i++) { + cane(c, l + 18f * u + i * 30f * u, 488f * u, u, i < lit); + } + } + + private void cane(Canvas c, float x, float foot, float u, boolean on) { + path.reset(); + path.moveTo(x, foot); + path.lineTo(x, foot - 58f * u); + path.cubicTo(x, foot - 78f * u, x + 18f * u, foot - 78f * u, x + 18f * u, foot - 62f * u); + line.setStrokeWidth(9f * u); + line.setColor(on ? 0xFFFFFFFF : 0x40FFFFFF); + c.drawPath(path, line); + line.setColor(on ? RED : 0x40E53946); + line.setPathEffect(new DashPathEffect(new float[] { 7f * u, 7f * u }, 0f)); + c.drawPath(path, line); + line.setPathEffect(null); + } + + private void drawGears(Canvas c, int w, float u) { + float rr = w - 40f * u, l = rr - 250f * u, cy = 540f * u, rad = 25f * u; + String[] gears = { "P", "R", "N", "D" }; + String now = Gear.label(v.gear); + float step = (rr - l) / 4f; + for (int i = 0; i < gears.length; i++) { + float mid = l + (i + 0.5f) * step; + boolean on = gears[i].equals(now); + if (on) { + fill.setColor(0x55FFD04A); + c.drawCircle(mid, cy, 42f * u, fill); + } + fill.setColor(GOLD); + r.set(mid - 7f * u, cy - rad - 9f * u, mid + 7f * u, cy - rad + 3f * u); + c.drawRect(r, fill); + fill.setColor(on ? GOLD : 0xFF7A1F2A); + c.drawCircle(mid, cy, rad, fill); + line.setColor(on ? 0xFFFFF0B0 : 0xFFB03A48); + line.setStrokeWidth(2f * u); + c.drawCircle(mid, cy, rad, line); + label(c, gears[i], mid, cy + 11f * u, 30f * u, on ? INK : 0xB0FFFFFF, serif, Paint.Align.CENTER); + } + } + + // ---------- What plays: a sleigh or an angel tows it on wrapping paper ---------- + + /** + * Santa's sleigh and an angel take turns towing a waving strip of wrapping paper with the song + * across the sky above the tree, behind the wreath and the tags, again and again while + * something plays. The paper's colour is fixed by the song. Returns whether it is drawn, so the + * view keeps animating for it. + */ + private boolean drawSong(Canvas c, int w, float u) { + if (v.songTitle == null) return false; + text.setTypeface(face); + text.setTextSize(19f * u); + text.setStyle(Paint.Style.FILL); + String s = SongBanner.line(text, v.songTitle, v.songArtist, Math.min(560f * u, w * 0.45f)); + float t = SystemClock.uptimeMillis() / 1000f; + float bw = text.measureText(s) + 40f * u, bh = 28f * u, tow = 80f * u; + float bx = SongBanner.left(t, w, bw, tow, u); + float yc = 66f * u + (float) Math.sin(t * 1.3f) * 3f * u; + int paper = PAPER[Math.floorMod((v.songTitle + "\n" + v.songArtist).hashCode(), PAPER.length)]; + boolean darkInk = paper == PAPER[3] || paper == PAPER[4]; + banner.draw(c, fill, line, text, s, bx, yc, bw, bh, t, u, 0f, paper, 0xCCFFFFFF, darkInk ? INK : 0xFFFFFFFF); + boolean sleigh = SongBanner.pass(t, w, bw, tow, u) % 2 == 0; + float right = bx + bw, cx = right + tow, cy = yc - 6f * u, size = (sleigh ? 70f : 56f) * u; + float[] tail = sleigh ? SongCarriers.sleighTail : SongCarriers.angelTail; + float ax = cx + tail[0] * size, ay = cy + tail[1] * size; + line.setColor(0xCCFFFFFF); + line.setStrokeWidth(1.5f * u); + c.drawLine(right, yc - bh / 2f + 3f * u, ax, ay, line); + c.drawLine(right, yc + bh / 2f - 3f * u, ax, ay, line); + if (sleigh) SongCarriers.sleigh(c, fill, line, path, r, cx, cy, size, t, u); + else SongCarriers.angel(c, fill, line, path, r, cx, cy, size, (float) Math.sin(t * 7f), u); + return true; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 3656657..eae862f 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -217,9 +217,9 @@ public class MainActivity extends AppCompatActivity { /** Whether the Overview hides the navigation (the button in the navigation's header). */ static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen"; - /** The Overview shows what plays (Tiles, Halloween); on by default. */ + /** The Overview shows what plays (the styles that have a place for it); on by default. */ static final String PREF_NOW_PLAYING = "now_playing"; - /** The Halloween pumpkin's light follows the music; off by default, so the analyser stays off. */ + /** The Halloween pumpkin's and the Christmas tree's light follows the music; off by default, so the analyser stays off. */ static final String PREF_MUSIC_LIGHT = "music_light"; /** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */ @@ -364,10 +364,13 @@ public class MainActivity extends AppCompatActivity { HalloweenView halloweenView = new HalloweenView(this); musicTap = new MusicTap(this); nowPlaying = new NowPlayingWatcher(this); + ChristmasView christmasView = new ChristmasView(this); halloweenView.setMusic(musicTap); + christmasView.setMusic(musicTap); java.util.Collections.addAll(canvasStyles, new MinimalView(this), terminalView, new NixieView(this), new ChronographView(this), gridView, gridMapView, new TimeCircuitsView(this), - new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), halloweenView); + new QuestView(this), visorView, new EmpireView(this), new MuertosView(this), halloweenView, + christmasView); View[] canvasPanes = new View[canvasStyles.size()]; for (int i = 0; i < canvasPanes.length; i++) canvasPanes[i] = drawnPane(canvasStyles.get(i)); // Grid with map: its map layer goes under it, in the same pane. @@ -1787,7 +1790,8 @@ public class MainActivity extends AppCompatActivity { private static boolean showsNowPlaying(OverviewStyle style) { return style == OverviewStyle.TILES || style == OverviewStyle.HALLOWEEN || style == OverviewStyle.MUERTOS || style == OverviewStyle.QUEST || style == OverviewStyle.EMPIRE - || style == OverviewStyle.TERMINAL || style == OverviewStyle.SCIFI; + || style == OverviewStyle.TERMINAL || style == OverviewStyle.SCIFI + || style == OverviewStyle.CHRISTMAS; } /** The media sessions are watched only while what plays can be seen, and the switch is on. */ @@ -1835,13 +1839,18 @@ public class MainActivity extends AppCompatActivity { if (!android.text.TextUtils.equals(view.getText(), text)) view.setText(text); } + /** The styles with a light that can follow the music: Halloween's pumpkin and Christmas's tree. */ + private static boolean hasMusicLight(OverviewStyle style) { + return style == OverviewStyle.HALLOWEEN || style == OverviewStyle.CHRISTMAS; + } + /** - * The pumpkin listens only while it can be seen (the app in front, on the Overview, in - * Halloween) and only when "Pumpkin light follows the music" is on. + * The light listens only while it can be seen (the app in front, on the Overview, in + * Halloween or Christmas) and only when "Light follows the music" is on. */ private void updateMusicTap() { ViewPager2 pager = findViewById(R.id.content); - musicTap.setRunning(inFront && overviewStyle() == OverviewStyle.HALLOWEEN + musicTap.setRunning(inFront && hasMusicLight(overviewStyle()) && prefs.getBoolean(PREF_MUSIC_LIGHT, false) && pager != null && pager.getCurrentItem() == 0); } @@ -1909,7 +1918,7 @@ public class MainActivity extends AppCompatActivity { int playing = showsNowPlaying(style) ? View.VISIBLE : View.GONE; settingsPane.findViewById(R.id.now_playing_switch).setVisibility(playing); settingsPane.findViewById(R.id.now_playing_hint).setVisibility(playing); - int music = style == OverviewStyle.HALLOWEEN ? View.VISIBLE : View.GONE; + int music = hasMusicLight(style) ? View.VISIBLE : View.GONE; settingsPane.findViewById(R.id.music_light_switch).setVisibility(music); settingsPane.findViewById(R.id.music_light_hint).setVisibility(music); } @@ -1927,7 +1936,8 @@ public class MainActivity extends AppCompatActivity { getString(R.string.overview_style_grid_map), getString(R.string.overview_style_time_circuits), getString(R.string.overview_style_quest), getString(R.string.overview_style_visor), getString(R.string.overview_style_empire), - getString(R.string.overview_style_muertos), getString(R.string.overview_style_halloween) }; + getString(R.string.overview_style_muertos), getString(R.string.overview_style_halloween), + getString(R.string.overview_style_christmas) }; } /** The style drop-down; every style is fed on every reading, only the chosen one is shown. */ diff --git a/app/src/main/java/com/evsuite/abrp/OverviewStyle.java b/app/src/main/java/com/evsuite/abrp/OverviewStyle.java index 3d96145..3fea941 100644 --- a/app/src/main/java/com/evsuite/abrp/OverviewStyle.java +++ b/app/src/main/java/com/evsuite/abrp/OverviewStyle.java @@ -33,7 +33,8 @@ enum OverviewStyle { VISOR(true, true), EMPIRE(false, false), MUERTOS(false, false), - HALLOWEEN(false, false); + HALLOWEEN(false, false), + CHRISTMAS(false, false); static final String PREF_KEY = "overview_style"; diff --git a/app/src/main/java/com/evsuite/abrp/SongCarriers.java b/app/src/main/java/com/evsuite/abrp/SongCarriers.java index 72f588b..7719d7c 100644 --- a/app/src/main/java/com/evsuite/abrp/SongCarriers.java +++ b/app/src/main/java/com/evsuite/abrp/SongCarriers.java @@ -7,14 +7,16 @@ import android.graphics.RectF; /** * The creatures that tow a {@link SongBanner} in the styles with a sky for it, drawn flying right - * with their centre at (x, y) and about {@code s} wide: a dragon (Quest) and an eagle (Empire). + * with their centre at (x, y) and about {@code s} wide: a dragon (Quest), an eagle (Empire), Santa's + * sleigh with a reindeer and an angel (Christmas). * {@code flap} runs from −1 to 1 with the wing beat, {@code t} is the time in seconds. The caller * passes its own paints and scratch objects; the paints' colour and width are set here. Where the - * tow lines tie on is {@link #dragonTail} and {@link #eagleTail}, as offsets in units of {@code s}. + * tow lines tie on is {@link #dragonTail}, {@link #eagleTail}, {@link #sleighTail} and {@link #angelTail}, as offsets in units of {@code s}. */ final class SongCarriers { - static final float[] dragonTail = { -1.0f, -0.25f }, eagleTail = { -0.55f, 0.05f }; + static final float[] dragonTail = { -1.0f, -0.25f }, eagleTail = { -0.55f, 0.05f }, + sleighTail = { -0.62f, 0f }, angelTail = { -0.45f, 0.1f }; private SongCarriers() {} @@ -134,4 +136,132 @@ final class SongCarriers { c.drawLine(0.05f * s, 0.13f * s, 0.02f * s, 0.26f * s, line); c.restore(); } + + /** A reindeer with a glowing nose pulling Santa's sleigh, drawn about 1.7 s wide. */ + static void sleigh(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s, + float t, float u) { + final int brown = 0xFF8A5A33, red = 0xFFC62828, gold = 0xFFFFD04A; + c.save(); + c.translate(x, y); + // The reindeer: four legs trotting, a body, a neck, a head with antlers and a red nose. + float swing = (float) Math.sin(t * 8f) * 0.18f; + line.setColor(brown); + line.setStrokeWidth(3f * u); + c.drawLine(0.72f * s, 0.05f * s, (0.72f + swing) * s, 0.3f * s, line); + c.drawLine(0.66f * s, 0.05f * s, (0.66f - swing) * s, 0.3f * s, line); + c.drawLine(0.44f * s, 0.05f * s, (0.44f - swing) * s, 0.3f * s, line); + c.drawLine(0.38f * s, 0.05f * s, (0.38f + swing) * s, 0.3f * s, line); + r.set(0.32f * s, -0.15f * s, 0.8f * s, 0.1f * s); + fill.setColor(brown); + c.drawOval(r, fill); + line.setStrokeWidth(0.1f * s); + c.drawLine(0.76f * s, -0.08f * s, 0.86f * s, -0.2f * s, line); + c.drawCircle(0.88f * s, -0.22f * s, 0.075f * s, fill); + line.setColor(0xFFD9C08A); + line.setStrokeWidth(2f * u); + c.drawLine(0.85f * s, -0.28f * s, 0.78f * s, -0.46f * s, line); + c.drawLine(0.82f * s, -0.37f * s, 0.9f * s, -0.4f * s, line); + c.drawLine(0.9f * s, -0.28f * s, 0.97f * s, -0.46f * s, line); + c.drawLine(0.94f * s, -0.37f * s, 0.86f * s, -0.42f * s, line); + float glow = 0.75f + 0.25f * (float) Math.sin(t * 6f); + fill.setColor(0x55FF3B30); + c.drawCircle(0.97f * s, -0.2f * s, 0.1f * s * glow, fill); + fill.setColor(0xFFFF3B30); + c.drawCircle(0.96f * s, -0.2f * s, 0.04f * s, fill); + // The harness, and the sleigh: a red body with a curled front on a gold runner. + line.setColor(gold); + line.setStrokeWidth(1.5f * u); + c.drawLine(0.36f * s, -0.02f * s, 0.12f * s, -0.02f * s, line); + path.reset(); + path.moveTo(-0.55f * s, -0.12f * s); + path.lineTo(0.05f * s, -0.12f * s); + path.quadTo(0.22f * s, -0.12f * s, 0.2f * s, -0.28f * s); + path.lineTo(0.12f * s, 0.1f * s); + path.lineTo(-0.5f * s, 0.1f * s); + path.quadTo(-0.64f * s, 0.08f * s, -0.6f * s, -0.12f * s); + path.close(); + fill.setColor(red); + c.drawPath(path, fill); + line.setColor(gold); + line.setStrokeWidth(2f * u); + c.drawPath(path, line); + path.reset(); + path.moveTo(-0.62f * s, 0.19f * s); + path.lineTo(0.18f * s, 0.19f * s); + path.quadTo(0.32f * s, 0.19f * s, 0.3f * s, 0.07f * s); + line.setStrokeWidth(3f * u); + c.drawPath(path, line); + c.drawLine(-0.4f * s, 0.1f * s, -0.4f * s, 0.19f * s, line); + c.drawLine(0.0f, 0.1f * s, 0.0f, 0.19f * s, line); + // Santa with his sack. + fill.setColor(0xFF7A4A22); + c.drawCircle(-0.5f * s, -0.18f * s, 0.11f * s, fill); + fill.setColor(red); + c.drawCircle(-0.25f * s, -0.2f * s, 0.12f * s, fill); + fill.setColor(0xFFF2CFA0); + c.drawCircle(-0.22f * s, -0.36f * s, 0.07f * s, fill); + fill.setColor(0xFFFFFFFF); + r.set(-0.28f * s, -0.37f * s, -0.15f * s, -0.28f * s); + c.drawOval(r, fill); + path.reset(); + path.moveTo(-0.29f * s, -0.4f * s); + path.lineTo(-0.14f * s, -0.4f * s); + path.lineTo(-0.3f * s, -0.56f * s); + path.close(); + fill.setColor(red); + c.drawPath(path, fill); + fill.setColor(0xFFFFFFFF); + c.drawCircle(-0.3f * s, -0.57f * s, 0.035f * s, fill); + c.restore(); + } + + /** A small angel flying right with a trumpet, wings beating. */ + static void angel(Canvas c, Paint fill, Paint line, Path path, RectF r, float x, float y, float s, + float flap, float u) { + final int white = 0xFFF4F8FF, gold = 0xFFFFD04A; + c.save(); + c.translate(x, y); + for (int i = 0; i < 4; i++) { + c.save(); + c.translate(-0.05f * s, -0.12f * s); + c.rotate(-125f + flap * 28f + i * 14f); + r.set(0, -0.06f * s, (0.85f - 0.07f * i) * s, 0.06f * s); + fill.setColor(white); + c.drawOval(r, fill); + line.setColor(0xFFB9C6DC); + line.setStrokeWidth(1.5f * u); + c.drawOval(r, line); + c.restore(); + } + // The robe trails behind, with a gold sash. + path.reset(); + path.moveTo(0.2f * s, -0.14f * s); + path.quadTo(0.28f * s, 0.05f * s, 0.05f * s, 0.12f * s); + path.lineTo(-0.5f * s, 0.22f * s); + path.quadTo(-0.35f * s, -0.02f * s, -0.1f * s, -0.14f * s); + path.close(); + fill.setColor(white); + c.drawPath(path, fill); + line.setColor(0xFFB9C6DC); + line.setStrokeWidth(1.5f * u); + c.drawPath(path, line); + line.setColor(gold); + line.setStrokeWidth(3f * u); + c.drawLine(0.02f * s, -0.12f * s, 0.12f * s, 0.06f * s, line); + // The head with a halo, and the trumpet held out in front. + fill.setColor(0xFFF2CFA0); + c.drawCircle(0.3f * s, -0.2f * s, 0.1f * s, fill); + fill.setColor(gold); + r.set(0.2f * s, -0.4f * s, 0.4f * s, -0.33f * s); + c.drawOval(r, line); + line.setStrokeWidth(2.5f * u); + c.drawLine(0.3f * s, -0.04f * s, 0.7f * s, -0.1f * s, line); + path.reset(); + path.moveTo(0.66f * s, -0.1f * s); + path.lineTo(0.9f * s, -0.22f * s); + path.lineTo(0.9f * s, 0.02f * s); + path.close(); + c.drawPath(path, fill); + c.restore(); + } } diff --git a/app/src/main/java/com/evsuite/abrp/StylePalette.java b/app/src/main/java/com/evsuite/abrp/StylePalette.java index abcffbd..5838f9a 100644 --- a/app/src/main/java/com/evsuite/abrp/StylePalette.java +++ b/app/src/main/java/com/evsuite/abrp/StylePalette.java @@ -26,6 +26,7 @@ final class StylePalette { case EMPIRE: return R.style.ThemeOverlay_EV_Style_Empire; case MUERTOS: return R.style.ThemeOverlay_EV_Style_Muertos; case HALLOWEEN: return R.style.ThemeOverlay_EV_Style_Halloween; + case CHRISTMAS: return R.style.ThemeOverlay_EV_Style_Christmas; default: return 0; } } diff --git a/app/src/main/res/layout-w1000dp/pane_settings.xml b/app/src/main/res/layout-w1000dp/pane_settings.xml index ef4e991..3e0f76e 100644 --- a/app/src/main/res/layout-w1000dp/pane_settings.xml +++ b/app/src/main/res/layout-w1000dp/pane_settings.xml @@ -26,10 +26,10 @@ - + - + diff --git a/app/src/main/res/layout/pane_settings.xml b/app/src/main/res/layout/pane_settings.xml index d7799c0..14b4f88 100644 --- a/app/src/main/res/layout/pane_settings.xml +++ b/app/src/main/res/layout/pane_settings.xml @@ -17,10 +17,10 @@ - + - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5b782f4..6cf4d75 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -136,7 +136,7 @@ Radio Media %1$s · paused - Pumpkin light follows the music + Light follows the music Flares on the kick drum over Bluetooth or CarPlay, not the radio. Levels only, no sound kept. %1$s has its own colours, and the app takes them on. To change the accent colour, pick a style that uses it. Overview full screen: hide the navigation @@ -152,6 +152,7 @@ Empire Día de Muertos Halloween + Christmas Nixie tubes Chronograph BATTERY diff --git a/app/src/main/res/values/style_palettes.xml b/app/src/main/res/values/style_palettes.xml index cdf96a1..3353a45 100644 --- a/app/src/main/res/values/style_palettes.xml +++ b/app/src/main/res/values/style_palettes.xml @@ -124,4 +124,14 @@ #80804A1E #6A3F18 + + diff --git a/screenshots/themes/christmas-panel.png b/screenshots/themes/christmas-panel.png new file mode 100644 index 0000000..8c0139c Binary files /dev/null and b/screenshots/themes/christmas-panel.png differ diff --git a/screenshots/themes/christmas.png b/screenshots/themes/christmas.png new file mode 100644 index 0000000..7e6050d Binary files /dev/null and b/screenshots/themes/christmas.png differ