From 4cd5f7a7ba79d1328fbe9f2bf6dced2d8354b45c Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Sun, 25 Oct 2026 08:53:41 +0100 Subject: [PATCH] App 2.31.0: full screen from the navigation, swipe in from the left to return The Overview's full-screen button moved from the page's corner into the navigation, beside the MG4 logo. A swipe in from the left edge (EdgeSwipe, unit-tested) brings the navigation back; a faint handle marks the edge. Sideways swipes no longer change the page: only the navigation does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6 --- AGENTS.md | 2 +- README.md | 2 +- SPECS.md | 10 +++ app/build.gradle | 4 +- .../main/java/com/evsuite/abrp/EdgeSwipe.java | 44 ++++++++++++ .../java/com/evsuite/abrp/MainActivity.java | 67 ++++++++++++++++--- app/src/main/res/drawable/bg_edge_handle.xml | 6 ++ .../res/drawable/bg_fullscreen_button.xml | 2 +- .../main/res/drawable/ic_fullscreen_exit.xml | 11 --- .../main/res/layout-w1000dp/activity_main.xml | 14 ++-- app/src/main/res/layout/activity_main.xml | 5 +- app/src/main/res/values/strings.xml | 1 - .../java/com/evsuite/abrp/EdgeSwipeTest.java | 45 +++++++++++++ 13 files changed, 179 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/com/evsuite/abrp/EdgeSwipe.java create mode 100644 app/src/main/res/drawable/bg_edge_handle.xml delete mode 100644 app/src/main/res/drawable/ic_fullscreen_exit.xml create mode 100644 app/src/test/java/com/evsuite/abrp/EdgeSwipeTest.java diff --git a/AGENTS.md b/AGENTS.md index 04d2cb4..0a10dcc 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph, Mascot (`MascotView`, an anime co-pilot whose face follows `MascotMood`; classic, `ChibiFigure` or `FoxFigure` on `ChibiBase`, chosen by `MascotCharacter`); the drawn styles share `ClusterValues`. A button in the top-right corner hides the app's navigation for a full-width Overview (`PREF_OVERVIEW_FULLSCREEN`) | +| 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), Futuristic (`FlowView`, animated energy flow), Sci-fi (`SciFiView`, starship console), Cartoon (`CartoonView`), Blueprint (`BlueprintView`, top-view drawing with tyre pressures), MG (`OctagonView`, octagonal dials after the classic MG dashboards), and on `ClusterCanvasView`: Minimal, Terminal, Aviation, Nixie tubes, Chronograph, Mascot (`MascotView`, an anime co-pilot whose face follows `MascotMood`; classic, `ChibiFigure` or `FoxFigure` on `ChibiBase`, chosen by `MascotCharacter`); the drawn styles share `ClusterValues`. 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. 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/README.md b/README.md index 99da8e1..0d5104f 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, Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes, Chronograph and Mascot (an anime co-pilot who reacts to the drive, classic, chibi or a chibi fox). The small button in the top-right corner hides the app's navigation for a full-width Overview; tap it again to bring it back | +| **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, Futuristic, Sci-fi, Cartoon, Blueprint, MG, Minimal, Terminal, Aviation, Nixie tubes, Chronograph and Mascot (an anime co-pilot who reacts to the drive, classic, chibi or a chibi fox). The small button next to the MG4 logo in the navigation hides the navigation for a full-width Overview; swipe in from the left edge (a faint handle marks it) to bring it back. Pages change only by the navigation, not by swiping sideways | | **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 ee48494..8b72fab 100644 --- a/SPECS.md +++ b/SPECS.md @@ -131,6 +131,16 @@ tyre pressures, GPS (lat, lon, elevation, heading) and the is_* flags. It does * confirmed car facts, app structure, UI conventions, build/test/ship, documents. It replaces upstream's EVABRPUploader text. The test emulator is now in the flake: `nix run .#car-emulator` (Android 9 AOSP at 1920×720, 160 dpi; the car build installs as the system user — checked). +- 2026-10-25 — App 2.31.0 (branch `styles-retro-future`): the Overview's full screen moved into + the navigation: its button now sits beside the MG4 logo (at the end of the bottom bar on narrow + screens) instead of over the page's top-right corner, and only shows on the Overview. A swipe + in from the left edge brings the navigation back (`EdgeSwipe`, pure and unit-tested: starts + within 48 dp of the edge, 64 dp to the right, more sideways than up or down; the page under it + gets a cancel). A faint handle at the left edge marks it in full screen. Swiping sideways no + longer changes the page (`ViewPager2.setUserInputEnabled(false)`); the navigation does. The + exit icon and its string went. Checked on the emulator: the button enters full screen, a + swipe in the middle does nothing, a swipe from the edge brings the navigation back, sideways + swipes keep the page, the narrow layout opens. Not tried on the car. - 2026-10-24 — App 2.30.0 (branch `styles-retro-future`): a third Mascot, the **chibi fox** (`FoxFigure`): a sitting fox with a big head, pointed ears, cream cheeks, chest and tail tip, dark paws and a scarf in the accent colour. Ears and tail act the mood with the face: the diff --git a/app/build.gradle b/app/build.gradle index 63cd76c..bdb8dc0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -34,8 +34,8 @@ android { applicationId "com.stinnesbeck.nilsmg4" minSdk 27 targetSdk 36 - versionCode 127 - versionName "2.30.0" + versionCode 128 + versionName "2.31.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/EdgeSwipe.java b/app/src/main/java/com/evsuite/abrp/EdgeSwipe.java new file mode 100644 index 0000000..7efcfba --- /dev/null +++ b/app/src/main/java/com/evsuite/abrp/EdgeSwipe.java @@ -0,0 +1,44 @@ +package com.evsuite.abrp; + +/** + * The swipe in from the left edge that brings the navigation back in the Overview's full + * screen: a touch that starts within {@code edge} of the left side and travels at least + * {@code travel} to the right, more sideways than up or down. Distances in pixels. + * Pure Java, unit-tested. + */ +final class EdgeSwipe { + + private final float edge, travel; + private boolean tracking; + private float startX, startY; + + EdgeSwipe(float edge, float travel) { + this.edge = edge; + this.travel = travel; + } + + /** A finger went down: only one near the left edge can become the swipe. */ + void down(float x, float y) { + tracking = x <= edge; + startX = x; + startY = y; + } + + /** The finger moved; true once, when this touch has become the swipe. */ + boolean move(float x, float y) { + if (!tracking) return false; + float dx = x - startX, dy = Math.abs(y - startY); + if (dx >= travel && dx > dy) { + tracking = false; + return true; + } + // Mostly up or down already: a scroll, not this swipe. + if (dy > travel) tracking = false; + return false; + } + + /** The finger lifted or the touch was cancelled. */ + void up() { + tracking = false; + } +} diff --git a/app/src/main/java/com/evsuite/abrp/MainActivity.java b/app/src/main/java/com/evsuite/abrp/MainActivity.java index 7a2023b..1d29879 100644 --- a/app/src/main/java/com/evsuite/abrp/MainActivity.java +++ b/app/src/main/java/com/evsuite/abrp/MainActivity.java @@ -183,6 +183,9 @@ public class MainActivity extends AppCompatActivity { // Keep all pages alive: the log and vehicle readings are refreshed // by the tick even when their page is off screen. pager.setOffscreenPageLimit(panes.length - 1); + // Pages change only by the navigation: a sideways swipe on a page (a slider, a chart, + // the swipe that brings the navigation back) must not turn it. + pager.setUserInputEnabled(false); pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { @Override public void onPageSelected(int position) { markCurrentPage(position); @@ -196,30 +199,72 @@ public class MainActivity extends AppCompatActivity { for (int i = 0; i < TAB_IDS.length; i++) { final int index = i; - // Animated, so the button does the same thing the swipe does: the direction of - // travel is what tells the driver where they are in the row. findViewById(TAB_IDS[i]).setOnClickListener(v -> pager.setCurrentItem(index, false)); } markCurrentPage(pager.getCurrentItem()); applyOverviewFullScreen(pager.getCurrentItem()); } - /** Whether the Overview hides the navigation (the button in its corner). */ + /** Whether the Overview hides the navigation (the button in the navigation's header). */ static final String PREF_OVERVIEW_FULLSCREEN = "overview_fullscreen"; + /** True while the Overview is full screen: the navigation hidden, the edge swipe armed. */ + private boolean overviewFull; + /** * The Overview full screen: the navigation hidden while the Overview is the page and the - * driver chose it; every other page always has its navigation, and swiping still works. - * The button only shows on the Overview, its icon saying what a tap does. + * driver chose it, with a handle at the left edge where a swipe brings it back. Every + * other page always has its navigation. The button sits in the navigation and only shows + * on the Overview (invisible elsewhere, so the header keeps its layout). */ private void applyOverviewFullScreen(int position) { boolean overview = position == 0; - boolean full = overview && prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false); - findViewById(R.id.nav_bar).setVisibility(full ? View.GONE : View.VISIBLE); - android.widget.ImageButton button = findViewById(R.id.overview_fullscreen); - button.setVisibility(overview ? View.VISIBLE : View.GONE); - button.setImageResource(full ? R.drawable.ic_fullscreen_exit : R.drawable.ic_fullscreen); - button.setContentDescription(getString(full ? R.string.overview_fullscreen_exit : R.string.overview_fullscreen_enter)); + overviewFull = overview && prefs.getBoolean(PREF_OVERVIEW_FULLSCREEN, false); + findViewById(R.id.nav_bar).setVisibility(overviewFull ? View.GONE : View.VISIBLE); + findViewById(R.id.edge_handle).setVisibility(overviewFull ? View.VISIBLE : View.GONE); + findViewById(R.id.overview_fullscreen).setVisibility(overview ? View.VISIBLE : View.INVISIBLE); + } + + /** The swipe in from the left edge, set up with this screen's density. */ + private EdgeSwipe edgeSwipe; + /** The rest of a touch that became the edge swipe: kept from the page underneath. */ + private boolean swallowTouch; + + /** + * Watches every touch in the Overview's full screen for the swipe in from the left edge, + * which brings the navigation back. The page still gets the touch until it becomes the + * swipe; then it gets a cancel and nothing more of that touch. + */ + @Override + public boolean dispatchTouchEvent(android.view.MotionEvent ev) { + int action = ev.getActionMasked(); + if (swallowTouch) { + if (action == android.view.MotionEvent.ACTION_UP + || action == android.view.MotionEvent.ACTION_CANCEL) swallowTouch = false; + return true; + } + if (overviewFull) { + if (edgeSwipe == null) { + float density = getResources().getDisplayMetrics().density; + edgeSwipe = new EdgeSwipe(48 * density, 64 * density); + } + if (action == android.view.MotionEvent.ACTION_DOWN) { + edgeSwipe.down(ev.getX(), ev.getY()); + } else if (action == android.view.MotionEvent.ACTION_MOVE && edgeSwipe.move(ev.getX(), ev.getY())) { + android.view.MotionEvent cancel = android.view.MotionEvent.obtain(ev); + cancel.setAction(android.view.MotionEvent.ACTION_CANCEL); + super.dispatchTouchEvent(cancel); + cancel.recycle(); + swallowTouch = true; + prefs.edit().putBoolean(PREF_OVERVIEW_FULLSCREEN, false).apply(); + applyOverviewFullScreen(((ViewPager2) findViewById(R.id.content)).getCurrentItem()); + return true; + } else if (action == android.view.MotionEvent.ACTION_UP + || action == android.view.MotionEvent.ACTION_CANCEL) { + edgeSwipe.up(); + } + } + return super.dispatchTouchEvent(ev); } /** diff --git a/app/src/main/res/drawable/bg_edge_handle.xml b/app/src/main/res/drawable/bg_edge_handle.xml new file mode 100644 index 0000000..8180f57 --- /dev/null +++ b/app/src/main/res/drawable/bg_edge_handle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_fullscreen_button.xml b/app/src/main/res/drawable/bg_fullscreen_button.xml index 09b553a..6544c2c 100644 --- a/app/src/main/res/drawable/bg_fullscreen_button.xml +++ b/app/src/main/res/drawable/bg_fullscreen_button.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/drawable/ic_fullscreen_exit.xml b/app/src/main/res/drawable/ic_fullscreen_exit.xml deleted file mode 100644 index c568eb4..0000000 --- a/app/src/main/res/drawable/ic_fullscreen_exit.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout-w1000dp/activity_main.xml b/app/src/main/res/layout-w1000dp/activity_main.xml index 58455d8..4f6c4ad 100644 --- a/app/src/main/res/layout-w1000dp/activity_main.xml +++ b/app/src/main/res/layout-w1000dp/activity_main.xml @@ -4,8 +4,14 @@ - - + + + + + + + + @@ -16,10 +22,10 @@ - + - + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 8f5fa92..cb6f151 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,10 +2,10 @@ - + - + @@ -15,6 +15,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f217a2..9fcfabe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -132,7 +132,6 @@ MG %1$s has its own colours. To change the accent colour, pick a style that uses it. Overview full screen: hide the navigation - Show the navigation again Minimal Terminal Original diff --git a/app/src/test/java/com/evsuite/abrp/EdgeSwipeTest.java b/app/src/test/java/com/evsuite/abrp/EdgeSwipeTest.java new file mode 100644 index 0000000..7a9c50c --- /dev/null +++ b/app/src/test/java/com/evsuite/abrp/EdgeSwipeTest.java @@ -0,0 +1,45 @@ +package com.evsuite.abrp; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** The swipe from the left edge that brings the navigation back — pure JVM. */ +public class EdgeSwipeTest { + + @Test + public void swipeFromTheEdgeCounts() { + EdgeSwipe swipe = new EdgeSwipe(48f, 80f); + swipe.down(10f, 300f); + assertFalse(swipe.move(50f, 305f)); + assertTrue(swipe.move(95f, 310f)); + // Only once per touch. + assertFalse(swipe.move(200f, 310f)); + } + + @Test + public void touchAwayFromTheEdgeIsIgnored() { + EdgeSwipe swipe = new EdgeSwipe(48f, 80f); + swipe.down(200f, 300f); + assertFalse(swipe.move(400f, 300f)); + } + + @Test + public void verticalScrollIsNotTheSwipe() { + EdgeSwipe swipe = new EdgeSwipe(48f, 80f); + swipe.down(10f, 100f); + assertFalse(swipe.move(30f, 200f)); + // Gone sideways afterwards: still the scroll. + assertFalse(swipe.move(150f, 210f)); + } + + @Test + public void leftwardOrLiftedIsNotTheSwipe() { + EdgeSwipe swipe = new EdgeSwipe(48f, 80f); + swipe.down(40f, 300f); + assertFalse(swipe.move(0f, 300f)); + swipe.up(); + assertFalse(swipe.move(200f, 300f)); + } +}