Compare commits

..

14 Commits

Author SHA1 Message Date
1920b2b822 docs: tunie research for bonneville 2026-09-30 16:54:09 -05:00
f830f8937d Log real-world road test results and correct/extend the CO-trim theory
New research/ROAD_TEST_LOG.md: the 2026-09-15 flash of
20262-arrow-delete-lowthrottle-composed.hex was a major real-world
success (stalling fully resolved, decel backfire much reduced but not
eliminated). Captures the live diagnostic reasoning that followed:
correcting an overly-lean-leaning framing mid-session (residual decel pop
is more likely a rich-unburned-mixture-with-no-SAI-assist problem, not a
lean one), a structural hypothesis that the Idle Fuel Trim (CO) curve
likely applies whenever the throttle is closed at any RPM rather than
only at a literal stop (only 2 of its 32 points are currently corrected,
and the untouched middle range lines up with the residual pop's RPM
window), and the reasoning for rejecting airbox-baffle removal as a fix
for decel popping specifically (wrong mechanism -- closed throttle isn't
airbox-limited -- and risks worsening the separately-flagged, still
unaddressed main-VE-table leanness gap).

Also updates TABLES.md with the CO-trim applicability open question, and
brings TUNING_GUIDE.md's "local research assets" section up to date (it
still said maps were gitignored/unflashable, both stale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-09-16 09:07:35 -05:00
7ec28c7b5f Commit the flash-day procedure doc (was written but never committed)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-09-15 16:38:26 -05:00
2ad5fab677 Add a layered low-throttle fuel correction on top of the Arrow delete tune
Real-world reports: stalling/choking under ~10% throttle and while
blipping the throttle at low speed, after snorkel removal with no
main-table correction for the extra airflow. Confirmed via a genuine
percentage-delta pulled from 20184dynoTuneSteveO2-Disable.hex (also
snorkel-removed) vs its own stock baseline 20186Map.hex, in the
Low-throttle fuel tables, restricted to the throttle columns actually
implicated (raw throttle <= 100, ~10%).

compose_lowthrottle.py composes onto the already-composed
20262-arrow-delete-composed.hex (not raw stock 20262) -- additive on top
of the existing SAI/O2-off + idle-trim layer, same "respect what's
already there" principle as compose_arrow_delete.py. Percentage-based
per-cell, not flat additive, since Arrow's own table already has its own
exhaust-specific correction baked in.

Output: derived/20262-arrow-delete-lowthrottle-composed.hex. Checksum
patched and verified valid; confirmed layer-1's device flags and trim
bytes survive unchanged underneath.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-09-10 15:39:10 -05:00
b1b81a6dc6 Patch the flash checksum into the composed Arrow delete tunes
The composed .hex files were missing a valid flash checksum entirely
(checksum.py flagged MISMATCH on both) -- not flash-ready as committed.
Patches the checksum in-place for both, and fixes compose_arrow_delete.py
to patch it automatically on future regenerations instead of leaving it
as a manual follow-up step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-08-27 01:41:04 -05:00
4aa5da53d2 Commit tune maps and research so tunes are reachable from the phone
Removes the *.hex/maps_cache gitignore rule (explicit user call, reversing
the earlier no-redistribution stance) so the official TuneECU catalogue
maps, derived SAI/O2-delete composites, and the checksum/composition
tooling are actually available to pull up on a phone browser when using
the real TuneECU app. Also folds in tonight's KWP2000 fixes (TesterPresent
keep-alive, connect-failure cleanup, slow-init StartCommunication fix) and
the accumulated research docs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FP2GaxS9HkUdL5sLBnjKje
2026-08-27 01:34:05 -05:00
587f75eab4 Refresh the Flutter snapshot: UI-01 through UI-09, plus a fresh installable APK
Full UI redesign pass complete: persistent tab shell with an always-visible
background map, Modern Professional Dark theme, monochrome dark map tiles,
offline skeleton map, a shared GlassPanel/FloatingPill component kit,
customizable HUD telemetry widgets, and the Map HUD / Plan & Route Planning /
Rides History screen rebuilds. 374 tests passing, up from 316.

The APK is a fresh release build (debug-signed, no release signing config
exists yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Xki7YAcc2TiN2PRZJ2tXr
2026-08-24 14:41:35 -05:00
4c634354bd Refresh the Flutter snapshot: v3 tickets V3-04 through V3-16, plus a fresh installable APK
V3-04 through V3-07, V3-10, V3-16 shipped complete; V3-11/V3-12/V3-14 shipped code-complete pending device/account verification; V3-08/V3-09 deferred behind a new V3-17 (self-hosted OSRM investigation). 316 tests passing, up from 221.

The APK is a fresh release build (debug-signed, no release signing config exists yet) with two build fixes applied: core library desugaring enabled for flutter_local_notifications, and sentry_flutter bumped to 9.27.0 (8.14.2's bundled Kotlin plugin was incompatible with this project's Kotlin 2.4.0 toolchain).
2026-08-19 13:36:04 -05:00
14289befb0 Refresh the Flutter snapshot: LAUNCH.md and the iPhone verification pass
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:45:10 -05:00
e8d8bcacda Refresh the Flutter snapshot and APK with the real Rippr icons
The previous APK shipped the default Flutter logo on the home screen. This build
carries the adaptive launcher icon, the monochrome notification icon, and dark
launch screens, all verified on an Android emulator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 01:31:22 -05:00
3c801e9114 Refresh the native Rippr snapshot to match the claimed commit
RIPPR.md said the native snapshot was at ba57a92, but rippr-src and the bundle
were still at d418920 -- so the superseded notice and the two recorded bugs were
missing from the copy. Re-exported both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:38:02 -05:00
0bc42b2e5a Add the Rippr Flutter port: source, history bundle, and installable APK
The port runs on Android and iOS and is feature-complete; the native Android app
is superseded but kept, since it is still the only version that has recorded real
rides.

rippr-flutter-1.0-debug.apk is package com.rippr.port, deliberately different
from the native com.rippr so both install side by side. Recording the same ride
on both at once is the strongest available check that the port is faithful.

Added INSTALL.md covering both platforms. Android is a one-line adb install; iOS
has no APK equivalent and must be built and signed through Xcode with a free
Apple ID, which gives a 7-day profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:37:45 -05:00
64f5dff30b Refresh Rippr snapshot: waypointing and handlebar-mount notes
Re-exported at d418920.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:09:35 -05:00
24bbeeffd3 Refresh Rippr snapshot: v3 Ideas section
Re-exported at 957392f.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:55:32 -05:00
713 changed files with 216855 additions and 90 deletions

6
.gitignore vendored
View File

@@ -177,9 +177,3 @@ cython_debug/
# macOS # macOS
.DS_Store .DS_Store
# proprietary TuneECU/Triumph map binaries — do not redistribute
*.hex
*.dec.bin
maps_cache/
tunie/**/table_map.json

141
INSTALL.md Normal file
View File

@@ -0,0 +1,141 @@
# Installing Rippr
Two platforms, two very different stories. Android takes a minute; iOS needs a Mac and
about ten.
---
## Android — install the APK
`rippr-flutter-1.0-debug.apk` · package `com.rippr.port` · label **Rippr** · Android 8.0+
It is **debug-signed**, so it installs directly and coexists with the native app
(`com.rippr`).
### Over USB
```bash
adb install -r rippr-flutter-1.0-debug.apk
```
If you already have it installed and signatures clash:
```bash
adb uninstall com.rippr.port && adb install rippr-flutter-1.0-debug.apk
```
### Without a cable
Copy the APK to the phone (Drive, email to yourself, `adb push`, whatever), open it in
Files, and allow the installing app to install unknown apps when prompted.
### First launch
1. Grant **location** — "While using the app" is enough; the foreground service covers
the rest. Rippr never asks for "Allow all the time".
2. Grant **notifications** on Android 13+. Not optional in practice: the ongoing
notification is what keeps the recording service alive.
3. On Samsung/Xiaomi/OnePlus, exempt Rippr from battery optimisation, or the OS may kill
it mid-ride. Settings → Apps → Rippr → Battery → Unrestricted.
### Running both apps at once
This is worth doing deliberately. The two use different application ids so they can
record the **same ride simultaneously**, which is the strongest check that the port is
faithful:
```bash
adb install -r rippr-2.0.1-debug.apk # native, com.rippr
adb install -r rippr-flutter-1.0-debug.apk # port, com.rippr.port
```
Start both, ride, stop both, compare distance / max speed / moving time. See
`rippr-flutter-src/docs/port/REAL-RIDE-CHECKLIST.md`.
> The debug APK is ~172 MB because it carries every ABI and no code shrinking. A release
> build would be a fraction of that, but needs a signing key — see below.
---
## iOS — you have to build and sign it yourself
**There is no APK equivalent.** iOS will not run an app that is not signed by a
certificate the device trusts, so an unsigned `.app` or `.ipa` cannot simply be copied
over. The only routes are the App Store, TestFlight, an Apple Developer account with
ad-hoc provisioning, or **free personal signing through Xcode** — which is what follows.
### What you need
- A Mac with Xcode (already installed)
- Flutter (already installed)
- Your iPhone and a Lightning/USB-C cable
- A free Apple ID — **no paid Developer account required**
### Steps
```bash
git clone rippr-flutter-history.bundle rippr-flutter
cd rippr-flutter
flutter pub get
dart run build_runner build # Drift codegen
open ios/Runner.xcworkspace # the workspace, NOT the .xcodeproj
```
In Xcode:
1. Select the **Runner** target → **Signing & Capabilities**
2. Tick **Automatically manage signing**
3. **Team** → Add an Account… → sign in with your Apple ID → select it as the team
4. **Change the Bundle Identifier** to something globally unique, e.g.
`com.dylan.rippr.port`. A free personal team cannot claim an identifier someone else
has registered, and `com.rippr.port` may collide.
5. Plug in the iPhone and pick it as the run destination
6. Press **Run** (⌘R)
On the phone, first launch will refuse with *"Untrusted Developer"*. Go to
**Settings → General → VPN & Device Management → your Apple ID → Trust**, then launch
again.
Or from the terminal once signing is configured in Xcode:
```bash
flutter devices # find your phone's id
flutter run --release -d <device-id>
```
### The catch with free signing
A free Apple ID gives a **7-day** provisioning profile. After a week the app refuses to
launch and you re-run it from Xcode to refresh. You are also limited to three apps
sideloaded at a time.
A paid Developer account ($99/yr) raises that to a year and unlocks TestFlight, which is
the sane route if you want the app on a phone for a whole riding season.
### First launch on iOS
1. Grant **location**. Choose **"Allow While Using App"** first; iOS will later ask to
upgrade to Always once it sees background usage — accept it, or recording stops when
the screen locks.
2. A **blue pill** appears in the status bar while recording in the background. That is
correct and intentional.
3. iOS gives no equivalent of Android's battery-optimisation exemption. Low Power Mode
*will* affect GPS behaviour — worth testing with it off first.
---
## Known limitations of both builds
- **Neither has ever recorded a real ride.** Everything is verified by 175 automated tests
and a simulator, and no simulator produces velocity.
- **No notification actions.** The native app had Pause/Resume buttons in the shade; the
Flutter port does not. Tapping the notification opens the app.
- **The app icon is still the Flutter default.**
- **Debug builds.** Slower than release and much larger. For Android, a release build
needs a keystore:
```bash
keytool -genkey -v -keystore ~/rippr.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias rippr
# then configure signingConfigs in android/app/build.gradle.kts
flutter build apk --release
```

View File

@@ -1,54 +1,83 @@
# Rippr — copies held here # Rippr — copies held here
Rippr is a native Android GPS ride recorder for motorcycles. Its canonical repo is Rippr is a GPS ride recorder for motorcycles. There are now **two** codebases, and
`~/dojo/rippr`, which **has no git remote** — it exists only on that machine, so the neither has a git remote — they exist only on that machine, so the bundles below are the
bundle below is the only off-machine copy of its history. only off-machine copies of their history.
**Snapshot taken at:** `46a0726` — "Document the whole project: README, architecture, v1 history, v3 backlog" (19 commits) | Repo | What it is |
|---|---|
| `~/dojo/rippr` | The original **native Android** app (Kotlin, v1 → v2.0.1). **Superseded**, but still the only version that has recorded real rides. |
| `~/dojo/rippr-flutter` | The **Flutter port** — Android *and* iOS. Feature-complete, better tested, never yet ridden. |
**Snapshots taken at:** native `ba57a92`, Flutter `04127d7`.
## What is here ## What is here
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `rippr-src/` | Browsable source, exported with `git archive HEAD`. No history; will drift. | | `rippr-flutter-src/` | Flutter port, browsable source (`git archive HEAD`) |
| `rippr-full-history.bundle` | Full git history. The actual backup. | | `rippr-flutter-history.bundle` | Flutter port, full git history — the actual backup |
| `rippr-2.0.1-debug.apk` | Latest installable build (debug-signed) | | `rippr-flutter-1.0-debug.apk` | **Installable Flutter build** (`com.rippr.port`) |
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Previous builds | | `rippr-src/` | Native app, browsable source |
| `rippr-full-history.bundle` | Native app, full git history |
| `rippr-2.0.1-debug.apk` | Native app, latest installable build (`com.rippr`) |
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Older native builds |
`rippr-src/` contains tracked files only — no build outputs, no `local.properties`, no **The two APKs use different application ids on purpose** (`com.rippr` vs
nested `.git`. `com.rippr.port`), so both install side by side. That is not an accident — validating the
port depends on recording the same ride on both at once.
## Restoring the full repo ## Installing
See **[INSTALL.md](INSTALL.md)** for Android and iOS, including the sideloading route for
iPhone (which needs a Mac and takes about ten minutes).
## Restoring either repo
```bash ```bash
git clone rippr-flutter-history.bundle rippr-flutter
cd rippr-flutter && flutter pub get && dart run build_runner build
flutter test
# or the native app
git clone rippr-full-history.bundle rippr git clone rippr-full-history.bundle rippr
cd rippr cd rippr && echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
./gradlew assembleDebug ./gradlew assembleDebug
``` ```
Needs **JDK 17–21** — AGP does not support 25 — and the Android SDK with platform 35 and Both need **JDK 17–21** for Android — AGP does not support 25.
build-tools 35.
## Where to start reading ## Where to start reading
Inside `rippr-src/`: Inside `rippr-flutter-src/`:
- `README.md` — what the app is and its current state - `README.md` — status, and the two bugs the port found in the native app
- `docs/ARCHITECTURE.md` — the design decisions and why - `docs/port/PARITY-AUDIT.md` — feature by feature, with the evidence behind each claim
- `docs/v3/BACKLOG.md` — known gaps, deferred work, what must not regress - `docs/port/REAL-RIDE-CHECKLIST.md` — **the outstanding work**
- `docs/TESTING.md` — especially "What the emulator cannot verify" - `docs/ARCHITECTURE.md` — why it is built this way
- `docs/v2/PROGRESS.md` — every bug found during v2 development - `docs/port/PROGRESS.md` — everything that went wrong during the port
Inside `rippr-src/` (native): `docs/v2/PROGRESS.md` and `docs/TESTING.md` remain the
record of how the app got here.
## Keeping these current ## Keeping these current
```bash ```bash
cd ~/dojo/samplez cd ~/dojo/samplez
rm -rf rippr-flutter-src && mkdir rippr-flutter-src
git -C ~/dojo/rippr-flutter archive HEAD | tar -x -C rippr-flutter-src
(cd ~/dojo/rippr-flutter && git bundle create /tmp/fb.bundle --all) \
&& mv /tmp/fb.bundle rippr-flutter-history.bundle
rm -rf rippr-src && mkdir rippr-src rm -rf rippr-src && mkdir rippr-src
git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) && mv /tmp/rb.bundle rippr-full-history.bundle (cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) \
&& mv /tmp/rb.bundle rippr-full-history.bundle
git bundle verify rippr-flutter-history.bundle
git bundle verify rippr-full-history.bundle git bundle verify rippr-full-history.bundle
``` ```
Note the export wipes `rippr-src/` wholesale, which is why this file lives at the repo The exports wipe those directories wholesale, which is why this file and `INSTALL.md`
root rather than inside it. live at the repo root rather than inside them.

BIN
rippr-flutter-1.0-debug.apk Normal file

Binary file not shown.

Binary file not shown.

52
rippr-flutter-src/.gitignore vendored Normal file
View File

@@ -0,0 +1,52 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# Build artifacts (large; regenerable)
build/
.dart_tool/

View File

@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
- platform: android
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
- platform: ios
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

106
rippr-flutter-src/README.md Normal file
View File

@@ -0,0 +1,106 @@
# Rippr
A GPS ride recorder for motorcycles, on **Android and iOS**. Records telemetry into a
local SQLite database, renders the traversed path on a map afterwards, and exports to
GPX/GeoJSON.
Built for one specific use: **hit start, put the phone in a pocket, ride, hit stop.**
Most of the design follows from that.
This is a Flutter port of the native Android app in `~/dojo/rippr` (v1 → v2.0.1). The port
is feature-complete; see [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) for
exactly what is verified and what is not.
```
Flutter 3.47 · Dart 3.13 Android minSdk 26 · iOS 12+
~4,600 lines Dart 175 tests (171 unit/widget, 4 integration)
```
## Status
| Feature | Status |
|---|---|
| GPS recording, foreground on Android / background mode on iOS | Implemented; **not yet ridden** |
| Trips with pause / resume / stop / discard | Working, 50 tests |
| Live speed + elapsed clock | Working |
| Path on OpenStreetMap, per-segment, speed-coloured | Working; colouring unverifiable without a real ride |
| Ride statistics + charts | Working; bit-identical to the Kotlin original |
| Rename / delete / merge rides | Working |
| GPX + GeoJSON export | Working; byte-identical to the Kotlin original |
| Upload to a REST endpoint | Implemented, **no UI** (as in the native app) |
| Notification actions (Pause/Resume in the shade) | **Absent** — the one capability lost |
| Live map while recording · group ride | Deferred to v3 |
> **The port has never recorded a real ride.** No simulator produces velocity, so max
> speed, moving time, speed colouring, elevation against real GPS error, battery, and iOS
> stationary suspension are all unverified. That is
> [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md), and it is the next
> thing that should happen.
## Quick start
Requires **JDK 17–21** for Android (AGP rejects 25) and Xcode for iOS.
```bash
flutter pub get
dart run build_runner build # Drift codegen
flutter analyze && flutter test
flutter test integration_test -d <device-id>
flutter run
```
**Watch the disk.** A full dual-platform build cycle costs roughly 10 GB, and the Android
emulator needs 7.4 GB free just to boot. Run `flutter clean` before booting it.
## Cross-language parity
The strongest guarantee in this repo. It compiles the **real Kotlin sources** from the
native app and diffs them against the Dart port across twenty fixtures:
```bash
brew install kotlin
JAVA_HOME=<jdk-21> ./tool/parity/run.sh
```
Everything matches to the last digit — including the elevation accumulator at
`38.959594555022136` and byte-identical GPX/GeoJSON. The single expected difference is
speed-derived values, where Kotlin's 32-bit `Float` widens with artefacts Dart's uniform
`double` does not reproduce.
## Documentation
| Document | Contents |
|---|---|
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | Why it is built this way, and what changed from the native app |
| [docs/v3/](docs/v3/) | v3 tickets — one file per feature, ready to pick up |
| [docs/BACKLOG.md](docs/BACKLOG.md) | The v3/v4 reasoning behind those tickets |
| [docs/LAUNCH.md](docs/LAUNCH.md) | Getting from "on my phone" to the app stores, in stages, with costs |
| [docs/port/IOS-VERIFICATION.md](docs/port/IOS-VERIFICATION.md) | What a Mac can prove about the iPhone build, and what cannot |
| [docs/port/PLAN.md](docs/port/PLAN.md) | The 28-task migration plan |
| [docs/port/PROGRESS.md](docs/port/PROGRESS.md) | What actually happened, including every bug found |
| [docs/port/PARITY-AUDIT.md](docs/port/PARITY-AUDIT.md) | Feature-by-feature, with the evidence behind each claim |
| [docs/port/REAL-RIDE-CHECKLIST.md](docs/port/REAL-RIDE-CHECKLIST.md) | **The outstanding work** |
| [docs/port/RELEASE-IOS.md](docs/port/RELEASE-IOS.md) | App Review readiness |
| [docs/PORT_RESEARCH.md](docs/PORT_RESEARCH.md) | The original research this plan was built on |
The native repo's `docs/v1/`, `docs/v2/` and `docs/v3/BACKLOG.md` remain the record of how
the app got here and where it is going.
## Two bugs this port found in the native app
**Crash recovery measured the dead time as distance.** `restoreAfterProcessDeath` says it
resumes into a new segment; it actually adopts the segment a crash left open, so the
authoritative recomputation measures straight through the gap. Reproduced at **111 km** of
phantom distance. Fixed here.
**The elevation regression guard passes on seed luck.** On a shared fixture the algorithm
yields ~39 m, which would fail the native test's own 35 m bound. It passes only because
`kotlin.random.Random(42)` happens to draw a benign sequence.
## Before shipping
- Switch the application id from `com.rippr.port` back to `com.rippr`. The suffix exists
so the native app can be installed alongside during the port — **keep it until the real
ride comparison is done**.
- Replace the default Flutter app icon; the native artwork is in `~/dojo/rippr/design/`.
- Build and test a release build. Everything so far has been debug.

View File

@@ -0,0 +1,38 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
rippr-flutter-src/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,55 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.rippr"
compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
// flutter_local_notifications (V3-06) requires this on API levels below 33.
isCoreLibraryDesugaringEnabled = true
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.rippr.port"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = 26 // parity with the native app (Android 8.0)
targetSdk = 36
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,64 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Recording permissions. Mirrors the native app's manifest.
ACCESS_BACKGROUND_LOCATION is deliberately NOT requested. The ride is recorded by
a foreground service with a visible notification, which is exactly the case
Android intends foregroundServiceType="location" to cover. Asking for background
location as well would trigger the "Allow all the time" flow, which is a harder
permission to be granted and is not needed for pocket-to-stop recording. -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- GPS is the whole point; do not offer the app to devices without it. -->
<uses-feature android:name="android.hardware.location.gps" android:required="true"/>
<application
android:label="Rippr"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.rippr
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launch window, shown before Flutter has drawn anything.
Deliberately the app's own tarmac colour rather than the generated white: a white
flash before a dark app is jarring, and it is the first thing seen on every cold
start. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/ic_launcher_background" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/launch_mark" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Track ring. -->
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#66727E"
android:strokeWidth="3.5"
android:strokeAlpha="0.6" />
<!-- Material Symbols `route`, Apache-2.0. Scaled from its 960 box onto the icon canvas. -->
<group
android:translateX="34"
android:translateY="74"
android:scaleX="0.041667"
android:scaleY="0.041667">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#F2F5F8" />
</group>
<!-- Recording-progress segment: the SVG's dash pattern, cut with trimPath. -->
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#FF5722"
android:strokeWidth="3.5"
android:strokeLineCap="round"
android:trimPathStart="0.3701"
android:trimPathEnd="0.4960" />
</vector>

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:pathData="M86.0,54.0 A32.0,32.0 0 1 1 22.0,54.0 A32.0,32.0 0 1 1 86.0,54.0"
android:strokeColor="#FF000000"
android:strokeWidth="3.5" />
<group
android:translateX="34"
android:translateY="74"
android:scaleX="0.041667"
android:scaleY="0.041667">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#FF000000" />
</group>
</vector>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated by design/to_vector_drawable.py — do not edit by hand. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFFFF">
<group
android:translateX="2"
android:translateY="22"
android:scaleX="0.020833"
android:scaleY="0.020833">
<path
android:pathData="M247-167q-47-47-47-113v-327q-35-13-57.5-43.5T120-720q0-50 35-85t85-35q50 0 85 35t35 85q0 39-22.5 69.5T280-607v327q0 33 23.5 56.5T360-200q33 0 56.5-23.5T440-280v-400q0-66 47-113t113-47q66 0 113 47t47 113v327q35 13 57.5 43.5T840-240q0 50-35 85t-85 35q-50 0-85-35t-35-85q0-39 22.5-70t57.5-43v-327q0-33-23.5-56.5T600-760q-33 0-56.5 23.5T520-680v400q0 66-47 113t-113 47q-66 0-113-47Zm-7-513q17 0 28.5-11.5T280-720q0-17-11.5-28.5T240-760q-17 0-28.5 11.5T200-720q0 17 11.5 28.5T240-680Zm480 480q17 0 28.5-11.5T760-240q0-17-11.5-28.5T720-280q-17 0-28.5 11.5T680-240q0 17 11.5 28.5T720-200ZM240-720Zm480 480Z"
android:fillColor="#FFFFFFFF" />
</group>
</vector>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The launch window, shown before Flutter has drawn anything.
Deliberately the app's own tarmac colour rather than the generated white: a white
flash before a dark app is jarring, and it is the first thing seen on every cold
start. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/ic_launcher_background" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/launch_mark" />
</item>
</layer-list>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- The tarmac ground behind the launcher icon. Matches the app's background. -->
<color name="ic_launcher_background">#101418</color>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")

View File

@@ -0,0 +1,13 @@
targets:
$default:
builders:
drift_dev:
options:
# Preserve Dart field names as SQL column names instead of snake_casing them.
#
# This keeps the schema column-for-column identical to the native app's Room
# schema (app/schemas/com.rippr.data.AppDatabase/2.json), which means the raw
# SQL in database.dart can stay byte-identical to the Kotlin DAO queries it was
# ported from -- much easier to review -- and leaves the door open to reading an
# old Room database file directly if an importer is ever wanted.
case_from_dart_to_sql: preserve

View File

@@ -0,0 +1,141 @@
# Architecture
Why Rippr is built this way. Ported from the native app's `docs/ARCHITECTURE.md`, keeping
the reasoning that still holds and recording what changed.
```
LocationSource (geolocator) ← the only file that knows the platforms differ
│ LocationFix
▼
RecordingEngine ── unbounded buffer ──► single writer loop (25 fixes / 2 s)
│ │
│ ┌─────────────┴──────────────┐
│ ▼ ▼
│ TripRepository Accumulator
│ (all transitions) distance / moving time /
│ │ elevation, folded per batch
│ ▼
│ Drift (SQLite)
│ Trip → Segment → TrackPoint
│ │
└──► LiveTelemetry ▼
(speedo) Riverpod providers
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
RecordScreen TripsScreen TripDetailScreen
└─ RideMap (flutter_map)
```
---
## The decisions that carry the most weight
### The fix path never blocks
A GPS fix is appended to an in-memory list and nothing else. A single writer loop drains
it in batches every two seconds. Slow disk stalls the writer, never the fix stream, and no
fix is dropped under back pressure.
Kotlin used `Channel(UNLIMITED)` plus a coroutine blocking on `receive()`. Dart has no
blocking receive and its single-threaded event loop makes one unnecessary — but the
guarantee is identical, and it is the reason a 2 Hz stream does not become two database
transactions per second.
### Recording state lives in the database
A row in `trips` with `endedAt IS NULL` **is** the fact of an in-progress ride. It survives
process death, which an in-memory flag cannot: Android can restart the process with no
intent, iOS can suspend and resume, and in both cases a flag would come back `false` while
a ride was genuinely underway.
### Points are stamped at creation
Every point carries its `tripId` and `segmentId` from the moment it is built, never looked
up at write time. This is what makes pause correct: a fix still buffered when the rider
pauses is written to the segment it was actually recorded during.
Refactoring this into a write-time lookup would break the guarantee **silently**.
### Segments make pause correct rather than cosmetic
Without them, pausing at a gas station and resuming across town draws a straight line
through terrain never ridden — and counts it as distance. Segments propagate all the way
through: distance accumulation, map polylines, and GPX `<trkseg>`.
The same reasoning applies to a crash. See `TripRepository.resumeIntoNewSegment`, which
fixes a bug the native app has here.
### Whoever owns the writes owns the database
The single most important structural decision of the port. `TrackingService` wrote to Room
directly. If Dart owned the schema but a native service owned the writes, every fix would
cross a platform channel that is *dead while iOS suspends Dart*.
So Dart owns both, and the platform layer only has to keep the isolate alive. That is also
why there is **one isolate**: the foreground service provides liveness, not a second Dart
runtime, so two isolates never contend for one SQLite file.
### Aggregates are accumulated live, then recomputed authoritatively
Distance and elevation need consecutive-point differences, so they are folded in the
writer loop and persisted per flush — letting the recording screen show live numbers
without rescanning the point table.
Those values are an **estimate**. On completion they are replaced by `computeSummary` over
the stored points, so a mid-ride process kill cannot leave permanently skewed totals.
### Decimation is render-only
Douglas–Peucker exists in the map path and nowhere else. A three-hour ride is ~21,600
points and would jank an undecimated polyline, but storage and export must carry every raw
point. Tests assert exact counts in exports to catch a leak.
---
## What changed from the native app
| Native | Port | Why |
|---|---|---|
| Room | Drift | Same shape; runs on the Dart VM, so data-layer tests need no device |
| Foreground service (hand-written) | geolocator's own | Removes `flutter_foreground_task` and two deprecations. Costs notification actions. |
| `PARTIAL_WAKE_LOCK` | `enableWakeLock` | Same mechanism, configured rather than coded |
| ViewModel + StateFlow | Riverpod | Direct mapping |
| navigation-compose | go_router | Three destinations, same shape |
| osmdroid | flutter_map | Same OSM raster tiles, same no-API-key reasoning |
| FileProvider + ACTION_SEND | share_plus | Also handles the iPad popover anchor |
| OkHttp | package:http | Direct mapping |
| `Float` | `double` | Dart has no float32. See the parity note below. |
### The Float→double divergence
Kotlin stores speed and accuracy as 32-bit `Float`. Dart has no float32, so these widen.
Every other value in the app is bit-identical across the two implementations; speed-derived
values differ in the last digits (`40.030228` vs `40.03022888407912`).
This is verified rather than assumed — `tool/parity/run.sh` compiles the real Kotlin
sources and diffs them against the Dart port across twenty fixtures.
### iOS specifics that are easy to get wrong
- `pauseLocationUpdatesAutomatically: false` — CoreLocation otherwise decides the ride has
ended, and does not reliably restart.
- `activityType: otherNavigation`, **not** `automotiveNavigation` — the latter snaps fixes
to the road network, which silently falsifies a recording of where you actually went.
---
## Things that must not regress
1. The unbounded buffer and single batched writer. Never write to the database from the
fix callback.
2. Points stamped with `tripId`/`segmentId` at creation.
3. Recording state derived from the database, never an in-memory flag.
4. No destructive migration. Any schema change ships a real `Migration`.
5. Decimation is render-only.
6. The theme names its text colours explicitly — a missing content colour once rendered a
64 sp figure black-on-black, and no test caught it.
7. The map zoom clamp. A short ride will render an empty grid without it.
8. `PRAGMA foreign_keys = ON`. SQLite defaults it off and Drift does not set it; without
it every CASCADE is decorative.
9. Tests use in-memory databases. An instrumented test once wiped a real device's rides.

View File

@@ -0,0 +1,360 @@
> **Carried forward from the native repo** (`~/dojo/rippr/docs/v3/BACKLOG.md`) when the
> Flutter port completed. Two items below have changed status since it was written:
>
> - **Compose UI tests** — no longer a gap. The port has 15 widget tests plus 4
> integration tests; see `docs/port/PARITY-AUDIT.md`.
> - **Elevation gain accuracy** — the algorithm is now proven bit-identical across Kotlin
> and Dart (`tool/parity/run.sh`), so any future tuning can be checked against the
> original rather than guessed at. The instruction below still stands: **do not tune it
> blind.**
>
> Everything else carries over unchanged, including the v3 ideas and the
> "must not regress" list.
---
# Backlog — v3 and the ROADMAP
Everything known-outstanding, with enough context to pick up cold. Nothing here is
committed to — it is a menu, roughly ordered by value.
**v3 is now broken out into tickets: [v3/README.md](v3/README.md).** This document stays
as the reasoning; the tickets are the executable form.
**v3 is everything that can be built with no server. The ROADMAP is everything that
cannot, plus whatever bugs and features come up that need triage before the next v3-style
ticket batch.** The no-server split is still the most useful structural fact in this
document: it means v3 can proceed indefinitely without anyone deciding to run
infrastructure or hold other people's location data. The ROADMAP is where everything else
gets managed first.
**Before planning anything: run the real-ride checklist in
[the real-ride checklist](port/REAL-RIDE-CHECKLIST.md).** Several items below may turn out to be non-issues, and
others may appear that nobody has thought of.
---
## 1. Carried over from v2 — the honest debt
### Elevation gain accuracy · *needs real data first*
~30 m of phantom gain per ten stationary minutes against synthetic ±8 m uniform noise. Real
GPS altitude error is *correlated* rather than uniform, so the true behaviour is unknown.
The current implementation is a 15-sample moving average plus reversal hysteresis (see
[ARCHITECTURE.md](ARCHITECTURE.md)). A naive version reported 1498 m over a parked
bike, so the guard rails matter.
**Do not tune this blind.** Record a flat ride, check whether the reported gain is
plausible, and only then adjust. If it needs work, options are a longer smoothing window, a
larger threshold, or using barometric pressure where available (much more accurate than GPS
altitude, and most phones have the sensor).
### Compose UI tests · *the largest coverage gap*
Zero UI tests across six screens. Everything was verified by manual screenshot. Worth
covering: navigation record→trips→detail→back, rotation/state retention, empty states,
`NotFound`, chart degradation below two points, selection mode enabling Merge only at two.
### Unmeasured, and probably should be
- **Battery drain** over a multi-hour ride — never measured, and it is the thing most likely
to make the app unusable in practice
- **Map memory across repeated navigation** — the osmdroid lifecycle is a known hazard and
the wiring was never leak-tested
- **GPX import into Strava/Garmin** — validated against an XML parser, but schema validity
does not guarantee a consumer accepts it
---
## 2. Live map on the recording screen — **decision reversed**
v2 deliberately shipped no live map, on the reasoning that the phone rides in a pocket.
Dylan has since asked for one — for visual appeal, and because **people may mount the phone
on the handlebars** to watch the route live. Treat the v2 stance as superseded.
**The handlebar case changes the premise, not just the feature.** v1 and v2 were both built
around "start it, pocket it, stop it". A mounted phone is a different product with different
constraints, and it is worth deciding explicitly whether that becomes a first-class mode:
- **Screen on for the whole ride** — battery goes from "a background service" to "a
service plus a lit screen plus continuous map rendering". Measure before committing.
- **Sunlight legibility** — the current dark theme is chosen for glanceability, but daylight
behind a visor is a different problem.
- **Glove-sized targets** — already partly handled (72dp buttons); a map needs the same care.
- **Keep-screen-awake** handling, and what happens on a call or notification.
What still holds regardless:
- **`TrackingService` must never reference a map.** Rendering belongs to the Compose
lifecycle of a visible screen, not the service.
- **No tile fetch or redraw while backgrounded**, even in mounted mode.
---
## 3. Activity type per ride
Promoted out of the ideas list, because it turned out to be a **positioning decision**
rather than a feature. See [LAUNCH.md](LAUNCH.md).
Everything user-facing says motorcycle, but the recording pipeline never did: it records
positions, speeds and altitudes, and nothing in it cares what you are sitting on. Dylan
already rides both bikes and motorcycles.
**Why it matters beyond the feature:** it decides the store category, the screenshots and
who ever finds the app. Cyclists are a far larger audience than motorcyclists and are
already used to paying for ride apps. That question is much cheaper to settle before a
store listing exists than after.
### Schema
An `activity` column on `Trip`, stored as a text enum like `TripState`:
```
motorcycle · bicycle · scooter · skateboard · running · walking · other
```
This is the **first real migration** the port will ship. The destructive fallback is gone
for good, so it needs `m.addColumn(trips, trips.activity)` with a default of `motorcycle`
for existing rows, `schemaVersion` bumped to 2, and a migration test that opens a v1
database and asserts the rides survive. Getting that path right once matters more than the
feature does — every later schema change depends on it.
### Type should drive defaults, not just labels
This is where the real value is, and it is easy to miss:
| Setting | Why it differs |
|---|---|
| Speed noise floor (1.5 km/h) | Fine for a motorcycle; wrong for walking, where real movement lives near it |
| Speed histogram bucket (10 km/h) | Useless for running — everything lands in one bucket. Wants ~1 km/h. |
| Accuracy gate (50 m) | A motorcycle at speed can tolerate looser fixes than a walker |
| Elevation smoothing window | Tuned at 15 samples for 2 Hz road speed; a slower activity covers less ground per sample |
| Map fit zoom | A 2 km walk and a 200 km ride want different defaults |
Treat these as a per-activity profile rather than scattering `if (activity == …)` through
the code.
### Do not put a picker in front of Start
The founding premise is press-and-go with gloves on. A modal asking "what are you doing?"
before recording begins would undo that.
Better: **default to the last activity used**, and make it editable on the trip detail
screen afterwards, next to rename. Most people do the same thing most days, and the one
time they don't, they can fix it after.
### Follow-ons, once the column exists
- **Filter and group the trips list** by activity
- **GPX `<type>` on `<trk>`** — Strava and Garmin read it, so an exported ride imports as
the right activity instead of defaulting to something wrong
- Per-activity totals, if a stats screen ever appears
---
## 4. Route drawing and planning
Drop pins on a map, have the **shortest path between them resolved along real roads**, and
get distance and estimated ride time back before setting off. Save it, ride it later.
This is *pre*-ride planning: a genuinely new mode alongside recording, not an extension of
it. It needs no ride in progress, no server of its own, and could be built entirely
standalone — which makes it the largest thing in v3 that carries no dependency on anything
else.
### It needs a routing engine, and that is the whole decision
Straight lines between pins are easy and reuse `haversineMeters`. **Road-snapped shortest
path is not** — it needs a routing service over OpenStreetMap data:
| Option | Trade-off |
|---|---|
| **Public OSRM demo server** | Free, zero setup, **not for production use** and rate-limited. Fine for prototyping only. |
| **Self-hosted OSRM** | Fast, well-understood. Needs a machine and a regional OSM extract (a province is a few GB). |
| **GraphHopper** | Self-hostable, good cycling and motorcycle profiles, friendlier ETAs |
| **Valhalla** | Best multi-modal profiles, heavier to run |
| **Commercial (Mapbox, Google)** | No ops, per-request billing, an API key in the app |
**Profiles matter here more than usual.** A motorcycle route and a bicycle route between
the same two pins are genuinely different, and cycling engines avoid motorways while
motorcycle riders often want the twisty road rather than the fast one. This is where
[section 3](#3-activity-type-per-ride) pays off — the activity picks the routing profile.
### Estimated time is a promise, and an easy one to get wrong
Routing engines return a duration based on posted speed limits. That is not how long *you*
take. Once there is real ride history, a far better estimate comes from the rider's own
average moving speed for that activity — which the app already stores on every `Trip`.
Show the engine's estimate first, and replace it with a personal one when there is enough
history to justify it.
### Data model
`Route` + `Waypoint`, **separate from `Trip`** — a plan is not a recording, and conflating
them would put unridden kilometres into ride totals. The resolved geometry (the polyline
the engine returns) should be cached on the `Route` so a saved plan opens offline and does
not re-bill a routing request every time it is viewed.
### Follow-ons
- Follow a planned route on the live map while riding (needs section 2)
- Compare a recorded ride against the plan afterwards — where you deviated, how the real
time compared to the estimate
- Export a plan as GPX so it loads into a dedicated sat-nav
---
## 5. Smaller items
| Item | Notes |
|---|---|
| **Trip splitting** | Merge exists; split does not. The natural counterpart. |
| **SAF export** | Dropped in T16 as unnecessary — share sheet covers it. Add if a real need appears. |
| **Auto-pause** | Detect a stop and pause automatically. Rejected in v2 as unreliable in traffic; revisit only with real ride data showing it would help. |
| **Distance units** | Metric only, hardcoded. Trivial to add a preference. |
| **Settings screen** | None exists. `Config` has endpoint, deviceId, mapEnabled — the map toggle currently lives on trip detail because one switch did not justify a screen. |
| **Offline tile pre-download** | osmdroid caches what it renders; a mountain ride with no signal shows blank tiles. Respect OSM's usage policy — no bulk prefetch of their public servers. |
| **Notification live stats** | Show distance/duration in the ongoing notification, readable without unlocking. |
| **Crash reporting** | None. A recorder that dies mid-ride currently leaves no trace beyond logcat. |
---
## 6. Ideas
Terse on purpose. Unshaped, to be consolidated later.
- **Live map while recording.** More visually appealing than a numbers screen. Reverses the
v2 decision — see section 2 for the constraints that survive it.
- **Pick a real theme.** The current look is functional dark + safety orange, chosen to
match the icon. Decide on an actual visual identity and push the UI toward something
polished rather than merely clean.
### Threads running through these
Sign-up, cloud backup and group ride are one programme, not three: they all need a server,
identity, and a privacy stance. They have therefore been moved out to the **ROADMAP**
below, and should be scoped together or not at all.
Activity type (now section 4) and theming are independent and much cheaper — either could
ship alone, and activity type is the natural first v3 task because it forces the migration
path to be proven while the stakes are still low.
Live map, handlebar mounting and route *following* cluster: all three assume a visible
screen during the ride, and all three want the same map component. Route *planning*
(section 4) is the odd one out — it needs no ride in progress at all and could be built
entirely standalone.
---
---
# ROADMAP
Formerly "v4." Renamed because it now holds more than the server-dependent programme
below: it's also where Dylan's running list of bugs and features gets triaged before
becoming its own ticket batch, the way v3 was broken out. That triage takes priority over
the items below — nothing here is blocking, all of it has been waiting since before v3
started.
## The server-dependent programme
Split out from v3 deliberately. These three are **one programme, not three items**: each
needs a server, an identity system, and a privacy stance, and none of them is worth
building alone. Nothing in v3 depends on any of them.
**A fourth item that doesn't fit that programme but shares its precondition:**
self-hosted road-snapped routing (v3's V3-08/V3-09, gated on
[V3-17](v3/V3-17-osrm-hosting.md)). It needs a server the same way this section's three
items do, but nothing about identity, privacy, or the group-ride use case — it's routing
infrastructure, not a rider-facing programme. Filed under `docs/v3/` for now since that's
where the tickets it unblocks already live; flagged here because "v3 is everything
buildable with no server" stops being strictly true the moment V3-17 is picked up.
## Live group rides
Invite someone to a ride and see both of you on the map, live.
This was in the **v1 brief's goal statement**, so it has been the intended destination all
along — it was deferred from v2 as "needs real server work", and that is still the honest
summary.
### Already in place, from v1
- `TelemetryUploader` — batched POST, retry, offline-safe, and structurally unable to stall
recording
- The `synced` column and backlog semantics
- `trip_id` / `segment_id` carried **per point**, so a server can reconstruct rides *and*
their pauses
- `Config.deviceId` — a stable per-install id, enough to distinguish riders
### Missing
- **A server. Nothing exists.** This is the actual work.
- UI for the endpoint — still only reachable via `Config.setUploadEndpoint()`
- Auth, rider identity, group membership, invitations
- Other riders drawn on the map, which also needs the live map from v3 section 2
- **Live** delivery. The current uploader is a batched backlog drain on a 30 s timer, which
is right for archiving and useless for watching someone move. Live positions want a
websocket or similar, running *alongside* the existing uploader rather than replacing it —
the batch path is what guarantees no fix is ever lost.
### The decisions that are not technical
- **Location sharing is consent, not a feature.** Who can see you, for how long, and how
does it stop? Sharing that outlives the ride is a privacy incident waiting to happen.
- Ride invitations mean handling someone declining, leaving mid-ride, or losing signal for
twenty minutes — the map has to say "last seen 8 minutes ago", not silently freeze them
in place.
- This is the point where Rippr stops being a local-only app and starts holding other
people's location data.
## Accounts and sign-up
Prerequisite for everything else here. Note that Apple **requires in-app account deletion**
for any app offering account creation, and that a location app with accounts inherits real
obligations — see [LAUNCH.md](LAUNCH.md).
## Paid cloud backup
Ongoing storage of rides over time, and the only monetization model that justifies
recurring money — because it is the only one with recurring costs. Needs accounts first,
plus decisions on hosting, pricing, and what happens to someone's history when they stop
paying.
---
# Things that must not regress · all versions
Hard-won and easy to undo by accident. Each has a comment in the code explaining why.
1. **The unbounded `Channel` + single batched writer.** Do not write to the database from
the location callback.
2. **Points stamped with `tripId`/`segmentId` at creation.** Refactoring this into a
write-time lookup breaks the pause guarantee silently.
3. **Recording state derived from the database.** Never reintroduce an in-memory flag.
4. **`fallbackToDestructiveMigration()` stays removed.** Any schema change ships a
`Migration` against `app/schemas/com.rippr.data.AppDatabase/2.json`.
5. **Decimation is render-only.** It must never reach storage or export.
6. **`RipprTheme`'s `Surface`.** It sets `LocalContentColor`; without it, text without an
explicit colour renders black-on-black and disappears.
7. **The map zoom clamp.** `zoomToBoundingBox` ignores `maxZoomLevel`; a short ride will
render an empty grid without it.
8. **Instrumented tests use in-memory databases.** One previously wiped the real device
database in `setUp`.
---
# Reading order for picking this up cold
1. [../README.md](../README.md) — what the app is and its current state
2. [ARCHITECTURE.md](ARCHITECTURE.md) — why it is built this way
3. `~/dojo/rippr/docs/v2/PROGRESS.md` (native repo) — every bug found during v2 and how
4. [the real-ride checklist](port/REAL-RIDE-CHECKLIST.md) — **especially "What the emulator cannot verify"**
5. `~/dojo/rippr/docs/DEVELOPMENT.md` (native repo) — when you actually need to build something
The v2 planning approach worked well and is worth repeating: one document per task with
goal, context, design, acceptance criteria and risks, written *before* implementing, plus a
running progress log recording what actually went wrong. Several bugs were caught precisely
because the risk had been written down first — and one (the osmdroid lifecycle) was written
down and then walked into anyway, which is its own lesson.

View File

@@ -0,0 +1,195 @@
# Launch
How Rippr gets from "on my phone" to "on the App Store with money coming in" — in stages,
because the jump is much bigger than it looks and most of it is not code.
**Where things stand:** the app is feature-complete on both platforms and has never
recorded a real ride. Nothing below should start before
[port/REAL-RIDE-CHECKLIST.md](port/REAL-RIDE-CHECKLIST.md) is done.
---
## Stage 0 — you and your friends *(today, free)*
What works right now, with no accounts and no stores.
### Android — genuinely easy
Send them the APK. That's it.
```bash
adb install -r rippr-flutter-1.0-debug.apk # or just message them the file
```
They tap it in Files and allow "install unknown apps". It works, it keeps working, and it
costs nothing. **For riding buddies on Android, this is a complete answer** — you may
never need the Play Store.
Worth switching to a **release build** before handing it round: the debug APK is ~172 MB
and slower. That needs a keystore, kept somewhere you will not lose it:
```bash
keytool -genkey -v -keystore ~/rippr-release.jks -keyalg RSA -keysize 2048 \
-validity 10000 -alias rippr
flutter build apk --release --split-per-abi # ~3 files, ~20 MB each
```
> **Back that keystore up.** Lose it and you can never update an app that shipped with it.
### iOS — awkward, and the awkwardness is the point
Free signing (see `INSTALL.md` in the samplez repo, alongside the APK) gives **7-day
builds**, three apps at a time, and needs your Mac every time. Fine for you. Miserable for
a friend who just wants to ride.
There is no free way around it. Apple deliberately does not allow casual iPhone
sideloading.
**If any of your friends ride with an iPhone, Stage 1 is not optional.**
---
## Stage 1 — a real group of testers *(~$99/yr + $25 once)*
This is the stage that actually fits "me and my friends", and it is a big quality-of-life
jump over Stage 0.
### TestFlight — the single best upgrade
Requires the **Apple Developer Program, $99/yr**.
- Builds last **90 days**, not 7
- **100 internal testers** and up to **10,000 external** via a public link
- Friends install the TestFlight app and tap once — no Mac, no cables, no trust dialogs
- Updates push automatically
- External testers need a light App Review pass (usually a day); internal testers do not
For a riding group this is effectively "the app store, for people I choose". Many projects
happily live here forever and never publish publicly.
### Google Play internal testing — optional
**$25, one time, forever.** Up to 100 testers, no review for the internal track. Worth it
mainly if handing out APKs starts to annoy you, or you want automatic updates on Android
too.
### What you must add before either
- **A privacy policy URL.** Both stores require one for anything touching location, even
in testing. It can be a single static page: what is collected (GPS while recording),
where it goes (nowhere — stays on the device), and how to delete it (delete the ride, or
the app).
- Switch the bundle id from `com.rippr.port` to something you own, e.g. `com.dylan.rippr`.
- A release build, on both platforms.
---
## Stage 2 — public launch
Everything in Stage 1, plus the parts that are genuinely work.
### Apple
- **Privacy nutrition labels** — Location → Precise, *not* linked to identity, App
Functionality only. Details in [port/RELEASE-IOS.md](port/RELEASE-IOS.md).
- **Expect to justify background location.** It is the most common rejection for apps like
this. Head it off with a **demo video of a real ride** in the review notes, showing the
user pressing Start.
- App name, subtitle, keywords, screenshots at several device sizes, a support URL.
- Review is typically 24–48 hours, and a rejection round is normal. Budget two weeks.
### Google
- **Data Safety form** — the Play equivalent of nutrition labels, and it must match
reality.
- Background location gets **extra scrutiny on Play**: a written justification and often a
video. Rippr's case is strong (a ride recorder is the textbook example), but it is a real
review step.
- Store listing, feature graphic, screenshots.
### Things that only matter once strangers use it
- **Crash reporting.** Currently none — the v3 backlog notes a recorder that dies mid-ride
leaves no trace beyond logcat. Sentry or Crashlytics before public launch.
- **A support address** people can actually reach.
- Whatever the real ride teaches you about battery. Strangers will not forgive what a
friend would.
---
## A positioning note, since you ride bikes *and* motorcycles
Everything user-facing currently says motorcycle, but the recording pipeline is entirely
activity-agnostic — it records positions and speeds, and nothing in it cares what you are
sitting on. `BACKLOG.md` already carries **activity type per ride** as an idea.
That matters here rather than only in the backlog, because it decides the store category,
the screenshots and who finds the app. Cyclists are a far larger audience than
motorcyclists and are already used to paying for ride apps. Deciding whether Rippr is a
motorcycle app or a ride recorder is a positioning question worth settling **before**
writing a store listing, not after.
It is also cheap: an activity column on `Trip` and a picker. It needs a real `Migration`,
since the destructive fallback is gone.
---
## Stage 3 — monetization
Only worth designing once people are actually riding with it. But the shape matters
because it changes the architecture, and it is worth knowing which door you are opening.
### The options, from least to most commitment
**Paid up front — a few dollars, once.**
Simplest possible. No servers, no accounts, no recurring obligation. Sells poorly in a
category where Strava is free, but the cost to you is nearly zero and it fits an app that
works entirely offline.
**Free, with a one-time unlock.**
Recording free forever; pay once for extras — export formats, unlimited history, themes.
No servers needed if the extras are local. Good match for Rippr as it exists today.
**Subscription for cloud features.**
The v3 backlog already points here: accounts, cloud backup, group rides. This is the only
model that justifies recurring money, because **you have recurring costs**. It is also the
biggest jump: a server, authentication, a privacy stance, and an obligation to keep
someone's ride history alive.
> Note the coupling. Cloud backup needs accounts; accounts need a privacy policy with
> teeth, GDPR-style deletion, and — on iOS — **in-app account deletion**, which Apple
> requires of any app that lets you create an account. Group rides need all of that plus
> live location sharing between people, which is a consent question, not a technical one.
### The numbers
- **Apple and Google both take 30%**, dropping to **15%** under their small-business
programmes (under $1M/yr). Enrol; it is not automatic on Apple's side.
- Apple: $99/yr. Google: $25 once.
- Server costs are yours. A modest VPS runs a group-ride backend for single-digit dollars
a month until it doesn't.
- Payments are handled by the stores for digital goods — you may not route around them for
in-app features.
### What I would actually do
Keep it free while it is you and your friends. If it grows, the honest first step is a
**one-time unlock** for local extras — no servers, no obligations, no privacy surface.
Only build the subscription once someone who is not your friend asks for cloud backup.
Paid storage of other people's location history is a commitment that outlives your
interest in maintaining it, and that is worth entering deliberately.
---
## The order of operations
1. **Ride with it.** [port/REAL-RIDE-CHECKLIST.md](port/REAL-RIDE-CHECKLIST.md). Nothing
else matters until the numbers are right — and iOS background suspension (item I3) may
still force a change of GPS engine.
2. Release builds, real bundle id, a keystore you have backed up.
3. A one-page privacy policy.
4. $99 Apple → TestFlight. Your friends stop needing your Mac.
5. Live with it for a season. Let real use decide what is missing.
6. Only then: public listings, crash reporting, and a monetization model chosen from
evidence rather than from a table like this one.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,62 @@
# Stitch export — Rippr Minimalist Activity Tracker
Pulled from [stitch.withgoogle.com](https://stitch.withgoogle.com) project
`8187200995279352789` via the Stitch MCP server on 2026-08-23.
## Correction
An earlier version of this README got the design systems backwards. There are **three**
distinct design systems in this project (`list_design_systems`), and the four screens
below actually render with the one this doc originally called "stale." That's now fixed.
| Asset | Display name | Mode | Primary | Fonts | Used by the 4 screens below? |
|---|---|---|---|---|---|
| `assets/e73325c5365a4a56b93823236777e95a` | **Modern Professional Dark** | Dark | Blue `#1978e5` (rendered `#4090fe`/`#aac7ff`) | Inter (everywhere) | **Yes — confirmed by hex-matching the downloaded HTML** |
| `assets/41670631a4b64b118fb9ea1b54f1bd60` | Rippr (neon HUD) | Dark | Electric green `#00FF41` | Sora / Inter / JetBrains Mono | No |
| `assets/3938003907261369831` | Modern Professional | Light | Blue `#1275e2` | Inter | No |
**The one you specifically asked for** (screen ID
`asset-stub-assets_e73325c5365a4a56b93823236777e95a`, i.e.
`assets/e73325c5365a4a56b93823236777e95a`) is **Modern Professional Dark** — deep
charcoal/navy (`#0a0a0c` base, `#131313` surface, `#16161a` cards), Professional Blue
primary, Inter throughout, 8px rounding, tonal-layer depth (no shadows, lighter surface
= more elevated), and a soft blue glow on elevated/active elements instead of a shadow.
This is `design-system-modern-professional-dark.md` below, and it's the design system
actually used by all four screens already pulled.
`design-system-rippr-neon.md` is kept for reference — it's a real design system in the
project (`get_project`'s top-level `designTheme`, i.e. whatever a brand-new screen would
default to), but it is **not** what any of the four fetched screens render with.
## Contents
| File | What it is |
|---|---|
| `design-system-modern-professional-dark.md` | **The design system actually applied to the four screens below.** Full spec: color tokens, typography, spacing, elevation, shape, components. |
| `design-system-rippr-neon.md` | A second, unused-by-these-screens design system also present in the project (green/cyan HUD aesthetic). Reference only. |
| `screens/export-summary.md` | Stitch's own generated project overview. Its "Design System: Modern Professional Dark" section was correct all along. |
| `screens/map-hud-dark.html` + `.png` | Map HUD - Dark Mode: active-recording map screen, floating telemetry cards (speed, avg speed, distance, time), Pause/Stop controls. |
| `screens/plan-screen-dark.html` + `.png` | Plan Screen - Dark Mode: empty-state route planner, "Tap to drop a pin" prompt. |
| `screens/route-planning-dark.html` + `.png` | Route Planning - Dark Mode: pins dropped, distance/time/pin-count header, dotted route line, Start Route button. |
| `screens/rides-history-dark.html` + `.png` | Rides History - Dark Mode: search bar, unit toggle, ride cards with a mini-map thumbnail, date, distance, time, avg speed. |
Each `.html` is Stitch's generated markup (Tailwind CSS, vanilla JS) for that screen — a
reference for structure and interaction, not something to drop into the Flutter app
as-is.
## Not pulled as a screen/image
"Design System" (`assets_e73325c5365a4a56b93823236777e95a`) is a **design-system asset
instance**, not a regular screen — Stitch's `get_screen` call only works on
`projects/{project}/screens/{screen}` resources and returns `invalid argument` for an
`assets/{id}` resource. There is no separate HTML/screenshot file for it the way there is
for a screen; its full content (structured theme tokens + style-guideline prose) is
fetched via `list_design_systems` instead, and that's what
`design-system-modern-professional-dark.md` is built from.
## Next step
This is raw material, not yet applied to the app. `lib/src/ui/theme.dart`'s current
palette (safety-orange accent, warm near-black ground — see V3-16) is a different
direction from **either** of these Stitch design systems. Reconciling or replacing is a
decision for whoever picks up the actual UI work on this branch.

View File

@@ -0,0 +1,171 @@
---
name: Modern Professional Dark
colors:
surface: '#131313'
surface-dim: '#131313'
surface-bright: '#393939'
surface-container-lowest: '#0e0e0e'
surface-container-low: '#1c1b1b'
surface-container: '#201f1f'
surface-container-high: '#2a2a2a'
surface-container-highest: '#353534'
on-surface: '#e5e2e1'
on-surface-variant: '#c1c6d5'
inverse-surface: '#e5e2e1'
inverse-on-surface: '#313030'
outline: '#8b919f'
outline-variant: '#414753'
surface-tint: '#aac7ff'
primary: '#aac7ff'
on-primary: '#002f64'
primary-container: '#4090fe'
on-primary-container: '#002958'
inverse-primary: '#005db8'
secondary: '#aec7f6'
on-secondary: '#143057'
secondary-container: '#2e476f'
on-secondary-container: '#9db6e4'
tertiary: '#ffb68c'
on-tertiary: '#532200'
tertiary-container: '#e3711f'
on-tertiary-container: '#481d00'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#d6e3ff'
primary-fixed-dim: '#aac7ff'
on-primary-fixed: '#001b3e'
on-primary-fixed-variant: '#00458d'
secondary-fixed: '#d6e3ff'
secondary-fixed-dim: '#aec7f6'
on-secondary-fixed: '#001b3d'
on-secondary-fixed-variant: '#2e476f'
tertiary-fixed: '#ffdbc9'
tertiary-fixed-dim: '#ffb68c'
on-tertiary-fixed: '#321200'
on-tertiary-fixed-variant: '#763400'
background: '#131313'
on-background: '#e5e2e1'
surface-variant: '#353534'
surface-main: '#0a0a0c'
surface-card: '#16161a'
accent-error: '#ffb4ab'
on-surface-bright: '#ffffff'
on-surface-muted: '#9ca3af'
typography:
headline-lg:
fontFamily: Inter
fontSize: 32px
fontWeight: '700'
lineHeight: 40px
letterSpacing: -0.02em
headline-lg-mobile:
fontFamily: Inter
fontSize: 28px
fontWeight: '700'
lineHeight: 36px
letterSpacing: -0.01em
headline-md:
fontFamily: Inter
fontSize: 24px
fontWeight: '600'
lineHeight: 32px
headline-sm:
fontFamily: Inter
fontSize: 20px
fontWeight: '600'
lineHeight: 28px
body-lg:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
body-md:
fontFamily: Inter
fontSize: 14px
fontWeight: '400'
lineHeight: 20px
label-md:
fontFamily: Inter
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
letterSpacing: 0.5px
label-sm:
fontFamily: Inter
fontSize: 11px
fontWeight: '500'
lineHeight: 16px
letterSpacing: 0.5px
rounded:
sm: 0.25rem
DEFAULT: 0.5rem
md: 0.75rem
lg: 1rem
xl: 1.5rem
full: 9999px
spacing:
base: 4px
gutter: 24px
margin-mobile: 16px
margin-desktop: 32px
max-width: 1280px
---
## Brand & Style
This design system embodies a **Corporate / Modern** aesthetic reimagined for a premium dark-mode experience. The brand personality is authoritative, precise, and sophisticated. By shifting to a deep charcoal and navy foundation, the UI evokes a sense of focused calm and high-end engineering.
The visual style leverages subtle luminescence and high-contrast accents to guide the user's eye, moving away from traditional light surfaces to a more immersive, "command-center" feel. It is tailored for professional environments where clarity and reduced eye strain are paramount.
## Colors
The palette is anchored by a **Deep Dark Gray (#0a0a0c)** background to provide a true premium feel without the harshness of pure black.
- **Primary:** The Professional Blue (#1978e5) is the soul of the interface, used for key actions and brand presence.
- **Secondary:** A muted Slate Blue (#465f88) handles auxiliary UI elements and decorative accents.
- **Surface Strategy:** We use a "stepped" dark palette. The base is the darkest, while interactive containers or cards use a slightly lighter gray (#16161a) to create perceived depth.
- **Contrast:** All functional text is kept at a high luminance (Off-white to Pure White) to ensure AAA accessibility standards against the dark backdrop.
## Typography
This design system relies exclusively on **Inter** to maintain a systematic, utilitarian, and clean appearance.
- **Weight & Scale:** Headlines utilize tighter letter spacing and heavier weights to command attention against the dark background.
- **Readability:** Body text uses a generous line-height to prevent "halation"—where light text on dark backgrounds appears to bleed or blur.
- **Labels:** Small labels use all-caps or increased letter-spacing to maintain legibility at micro-scales.
## Layout & Spacing
The design system employs a **Fixed Grid** philosophy for desktop to maintain structural integrity, transitioning to a fluid model for smaller screens.
- **Grid:** A 12-column grid is used for desktop (1280px max-width) with 24px gutters.
- **Rhythm:** An 8px spatial system governs all padding and margins, ensuring a predictable and professional vertical rhythm.
- **Breakpoints:**
- **Mobile:** < 600px (4 columns, 16px margins)
- **Tablet:** 600px - 1024px (8 columns, 24px margins)
- **Desktop:** > 1024px (12 columns, 32px margins)
## Elevation & Depth
In dark mode, shadows are less effective. Instead, this design system uses **Tonal Layers** and **Low-Contrast Outlines**.
- **Elevation Levels:** Higher elevation is communicated by lighter surface colors. A "floating" modal will have a lighter hex than the background.
- **Borders:** Subtle 1px borders (#2d3037) are used to define boundaries where tonal shifts are too subtle.
- **Glow:** Highly elevated elements (like active primary buttons) may use a soft blue outer glow rather than a black shadow to emphasize their importance.
## Shapes
The shape language is defined as **Rounded (Level 2)**, striking a balance between the rigidity of corporate software and the approachability of modern SaaS.
- **Small Components:** Buttons and inputs use a 0.5rem (8px) radius.
- **Large Containers:** Cards and modals use a 1rem (16px) radius to soften the layout and create clear content groupings.
## Components
- **Buttons:** Primary buttons are solid Professional Blue with white text. Secondary buttons use a ghost style with a subtle white border and no fill.
- **Input Fields:** Backgrounds should be slightly darker or lighter than the container surface, with a 1px border that glows Professional Blue on focus.
- **Cards:** Cards use a #16161a background with a very subtle 1px border (#ffffff10) to separate them from the main background.
- **Chips:** Used for metadata, chips feature a desaturated secondary blue-grey background with high-contrast labels.
- **Lists:** Items are separated by hair-line dividers (#ffffff08) to maintain a clean vertical scan-path.

View File

@@ -0,0 +1,171 @@
---
name: Rippr
colors:
surface: '#131314'
surface-dim: '#131314'
surface-bright: '#3a393a'
surface-container-lowest: '#0e0e0f'
surface-container-low: '#1c1b1c'
surface-container: '#201f20'
surface-container-high: '#2a2a2b'
surface-container-highest: '#353436'
on-surface: '#e5e2e3'
on-surface-variant: '#b9ccb2'
inverse-surface: '#e5e2e3'
inverse-on-surface: '#313031'
outline: '#84967e'
outline-variant: '#3b4b37'
surface-tint: '#00e639'
primary: '#ebffe2'
on-primary: '#003907'
primary-container: '#00ff41'
on-primary-container: '#007117'
inverse-primary: '#006e16'
secondary: '#b9f1ff'
on-secondary: '#00363f'
secondary-container: '#00e0ff'
on-secondary-container: '#005f6d'
tertiary: '#fff8f4'
on-tertiary: '#442b10'
tertiary-container: '#ffd5ae'
on-tertiary-container: '#7a5b3c'
error: '#ffb4ab'
on-error: '#690005'
error-container: '#93000a'
on-error-container: '#ffdad6'
primary-fixed: '#72ff70'
primary-fixed-dim: '#00e639'
on-primary-fixed: '#002203'
on-primary-fixed-variant: '#00530e'
secondary-fixed: '#a5eeff'
secondary-fixed-dim: '#00daf8'
on-secondary-fixed: '#001f25'
on-secondary-fixed-variant: '#004e5a'
tertiary-fixed: '#ffdcbd'
tertiary-fixed-dim: '#e7bf99'
on-tertiary-fixed: '#2c1701'
on-tertiary-fixed-variant: '#5d4124'
background: '#131314'
on-background: '#e5e2e3'
surface-variant: '#353436'
typography:
display-telemetry:
fontFamily: JetBrains Mono
fontSize: 64px
fontWeight: '700'
lineHeight: 64px
letterSpacing: -0.04em
headline-lg:
fontFamily: Sora
fontSize: 32px
fontWeight: '600'
lineHeight: 40px
headline-md:
fontFamily: Sora
fontSize: 24px
fontWeight: '600'
lineHeight: 32px
body-lg:
fontFamily: Inter
fontSize: 18px
fontWeight: '400'
lineHeight: 28px
body-md:
fontFamily: Inter
fontSize: 16px
fontWeight: '400'
lineHeight: 24px
label-caps:
fontFamily: JetBrains Mono
fontSize: 12px
fontWeight: '500'
lineHeight: 16px
letterSpacing: 0.1em
display-telemetry-mobile:
fontFamily: JetBrains Mono
fontSize: 48px
fontWeight: '700'
lineHeight: 48px
rounded:
sm: 0.125rem
DEFAULT: 0.25rem
md: 0.375rem
lg: 0.5rem
xl: 0.75rem
full: 9999px
spacing:
unit: 4px
container-margin: 20px
gutter: 12px
stack-sm: 8px
stack-md: 24px
stack-lg: 48px
---
## Brand & Style
The design system centers on a high-performance, dark-mode aesthetic tailored for real-time activity tracking. The brand personality is kinetic, precise, and undistracted. It utilizes a **Minimalist-Technical** style, stripping away non-essential chrome to focus entirely on telemetry and movement.
The visual narrative is driven by a "HUD" (Heads-Up Display) concept: information is layered over the user's environment or activity maps using subtle glassmorphism and high-contrast data points. The emotional response should be one of focus and momentum, making the user feel like a precision instrument.
## Colors
The palette is dominated by **Pitch Black (#0A0A0B)** to preserve OLED battery life and minimize ocular strain during night activities.
- **Primary (Electric Neon):** Used exclusively for active states, completion goals, and primary "Go" actions.
- **Secondary (Cyan Telemetry):** Reserved for secondary data streams, such as cadence or heart rate, to provide visual separation from primary distance/time metrics.
- **Surface Strategy:** Backgrounds use deep charcoal. Overlays use a semi-transparent version of the surface color with a 20px background blur to create the HUD effect.
## Typography
The typography system uses a functional split: **Sora** for UI headers and brand moments, **Inter** for standard interface text, and **JetBrains Mono** for all numerical data and telemetry.
- **Data Legibility:** All numeric values must use the monospaced font to prevent "jumping" layouts during real-time updates.
- **Visual Hierarchy:** Use `label-caps` for units (e.g., KM/H, BPM) placed immediately adjacent to large telemetry displays.
- **Scale:** On mobile devices, telemetry data should prioritize width; if values exceed 5 digits, scale the font size down dynamically to fit the viewport width minus margins.
## Layout & Spacing
This design system employs a **Fluid HUD Grid**. Elements are anchored to the corners of the screen to keep the center clear for maps or camera feeds.
- **Grid Model:** A 6-column grid for mobile, 12-column for desktop.
- **Padding:** Use a generous 20px "Safe Zone" margin on all edges of the screen to ensure interactive elements are reachable and not clipped by hardware notches or rounded device corners.
- **Density:** High density for data readouts (8px spacing between related metrics) and low density for navigation/settings (24px+ spacing) to prevent accidental taps during motion.
## Elevation & Depth
Depth is expressed through **translucency** rather than shadows.
1. **Level 0 (Base):** Pure black (#0A0A0B).
2. **Level 1 (Substrate):** Dark charcoal (#161618) with 0% transparency.
3. **Level 2 (HUD Panels):** Surface color at 60% opacity with a `backdrop-filter: blur(20px)`.
4. **Level 3 (Modals):** Surface color at 80% opacity with a subtle 1px inner border in a low-opacity white (10%) to define the edge against dark backgrounds.
Shadows should be avoided entirely to maintain the "lightweight" digital feel.
## Shapes
The shape language is **Technical-Precision**.
- **Corners:** Use 0.25rem (4px) as the base radius for all containers to maintain a sharp, engineered appearance.
- **Interactive Elements:** Buttons and inputs follow the same 4px rule.
- **Progress Indicators:** Use sharp, non-rounded ends for progress bars to emphasize a "segmental" or "digital" look.
- **Iconography:** Use 2px stroke weights with squared-off ends to match the monospaced typographic theme.
## Components
### Buttons
- **Primary:** Solid Electric Neon background with Black text. No rounding beyond the base 4px.
- **Ghost:** 1px Neon border with transparent background. Text matches border color.
- **Telemetry Toggle:** Small, square buttons using monospaced labels.
### Cards & HUD Panels
- All cards use the Level 2 Elevation (Glassmorphism).
- Remove all drop shadows. Use a 1px stroke of #FFFFFF at 10% opacity for definition.
### Data Inputs
- Underlined style only (no bounding box) for a lighter footprint.
- Active state changes the underline to the Primary Neon color.
- Use JetBrains Mono for all numeric input fields.
### Progress & Gauges
- **Circular Gauges:** Use thin 4px strokes. The "track" should be 5% opacity white; the "fill" should be the Primary Neon.
- **Pulse:** Active heart rate or GPS signals should use a "scanning" animation—a vertical line moving across the element—rather than a standard fade.
### Navigation
- A bottom-anchored, floating "Dock" with a glassmorphic background.
- Active icons are highlighted with a single Neon pixel-dot underneath.

View File

@@ -0,0 +1,50 @@
# Rippr Project Export
## Project Overview
Rippr is a minimalist activity tracker designed for cyclists and runners, featuring an immersive map-centric interface, real-time telemetry, and streamlined route planning.
## Design System: Modern Professional Dark
**ID:** {{DATA:DESIGN_SYSTEM:DESIGN_SYSTEM_3}}
### Theme Tokens
- **Color Mode:** Dark
- **Primary Color:** #1978e5 (Professional Blue)
- **Typography:** Inter
- **Roundness:** 8px (Round Eight)
### Core Components
- **TopAppBar:** Minimalist, often hidden for immersive map views.
- **BottomNavBar:** 4-icon layout (Map, History, Plan, Settings) with a signature active indicator dot.
- **Telemetry Cards:** Floating, semi-transparent modules (`bg-surface/60`) with `backdrop-blur-xl` and `white/10` borders.
---
## Screen Inventory
### 1. Map HUD (Active Navigation)
- **ID:** {{DATA:SCREEN:SCREEN_8}}
- **Description:** Fullscreen map interface with floating telemetry cards at the top showing Speed, Avg Speed, Distance, and Time. Units are integrated inline (e.g., "24.5 km/h").
- **Controls:** Detached Pause (Yellow) and Stop (Red) buttons positioned above the bottom navigation.
### 2. Plan Screen (Ready state)
- **ID:** {{DATA:SCREEN:SCREEN_7}}
- **Description:** Clean map interface identical to the HUD but without telemetry or ride controls. Features a subtle floating tooltip: "Tap to drop a pin".
### 3. Route Planning (Active Pins)
- **ID:** {{DATA:SCREEN:SCREEN_5}}
- **Description:** Secondary state of the Plan screen showing 5 dropped pins connected by a blue dotted line. A floating header displays total distance, estimated time, and pin count.
### 4. Rides History
- **ID:** {{DATA:SCREEN:SCREEN_6}}
- **Description:** List of previous routes displayed as cards over a blurred live map background. Each card shows a mini-map preview, date, distance, time, and average speed.
### 5. Settings / Profile
- **ID:** {{DATA:SCREEN:SCREEN_4}}
- **Description:** Structured list of user preferences, account details, and cloud sync options. Designed with clean hierarchy and professional dark mode styling.
---
## Technical Specifications
- **Framework:** HTML5 / CSS3 (Tailwind CSS)
- **Interactivity:** Vanilla JavaScript for map state and telemetry updates.
- **Visual Effects:** Backdrop filters for UI depth and high-contrast vector paths for route visualization.

View File

@@ -0,0 +1,317 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head></head><body class="antialiased selection:bg-primary-container selection:text-on-primary-container flex flex-col h-screen relative text-on-surface bg-surface"><svg aria-hidden="true" class="inline-defs-container" style="position:absolute;width:0;height:0;overflow:hidden"></svg>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"/>
<title>RIPPR - Record Activity</title>
<link href="https://fonts.googleapis.com" rel="preconnect"/>
<link crossorigin="" href="https://fonts.gstatic.com" rel="preconnect"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@400;600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
],
"display-telemetry": ["Inter"],
"label-caps": ["Inter"]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"display-telemetry": ["64px", { "lineHeight": "64px", "letterSpacing": "-0.04em", "fontWeight": "700" }],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }]
}
},
},
}
</script>
<style>
body { margin: 0; overflow: hidden; background-color: #131313; color: #e5e2e1; min-height: max(884px, 100dvh); }
.hud-panel { background: rgba(32, 31, 31, 0.8); backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.1); }
.glass-btn { background: rgba(32, 31, 31, 0.9); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); }
</style>
<!-- Background Map Placeholder -->
<div class="absolute inset-0 z-0 bg-surface">
<div class="w-full h-full bg-cover bg-center opacity-40 mix-blend-luminosity" data-alt="A light mode stylized city map view for a fitness tracking application. Minimalist aesthetic with high contrast blue routes highlighting paths against light gray blocks representing buildings and terrain. The map appears like a clean interface overlay." data-location="Tokyo" style="background-image: url('https://lh3.googleusercontent.com/aida-public/AB6AXuDr5_s-KgoDdw3V0jHBFYtHVB3aikGStl0j7TM7kPJnauzs3U1eRnVMaMh4ywSZTMLAo_LxEZnu9rTzBPq-EGev_TiD3t0VY9osDcW0xKKUZxL2rx7TPn0qh2lor7H-TT1FWRur2eVHGsIsu-YT3ht-zwG1VrgIArB7Dxx5IgNThsHCroZ6oFbBB_Uo1Y8rIjPn8NPkxuUVxmKjpNze6RAmNCuYj9V3ivWdnkUNS_Z6ybPWERqbvRA4')"></div>
<!-- Routes and Location Overlay -->
<svg class="absolute inset-0 w-full h-full pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 400 800">
<defs>
<lineargradient id="speedGradient" x1="10%" x2="50%" y1="90%" y2="50%">
<stop offset="0%" stop-color="#4090fe"></stop>
<stop offset="40%" stop-color="#aac7ff"></stop>
<stop offset="70%" stop-color="#ffb4ab"></stop>
<stop offset="100%" stop-color="#ffb4ab"></stop>
</lineargradient>
</defs>
<!-- Planned Route -->
<path d="M 200 400 C 250 250, 150 150, 300 50" fill="none" opacity="0.8" stroke="#8b919f" stroke-dasharray="10 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Completed Route -->
<path d="M 100 750 C 150 600, 100 500, 200 400" fill="none" stroke="url(#speedGradient)" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Pulsing Location Dot -->
<circle cx="200" cy="400" fill="#4090fe" r="8"></circle>
<circle cx="200" cy="400" fill="none" r="8" stroke="#4090fe" stroke-width="2">
<animate attributename="r" dur="1.5s" from="8" repeatcount="indefinite" to="24"></animate>
<animate attributename="opacity" dur="1.5s" from="1" repeatcount="indefinite" to="0"></animate>
</circle>
</svg>
</div>
<!-- TopAppBar JSON Component -->
<!-- Main Content Canvas (HUD Layout) -->
<main class="flex-1 relative z-10 flex flex-col pb-40 px-2 md:px-margin-desktop w-full max-w-7xl mx-auto md:ml-24 md:w-[calc(100%-6rem)] p-2">
<!-- Compact Telemetry Row -->
<div class="flex flex-row gap-1 w-full" id="telemetry-container">
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Speed</span>
<div class="metric-value font-display-telemetry text-base text-primary mt-1 transition-all duration-300 whitespace-nowrap">24.5 km/h</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Avg Speed</span>
<div class="metric-value font-display-telemetry text-lg text-on-surface mt-1 transition-all duration-300">22.1</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Dist</span>
<div class="metric-value font-display-telemetry text-base text-secondary mt-1 transition-all duration-300 whitespace-nowrap">12.4 km</div>
</div>
<div class="metric-card flex-1 hud-panel p-2 rounded-lg flex flex-col items-center justify-center cursor-pointer transition-all duration-300 overflow-hidden min-w-0">
<span class="metric-title font-label-caps text-[9px] sm:text-[10px] text-on-surface-variant uppercase whitespace-nowrap truncate w-full text-center">Time</span>
<div class="metric-value font-display-telemetry text-lg text-on-surface mt-1 transition-all duration-300">45:12</div>
</div>
</div>
</main>
<!-- Full Width Recording Control Bar -->
<div class="fixed bottom-[80px] md:bottom-0 md:left-24 w-full md:w-[calc(100%-6rem)] z-40">
<div class="flex flex-row w-full h-16 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.5)]">
<button class="flex-1 bg-tertiary-container text-on-tertiary-container font-headline-md flex items-center justify-center transition-colors duration-300 border-r border-white/10"><span class="material-symbols-outlined text-[32px]" style='font-variation-settings: "FILL" 1;'>pause</span></button>
<button class="flex-1 bg-error-container text-on-error-container font-headline-md flex items-center justify-center transition-colors duration-300"><span class="material-symbols-outlined text-[32px]" style='font-variation-settings: "FILL" 1;'>stop</span></button>
</div>
</div>
<!-- BottomNavBar JSON Component -->
<nav class="bg-surface/80 backdrop-blur-xl fixed bottom-0 w-full z-50 pb-safe border-t border-white/10 md:hidden">
<div class="flex justify-around items-center h-20 px-margin-mobile font-label-caps text-label-caps font-display-telemetry"><button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary/80 transition-transform duration-150 scale-95">
<span class="material-symbols-outlined" style='font-variation-settings: "FILL" 1;'>map</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style='font-variation-settings: "FILL" 0;'>book</span></button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">settings</span>
</button></div>
</nav>
<!-- Desktop Side Nav (Visible only on md+) -->
<aside class="hidden md:flex flex-col fixed left-0 top-16 bottom-0 w-24 bg-surface/80 backdrop-blur-xl border-r border-white/10 z-40 items-center py-8 gap-12">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps">
<span class="material-symbols-outlined mb-2 text-2xl">map</span>
Map
</button>
<button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:left-0 after:top-1/2 after:-translate-y-1/2 after:w-1 after:h-8 after:bg-primary after:rounded-r-full hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps bg-white/5">
<span class="material-symbols-outlined mb-2 text-2xl" style="font-variation-settings: 'FILL' 1;">directions_bike</span>
Rides
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-colors w-full py-4 font-label-caps text-label-caps">
<span class="material-symbols-outlined mb-2 text-2xl">route</span>
Plan
</button>
</aside>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Recording Toggle Logic
const toggleBtn = document.getElementById('recordingToggle');
const toggleIcon = document.getElementById('toggleIcon');
const toggleText = document.getElementById('toggleText');
let isRecording = true;
toggleBtn?.addEventListener('click', () => {
isRecording = !isRecording;
if(isRecording) {
toggleBtn.classList.remove('bg-error-container', 'text-on-error-container');
toggleBtn.classList.add('bg-primary-container', 'text-on-primary-container');
toggleIcon.textContent = 'stop_circle';
toggleText.textContent = 'Stop Recording';
} else {
toggleBtn.classList.remove('bg-primary-container', 'text-on-primary-container');
toggleBtn.classList.add('bg-error-container', 'text-on-error-container');
toggleIcon.textContent = 'play_arrow';
toggleText.textContent = 'Start Recording';
}
});
// Telemetry Expansion Logic
const metrics = document.querySelectorAll('.metric-card');
metrics.forEach(metric => {
metric.addEventListener('click', () => {
const isExpanded = metric.classList.contains('flex-[2.5]');
// Reset all
metrics.forEach(m => {
m.classList.remove('flex-[2.5]', 'bg-surface-container-highest');
m.classList.add('flex-1');
m.querySelector('.metric-value').classList.remove('text-2xl', 'md:text-3xl');
m.querySelector('.metric-value').classList.add('text-lg');
});
// Expand if it wasn't already expanded
if (!isExpanded) {
metric.classList.remove('flex-1');
metric.classList.add('flex-[2.5]', 'bg-surface-container-highest');
metric.querySelector('.metric-value').classList.remove('text-lg');
metric.querySelector('.metric-value').classList.add('text-2xl', 'md:text-3xl');
}
});
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Plan Route</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@600;700&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px",
"unit": "4px",
"stack-sm": "8px",
"stack-lg": "48px",
"stack-md": "24px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
],
"label-caps": ["JetBrains Mono"],
"display-telemetry-mobile": ["JetBrains Mono"],
"display-telemetry": ["JetBrains Mono"]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }],
"display-telemetry-mobile": ["48px", { "lineHeight": "48px", "fontWeight": "700" }],
"display-telemetry": ["64px", { "lineHeight": "64px", "letterSpacing": "-0.04em", "fontWeight": "700" }]
}
},
},
}
</script>
<style>
.map-grid-overlay {
background-image:
linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
.pulse-ring {
animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0.7);
}
70% {
transform: scale(1);
opacity: 0;
box-shadow: 0 0 0 20px rgba(170, 199, 255, 0);
}
100% {
transform: scale(0.8);
opacity: 0;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0);
}
}
.route-line {
stroke-dasharray: 10, 10;
animation: dash 20s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -1000;
}
}
.tooltip-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden relative selection:bg-primary-container selection:text-on-primary-container antialiased flex flex-col dark">
<!-- Fullscreen Map Canvas -->
<main class="flex-grow relative h-full w-full z-0 overflow-hidden">
<!-- Base Map Layer -->
<div class="absolute inset-0 z-0 bg-surface-container-lowest">
<img class="w-full h-full object-cover opacity-60 mix-blend-screen invert" data-alt="A dark mode digital map showing a top-down view of city streets and terrain. The map is rendered in dark grey and black tones with subtle lighter outlines for roads." data-location="San Francisco, CA" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA-oqjvzL6bUKZVJD2vvMu-0LPsY-GmGnGrfOmA0Pv5d3jBrTEuOXQ2lhZUXb9SvIGOnr2Wz64OSx0LGVbxce3zkTseW9Q62p9djWl96s-LaFizAtCBJ7okbDdDDBZVyS8RutWkvabaIwEi_lE9yq26OZuanhZ7NS6Ny4AZx69Sah-DvoE1nzwD2AEEpWJXbfX_KRFa3iGbCUa02sKaAUWzoirpWg5yU6Gk6u2OWNRefC6YtASxYmBq"/>
</div>
<!-- Technical Grid Overlay -->
<div class="absolute inset-0 map-grid-overlay z-10 pointer-events-none"></div>
<!-- Planned Route Overlay (SVG) -->
<svg class="absolute inset-0 w-full h-full z-20 pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 1000 1000">
<!-- Path connecting dropped pins - Primary Blue -->
<path class="route-line opacity-80" d="M 300 700 Q 400 650 450 500 T 700 300" fill="none" stroke="#aac7ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="6"></path>
<!-- Waypoint 1 (Dropped Pin) -->
<circle cx="450" cy="500" fill="#aac7ff" r="6" stroke="#131313" stroke-width="2"></circle>
<!-- Waypoint 2 (Destination) -->
<circle cx="700" cy="300" fill="#131313" r="8" stroke="#aac7ff" stroke-width="3"></circle>
</svg>
<!-- User Location Marker (Pulsing) -->
<div class="absolute top-[70%] left-[30%] z-30 transform -translate-x-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none">
<div class="absolute w-4 h-4 bg-primary rounded-full opacity-30"></div>
<div class="absolute w-3 h-3 bg-primary rounded-full pulse-ring"></div>
<div class="w-2 h-2 bg-primary rounded-full relative z-10 shadow-[0_0_10px_rgba(170,199,255,0.8)]"></div>
<!-- Vision cone indicator -->
<div class="absolute -top-12 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-t-[40px] border-t-primary/20 origin-bottom transform rotate-45"></div>
</div>
<!-- Interactive Map Canvas Layer (Invisible, captures clicks for dropping pins) -->
<div class="absolute inset-0 z-40 cursor-crosshair" id="map-interaction-layer"></div>
</main>
<!-- Floating HUD Elements Layer -->
<div class="absolute inset-0 z-50 pointer-events-none flex flex-col justify-end">
<!-- Tooltip -->
<div class="w-full flex justify-center mb-stack-md pointer-events-none px-container-margin">
<div class="tooltip-float bg-surface-container-high/90 backdrop-blur-xl border border-outline-variant/30 rounded-full px-6 py-3 flex items-center gap-2 shadow-sm">
<span class="material-symbols-outlined text-primary text-xl" style="font-variation-settings: 'FILL' 1;">location_on</span>
<span class="font-label-caps text-label-caps text-on-surface">Tap to drop a pin</span>
</div>
</div>
<!-- Bottom Navigation Bar -->
<nav class="bg-surface-container/90 backdrop-blur-xl border-t border-outline-variant/20 w-full pb-safe flex justify-around items-center h-20 px-container-margin pointer-events-auto shrink-0 relative shadow-[0_-4px_20px_rgba(0,0,0,0.5)]">
<!-- Map (Inactive) -->
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150">map</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest">Map</span>
</button>
<!-- Rides (Inactive) -->
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150">directions_bike</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest">Rides</span>
</button>
<!-- Plan (Active) -->
<button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:bottom-2 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary transition-colors duration-200 w-16 h-full gap-1 group">
<span class="material-symbols-outlined text-2xl group-active:scale-95 transition-transform duration-150" style="font-variation-settings: 'FILL' 1;">route</span>
<span class="font-label-caps text-[10px] uppercase tracking-widest text-primary">Plan</span>
</button>
</nav>
</div>
<!-- Micro-interaction Script for Pin Dropping Simulation -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const mapLayer = document.getElementById('map-interaction-layer');
const svgLayer = document.querySelector('svg');
let pinCount = 0;
mapLayer.addEventListener('click', (e) => {
const rect = mapLayer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Visual feedback of drop
const ripple = document.createElement('div');
ripple.className = 'absolute rounded-full border-2 border-primary bg-primary/20 pointer-events-none transform -translate-x-1/2 -translate-y-1/2';
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.style.width = '0px';
ripple.style.height = '0px';
ripple.style.opacity = '1';
ripple.style.transition = 'all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)';
mapLayer.parentElement.appendChild(ripple);
// Trigger animation
requestAnimationFrame(() => {
ripple.style.width = '60px';
ripple.style.height = '60px';
ripple.style.opacity = '0';
});
// Clean up ripple
setTimeout(() => ripple.remove(), 500);
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Rides</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@400;600;700&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px"
},
"fontFamily": {
"headline-md": [
"Inter"
],
"label-sm": [
"Inter"
],
"body-lg": [
"Inter"
],
"body-md": [
"Inter"
],
"headline-sm": [
"Inter"
],
"headline-lg": [
"Inter"
],
"headline-lg-mobile": [
"Inter"
],
"label-md": [
"Inter"
]
},
"fontSize": {
"headline-md": [
"24px",
{
"lineHeight": "32px",
"fontWeight": "600"
}
],
"label-sm": [
"11px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
],
"body-lg": [
"16px",
{
"lineHeight": "24px",
"fontWeight": "400"
}
],
"body-md": [
"14px",
{
"lineHeight": "20px",
"fontWeight": "400"
}
],
"headline-sm": [
"20px",
{
"lineHeight": "28px",
"fontWeight": "600"
}
],
"headline-lg": [
"32px",
{
"lineHeight": "40px",
"letterSpacing": "-0.02em",
"fontWeight": "700"
}
],
"headline-lg-mobile": [
"28px",
{
"lineHeight": "36px",
"letterSpacing": "-0.01em",
"fontWeight": "700"
}
],
"label-md": [
"12px",
{
"lineHeight": "16px",
"letterSpacing": "0.5px",
"fontWeight": "500"
}
]
}
},
},
}
</script>
<style type="text/tailwindcss">
@layer utilities {
.glass-panel {
@apply bg-surface/80 backdrop-blur-[20px] border border-outline-variant;
}
}
</style>
</head>
<body class="bg-background text-on-surface min-h-screen pb-[100px]"><div class="fixed inset-0 z-0 bg-surface"><div class="w-full h-full bg-cover bg-center opacity-30 backdrop-blur-md backdrop-blur-xl" data-alt="A light mode stylized city map view for a fitness tracking application." data-location="Tokyo" style='background-image: url("https://lh3.googleusercontent.com/aida-public/AB6AXuDr5_s-KgoDdw3V0jHBFYtHVB3aikGStl0j7TM7kPJnauzs3U1eRnVMaMh4ywSZTMLAo_LxEZnu9rTzBPq-EGev_TiD3t0VY9osDcW0xKKUZxL2rx7TPn0qh2lor7H-TT1FWRur2eVHGsIsu-YT3ht-zwG1VrgIArB7Dxx5IgNThsHCroZ6oFbBB_Uo1Y8rIjPn8NPkxuUVxmKjpNze6RAmNCuYj9V3ivWdnkUNS_Z6ybPWERqbvRA4");'></div><svg class="absolute inset-0 w-full h-full pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 400 800"><path d="M 200 400 C 250 250, 150 150, 300 50" fill="none" opacity="0.3" stroke="#aac7ff" stroke-dasharray="10 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"></path></svg></div>
<!-- TopAppBar -->
<!-- Main Content -->
<main class="px-margin-mobile max-w-4xl mx-auto flex flex-col gap-6 pt-2 relative z-10">
<!-- Search & Filter -->
<div class="flex gap-gutter items-center p-2 rounded-lg border border-outline-variant bg-surface-container-lowest shadow-sm">
<div class="flex-1 relative">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant">search</span>
<input class="w-full bg-surface-container-lowest border-b border-outline-variant focus:border-primary text-on-surface pl-10 pr-4 py-2 font-body-md text-body-md outline-none transition-colors placeholder:text-on-surface-variant/70" placeholder="Search rides..." type="text"/>
</div>
<button class="w-10 h-10 flex items-center justify-center rounded hover:bg-surface-container-highest transition-colors text-primary border border-outline-variant shadow-sm bg-surface-container-lowest">
<span class="material-symbols-outlined">tune</span>
</button>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-gutter mb-2 z-10">
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
<div class="p-4 rounded-lg flex flex-col gap-1 bg-surface-container-lowest border border-outline-variant shadow-sm"><span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant uppercase">Total Dist</span><span class="font-headline-md text-headline-md text-primary">482<span class="text-label-sm tracking-wider ml-1 text-on-surface-variant">KM</span></span></div>
</div>
<!-- Rides List -->
<div class="flex flex-col gap-gutter">
<!-- Ride Item 1 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" data-alt="A dark mode minimalist map view showing a neon green GPS route line traversing through city blocks. High contrast, technical aesthetic, muted dark charcoal background." data-location="San Francisco" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAx1uDTgkMgXUBwI-JLBmH1_3Mw1sk91ovk-19wP19I5wktrsuJ9nM757sCThvGGNFlWi88pEzXj9cB_wlHAhQoTdkoXYp1csaGHd6e_iulV84cPEhZNlj6_M-EWzoEdv7CT-q9BJLlkOBPq9D5IaX6mQk1nqZQDfeR0k535iIYJ2rXJFCyAAgCEF0gKEYyumLsUopkQMD2ig13W01XANoAJPCP--li-S1iln0VfzpqY-vmyHLZsO9H"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Morning Circuit</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 24, 06:30 AM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">42.5 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">1:45:22</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">24.2 KPH</span>
</div>
</div>
</div>
</div>
<!-- Ride Item 2 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity" data-alt="A dark mode minimalist map view showing a neon green GPS route line winding through a mountainous region. High contrast, technical aesthetic, deep black background." data-location="Marin Headlands" src="https://lh3.googleusercontent.com/aida-public/AB6AXuA8dTKlnX4_8dnTgJ0UQpocYH5zvSgIxrAirD88D7z-dcBw6SziTL2p8niEuvn-Q-z1M3AwecI2VoHbFXE70wCOQEymO_yZatnGuP5BkKz-orUNu3Lpmy3ZnXrxYVUOF77CdUnY6WWo098sJre88SDaK82aid7U4mo6vcV5DtxUqQ-H9D0N6RvTzWc0gmev36tcOCKDZqlUsRiHMarZOD6QHiPfL7HzGu_KN1gdDF1n-7G0vLs6CzmA"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Hill Repeats</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 22, 17:15 PM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">28.1 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">1:12:05</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">23.4 KPH</span>
</div>
</div>
</div>
</div>
<!-- Ride Item 3 -->
<div class="rounded-lg flex flex-col md:flex-row overflow-hidden group cursor-pointer hover:border-primary/50 transition-colors opacity-75 bg-surface-container-lowest border border-outline-variant shadow-sm">
<div class="h-32 md:h-auto md:w-48 bg-surface-container-highest relative">
<img class="w-full h-full object-cover opacity-90 group-hover:opacity-100 transition-opacity grayscale group-hover:grayscale-0" data-alt="A dark mode minimalist map view showing a neon green GPS route line along a coastal highway. High contrast, technical aesthetic, muted dark charcoal background." data-location="Highway 1" src="https://lh3.googleusercontent.com/aida-public/AB6AXuAr-gumM8Tc0ayTzCw3s39Z1j0cydRKq7oGECkbJNXERMpAQaP4xrvoVkTVHzGe7mG83pDzxMMZGy9SpTk19uSKlnM5Vjoz3zurdx6AbWLUisqVcuB6CjXS4wt_hJlUK4TfOICbZdPN2UzCkVYUHClJ1jwfeRwA0SFVxWC2gfneSIXny-aaJMfddGQCFtCB7Xjh6hoFigOGUZB-2yjvcHcgwh_u3hZXLDETlfCtPZmyedY1zuZJ9Lft"/>
<div class="absolute inset-0 bg-gradient-to-t from-surface/80 to-transparent md:hidden"></div>
</div>
<div class="p-4 flex flex-col justify-between flex-1 gap-4">
<div>
<h3 class="font-headline-sm text-headline-sm text-on-surface mb-1">Coastal Cruise</h3>
<p class="font-body-md text-body-md text-on-surface-variant">Oct 18, 08:00 AM</p>
</div>
<div class="flex gap-6">
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">DISTANCE</span>
<span class="font-headline-sm text-headline-sm text-primary">85.4 KM</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">TIME</span>
<span class="font-headline-sm text-headline-sm text-primary">3:24:10</span>
</div>
<div class="flex flex-col">
<span class="font-label-sm text-label-sm tracking-wider text-on-surface-variant mb-1 uppercase">AVG SPD</span>
<span class="font-headline-sm text-headline-sm text-primary">25.1 KPH</span>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- BottomNavBar -->
<nav class="fixed bottom-0 w-full z-50 pb-safe bg-surface-container-lowest/80 backdrop-blur-xl border-t border-outline-variant flex justify-around items-center h-20 px-margin-mobile md:hidden"><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">map</span></button><button class="flex flex-col items-center justify-center text-primary relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary after:rounded-full hover:text-primary/80 transition-transform duration-150 scale-95"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">book</span></button><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span></button><button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary/80 transition-transform duration-150"><span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">settings</span></button></nav>
<!-- Desktop Side Nav Overlay (Hidden on Mobile, replaces Bottom Nav visually in a real app, keeping it simple here) -->
<nav class="hidden md:flex fixed left-0 top-16 bottom-0 w-24 bg-surface-container-lowest/80 backdrop-blur-md border-r border-outline-variant flex-col items-center py-6 gap-6 z-40">
<button class="w-12 h-12 flex items-center justify-center rounded hover:bg-surface-container-highest text-on-surface-variant transition-colors group relative">
<span class="material-symbols-outlined">map</span>
<span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Map</span>
</button>
<button class="w-12 h-12 flex items-center justify-center rounded bg-primary-container/50 text-primary transition-colors group relative border border-primary/20"><span class="material-symbols-outlined" style='font-variation-settings: "FILL" 1;'>book</span><span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Journal</span></button>
<button class="w-12 h-12 flex items-center justify-center rounded hover:bg-surface-container-highest text-on-surface-variant transition-colors group relative">
<span class="material-symbols-outlined">route</span>
<span class="absolute left-14 bg-surface-container px-2 py-1 rounded font-label-sm text-label-sm tracking-wider text-on-surface opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">Plan</span>
</button>
</nav>
<style>
@media (min-width: 768px) {
body { padding-left: 96px; pb: 0; }
}
</style>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html class="dark" lang="en" style=""><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>RIPPR - Plan Route</title>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&amp;family=JetBrains+Mono:wght@400;500;700&amp;family=Sora:wght@600;700&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"secondary-fixed-dim": "#aec7f6",
"primary-fixed": "#d6e3ff",
"tertiary-fixed-dim": "#ffb68c",
"inverse-surface": "#e5e2e1",
"on-surface-muted": "#9ca3af",
"error": "#ffb4ab",
"inverse-primary": "#005db8",
"on-primary-fixed-variant": "#00458d",
"secondary": "#aec7f6",
"primary-fixed-dim": "#aac7ff",
"surface-container": "#201f1f",
"primary": "#aac7ff",
"tertiary-container": "#e3711f",
"surface-variant": "#353534",
"on-background": "#e5e2e1",
"on-secondary-fixed-variant": "#2e476f",
"primary-container": "#4090fe",
"outline-variant": "#414753",
"background": "#131313",
"surface": "#131313",
"on-tertiary": "#532200",
"on-tertiary-fixed": "#321200",
"on-surface": "#e5e2e1",
"surface-container-low": "#1c1b1b",
"on-primary-container": "#002958",
"secondary-fixed": "#d6e3ff",
"accent-error": "#ffb4ab",
"on-primary-fixed": "#001b3e",
"surface-container-lowest": "#0e0e0e",
"surface-tint": "#aac7ff",
"error-container": "#93000a",
"secondary-container": "#2e476f",
"surface-main": "#0a0a0c",
"tertiary-fixed": "#ffdbc9",
"surface-container-high": "#2a2a2a",
"on-primary": "#002f64",
"on-surface-bright": "#ffffff",
"on-tertiary-container": "#481d00",
"on-error": "#690005",
"surface-card": "#16161a",
"surface-container-highest": "#353534",
"surface-bright": "#393939",
"on-secondary-container": "#9db6e4",
"on-tertiary-fixed-variant": "#763400",
"on-error-container": "#ffdad6",
"on-secondary-fixed": "#001b3d",
"tertiary": "#ffb68c",
"outline": "#8b919f",
"surface-dim": "#131313",
"on-secondary": "#143057",
"inverse-on-surface": "#313030",
"on-surface-variant": "#c1c6d5"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
},
"spacing": {
"margin-mobile": "16px",
"gutter": "24px",
"margin-desktop": "32px",
"base": "4px",
"max-width": "1280px",
"unit": "4px",
"stack-sm": "8px",
"stack-lg": "48px",
"stack-md": "24px",
"container-margin": "20px"
},
"fontFamily": {
"headline-md": ["Inter"],
"label-sm": ["Inter"],
"body-lg": ["Inter"],
"body-md": ["Inter"],
"headline-sm": ["Inter"],
"headline-lg": ["Inter"],
"headline-lg-mobile": ["Inter"],
"label-md": ["Inter"],
"label-caps": ["JetBrains Mono"]
},
"fontSize": {
"headline-md": ["24px", { "lineHeight": "32px", "fontWeight": "600" }],
"label-sm": ["11px", { "lineHeight": "16px", "letterSpacing": "0.5px", "fontWeight": "500" }],
"body-lg": ["16px", { "lineHeight": "24px", "fontWeight": "400" }],
"body-md": ["14px", { "lineHeight": "20px", "fontWeight": "400" }],
"headline-sm": ["20px", { "lineHeight": "28px", "fontWeight": "600" }],
"headline-lg": ["32px", { "lineHeight": "40px", "letterSpacing": "-0.02em", "fontWeight": "700" }],
"headline-lg-mobile": ["28px", { "lineHeight": "36px", "letterSpacing": "-0.01em", "fontWeight": "700" }],
"label-md": ["12px", { "lineHeight": "16px", "letterSpacing": "0.5px", "fontWeight": "500" }],
"label-caps": ["12px", { "lineHeight": "16px", "letterSpacing": "0.1em", "fontWeight": "500" }]
}
}
}
}
</script>
<style>
.map-grid-overlay {
background-image:
linear-gradient(to right, rgba(255,255,255,0.05) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.05) 1px, transparent 1px);
background-size: 40px 40px;
}
.pulse-ring {
animation: pulse-ring 2s cubic-bezier(0.215, 0.61, 0.355, 1) infinite;
}
@keyframes pulse-ring {
0% {
transform: scale(0.8);
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0.5);
}
70% {
transform: scale(1);
opacity: 0;
box-shadow: 0 0 0 20px rgba(170, 199, 255, 0);
}
100% {
transform: scale(0.8);
opacity: 0;
box-shadow: 0 0 0 0 rgba(170, 199, 255, 0);
}
}
.route-line {
stroke-dasharray: 10, 10;
animation: dash 20s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -1000;
}
}
.tooltip-float {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-8px); }
100% { transform: translateY(0px); }
}
</style>
<style>
body {
min-height: max(884px, 100dvh);
}
</style>
</head>
<body class="bg-background text-on-surface h-screen w-full overflow-hidden relative selection:bg-primary-container selection:text-on-primary-container antialiased flex flex-col">
<!-- Fullscreen Map Canvas -->
<main class="flex-grow relative h-full w-full z-0 overflow-hidden">
<div class="absolute top-4 left-1/2 -translate-x-1/2 z-50 w-auto px-container-margin pointer-events-none mt-4">
<div class="bg-surface/90 backdrop-blur-xl border border-outline-variant/30 rounded-full px-8 py-4 flex items-center gap-8 shadow-sm pointer-events-auto">
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Distance</span>
<span class="font-headline-md text-primary-container">12.4<span class="text-sm ml-1">km</span></span>
</div>
<div class="w-px h-8 bg-outline-variant/30"></div>
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Est. Time</span>
<span class="font-headline-md text-primary-container">45<span class="text-sm ml-1">m</span></span>
</div>
<div class="w-px h-8 bg-outline-variant/30"></div>
<div class="flex flex-col items-center">
<span class="text-on-surface-variant font-headline-md text-[10px] uppercase tracking-widest mb-1">Pins</span>
<span class="font-headline-md text-primary-container">5</span>
</div>
</div>
</div>
<!-- Base Map Layer -->
<div class="absolute inset-0 z-0 bg-surface-container">
<img class="w-full h-full object-cover opacity-30 mix-blend-screen" data-alt="A digital map showing a top-down view of city streets and terrain." data-location="San Francisco, CA" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDtux0iJ6r19x5fCEWn0L1Ai3ziybnKhwYCd-8xyKm8ebAQHTJPdulojF6Pi_31mtsUlhqpI5ejusGg8Rk7y9c20eWK9sqj3uubky70kqSRiHsd37IJzOiRUTYdHCyOg2g7K5uADAg-39nR48gfWmfotW13eKGb1I9OMrc8MJBtj6lD9rtqapC2DG8kLoTz50c_QCt_AVShQyLrft6yW1SShV4mZXP2kwhf4AZsh3bvkzeCQzfIB43E"/>
</div>
<!-- Technical Grid Overlay -->
<div class="absolute inset-0 map-grid-overlay z-10 pointer-events-none"></div>
<!-- Planned Route Overlay (SVG) -->
<svg class="absolute inset-0 w-full h-full z-20 pointer-events-none" preserveaspectratio="xMidYMid slice" viewbox="0 0 1000 1000">
<path class="route-line opacity-80" d="M 400 400 L 450 420 L 500 380 L 550 430 L 600 400" fill="none" stroke="#4090fe" stroke-dasharray="10, 10" stroke-linecap="round" stroke-linejoin="round" stroke-width="4"></path>
<circle cx="400" cy="400" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="450" cy="420" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="500" cy="380" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="550" cy="430" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
<circle cx="600" cy="400" fill="#4090fe" r="6" stroke="#131313" stroke-width="2"></circle>
</svg>
<!-- User Location Marker (Pulsing) -->
<div class="absolute top-[70%] left-[30%] z-30 transform -translate-x-1/2 -translate-y-1/2 flex items-center justify-center pointer-events-none">
<div class="absolute w-4 h-4 bg-primary-container rounded-full opacity-30"></div>
<div class="absolute w-3 h-3 bg-primary-container rounded-full pulse-ring"></div>
<div class="w-2 h-2 bg-primary-container rounded-full relative z-10 shadow-[0_0_10px_rgba(64,144,254,0.5)]"></div>
<!-- Vision cone indicator -->
<div class="absolute -top-12 left-1/2 -translate-x-1/2 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-t-[40px] border-t-primary-container/20 origin-bottom transform rotate-45"></div>
</div>
<!-- Interactive Map Canvas Layer (Invisible, captures clicks for dropping pins) -->
<div class="absolute inset-0 z-40 cursor-crosshair" id="map-interaction-layer"></div>
</main>
<!-- Floating HUD Elements Layer -->
<div class="absolute inset-0 z-50 pointer-events-none flex flex-col justify-end">
<!-- Tooltip -->
<div class="w-full flex justify-center mb-stack-md pointer-events-none px-container-margin">
<button class="pointer-events-auto bg-primary-container text-on-primary-container font-headline-md text-label-caps px-12 py-4 rounded-xl shadow-md uppercase tracking-widest hover:brightness-110 active:scale-95 transition-all">Start Route</button>
</div>
<!-- Bottom Navigation Bar (Generated from JSON) -->
<nav class="bg-surface border-t border-outline-variant/30 w-full pb-safe flex justify-around items-center h-20 px-container-margin pointer-events-auto shrink-0 relative shadow-[0_-4px_20px_rgba(0,0,0,0.2)]">
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined mb-1" style="font-variation-settings: 'FILL' 0;">map</span>
</button>
<button class="flex flex-col items-center justify-center text-primary-container relative after:content-[''] after:absolute after:-bottom-1 after:w-1 after:h-1 after:bg-primary-container after:rounded-full hover:text-primary-container transition-colors duration-150 scale-95">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">book</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 0;">route</span>
</button>
<button class="flex flex-col items-center justify-center text-on-surface-variant hover:text-primary-container transition-colors duration-150">
<span class="material-symbols-outlined mb-1" style="font-variation-settings: 'FILL' 0;">settings</span>
</button>
</nav>
</div>
<!-- Micro-interaction Script for Pin Dropping Simulation -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const mapLayer = document.getElementById('map-interaction-layer');
const svgLayer = document.querySelector('svg');
let pinCount = 0;
mapLayer.addEventListener('click', (e) => {
const rect = mapLayer.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Visual feedback of drop
const ripple = document.createElement('div');
ripple.className = 'absolute rounded-full border-2 border-primary-container bg-primary-container/20 pointer-events-none transform -translate-x-1/2 -translate-y-1/2';
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.style.width = '0px';
ripple.style.height = '0px';
ripple.style.opacity = '1';
ripple.style.transition = 'all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1)';
mapLayer.parentElement.appendChild(ripple);
// Trigger animation
requestAnimationFrame(() => {
ripple.style.width = '60px';
ripple.style.height = '60px';
ripple.style.opacity = '0';
});
// Clean up ripple
setTimeout(() => ripple.remove(), 500);
});
});
</script>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -0,0 +1,89 @@
# iPhone verification — what a Mac can prove, and what needs a phone
Scoped and then executed. Everything in the first table has been **run**; everything in
the second cannot be done without hardware and a rider.
Reproduce with:
```bash
xcrun simctl location booted start --speed=15 --interval=1 \
51.0447,-114.0719 51.0530,-114.0719 51.0610,-114.0800 51.0700,-114.0900 &
( for i in $(seq 1 90); do xcrun simctl privacy booted grant location-always com.rippr.port; sleep 1; done ) &
flutter test integration_test/ride_simulation_test.dart -d <sim-id>
```
---
## The finding that matters most
**The iOS simulator moves, but still reports zero speed.**
`simctl location start --speed=15` genuinely interpolates between waypoints over time —
unlike Android's `adb emu geo fix`, which teleports. That looked like it might finally
make speed testable locally. It does not:
```
RIPPR-PROBE fixes=8 speeds=[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
```
Eight fixes, every one zero. Measured, not assumed — the assumption was worth attacking
and it survived.
The consequence is visible in a real recorded ride:
```
trip=1 points=18 distanceM=245.5 maxSpeedKmh=0.0 movingMs=0 segments=1
```
**245 metres travelled, zero moving time.** Distance comes from consecutive positions, so
it works. Moving time comes from `speedKmh >= 1.5`, so with speed pinned at zero every
sample falls under the noise floor. That is correct behaviour on bad input, and it is
exactly why `REAL-RIDE-CHECKLIST.md` cannot be skipped.
---
## ✅ Verified locally, on a Mac
| What | How | Evidence |
|---|---|---|
| App builds and launches on iOS | `flutter build ios --simulator`, `simctl launch` | Runs |
| The real UI renders | Screenshot of the record screen | Wordmark, SPEED headline, Ready state, 72dp button all correct |
| **The permission dialog and its usage string** | Screenshot | *"Rippr records the GPS track of your ride… Nothing is recorded until you press Start."* — the Guideline 5.1.1 item, confirmed rendering |
| Drift opens against real iOS storage | Integration test | `rippr_db.sqlite` + WAL created in the app container |
| Plugin registration resolves | Integration test | geolocator, path_provider, shared_preferences all load |
| CoreLocation delivers fixes | Integration test | 18 fixes in ~15 s |
| **Distance accumulates from real movement** | Integration test | 245.5 m, latSpan 0.00197° — consistent |
| A trip persists and is listed | Integration test | `completedTrips=1`, Card present |
| Segments are created | Integration test | `segments=1` |
| Trip detail renders, including the map | Integration test | `DISTANCE` found on screen |
| Navigation record → rides → detail | Integration test | All transitions land |
| Cold start with no ride | Integration test | Lands idle, no crash |
| App icon under the iOS mask | Rendered preview | Ring and orange accent survive the superellipse |
## ⛔ Needs a real iPhone
| What | Why a simulator cannot |
|---|---|
| **Max speed, average moving speed** | CoreLocation reports 0.0 — measured above |
| **Moving time** | Derived from speed; reads 00:00:00 locally even after 245 m |
| **Speed colouring on the map** | Every path renders in one colour |
| **Elevation gain** | Simulated altitude has no correlated GPS error; the ~30 m drift question is unanswerable here |
| **Background suspension when stationary (I3)** | The simulator does not model iOS's power-management suspension. **This decides whether `geolocator` is sufficient or the paid engine is needed.** |
| **Screen-lock continuation** | Real background lifecycle only |
| **Phone call / interruption handling** | No telephony |
| **Battery drain** | No meaningful power model |
| **The blue background-location indicator** | Real background mode only |
| **Real GPS accuracy and dropouts** | Simulated fixes are perfect; the accuracy gate never rejects anything |
| **App Store review** | Requires submission |
## 🟡 Gap in local coverage, worth naming
**No screenshot of the iOS trips list or detail screen.** They are verified
*functionally* — the integration test asserts the Card and the `DISTANCE` readout are
present — but not *visually*, because tapping the iOS simulator is not scriptable the way
`adb shell input tap` is, and every fresh install re-prompts for location, which sits over
the UI during the capture window.
Android's equivalents were checked visually. On iOS this is worth a manual look next time
the app is built for a device: launch it, record a short ride by hand, and eyeball the
list, the detail stats and the map.

View File

@@ -0,0 +1,168 @@
# T27 — parity audit
Every row of the native app's v2.0.1 feature table, with the evidence behind each claim.
**Deliberately not ticked in bulk.** A v2 task once had every acceptance criterion checked
by a blanket regex, including items nobody had verified. So each row below states *how* it
is known, and three categories are kept apart:
| | Meaning |
|---|---|
| ✅ **Verified** | An automated test asserts it, or it was demonstrated running on a device |
| 🟡 **Implemented, unproven** | The code is there and reviewed, but nothing has exercised it in the conditions that matter |
| ⛔ **Gap** | Present in the native app, absent here |
**175 automated tests** — 171 unit and widget, 4 integration on a real iOS simulator.
---
## The feature table
### GPS recording via foreground service — ✅ / 🟡
**Verified:** the pipeline has 24 tests covering all five actions, batching, the unbounded
buffer, accuracy filtering, noise-floor sanitisation and process-death resume. The app
launches and runs on an iOS simulator.
**Unproven:** that it records a real ride. No simulator produces velocity, so speed,
moving time and speed colouring are structurally untestable here.
Android liveness moved from a hand-written `TrackingService` to geolocator's own
foreground service (`foregroundServiceType="location"`, `enableWakeLock`), verified
present in the merged manifest. iOS uses `UIBackgroundModes: [location]` with
`allowBackgroundLocationUpdates`.
→ `REAL-RIDE-CHECKLIST.md` items 1–11, A1–A3, I1–I5.
### Trips with pause/resume/stop/discard — ✅
26 repository tests plus 24 engine tests. Every transition idempotent inside a
transaction; `startTrip` adopts rather than duplicating; pausing closes the segment;
discard cascades; a ride that captured nothing is dropped rather than saved.
The pause guarantee has its own test: **a fix buffered before a pause is written with the
old segment id**, because ids are stamped at creation.
### Live speed + elapsed clock — ✅
The 2.0.1 fix is pinned by two widget tests: the headline reads `SPEED`, not `MAX SPEED`,
and a separate test asserts the figure names a colour distinct from the background — the
black-on-black regression that only a screenshot caught in v2.
The clock now ticks **only while a ride is active**, which is both correct and what makes
the screen testable at all.
### Path rendered on OpenStreetMap — ✅ / 🟡
**Verified** by four map tests, which the native map never had: one polyline per segment
with a test asserting none straddles a pause, render-only decimation, the zoom clamp at
OSM's max tile zoom 19, and a `shortRideZoom` fallback for degenerate bounds — the v2.0
empty-grid bug, now guarded.
**Unproven:** speed colouring. Every simulated path renders in one colour because speed is
always zero.
### Ride statistics + charts — ✅
21 statistics tests, and stronger evidence than the native app ever had: the parity
harness drives the real Kotlin and the Dart port from one shared fixture and they agree
**to the last digit**, including the elevation accumulator at `38.959594555022136`.
Charts degrade to a message below two points rather than rendering a blank box, with a
widget test for it.
**Carried over deliberately:** the ~30 m elevation drift against synthetic noise. Fixing
it during translation would have made every differential failure ambiguous. It stays in
the v3 backlog, where the standing instruction is *do not tune this blind*.
### Rename / delete / merge rides — ✅
Repository tests cover merge re-parenting, rejection of self/active/missing merges,
atomicity, and the two properties that matter: **segments are never joined**, and
aggregates are **recomputed rather than summed** because distance is not additive across
the gap. Widget tests cover the UI, including Merge enabling at exactly two selections.
Rename collapses empty and whitespace-only input to null.
### GPX + GeoJSON export — ✅
15 tests parsed with a real XML parser and `jsonDecode`, not substring matching. The
parity harness additionally confirms both formats are **byte-identical** to the Kotlin
output — same length, same FNV hash, including an escaped hostile trip name.
Exports carry the raw stored points; decimation never reaches them.
### Upload to a REST endpoint — ✅ (still no UI, as before)
7 tests: disabled endpoint, success, rejection, network failure, multi-batch backlog,
partial failure, and per-point trip/segment identity. Runs on its own timer so a dead
endpoint cannot disturb recording.
Parity is exact, including the absence of UI.
### Live map during recording — ⛔ by design
Absent in v2, absent here. It is a **v3 decision**, and the backlog records that Dylan has
since asked for it and that handlebar mounting changes the app's founding premise.
### Group ride view — ⛔ by design
Deferred to v3 in the native app; unchanged.
---
## Gaps and departures
### ⛔ Notification actions — the one capability lost
The native notification carried Pause/Resume buttons. geolocator's
`ForegroundNotificationConfig` cannot carry actions, so the notification is display-only;
tapping it opens the app.
This was the price of dropping `flutter_foreground_task`, which bought the removal of two
deprecations that Flutter says become hard build errors. Worth revisiting if the shade
controls turn out to matter on a real ride — a separate notification plugin could add them
back without touching the engine.
### ✅ A departure that fixes a native bug
`restoreAfterProcessDeath` now resumes into a genuinely new segment. The native version
adopts the segment a crash left open, so the authoritative recomputation measures straight
through the dead time — **111 km of phantom distance** in the reproduction. Documented at
length in `PROGRESS.md` and in `TripRepository.resumeIntoNewSegment`.
### Improvements that are not parity items
- **The first UI tests this project has ever had** (15), closing what the v3 backlog names
as v2's largest coverage gap.
- **Instrumented tests became unit tests.** `SchemaTest`, `TripRepositoryTest` and
`MergeTest` were 666 lines needing a booted emulator; they now run in about two seconds
with nothing running.
- **A cross-language parity harness** that can be re-run at any time.
- A **record screen layout bug** fixed that Compose was clipping silently.
---
## Not done, and why
**The bundle id is still `com.rippr.port`.** Switching it to `com.rippr` is the final
cutover step — but doing it now would make the two apps unable to coexist, and
`REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the same ride on both
simultaneously. That comparison is the strongest evidence available that the port is
faithful.
**Switch it after T25, not before.**
Also outstanding before any release: the app icon is still the Flutter default, and every
build so far has been debug.
---
## Verdict
Feature parity is **complete in implementation** and **verified as far as anything can be
without riding**. One capability is lost (notification actions), one native bug is fixed,
and the test coverage is substantially better than the app being replaced.
What remains is not code. It is a rider, two phones, and
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).

View File

@@ -0,0 +1,349 @@
# Rippr — Flutter Port (Android + iOS)
## Context
Rippr is a native Android GPS ride recorder: ~3,800 lines of Kotlin across 34 files,
shipped through v1 and v2.0.1, validated on real rides. It records telemetry from a
foreground service into Room, renders the path on osmdroid, and exports GPX/GeoJSON.
Dylan wants one codebase running on **both iOS and Android** before any further features
are built. This is a **full rewrite in Flutter**, not an incremental or add-to-app
migration — the research in `docs/PORT_RESEARCH.md` puts the cutover threshold at 10
screens and Rippr has **six**, with no legacy debt worth preserving in Kotlin form.
**The goal is parity, not improvement.** Every capability in the v2.0.1 feature table
works on both platforms; v3 features stay in the backlog. Deliberate scope discipline —
the port is finished when it does exactly what the Kotlin app does.
### Three findings that shape this plan
**The pure-logic core ports almost verbatim.** Eight files (~890 lines) carry zero Android
imports — a discipline held deliberately since v1. `Geo`, `RideStatistics`,
`RideAccumulator`, `RideExport`, `Telemetry`, `Format`, `LiveTelemetry`, `UploadStatus`.
Their ~965 lines of existing JVM tests become a **differential oracle**: the same fixtures
must produce the same numbers in Dart. This is the single biggest de-risker available and
Phase 1 exists to cash it in first.
**Whoever owns the writes must own the database.** `TrackingService` writes to Room
directly from its writer loop. If Dart owns the schema but a native service owns the
writes, every fix crosses a platform channel that is *dead while iOS suspends Dart*. So
Dart owns both, and the background layer only has to keep the Dart isolate alive.
**iOS suspends a stationary app; Android does not.** This is the one place where "write
once" is partly an illusion, and it gets its own task and its own real-world validation
rather than being discovered late.
### Decisions taken
| Decision | Choice | Rationale |
|---|---|---|
| Strategy | **Full rewrite** | 6 screens, well below the 10-screen threshold |
| Repo | **`~/dojo/rippr-flutter`**, new git history | Native app stays installable as reference and fallback |
| Existing rides | **Start fresh** | No importer; export to GPX first if any ride matters |
| Location engine | **`geolocator` + `flutter_foreground_task`** | MIT/free; avoids the ~$500/yr Transistor licence |
| Isolate model | **Single isolate** | Foreground service for process liveness only — no second isolate, no cross-isolate SQLite |
| Database | **Drift** | Type-safe, streams, real migrations, runs on the Dart VM |
| State | **Riverpod** | Maps cleanly from ViewModel + StateFlow |
| Routing | **go_router** | Three destinations, same shape as navigation-compose |
| Map | **flutter_map** | OSM raster tiles, no API key — same reasoning that chose osmdroid |
| Live map while recording | **Still no** | Parity target is v2.0.1; it is a v3 decision |
### Non-goals
Every v3 backlog item: live map, waypointing, activity type, accounts, cloud backup,
theming overhaul, group ride. Also no new features of any kind, and no data importer.
---
## Architecture mapping
```
TrackingService (Kotlin) RecordingEngine (Dart, main isolate)
FusedLocationProviderClient ──► geolocator.getPositionStream
Channel<TrackPoint>(UNLIMITED) ──► StreamController (unbounded)
single writer coroutine ──► single async drain loop (batch 25 / 2s)
Mutex ──► package:synchronized Lock
Room + @Transaction ──► Drift + transaction()
Flow<Trip?> ──► Stream<Trip?> (Drift .watch)
PARTIAL_WAKE_LOCK ──► flutter_foreground_task (Android)
START_STICKY + adopt active ──► on-launch adopt of `endedAt IS NULL`
ViewModel + StateFlow ──► Riverpod Notifier / AsyncNotifier
navigation-compose ──► go_router
osmdroid MapView ──► flutter_map
FileProvider + ACTION_SEND ──► share_plus
SharedPreferences ──► shared_preferences
OkHttp ──► package:http
```
**Platform-divergent, by necessity:** Android runs a foreground service with a persistent
notification to keep the process alive. iOS declares `UIBackgroundModes: location` and
sets `allowsBackgroundLocationUpdates` with `pauseLocationUpdatesAutomatically = false`.
Both keep the *same* Dart pipeline running; only the liveness mechanism differs.
**Invariants carried over verbatim** (from `docs/v3/BACKLOG.md` §6 — each has a comment in
the Kotlin explaining why, and each must survive the port):
1. The location callback never blocks on disk — unbounded buffer, single batched writer
2. Points stamped with `tripId`/`segmentId` **at creation**, never looked up at write time
3. Recording state derived from the database, never an in-memory flag
4. No destructive migration — Drift migrations from v1 of the Dart schema onward
5. Decimation is render-only, never reaching storage or export
6. Theme sets a default content colour (Kotlin's `Surface` lesson; Dart's is `DefaultTextStyle`)
7. The map zoom clamp — a short ride must not zoom past the tile server's max
8. Tests use in-memory databases
---
## Phases and tasks
Each task gets `docs/port/NN-slug.md` in the new repo, following the v2 template that
worked well: Goal · Context · Design · Implementation · Acceptance criteria · Tests ·
Risks · Out of scope. Plus a running `docs/port/PROGRESS.md` recording what actually went
wrong — the v2 feedback loop caught real bugs and is worth repeating.
### Phase 0 — Ground clearing *(blocking; nothing else can start)*
| # | Task | Depends |
|---|---|---|
| T00 | Disk space + toolchain | — |
| T01 | Repo scaffold | T00 |
**T00 — This is a genuine blocker, not a formality.** The machine has **16 GiB free at 92%
capacity** and needs, roughly: Flutter SDK + artifacts ~5 GB, an iOS simulator runtime
(**none installed**) ~9 GB, CocoaPods (**not installed**; system Ruby is 2.6.10, so install
via Homebrew, not `gem`), plus the Android emulator's non-negotiable **7.4 GB free-space
floor** and two build trees. That does not fit — v1 hit this same wall and lost real time
to it. Reclaim first (`brew cleanup -s`, Homebrew and Playwright caches, `pip cache purge`,
`go clean -cache`, old Gradle caches, stale AVDs), then install. Xcode 26.0.1 is present.
**Exit criteria:** `flutter doctor -v` clean for both toolchains, an iOS simulator *and*
the Android emulator each boot, and ≥15 GB still free afterwards.
**T01** — `flutter create` with both platforms, bundle/application id `com.rippr`, git
init, initial commit. Add the dependency set. Write `docs/port/` scaffolding and a
`README` pointing back at the native repo's `ARCHITECTURE.md`, `TESTING.md`, and v2
`PROGRESS.md` as the source of truth for *why* things are shaped as they are. Confirm
`flutter test` and a debug build on both platforms before a line of real code.
### Phase 1 — Pure logic *(no platform, no UI, no database)*
Highest value per unit risk, and it builds Dart fluency on code whose correct answers are
already known. Each task ports the Kotlin file **and its existing test suite**.
| # | Task | Ports | Depends |
|---|---|---|---|
| T02 | Geo utilities | `geo/Geo.kt` + `GeoTest` (165 + 210 ln) | T01 |
| T03 | Telemetry + formatting | `Telemetry.kt`, `ui/Format.kt`, `UploadStatus.kt` + `TelemetryTest` | T01 |
| T04 | Ride statistics | `stats/RideStatistics.kt` + `RideStatisticsTest` (302 + 265 ln) | T02, T03 |
| T05 | Live accumulator | `RideAccumulator.kt`, `LiveTelemetry.kt` + `AccumulatorTest` | T04 |
| T06 | Export writers | `export/RideExport.kt` + `RideExportTest` (151 + 211 ln) | T02 |
| T07 | Cross-language parity harness | — | T02–T06 |
**T04 is the hardest-won code in the project.** `ElevationAccumulator` — 15-sample moving
average, reversal hysteresis, `gainIncludingPending()`, and a `finish()` that reconciles
against `lastRaw`. A naive version once reported **1498 m of climbing over a parked bike**.
Port it structurally faithfully; do not "improve" it during translation.
**T07** — Drive identical fixtures through both implementations and assert agreement to
six decimal places: haversine over known pairs, Douglas–Peucker output, elevation gain over
the noisy-stationary fixture, batched-vs-single-batch distance, GPX/GeoJSON byte output.
**Watch for a harness that reports suspiciously identical results** — a v2 sweep returned
four identical values because a quoting bug corrupted the source while the compile error
hid behind `/dev/null`. Never redirect a build to `/dev/null` inside a measurement loop.
### Phase 2 — Data layer
| # | Task | Depends |
|---|---|---|
| T08 | Drift schema | T01 |
| T09 | Trip repository | T08, T05 |
**T08** — Mirror `app/schemas/com.rippr.data.AppDatabase/2.json`: `Trip` (with
`TripState` RECORDING/PAUSED/COMPLETED), `Segment`, `TrackPoint`; FK `CASCADE`, indices on
`tripId`/`segmentId`, WAL. Starts at Dart schema version 1 with real migrations from day
one — the destructive fallback never comes back.
**T09** — Port `TripRepository`: every transition idempotent inside a transaction,
`startTrip` **adopts** an active trip rather than duplicating one, `mergeTrips`
re-parents segments and points without ever joining segments, then recomputes aggregates.
**A quiet win here:** `TripRepositoryTest`, `SchemaTest`, and `MergeTest` (666 lines) are
*instrumented* tests today, needing a device. Against Drift on the Dart VM they become
plain unit tests — faster, and runnable without an emulator booted.
### Phase 3 — Recording engine *(the risky phase)*
| # | Task | Depends |
|---|---|---|
| T10 | Location source seam | T03 |
| T11 | Recording pipeline | T09, T10 |
| T12 | Android foreground service | T11 |
| T13 | iOS background location | T11 |
| T14 | Process-death resume | T11, T12, T13 |
**T10** — A `LocationSource` interface with a `geolocator` implementation and a fake for
tests. This seam is what makes the engine testable without a device, and it is also the
escape hatch: if `geolocator` proves unreliable on a real iOS ride, swapping in
`flutter_background_geolocation` becomes one implementation rather than a rewrite.
**T11** — The heart. Unbounded `StreamController`, single async drain loop batching 25
fixes / 2 s, accumulator folded per batch, aggregates persisted per flush. Five actions:
start / pause / resume / stop / discard. Pause closes the open segment; resume opens a new
one. **Stamp `tripId`/`segmentId` at point creation** — a fix in flight during a pause must
land in the segment it actually belongs to. Stop discards a trip with zero points.
**T12** — `flutter_foreground_task` for process liveness and the persistent notification,
`foregroundServiceType="location"`, wake lock, notification actions. Configured **without**
a separate Dart isolate — the recording loop stays on the main isolate, so there is never
cross-isolate access to one SQLite file.
**T13 — where the platforms genuinely diverge.** `Info.plist` needs
`NSLocationWhenInUseUsageDescription`, `NSLocationAlwaysAndWhenInUseUsageDescription`, and
`UIBackgroundModes: [location]`, with **context-rich** strings — generic ones are the
leading cause of Guideline 5.1.1 rejection. Set `allowsBackgroundLocationUpdates = true`
and `pauseLocationUpdatesAutomatically = false`. Then **document and measure** what
actually happens when the bike stops at a light versus parks for ten minutes; the app must
resume cleanly rather than silently ending a ride.
**T14** — On launch, adopt any trip with `endedAt IS NULL` and restore the accumulator from
the persisted row, matching the Kotlin restart path. Verify by force-killing mid-ride on
both platforms.
### Phase 4 — UI
| # | Task | Ports | Depends |
|---|---|---|---|
| T15 | Shell: router, Riverpod, theme | `RipprNavHost`, `ui/theme/` | T01 |
| T16 | Record screen | `ui/record/` | T11, T15 |
| T17 | Trips list | `ui/trips/` | T09, T15 |
| T18 | Trip detail: stats + charts | `ui/detail/`, `ui/components/Stats.kt` | T04, T15 |
| T19 | Map + path rendering | `ui/components/RideMap.kt` | T02, T18 |
| T20 | Rename / delete / merge | | T17, T18 |
| T21 | Export UI | T06 | T18 |
**T15** — Functional dark + safety orange, matching the icon. The theme must set a default
content colour: in Compose, removing `Surface` once made a 64 sp speed figure render
black-on-black and **no test caught it — only a screenshot did**. Flutter's equivalent
exposure is `DefaultTextStyle`.
**T16** — Headline is **live speed plus a wall-clock elapsed ticker**, not max speed. v2.0
shipped max-speed-as-headline and it read as a frozen, broken screen on a real ride,
because on an emulator every value is zero and a number that never moves looks fine.
Keep the 72 dp glove-sized controls; confirm on Discard only.
**T19** — Per-segment polylines so pauses leave visible gaps, speed-bucketed colouring,
Douglas–Peucker decimation **render-only**, fit-to-bounds followed by a **zoom clamp**: a
50 m ride once zoomed past OSM's max tile zoom of 19 and rendered an empty grid. Set a real
user agent before any tile fetch or OSM returns 403, and cache tiles in app-private
storage. Guard the map's own bounds so it cannot overdraw adjacent controls.
### Phase 5 — Verification
| # | Task | Depends |
|---|---|---|
| T22 | Uploader + config | T09 |
| T23 | Widget tests | Phase 4 |
| T24 | Integration tests, both platforms | Phase 4 |
| T25 | Real-ride validation | T24 |
| T26 | iOS release readiness | T25 |
**T22** — `package:http` uploader with batching, retry, offline-safe backlog via the
`synced` column, carrying `trip_id`/`segment_id` in the payload. `shared_preferences` for
endpoint, `deviceId`, `mapEnabled`. Parity note: this still has **no UI**, exactly as today.
**T23 — closes v2's largest known gap.** Zero UI tests exist across six screens today;
Flutter makes widget tests cheap enough that there is no excuse to carry that debt into the
port. Cover navigation record→trips→detail→back, empty states, chart degradation below two
points, and merge enabled only at exactly two selections.
**T25** — Run the checklist in `docs/TESTING.md` on **both** platforms. Non-negotiable,
because **neither simulator can produce velocity** — `adb emu geo fix` teleports and the
iOS simulator's synthetic locations are no better, so max speed, average moving speed,
moving time, and **speed colouring on the map** are all unverifiable in CI. Also ride
**short and long**: a ~900 m fixture hid the short-ride zoom bug completely.
**T26** — Privacy nutrition labels matching actual runtime behaviour, usage strings
audited, background-location justification ready for review.
### Phase 6 — Cutover
| # | Task | Depends |
|---|---|---|
| T27 | Parity audit and handover | all |
Walk the v2.0.1 feature table row by row and demonstrate each on both platforms. **Do not
tick boxes in bulk** — a v2 task once had every criterion checked by a blanket regex
including items never actually verified. Then: port `ARCHITECTURE.md` with the decisions
that changed, carry `docs/v3/BACKLOG.md` forward, and mark the native repo archived with a
pointer to its replacement.
---
## Dependency graph
```
T00 ─► T01 ─┬─► T02 ─┬─► T04 ─► T05 ─┐
│ └─► T06 ─┐ │
│ T03 ──► T04 │ │
│ └─► T10 │ │
│ │ │
├─► T08 ─► T09 ───┼──────┴─► T11 ─┬─► T12 ─┐
│ │ │ ├─► T13 ─┼─► T14
│ │ │ │ │
└─► T15 ─┬───┼────┼───────────────┘ │
│ │ │ │
T02─┬─► T07 │ │ │ │
│ │ │ │ │
└────────┴───┴────┴─► T16/T17/T18 ─► T19 ─► T20/T21
│
T22 ──────────────────────► T23 ─► T24 ─► T25 ─► T26 ─► T27
```
Critical path: **T00 → T01 → T08 → T09 → T11 → T12/T13 → T14 → T16 → T24 → T25 → T27**.
Phase 1 is almost entirely parallelisable and carries near-zero risk — but per your v2
preference, everything runs **sequentially** so dependent architecture surfaces before it
becomes expensive to change.
---
## Key risks
| Risk | Mitigation |
|---|---|
| **Disk space blocks the toolchain** | T00 gates everything; hard exit criteria before any code |
| **iOS suspends a stationary app mid-ride** | T13 measures it explicitly; T10's seam makes swapping to `flutter_background_geolocation` cheap if free tooling loses rides |
| `geolocator` proves less reliable than FusedLocation | T25 rides both a real Android and a real iOS device before the native app is retired |
| Elevation hysteresis subtly mistranslated | T07 asserts six-decimal agreement against the Kotlin implementation |
| Two isolates racing one SQLite file | Avoided by design — foreground service provides liveness only, recording stays on the main isolate |
| Decimation leaking into storage or export | Render-only, and T06's ported tests assert exact point counts |
| Simulators hide velocity-dependent bugs | Stated up front in T25; **a green suite proves nothing about speed** |
| Silent parity loss | T27 audits the feature table row by row, demonstrated not asserted |
---
## Verification
```bash
flutter analyze && flutter test # pure logic, data layer, widgets
flutter test integration_test -d <android-emulator>
flutter test integration_test -d <ios-simulator>
flutter build apk --debug && flutter build ios --debug --no-codesign
```
Plus the **cross-language parity harness** (T07): identical fixtures through Kotlin and
Dart, agreement asserted to six decimals — the port's strongest single guarantee, and the
reason Phase 1 comes first.
**Definition of done:** every row of the v2.0.1 feature table demonstrated on a real
Android phone *and* a real iPhone, the `docs/TESTING.md` checklist passed on both, and no
capability lost.
---
## Open question, deferred deliberately
The native app's known **elevation drift** (~30 m per ten stationary minutes against
synthetic noise) ports along with the algorithm — faithfully, bug included. That is correct
for a parity port: fixing it during translation would make any differential test failure
ambiguous. It stays in the v3 backlog, where it already says *do not tune this blind*.

View File

@@ -0,0 +1,832 @@
# Port progress log
Running record of what actually happened, task by task — including what went wrong.
The v2 equivalent of this file caught real bugs by making risks explicit before they
were walked into, so the practice carries over.
Plan: [PLAN.md](PLAN.md) · Source of truth for *why*: the native repo's
`docs/ARCHITECTURE.md`, `docs/TESTING.md`, `docs/v2/PROGRESS.md`.
---
## T00 — Disk space + toolchain · **complete**
**Outcome:** `flutter doctor` reports no issues in any category. Flutter 3.47.0 (Dart
3.13.0), Xcode 26.0.1, CocoaPods 1.17.0, Android SDK 36.0.0, iOS 26.0.1 simulator runtime.
### The disk panic was largely a false alarm — but measure twice
Planning measured **16 GiB free at 92%**, which drove a whole cleanup strategy. A second
measurement minutes later, before deleting anything, showed **36 GiB free at 81%**. Most
likely APFS local snapshots aging out.
**Nothing was deleted.** Gradle caches, AVDs, and `~/.cargo` were all left intact.
Post-install the machine sits at ~22 GiB free.
**Lesson:** re-measure immediately before acting on a disk-space number. Had the plan been
followed literally, ~9 GB of still-useful caches would have been destroyed for no reason.
### Things that actually needed fixing
| Problem | Fix |
|---|---|
| `cmdline-tools component is missing` | `sdkmanager --sdk_root=$ANDROID_HOME "cmdline-tools;latest"` — the exact trap already documented in the native repo's `DEVELOPMENT.md`: brew's `sdkmanager` resolves its own SDK root and does not see `~/Library/Android/sdk` unless `--sdk_root` is passed explicitly |
| Android licenses unaccepted | `yes \| sdkmanager --sdk_root=$ANDROID_HOME --licenses` |
| Default JDK is 25, which AGP rejects | `flutter config --jdk-dir=<temurin-21>` — same constraint as the native build, now pinned in Flutter's config rather than relying on an exported `JAVA_HOME` |
| No iOS simulator runtime installed | `xcodebuild -downloadPlatform iOS` (8.05 GB). Note simulator *devices* already existed for a runtime that did not — `simctl list devices` looked populated while `list runtimes` was empty |
| CocoaPods absent, system Ruby 2.6.10 | `brew install cocoapods`, deliberately not `gem install` |
---
## T01 — Repo scaffold · **complete**
`~/dojo/rippr-flutter`, `flutter create --org com.rippr --project-name rippr`.
### Two deliberate deviations from the generated defaults
**Bundle id is `com.rippr.port`, not `com.rippr`.** `--org com.rippr` + name `rippr`
produces `com.rippr.rippr`, which is wrong either way. The choice of `com.rippr.port` is
deliberate and temporary: the plan requires the native app to stay installable as a
reference and fallback, and **two apps cannot share an applicationId**. Keeping them
distinct means both can sit on the same phone — which also enables the strongest possible
validation in T25: record the same ride on both simultaneously and compare the numbers.
> **T27 must switch this to `com.rippr`** at cutover. Recorded here because it is exactly
> the kind of temporary decision that silently becomes permanent.
The Android `namespace` stays `com.rippr` and the Kotlin source was moved from
`kotlin/com/rippr/rippr/` to `kotlin/com/rippr/` to match.
### Builds verified on both platforms
`✓ build/ios/iphonesimulator/Runner.app` and `✓ build/app/outputs/flutter-apk/app-debug.apk`.
Android needed three changes to the generated `build.gradle.kts`:
```kotlin
compileSdk = 37 // a dependency demands it; the build fails outright on 36
minSdk = 26 // parity with the native app (Android 8.0)
targetSdk = 36
```
`sdkmanager` cannot fetch `platforms;android-37` from the stable channel — it reports
"Failed to find package". **AGP installed it automatically** during the build (as
`android-37.0`), along with CMake 3.22.1 and a 2.8 GB NDK, because the licences had
already been accepted. Convenient, but see the disk note below.
### ⚠ `flutter_foreground_task` is on two deprecation paths
Both warnings name the same package — the one chosen for Android background liveness in
T12:
- **iOS:** does not support Swift Package Manager. *"This will become an error in a future
version of Flutter."*
- **Android:** applies the Kotlin Gradle Plugin. *"Future versions of Flutter will fail to
build if your app uses plugins that apply KGP."*
Neither breaks today's build. Both should be re-checked at T12, and they strengthen the
case for the `LocationSource` seam in T10 — the background layer needs to stay swappable.
### The disk problem was real, just not where the plan predicted
The plan braced for SDK *installs* filling the disk. The actual consumption was **builds**:
free space fell from 22 GiB to **3.9 GiB** during the first Android build — Gradle caches
grew 3.7 → 8.0 GB, the NDK added 2.8 GB, and `build/` alone reached 2.7 GB.
Recovery, in order of how safe each step was:
| Action | Reclaimed |
|---|---|
| Delete `build/` + `flutter clean` + the native app's `app/build` | ~3.8 GiB |
| Prune Gradle caches for versions **no project uses** (9.5.0, 9.7.0), `build-cache-1`, and the native project's 8.11.1 distribution | ~3 GiB |
Ended at **11 GiB free (94% used)**. `modules-2` (1.9 GB) was deliberately **kept** —
deleting it forces a re-download of every dependency, which is a real time cost for space
we do not currently need. Only two Gradle versions are actually in use: 9.3.1 (this
project) and 8.11.1 (the native app, whose distribution cache re-downloads on demand).
**Standing risk for T24:** the Android emulator needs a fixed 7.4 GiB free to boot. At 11
GiB that works, but one more full build cycle could eat the margin. Run `flutter clean`
before booting the emulator.
### Dependencies
`flutter_riverpod` · `go_router` · `drift` + `drift_flutter` · `path_provider` ·
`geolocator` · `flutter_foreground_task` · `permission_handler` · `flutter_map` +
`latlong2` · `share_plus` · `shared_preferences` · `http` · `synchronized`.
Dev: `drift_dev`, `build_runner`, `mocktail`, `integration_test`.
**`sqlite3_flutter_libs` resolves to an `+eol`-tagged release (0.6.0+eol).** It was added
explicitly at first, then removed — `drift_flutter` depends on it transitively regardless,
so the pin belongs to drift, not to us. Worth watching when drift next majors, but not
actionable now.
---
## T02 — Geo utilities · **complete**
`lib/src/geo/geo.dart` + `test/geo_test.dart`. **23/23 passing.**
Ported structurally faithfully from `com.rippr.geo.Geo`: haversine (with the
`asin(sqrt(a))` conditioning note), iterative Douglas–Peucker with an explicit stack,
equirectangular perpendicular distance with clamped projection, null-on-empty bounds,
path length. Every test case and tolerance carried over unchanged, including the
Calgary–Edmonton 280.9 km figure that was corrected during v2 after the *test* proved
wrong rather than the code.
**Deliberate API divergence:** Kotlin's `object Geo` namespace became top-level functions,
which is idiomatic Dart. `LatLon` is our own type rather than `latlong2`'s `LatLng` — the
pure layer must not depend on the map package; conversion happens at the render boundary.
### Two things went wrong
**`library;` after the import.** Dart requires the library directive before all other
directives. Caught immediately by the compiler — noted only because a file-level doc
comment is otherwise easy to attach wrongly.
**A hang that was not a hang.** `flutter test` and `flutter build ios` were run
concurrently and both sat at 0% CPU for minutes. The suspicion was Rosetta, because
`flutter_tester` lives under `artifacts/engine/darwin-x64/` — **that was wrong**: the
binary there is arm64 and the directory name is legacy. The real cause was
`ibtool`/`actool` spawning `IBAgent-iOS` and `AssetCatalogSimulatorAgent`, which deadlock
against a booted simulator. Run alone with simulators shut down, the same suite finishes
in under a second.
Two lessons, both echoing v2's harness troubles:
- **Do not run an iOS build against a booted simulator** if anything else needs it.
- **A killed background job still reports exit code 0.** Both jobs "completed
successfully" *because they were killed*. Never read a success code from a process you
terminated — re-run it cleanly.
---
## T03 — Telemetry, formatting, ephemeral state · **complete**
`telemetry.dart` (msToKmh, sanitizeSpeedKmh, isUsableFix, formatDuration, encodeBatch),
`ui/format.dart`, `telemetry/live_telemetry.dart`. **8 tests.**
Also added `domain/models.dart` — `Trip`, `Segment`, `TrackPoint`, `TripState`,
`RideStats` as plain Dart with **no persistence dependency**. Drift will map *to* these
in T08 rather than the domain depending on the database. This is the Dart equivalent of
the discipline that made the Kotlin logic testable on the JVM.
**Kotlin `Float` becomes Dart `double`.** Dart has no float32. Widening is the right
call — a shim would be friction for sub-millimetre precision on GPS-derived values — but
it means speed-derived values cannot be compared bit-for-bit. See T07 for the measured
consequence.
**`Format` builds its `DateFormat` per call**, unlike the Kotlin original which captured
`Locale.getDefault()` once at class-init. Doing the same in Dart would freeze the format
for the process lifetime and ignore a locale change.
---
## T04 — Ride statistics · **complete**
`stats/ride_statistics.dart` including `ElevationAccumulator`. **21 tests.**
Ported structurally faithfully — moving average, reversal hysteresis,
`gainIncludingPending()`, and the `finish()` reconciliation against `lastRaw`. Nothing
was "improved" during translation, per the plan.
### The failure that proved the port correct
The ported elevation test failed: **50.86 m against Kotlin's 35 m bound.** That looks
exactly like a porting bug in the hardest code in the project.
It was not. The two languages' `Random(42)` are different streams. Rather than tune the
bound blind — which `docs/v3/BACKLOG.md` explicitly warns against — the question was
settled by building the T07 harness early and driving **both implementations from one
shared LCG**:
```
noisy_gain = 38.959594555022136 ← Kotlin
noisy_gain = 38.959594555022136 ← Dart
```
Bit-identical. The port is exact.
**This also found something about the native app.** On the shared fixture the algorithm
yields ~39 m, which would **fail Kotlin's own 35 m bound**. The native test passes on
seed luck, not on a property of the algorithm. A sweep of 25 Dart seeds spanned
24.7–46.7 m (median 36). The Dart test now uses the shared LCG, asserts bit-equality
with Kotlin, and sets its bound from measured behaviour with headroom.
> Worth carrying back to the native repo if it is ever revived: that guard is weaker
> than it looks.
---
## T05 — Live accumulator · **complete**
`recording/ride_accumulator.dart`. **12 tests, green on the first run.**
The cross-batch anchor is intact — `distance across many small batches matches one big
batch` is the guard, and its absence under-reports distance by a few percent invisibly.
---
## T06 — Export writers · **complete**
`export/ride_export.dart`. **15 tests, green on the first run.** Parsed with a real XML
parser and `jsonDecode`, not substring matching, exactly as the Kotlin suite did.
---
## T07 — Cross-language parity harness · **complete**
`tool/parity/` — `run.sh`, `main.kt` (Kotlin oracle), `probe.dart`. Run it with
`JAVA_HOME` set to a 17–21 JDK; needs `kotlinc` (`brew install kotlin`, ~95 MB).
It copies `Geo.kt`, `RideStatistics.kt` and `RideExport.kt` **verbatim** from the native
repo and compiles them against minimal stand-ins for the Room-annotated holders and one
`Telemetry` constant. The files under test are never reimplemented.
### Result
Every key byte-identical across 20 fixtures, including:
| | |
|---|---|
| `noisy_gain` | `38.959594555022136` — the elevation accumulator, to the last digit |
| `simplify_count` | 218 of 21,600 points, identical Douglas–Peucker decisions |
| `gpx_len` / `gpx_fnv` | GPX output byte-identical, including the escaped hostile name |
| `geojson_len` / `geojson_fnv` | GeoJSON byte-identical |
**The single accepted difference** is `run_avg_speed`: `40.030228` (Kotlin `Float`) vs
`40.03022888407912` (Dart `double`) — precisely the divergence predicted in T03. The
harness prints this explanation on failure so a future reader is not left guessing.
### Two traps hit while building it
**`String.hashCode` is not comparable across languages.** The first version compared
Java's and Dart's hashes of the GPX output — which would have "failed" forever for no
reason. Replaced with an FNV-1a implemented identically in both.
**A missing jar silently passed as success.** When `RideExport.kt` was not yet copied,
compilation failed, `java -jar` errored, and the pipeline continued. `run.sh` now checks
for the jar and exits non-zero. This is the same class of bug as the v2 sweep that
returned four identical results because a compile error hid behind `/dev/null` — the
comment in `run.sh` says so explicitly.
---
## Phase 1 complete
**79 tests passing, `flutter analyze` clean.** ~890 lines of Kotlin logic ported, with
its ~965 lines of tests, and proven equivalent rather than assumed equivalent.
Next: **T08 (Drift schema)**, the first task that touches persistence.
---
## T08 — Drift schema · **complete**
`lib/src/data/database.dart` (+ generated `database.g.dart`). **16 tests.**
Mirrors `app/schemas/com.rippr.data.AppDatabase/2.json`: three tables, CASCADE foreign
keys, indices on `tripId` / `segmentId` / `synced`, WAL with `synchronous = NORMAL`.
Starts at Dart schema version 1 with a real `MigrationStrategy` — the destructive
fallback never comes back.
### Foreign keys are OFF by default in SQLite
Room switched them on for us. **Drift does not.** Without `PRAGMA foreign_keys = ON` in
`beforeOpen`, every `CASCADE` in the schema is decorative and deleting a trip silently
orphans all of its points. There is now a test that reads the pragma back and asserts it
is `1`, because this is invisible until data is already wrong.
### Two collisions worth recording
**Drift generates row classes named after the table.** `Trips` → `Trip`, colliding with
the domain model of the same name and producing 21 confusing analyzer errors of the form
*"Trip can't be assigned to Trip"*. Fixed with `@DataClassName('TripRow')` etc. The
mapping functions `_toTrip` / `_toSegment` / `_toPoint` convert row → domain, so the
domain layer stays unaware Drift exists.
**Drift snake_cases column names.** `speedKmh` became `speed_kmh`, which broke the one
raw-SQL query (the live stats aggregate) with `no such column: speedKmh`. Rather than 30
`.named()` annotations, `build.yaml` sets `case_from_dart_to_sql: preserve`. That keeps
the schema column-for-column identical to Room's, lets the raw SQL stay byte-identical to
the Kotlin DAO query it was ported from, and leaves a Room-file importer possible later.
### The instrumented-to-unit win, realised
`SchemaTest` needed a device and an emulator. The Drift equivalent runs on the Dart VM in
well under a second with nothing booted. `TripRepositoryTest` and `MergeTest` (441 more
lines) should convert the same way in T09.
**95 tests passing, analyze clean.**
---
## T09 — Trip repository · **complete**
`lib/src/data/trip_repository.dart`. **26 tests, green on the first run.**
Every lifecycle transition ported: `startTrip` (adopting, never duplicating), `pauseTrip`,
`resumeTrip`, `completeTrip`, `discardTrip`, `renameTrip`, `deleteTrip`, `mergeTrips`,
`recomputeAggregates`. Each runs inside `_db.transaction` and each is idempotent, because
the platform can restart the recorder from any state.
`Room.withTransaction` maps onto Drift's `transaction()` almost exactly, so this was the
most mechanical port so far.
### What the tests protect
- **Adoption over rejection.** `startTrip` on an already-active trip returns the *same*
handle rather than opening a second trip — the behaviour that makes a process kill
survivable.
- **Merge never joins segments.** The boundary between two merged rides stays a segment
boundary, exactly like a pause. The regression test puts the two rides a degree of
latitude apart and asserts the ~111 km gap never reaches `distanceM`.
- **Aggregates are recomputed, not summed** — because distance is not additive across
that gap.
- **Rename collapses empty and whitespace-only input to null**, so a stored `""` can
never diverge from the UI's date-label branch.
- **Merge rejects** self-merge, an active trip, and a missing id, and leaves no orphans.
### One piece of speculative code removed
A `mergeTableUpdates` helper was written to nudge Drift's stream queries after
re-parenting, then deleted before commit: Drift's own `update()` already notifies
dependent streams, so it earned nothing and would have been misleading scaffolding.
### The instrumented-to-unit win, totalled
`TripRepositoryTest` + `MergeTest` + `SchemaTest` were **666 lines of instrumented tests
requiring a booted emulator**. All three are now plain unit tests finishing in about two
seconds with nothing running.
---
## Phase 2 complete
**121 tests passing, `flutter analyze` clean.** The data layer is done and the domain,
statistics and export layers above it are proven equivalent to the Kotlin original.
Next: **Phase 3, the recording engine** — the risky phase. T10's `LocationSource` seam
first, then the pipeline, then the two platform liveness stories. The
`flutter_foreground_task` deprecation warnings recorded under T01 become relevant at T12.
---
## T10 — Location source seam · **complete**
`lib/src/recording/location_source.dart`: `LocationFix`, `LocationSource`,
`LocationException`, and `FakeLocationSource`.
`LocationFix` is deliberately **not** `TrackPoint` — a fix has no trip or segment
identity. Those ids are stamped on by the engine at creation, which is the whole basis of
the pause guarantee.
### `geolocator` can replace `flutter_foreground_task` entirely
`geolocator_android` ships `ForegroundNotificationConfig`, which raises a foreground
service with `foregroundServiceType=location` for as long as the position stream is
active, and exposes `enableWakeLock` and `setOngoing`. That is **everything**
`TrackingService` used a foreground service and a `PARTIAL_WAKE_LOCK` for.
Dropping `flutter_foreground_task` would remove both deprecation paths recorded under
T01 (no Swift Package Manager on iOS; applies KGP on Android) at no cost to the design.
**One parity casualty:** geolocator's notification config has no support for *actions*,
so the notification would be display-only — the native app's Pause/Resume buttons in the
shade would be lost. Decision deferred to T12, where it actually bites. The seam means
neither choice touches the engine.
---
## T11 — Recording pipeline · **complete**
`lib/src/recording/recording_engine.dart`. **24 tests.**
The Kotlin `Channel(UNLIMITED)` + blocking-`receive` writer coroutine becomes a plain
`List` buffer plus a periodic `Timer`. Dart has no blocking receive and its single
threaded event loop makes one unnecessary — the guarantee is unchanged: the fix callback
only appends and returns, so disk latency can never stall GPS. `Mutex` becomes
`synchronized`'s `Lock`, serialising the periodic flush against explicit drains at pause,
stop and discard.
All five actions ported: start (adopting), pause, resume (via start), stop, discard, plus
`restoreAfterProcessDeath`.
---
## 🐞 A real bug found in the native app
**`TrackingService.restoreAfterProcessDeath` does not do what its comment says.**
```kotlin
// Resume into a *new* segment: the time the process was dead is a real
// gap in the recording and should render as one.
trips.resumeTrip(System.currentTimeMillis())?.let { handle ->
```
`resumeTrip` calls `adoptOrOpenSegment`, which returns the **existing open segment** if
there is one. After a *pause* that is correct, because pausing closes the segment first.
After a *crash* nothing closed it — so the adopt branch wins and the stated intent is
silently not met.
### The consequence is data corruption, not cosmetics
Points either side of the dead time land in one segment. `RideStatistics.compute` groups
by segment, and it is the **authoritative** pass that overwrites the live estimate when
the trip completes. So the gap gets measured as if it had been ridden.
Measured, by reverting the fix and running the guard:
```
the dead time leaked into distance: 111217.31924957958 m
```
**111 km of phantom distance** added to a ride because the process died and the rider
relaunched somewhere else. The map would also draw a straight line across roads never
ridden — exactly the artefact segments exist to prevent.
The live accumulator gets this right (it is re-seeded with a null anchor). The
authoritative recomputation then overwrites the correct figure with the wrong one.
### The fix
`TripRepository.resumeIntoNewSegment` closes the stale segment and opens a fresh one.
The stale segment is closed **at its last recorded point**, not at `now` — recording
genuinely stopped when the process died, and `computeSummary` sums closed segment spans
for elapsed time, so closing at `now` would bill the dead time as ride time.
This is a deliberate, documented **departure from parity**. The plan says port bugs
faithfully, and that holds for the elevation drift where a "fix" would make differential
testing ambiguous. It does not hold here: the code contradicts its own stated intent and
the result is silently wrong data.
> Worth carrying back to the native app if it is ever revived. Second finding of its kind
> after the seed-lucky elevation bound.
### And a near-miss worth recording
The first version of the guard **passed with the bug still present**. The fixture called
`pause()` to flush points to disk — but pausing *closes* the segment, so the crash state
was never reproduced. It was only caught by deliberately reverting the fix and checking
the test failed.
The rewritten fixture writes points through the repository directly, leaving the segment
open exactly as a crash does. It now fails at 111 km with the native behaviour and passes
with the fix.
**A regression test nobody has watched fail is not yet a regression test.**
**145 tests passing, analyze clean.**
---
## T12 / T13 — Platform liveness · **implemented, unvalidated on a real ride**
`lib/src/recording/geolocator_location_source.dart`, plus the Android manifest and iOS
`Info.plist`. This is the **only** file in the app that knows the two platforms differ.
### `flutter_foreground_task` dropped
Dylan's call, on the T10 finding. `geolocator_android`'s `ForegroundNotificationConfig`
raises a service with `foregroundServiceType="location"` and holds a wake lock for as
long as the position stream is subscribed. The plugin declares its own
`GeolocatorLocationService` in its manifest, which merges automatically — nothing to
declare ourselves. Verified in the merged manifest.
**Both deprecation warnings recorded under T01 are now gone from the build output.**
> **⚠ Parity gap for T27:** geolocator's notification cannot carry *actions*, so the
> native app's Pause/Resume buttons in the notification shade are not reproduced. Tapping
> the notification opens the app. This is the only known capability lost so far.
### Android
Permissions mirror the native manifest. **`ACCESS_BACKGROUND_LOCATION` is deliberately
not requested** — recording runs under a foreground service with a visible notification,
which is exactly what `foregroundServiceType="location"` is for. Asking for background
location would trigger the harder "Allow all the time" flow for no benefit.
### iOS — two settings that matter more than they look
```dart
pauseLocationUpdatesAutomatically: false // CoreLocation does not reliably restart
activityType: ActivityType.otherNavigation // NOT automotiveNavigation
```
`automotiveNavigation` makes CoreLocation **snap fixes to the road network**. For a
navigation app that is a feature; for a recorder whose entire purpose is the path actually
travelled, it silently falsifies the data. `otherNavigation` is the correct choice for a
motorcycle.
`UIBackgroundModes: [location]` plus `allowBackgroundLocationUpdates` keeps the process —
and therefore the Dart isolate and the writer timer — alive while updates flow. Usage
strings are written specifically rather than generically, since vague text is the leading
cause of Guideline 5.1.1 rejection.
**None of this is validated.** Whether iOS suspends a stationary app mid-ride is
answerable only by riding. It belongs to T25.
---
## T14 — Process-death resume · **complete**
Implemented in `RecordingEngine.restoreAfterProcessDeath` and called from a post-frame
callback at startup. Covered by four tests, including the 111 km guard documented under
T11. The remaining acceptance criterion — force-killing mid-ride on real hardware —
belongs with T25.
---
## The app runs
Built and launched on the iOS simulator. Drift opened against app-private storage,
Riverpod resolved the graph, `restoreAfterProcessDeath` correctly found no active trip,
and the controls were in the right state (START enabled, PAUSE/STOP disabled while idle).
`lib/main.dart` is a deliberate **harness**, not the real UI — it exists so the pipeline
can be exercised on hardware before Phase 4 builds any screens, because the pipeline is
precisely the part unit tests and emulators cannot validate. T15 replaces it.
---
## ⚠ Disk: the real environmental constraint
Today's arc: **36 GiB free → 355 MiB** at the worst point, with roughly 7 GiB reclaimed
along the way and still ending at 6.6 GiB.
A full dual-platform build cycle costs roughly **10 GiB** — Gradle caches, a 2.8 GB NDK,
CocoaPods, DerivedData, `build/`, and the installed simulator app. It is reclaimable, but
it is not optional space.
Standing footprint: `/opt/homebrew` 17 GB, `~/Library/Android` 7.5 GB, Flutter SDK 3.9 GB,
iOS runtime ~8 GB.
**Before T24 boots the Android emulator** (fixed 7.4 GiB free required):
```bash
flutter clean && rm -rf ios/Pods ~/Library/Developer/Xcode/DerivedData/*
```
Realistically this machine needs headroom freed outside the dev tooling before Phase 5.
---
## Phase 3 complete
**145 tests passing, analyze clean, both platforms building and the app running on iOS.**
Next: **Phase 4, the UI** — six screens, and the first widget tests this project has ever
had.
---
## Phase 4 — UI · **complete**
**T15** shell (theme, `go_router`, shared components) · **T16** record screen · **T17**
trips list · **T18** trip detail with stats and charts · **T19** map · **T20**
rename/delete/merge · **T21** export · **T23** widget tests, brought forward.
**164 tests passing, analyze clean.**
### The theme's load-bearing detail, made structural
Compose's `Surface` set `LocalContentColor`; removing it once made a 64 sp speed figure
render black-on-black, and **no test caught it — only a screenshot did**. The Dart theme
sets `bodyColor`/`displayColor` on `TextTheme` explicitly rather than relying on a
wrapping widget, so the failure cannot recur by someone deleting a container. `BigStat`
also names its colour directly, and a widget test asserts that colour differs from the
ground.
### The widget tests immediately earned their keep
**A real layout bug, first run:** with a ride active the record screen grows to six stat
rows and `RenderFlex overflowed by 20 pixels`. Compose *clips this silently*, so the same
bug may well be latent in the native app and simply invisible. Fixed by making the screen
scrollable while still centring when there is room — which matters more here than usual,
because 72 dp glove-sized controls make the content genuinely tall.
### Two Flutter-testing traps, both costly
**`pumpAndSettle` never settles against a repeating timer.** The elapsed clock ticks every
second, so the first widget-test run sat at the framework's 10-minute timeout — for
*every* test. Two fixes: the ticker now runs **only while a ride is active** (better
behaviour regardless — an idle screen has no clock to advance), and tests that do have a
live ride use explicit `pump()` calls.
**`flutter_test` asserts no `Timer` is pending after disposal**, which Drift trips: it
keeps a stream query alive briefly after its last listener leaves so re-subscribing is
cheap. Tests now run through a `screenTest` wrapper that removes the tree and pumps past
that window. Run time went from *timeout* to **two seconds**.
> And again: a killed background job reports exit code 0. Twice during this phase a
> "completed" run had actually been terminated. Always re-read the log.
### Map
`flutter_map`, same OSM raster tiles and same no-API-key reasoning that chose osmdroid.
All four load-bearing behaviours carried over and now **tested**, which the native app's
map never was:
- one polyline per segment, so a pause is a visible gap — the test puts two segments a
degree apart and asserts no polyline straddles it
- decimation **render-only**; a test asserts vertices drop while endpoints survive
- the **zoom clamp** at OSM's max tile zoom of 19, plus a `shortRideZoom` fallback for
degenerate bounds — this is the v2.0 empty-grid bug, now pinned by a test
- a real user agent, or the tile servers return 403
Speed colouring is bucketed into one polyline per run rather than per-vertex paint —
`PolyChromaticPaintList` was fiddly in osmdroid and flutter_map has no equivalent either.
**Still unvalidatable:** no simulator produces velocity, so every path renders in one
colour until a real ride.
### Export
`share_plus` replaces `FileProvider` + `ACTION_SEND`, and handles the iOS popover anchor
an iPad needs. Files are written to the temporary directory — they are a transfer
artefact, not storage. The export deliberately passes the **raw stored points**, never
the map's decimated path.
---
## Remaining
Phases 0–4 are complete. What is left is verification and cutover:
- **T22** uploader + config (the last piece of parity; still no UI, exactly as today)
- **T24** integration tests on both platforms — needs the emulator, so check disk first
- **T25** the real-ride checklist on **both** platforms. Nothing above substitutes for it:
neither simulator produces velocity, so max speed, moving time and speed colouring are
all still unverified.
- **T26** iOS release readiness · **T27** parity audit, including switching the
applicationId from `com.rippr.port` back to `com.rippr`
**Known parity gap so far:** notification actions (Pause/Resume in the shade), lost with
`flutter_foreground_task`.
---
## Phase 5 — Verification
### T22 — Uploader and config · **complete**
`lib/src/telemetry/telemetry_uploader.dart` and `lib/src/config/config.dart`. **7 tests.**
`package:http` with a `MockClient` replaces OkHttp with MockWebServer, and the tests run
against a real in-memory Drift database rather than a fake DAO — closer to production and
still no device. Batching, retry, the offline-safe backlog via `synced`, and per-point
trip/segment identity all carried over.
Upload runs on **its own timer** in the engine, injected as a callback so the engine has
no opinion about HTTP and tests need no network. Every failure path is swallowed: nothing
about uploading may disturb recording.
`Config` uses `shared_preferences`, with a hand-rolled UUID v4 rather than a package for
sixteen bytes. `mapEnabled` now comes from preferences instead of being hardcoded.
**Parity note:** there is still **no UI** for the endpoint, exactly as in the native app.
> Two `prefer_initializing_formals` lints are suppressed with a reason: Dart does not
> permit a named parameter whose name begins with an underscore, so the lint's suggested
> fix does not compile.
### T23 — Widget tests · **complete** (delivered in Phase 4)
### T24 — Integration tests · **complete**
`integration_test/app_test.dart`. **4 tests, passing on the iOS simulator.**
These cover what widget tests structurally cannot: Drift opening against real platform
storage, plugin registration resolving, `go_router` driving a real Navigator, and a cold
start surviving. The database test in particular is meaningful — an in-memory Drift
instance cannot prove the sqlite3 native library loaded on the device.
```
flutter test integration_test -d <device-id> → 00:50 +4: All tests passed!
```
**Not yet run on the Android emulator**, which needs 7.4 GiB free to boot; run
`flutter clean` first.
### T25 — Real ride · **outstanding, and it is the important one**
Written up as [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md): eleven shared checks, three
Android-only, five iOS-only.
Three things in it are worth calling out:
- **A2 (Android):** force-stop mid-ride and confirm the dead time is *not* added to
distance — the native bug found in T11, measured at 111 km.
- **I3 (iOS):** park for fifteen minutes mid-recording and see whether recording resumes.
**This decides the platform strategy.** If `geolocator` loses rides to suspension, the
`LocationSource` seam exists so `flutter_background_geolocation` (~$500/yr) can replace
it as one new implementation.
- **Record the Android ride on both apps at once.** They have different application ids
precisely so they can coexist, and a direct numeric comparison is far stronger evidence
than either app alone.
### T26 — iOS release readiness · **configured, not submitted**
[RELEASE-IOS.md](RELEASE-IOS.md). Usage strings are written specifically rather than
generically (the leading 5.1.1 rejection cause), privacy-label answers are decided, and
the pre-submission list covers the bundle-id switch, the still-default app icon, a release
build, and a demo video for the review notes.
---
## Phase 5 status
**T22, T23, T24 complete. T25 and T26 need a real device and a real rider.**
`175 tests passing` — 171 unit and widget, plus 4 integration on device. Analyze clean.
> Corrected: an earlier summary in this file said 178. 171 + 4 is 175.
---
## T27 — Parity audit and handover · **complete, except the cutover step**
[PARITY-AUDIT.md](PARITY-AUDIT.md) walks every row of the v2.0.1 feature table with the
evidence behind each claim, keeping three categories strictly apart: **verified** (a test
asserts it, or it ran on a device), **implemented but unproven**, and **gap**.
Deliberately not ticked in bulk — a v2 task once had every criterion checked by a blanket
regex including unverified items, and that lesson is written into the audit's preamble.
### Documentation carried forward
- `docs/ARCHITECTURE.md` — the durable reasoning, with a table of what changed and why,
the Float→double divergence, and the two iOS settings that are easy to get wrong
- `README.md` — status, the parity harness, and the two native bugs this port found
- `docs/BACKLOG.md` — carried over with a header noting the two items whose status
changed (UI tests are no longer a gap; elevation can now be checked against Kotlin)
- The native repo's `README.md` now opens with a pointer here, and records both bugs
All internal documentation links verified to resolve.
### The cutover step is deliberately NOT done
**The application id stays `com.rippr.port`.**
Switching it to `com.rippr` is the last act of the port, but doing it now would stop the
two apps coexisting — and `REAL-RIDE-CHECKLIST.md` item **A3** depends on recording the
same ride on both simultaneously. That side-by-side comparison is the strongest evidence
available that the port is faithful, and it is worth more than finishing the checklist
tidily.
The native repo is therefore marked **superseded, not archived**: it is still the only
version that has recorded a real ride.
### An arithmetic correction
An earlier entry in this file said 178 tests. The real figure is **175** — 171 unit and
widget, plus 4 integration. Corrected in place.
---
## The port is complete
| | |
|---|---|
| **175 tests** | 171 unit and widget, 4 integration on a real iOS simulator |
| **~4,600 lines** of Dart | plus 3,000 generated by Drift |
| **Both platforms build** | and the app runs on an iOS simulator |
| **Analyze clean** | throughout |
| **Parity proven, not assumed** | `tool/parity/run.sh` diffs against the real Kotlin |
Two bugs found in the native app. One capability lost (notification actions). The first UI
tests the project has ever had.
**What remains is not code.** It is a rider, two phones, and
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).
---
## iPhone verification pass
Scoped and executed: [IOS-VERIFICATION.md](IOS-VERIFICATION.md).
**An assumption was attacked and survived.** `simctl location start --speed=15`
interpolates between waypoints over time — unlike Android's teleporting `geo fix` — which
looked like it might make speed testable locally at last. A probe captured 8 fixes, every
one reporting **0.0 m/s**. So "no simulator produces velocity" holds on iOS too, and the
real-ride checklist remains mandatory.
A full ride was recorded on the simulator through the real UI:
```
trip=1 points=18 distanceM=245.5 maxSpeedKmh=0.0 movingMs=0 segments=1
```
245 metres of travel with **zero moving time** — correct behaviour given speed is pinned
at zero, and a compact illustration of exactly what local testing cannot reach.
Also confirmed visually on iOS: the record screen renders correctly, and the location
permission dialog displays the specific usage string written for Guideline 5.1.1.
`integration_test/ride_simulation_test.dart` is kept — it is a reusable local
verification, not a throwaway.
**Named gap:** no screenshot of the iOS trips list or detail screen. Both are verified
functionally, but tapping the iOS simulator is not scriptable the way `adb shell input
tap` is, and a fresh install re-prompts for location over the UI. Android's equivalents
were checked visually.

View File

@@ -0,0 +1,100 @@
# T25 — the real-ride checklist
**This is the only task in the plan that cannot be automated, and it is the one that
matters most.** 171 unit and widget tests plus 4 integration tests are green, and none of
them prove the app records a ride correctly.
Adapted from the native repo's `docs/TESTING.md`, extended for two platforms.
---
## Why a green suite proves nothing here
**Neither simulator produces velocity.** `adb emu geo fix` teleports the device and iOS's
simulated locations are no better, so every recorded `speedKmh` is `0.0`. That makes the
following **structurally unverifiable** without riding:
- max speed, average moving speed
- moving time (everything sits under the 1.5 km/h noise floor, so it stays 00:00:00)
- speed colouring on the map — the whole path renders in one colour
- elevation gain against real, *correlated* GPS altitude error
- battery over a multi-hour ride
- whether iOS suspends a stationary app mid-ride
And the subtler trap, which cost v2.0 a release: **when a value cannot change under test,
the UI around it cannot be judged either.** Max speed as the headline looked perfectly
fine on an emulator where every number was zero. On a real ride it read as a frozen,
broken screen.
---
## Before you ride
Install both apps. They have different application ids on purpose
(`com.rippr` and `com.rippr.port`), so they coexist:
```bash
flutter build apk --debug && flutter install # Flutter port
adb install -r ~/dojo/samplez/rippr-2.0.1-debug.apk # native reference
```
**Run both simultaneously on the Android ride.** Recording the same ride twice gives a
direct numeric comparison, which is far stronger evidence than either app alone.
---
## The ride
Do this once on **Android** and once on **iOS**. Pocket the phone — that is the founding
use case.
| # | Check | Why it is here |
|---|---|---|
| 1 | Start, pocket, ride ~20 min, stop | The actual usage pattern |
| 2 | Max speed plausible against the speedometer | Unverifiable on any simulator |
| 3 | Distance plausible against the odometer | Guards the cross-batch anchor |
| 4 | Moving time excludes stops | Noise floor behaviour on real data |
| 5 | **Elevation gain near zero on flat ground** | The most likely silent bug; synthetic noise is uniform, real error is correlated |
| 6 | Pause at a stop, resume — **no straight line across the gap** | The segment guarantee |
| 7 | Speed colouring visibly varies along the path | Cannot render in more than one colour on a simulator |
| 8 | **A short ride (under 100 m) still shows streets** | The v2.0 zoom bug |
| 9 | GPX opens correctly in Google Earth or Strava | Schema-valid is not the same as accepted |
| 10 | Battery drain over a multi-hour ride | Never measured, on either app |
| 11 | Pause/resume survives a screen-off stretch | Android wake lock, iOS background mode |
### Android only
| # | Check |
|---|---|
| A1 | The ongoing notification appears and persists with the screen off |
| A2 | Force-stop the app mid-ride, relaunch — the ride resumes into a **new segment**, and the dead time is **not** added to distance |
| A3 | Compare totals against the native app recording the same ride |
> **A2 is the fix for a real bug found in the native app.** See T11 in
> [PROGRESS.md](PROGRESS.md): the native version measures straight through the dead time,
> which a synthetic test showed adding **111 km**. Confirm the port does not.
### iOS only — the genuine unknown
| # | Check |
|---|---|
| I1 | The blue background-location indicator appears while recording |
| I2 | Lock the screen for 10 minutes of riding — fixes keep arriving |
| I3 | **Park for 15 minutes without stopping the recording, then ride again.** Does recording resume? |
| I4 | Take a phone call mid-ride; recording survives |
| I5 | Swipe the app away mid-ride — what happens? Document it, whatever it is |
**I3 is the decisive test for the whole platform strategy.** If iOS suspends the app when
stationary and does not reliably resume, `geolocator` is not sufficient and the
`LocationSource` seam exists precisely so `flutter_background_geolocation` (~$500/yr) can
be swapped in as one new implementation. Do not make that call without this data.
---
## Recording the results
Append findings to [PROGRESS.md](PROGRESS.md) under a T25 heading, including the numbers
from both apps where Android was recorded twice. If elevation gain on flat ground is
implausible, **do not tune it blind** — the native backlog says so explicitly, and the
port's parity harness (`tool/parity/run.sh`) means any change can be checked against the
Kotlin implementation first.

View File

@@ -0,0 +1,78 @@
# T26 — iOS release readiness
What App Review will look at, and what is already in place. Background-location apps get
more scrutiny than most, and the research in `docs/PORT_RESEARCH.md` names unclear
privacy disclosure as the leading rejection cause.
**Status: configured, not submitted.** Nothing here has been through review.
---
## Guideline 5.1.1 — data privacy and transparency
**Rejection cause:** generic usage strings like *"This app needs location"*.
Already in `ios/Runner/Info.plist`, written specifically:
| Key | Says |
|---|---|
| `NSLocationWhenInUseUsageDescription` | Records the GPS track of your ride so you can see route, speed and distance afterwards. **Nothing is recorded until you press Start.** |
| `NSLocationAlwaysAndWhenInUseUsageDescription` | Keeps recording while the phone is in your pocket or the screen is off, so a ride you started is captured beginning to end. **Recording stops the moment you press Stop.** |
Both name what is collected, why, and when it stops. That last clause matters — it is the
difference between "an app that wants your location" and "a recorder you control".
## App Privacy nutrition labels
Declare, and make sure it stays true:
- **Location → Precise Location**, linked to the user? **No.** Used for **App
Functionality** only.
- **No data collected for tracking**, no advertising identifiers, no analytics SDKs.
- **Data is not transmitted off-device by default.** The uploader exists but has no UI and
no endpoint configured; it is inert unless someone sets one deliberately.
> If a server ever ships (the v3 group-ride idea), these labels must change **before** it
> does. Undisclosed background transmission is a straightforward rejection.
## Guideline 2.1 — completeness and background stability
The exposure is crashing on background resume. Mitigations in place:
- Recording state lives in the database, so resuming after suspension reads real state
rather than guessing.
- `restoreAfterProcessDeath` runs at startup and is covered by four tests, including the
crash-gap guard.
- Every upload failure path is swallowed; the network cannot stop a recording.
- Database write failures are caught per batch — losing points beats losing the app.
**Still unproven:** what iOS actually does when the app is suspended while stationary.
That is item **I3** in [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md) and it must be
answered before submission.
## Guideline 4.2 — minimum functionality
Not a realistic risk: native Flutter UI, real hardware integration, offline-first storage,
no web view anywhere.
---
## Before submitting
- [ ] **Switch the bundle id** from `com.rippr.port` to `com.rippr` (T27). The `.port`
suffix exists only so the native app can be installed alongside during the port.
- [ ] `CFBundleName` is still the generated lowercase `rippr`; `CFBundleDisplayName` is
already `Rippr`. Make them consistent.
- [ ] App icon and launch screen — still Flutter defaults. The native app's adaptive icon
artwork lives in `~/dojo/rippr/design/` and needs re-exporting at iOS sizes.
- [ ] Run [REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md) on a real iPhone, especially I3.
- [ ] Record a demo video of a real ride for the review notes. Background-location apps
are frequently asked to justify the entitlement; a video pre-empts a rejection round.
- [ ] `flutter build ipa --release` and confirm the release build works — everything so
far has been debug.
## Known parity gap to disclose internally
The notification cannot carry actions, so the native app's Pause/Resume buttons in the
shade are absent on both platforms. Not an App Review issue; it is a feature difference
the T27 audit must record.

View File

@@ -0,0 +1,112 @@
# UI redesign tickets
Same shape as `docs/v3/`: one file per feature, Goal · Context · Design · Implementation ·
Acceptance criteria · Tests · Risks · Out of scope. Written before implementing.
Source material: `docs/design/stitch-export/` — four Stitch-generated screens (Map HUD,
Plan, Route Planning, Rides History) plus the "Modern Professional Dark" design system
actually used by all four. See that directory's `README.md` for how they were fetched and
a correction of an earlier mislabeling.
This is a **navigation-model change**, not a reskin: today's app is a stack rooted at
Record, with Trips/Settings/Routes as pushed children. The new design is a persistent
4-tab shell (Map / Rides / Plan / Settings) with the map visible, at some opacity, behind
every tab — not just the Map tab. Sequenced so the riskiest, highest-blast-radius piece
(the shell) lands first and `flutter test` stays green throughout, the same discipline
v3 held to.
## The tickets
| # | Ticket | Size | Depends on | Status |
|---|---|---|---|---|
| [UI-01](UI-01-tab-shell-background-map.md) | Persistent tab shell with an always-visible background map | L | — | Done |
| [UI-02](UI-02-offline-skeleton-map.md) | Offline / no-connection skeleton map | S | UI-01 | Done |
| [UI-03](UI-03-glass-component-kit.md) | Shared floating-glass component kit | M | UI-08 | Done |
| [UI-04](UI-04-customizable-hud-widgets.md) | Customizable HUD telemetry widgets (drag, resize, visibility toggle) | L | UI-03 | Done |
| [UI-05](UI-05-map-hud-record-screen.md) | Map HUD: Record screen redesign | M | UI-01, UI-03, UI-04 | Done |
| [UI-06](UI-06-plan-and-route-planning.md) | Plan & Route Planning redesign | M | UI-01, UI-03 | Done |
| [UI-07](UI-07-rides-history.md) | Rides History redesign | M | UI-01, UI-03 | Done |
| [UI-08](UI-08-theme-modern-professional-dark.md) | Theme migration to Modern Professional Dark | S | — | Done |
| [UI-09](UI-09-monochrome-dark-map-tiles.md) | Monochrome dark map tiles | S | — | Done |
## Dependencies
```
UI-01 ──┬──► UI-02
├──► UI-05
├──► UI-06
└──► UI-07
UI-08 ──► UI-03 ──┬──► UI-04 ──► UI-05
├──► UI-06
└──► UI-07
UI-09 ──┬──► UI-05
├──► UI-06
└──► UI-07
no dependencies: UI-01 · UI-08 · UI-09
```
## Suggested order
1. **UI-01** — the shell. Highest blast radius (touches every screen's entry point), so
get it merged and green before any visual work starts.
2. **UI-08** — theme. Every screen ticket after this needs the palette settled once, not
patched per-screen. Supersedes V3-16's safety-orange direction — see that ticket's
note.
3. **UI-09** — dark map tiles. Small, no dependencies, and every screen with a map looks
wrong without it regardless of how correct the HUD chrome on top is.
4. **UI-02** — skeleton map. Small, and exercises the background-map plumbing UI-01 just
built while it's fresh.
5. **UI-03** — the component kit (`GlassPanel`, `FloatingPill`, `PulsingLocationMarker`).
Build once, consumed by every screen ticket.
6. **UI-04** — the customizable HUD widget system (drag/resize/persist layout, a
visibility toggle in Settings). This is its own ticket, not folded into UI-05, because
drag-and-resize-with-persisted-layout is a genuinely separate piece of engineering
from "redesign the record screen" — a small widget-layout editor in miniature.
7. **UI-05 / UI-06 / UI-07** — the three screen redesigns, in any order once everything
above is done. Each is independently shippable.
## Things named explicitly by the person who commissioned this, not inferred from the mockups
- **The map is active on every tab**, including Rides History — blurred/dimmed behind
the content cards so it doesn't compete for attention, not hidden. This wasn't visible
in the Stitch export (Rides History's HTML uses a static blurred background image, not
a live map) — it's a deliberate product decision layered on top of the mockups.
- **No header, anywhere, ever** — the stated reason is screen real estate: the point of
the redesign is to show as much map as possible, and a persistent bottom nav plus zero
header chrome is what buys that space back.
- **A no-connection skeleton map** (UI-02), rather than a blank or broken map, since the
map is now a permanent background presence on every tab rather than something opened
deliberately.
- **The telemetry HUD widgets are user-customizable** (UI-04): draggable and resizable by
the rider, and which metrics show up live at all is a Settings toggle. The Stitch
mockup's tap-to-expand interaction is a fixed layout with one temporary focus state;
this goes further — the rider chooses the layout and which stats exist on screen at
all, and it persists.
- **The map itself is monochrome dark** (UI-09) — today's default OSM tan/cream tiles are
nothing like the mockups regardless of how correct the HUD chrome on top is. A real
tile-provider decision, not a color filter thrown on as an afterthought — see that
ticket for the trade-off against just filtering OSM's existing tiles.
## The Map screen is the design north star
The four Stitch exports don't agree with each other on styling — expected, since each
was a separate prompt and the AI generation drifted between them. **Map HUD is the
canonical reference** for color usage, icon choice/fill-state, spacing, and component
pattern across the whole redesign. Where another screen's mockup disagrees with Map
HUD on any of those, Map HUD wins.
Plan and Route Planning are correct **in principle** — fullscreen map, floating pill
summaries, pin-drop interaction, no header — but their specific styling (icon fill
states, exact nav bar treatment, some color choices) drifted from Map HUD across
prompts and should be restyled to match it, not copied as-is. UI-06 says this again at
the point where it matters.
## What the mockups got generated inconsistently — don't copy as intentional
- Bottom nav shows 4 icons on Map HUD and Rides History, only 3 (no Settings) on the Plan
screen. Build one nav bar with 4 destinations, always.
- Rides History's summary row is 4 copies of the same "Total Dist" card. Real, distinct
summary stats are UI-06's job to define.
- Route Planning's bottom nav highlights "Rides" as active while the screen shown is
Plan — a generation slip, not a deliberate cross-tab indicator.

View File

@@ -0,0 +1,182 @@
# UI-01 — Persistent tab shell with an always-visible background map
**Depends on** nothing · **Size** L · **Status** Done
## Goal
Replace the current push/pop stack rooted at Record with a persistent 4-tab shell (Map,
Rides, Plan, Settings), and make the map visible — at reduced opacity where it isn't the
primary content — behind every one of those tabs, not just Map.
## Context
Today's navigation (`router.dart`) is a hub-and-spoke stack: Record is `/`, and
Trips/Settings/Routes are pushed children you back out of with a `TextButton`/`AppBar`
back arrow. The Stitch redesign is a different model entirely: four co-equal,
always-reachable destinations behind a persistent bottom nav bar, each with its own
independent navigation stack (Trip Detail pushes *within* the Rides tab; the route
planner pushes *within* the Plan tab).
Layered on top of the mockups, by explicit instruction: **the map is active on every
tab**, including Rides History, dimmed/blurred behind the tab's actual content so it
doesn't pull focus. None of the four Stitch exports show this for Rides History (its HTML
uses a static blurred background image) — this is a deliberate product decision, not
something to infer from the mockups alone.
**Also by explicit instruction: no header, on any tab, ever.** The stated reason is
screen real estate — the whole point of this redesign is showing as much map as
possible, and a header is exactly the chrome a persistent bottom nav is meant to let you
remove. Every Stitch export already has an empty `<!-- TopAppBar -->` comment confirming
this; treat it as intentional, not an oversight.
## Design
**Navigation:** `go_router`'s `StatefulShellRoute.indexedStack` (or `.builder`) with four
branches — Map (`/`), Rides (`/rides`), Plan (`/plan`), Settings (`/settings`) — each
branch keeps its own navigator so pushing Trip Detail from Rides, or the route planner
from Plan, doesn't disturb the other tabs' state or the active tab index.
**Background map:** one persistent, live `RideMap`/`FlutterMap` instance sitting behind
the `IndexedStack`'s current branch, not four separate map instances. Rebuilding a real
`FlutterMap` on every tab switch would refetch tiles and lose camera position; a single
shared instance, with only its *content* (opacity, overlay, current position) varying by
tab, is both cheaper and matches "the map is always there, tabs are what floats on top of
it."
- **Map tab:** full opacity, the actual recording/HUD content.
- **Rides / Plan / Settings tabs:** dimmed (roughly 30-40% overlay per the Stitch
export's `opacity-30`/`opacity-40` treatment) and non-interactive — taps pass through
to the tab's real content, the map is decoration, not a control surface, on these tabs.
**No header:** each tab's `Scaffold` has no `appBar`. Screen identity comes from content
(a title inside the tab's own top content, if needed) or from the active nav item, never
from a persistent bar.
## Implementation
1. `AppShell` widget: `StatefulShellRoute` with 4 branches, wrapping a persistent
`BottomNavBar` (see UI-03 for the styled version; a plain one is fine to start) and the
shared background map behind an `IndexedStack`.
2. Extract the background-map hosting into its own widget (`PersistentMapBackground` or
similar) that every tab's `Scaffold` composes against, rather than four independent
`RideMap` constructions.
3. Move `RecordScreen`'s existing map-in-a-card logic to *become* the Map tab's full-
opacity state of this shared background, rather than a separate widget tree — UI-04
does the visual rework, this ticket only has to make the plumbing possible.
4. Remove `AppBar`s from every top-level tab screen. Settings' existing back button goes
away too — Settings becomes a tab, not a pushed screen, so there's nothing to back out
of.
5. Update `router.dart`'s `Routes` constants and every `context.push`/`context.pop` call
site that assumed the old flat stack.
## Acceptance criteria
- [ ] Four tabs are reachable from a persistent bottom nav bar on every screen
- [ ] Switching tabs does not rebuild/refetch the map (camera position survives a tab
switch)
- [ ] The map is visible, dimmed, behind Rides, Plan, and Settings — not just Map
- [ ] No `AppBar`/header exists on any of the four top-level tab screens
- [ ] Trip Detail still pushes within the Rides tab (back returns to the Rides list, not
to the Map tab)
- [ ] The route planner still pushes within the Plan tab
- [ ] Existing widget tests are updated to pump the shell rather than a bare screen, and
still pass
## Tests
- Widget: all four tabs are reachable and their content renders
- Widget: pushing Trip Detail from Rides and popping returns to Rides, not to Map
- Widget: the background map widget instance is not recreated on a tab switch (e.g.
assert identity/key stability, or that camera state is preserved)
- Widget: no `AppBar` is found on any of the four tab roots
## Risks
- **This is the highest-blast-radius ticket in the set** — it touches every screen's
entry point. Land it alone, get `flutter test` fully green, before starting any visual
ticket on top of it.
- A shared background map instance behind an `IndexedStack` is easy to get wrong in a way
that either rebuilds the map on every tab switch (defeating the point) or keeps it
alive so aggressively that it never tears down while the app is backgrounded — revisit
V3-04's lifecycle-aware `TileLayer` teardown to make sure it still applies correctly
once the map is shared across tabs rather than owned by one screen.
## Out of scope
The skeleton/offline map state (UI-02). Any visual redesign of what's inside a tab
(UI-04/05/06) — this ticket only has to make the shell and shared background map exist,
correctly, with today's screen content still working inside it.
## Outcome
Shipped as designed: `lib/src/ui/router.dart` now builds a single `StatefulShellRoute
.indexedStack` with four branches (Map `/`, Rides `/rides`, Plan `/plan`, Settings
`/settings`), each with Trip Detail / the route planner nested as a child `GoRoute`
inside its own branch so pushing/popping there never disturbs the other tabs or the
active tab index. `lib/src/ui/app_shell.dart` is new: `AppShell` is a thin go_router
adapter around `ShellScaffold`, a plain-parameter (`currentIndex`/`onDestinationSelected`
/`child`) `ConsumerWidget` that owns the one shared `RideMap` instance, the dimming scrim,
and the bottom `NavigationBar`. Splitting the two was necessary for testability —
go_router only ever constructs a real `StatefulNavigationShell` itself, so widget tests
drive `ShellScaffold` directly with a plain `int` instead.
Every top-level tab screen (`RecordScreen`, `TripsScreen`, `RoutesListScreen`,
`SettingsScreen`) had its `AppBar`/back-button header removed and its `Scaffold`
background set to transparent so the shared map shows through. `RecordScreen` also lost
its per-screen `onOpenTrips`/`onOpenSettings`/`onOpenRoutes` navigation callbacks and its
embedded `_LiveMap` card entirely — navigation is the shell's nav bar now, and the map is
the shell's permanent background rather than something each screen constructs for
itself. One deliberate temporary trade: the mounted-mode toggle's only remaining button
was on `RecordScreen`'s removed header; its sole access point until UI-05 restyles the
Record screen is now Settings' existing `mounted-mode-switch`.
**Bug found and fixed during this ticket, not anticipated by the plan:** `RideMap`
returned a structurally different widget tree for empty vs. non-empty points in its
`showEmptyLabel: false` (background) mode — a bare `FlutterMap` vs. `ClipRRect >
FlutterMap`. Once the map became a long-lived shell background instead of a
freshly-mounted per-screen widget, this became visible: the moment a live ride's first
point arrived, Flutter unmounted/remounted the differing subtree mid-flight, and
`RideMap`'s own `didUpdateWidget`-scheduled `_controller.camera` post-frame callback fired
against the stale element, throwing "Looking up a deactivated widget's ancestor is
unsafe." Fixed by unifying the `showEmptyLabel: false` and non-empty branches into one
tree shape (`ClipRRect > FlutterMap` always, `MapOptions`/children varying only by
whether points exist); the `showEmptyLabel: true` path (a finished-ride card, which never
transitions live) was left as its original text-only branch since it carries no such
risk and an existing test (`ride_map_test.dart`) already asserted no `FlutterMap` renders
there.
**Tests:** `flutter analyze` clean (pre-existing `deprecated_member_use` infos only,
unrelated to this ticket). `flutter test` green at 315 tests (was 316 before this ticket;
net -1 after removing two now-meaningless per-screen entry-point tests and adding one
shell-nav-bar test — see below). Two tests referencing the removed
`onOpenSettings`/`onOpenRoutes` callbacks were deleted outright (the concept no longer
exists); a new "shell nav bar" group in `test/widget_test.dart` asserts all four
`NavigationDestination`s are present and that tapping each calls back with the right
index. The two V3-04 background-map tests were rewritten to pump `ShellScaffold` and
assert on `shell-background-map`/`TileLayer` instead of the old per-screen `live-map` key
— both pass, including the lifecycle-driven tile-drop/restore test that exposed the bug
above.
**Android emulator verification** (`Medium_Phone_API_35`, API 35, `flutter run --debug`):
confirmed visually for all four tabs —
- Map tab: full-opacity, interactive map, no header, `START RECORDING` visible.
- Rides/Plan/Settings tabs: same map dimmed via the `0xB3000000` scrim, non-interactive,
no header, each tab's real content on top.
- Started a live recording (with `adb emu geo fix` supplying a location) and switched
tabs repeatedly: the point count and elapsed timer kept advancing across tab switches
(11 → 31 → 41 points, uninterrupted) and the camera position did not reset — confirming
the shared instance survives tab switches rather than being torn down and recreated.
- Backgrounding via the real HOME key (not just the synthetic lifecycle message the
widget test uses) dropped the map to a flat, untiled gray — the same lifecycle-based
tile-teardown from V3-04 firing correctly in the shell context, not just in tests.
- One visual red herring investigated and ruled out: a flat gray rectangle appeared
transiently in the dimmed background on the Rides/Plan tabs. Ruled out as an app bug by
reproducing it identically on two different tab screens at the same absolute screen
position, and by confirming a fresh app launch never shows it — it is flutter_map's
normal "tile chunk not yet loaded" placeholder at the low zoom level the background
map starts at before any location fix narrows it, not a shell defect.
- Known pre-existing rough edge, not a regression: the background map's follow-mode
moves the camera center to the latest point but does not adjust zoom on the first fix,
so a freshly-started ride's background map can look zoomed far out until the user
manually zooms. This behavior predates UI-01 (the same `_controller.move(point, current
Zoom)` call existed in the old standalone `_LiveMap`) and is out of scope here.
Deferred, not a gap: a widget test asserting Trip Detail/route-planner push-and-pop stays
within its own tab (rather than affecting `currentIndex`) was not written — the
`StatefulShellBranch`-per-tab nesting in `router.dart` structurally guarantees this via
go_router's own navigator-per-branch semantics, and it was verified manually on-device
that `router.dart`'s nested `GoRoute`s compile and resolve correctly. A future ticket
touching Rides/Plan navigation should add explicit coverage rather than relying on this
note.

View File

@@ -0,0 +1,148 @@
# UI-02 — Offline / no-connection skeleton map
**Depends on** UI-01 · **Size** S · **Status** Done
## Goal
When the map has no tiles to show — no cached tiles for the current view and no network
to fetch fresh ones — show an animated skeleton in place of a broken or blank map, on
every tab, since UI-01 makes the map a permanent background presence rather than
something a rider opts into per-screen.
## Context
By explicit instruction: *"If we do not have connection for the map, we should just show
an animated skeleton map so it doesn't look weird."* Once UI-01 ships, the map is always
on screen somewhere — a blank grey rectangle or a grid of broken-image icons behind every
tab, all the time, on a phone with no signal, is a much worse look than it was when the
map only appeared on the one screen a rider explicitly opened.
This composes with existing infrastructure rather than duplicating it:
`CachedTileProvider` (V3-11) already tries the offline tile cache before the network, so
"no connection" in practice means *both* the cache and the network failed for the tiles
currently in view.
## Design
Detect at the `CachedTileProvider`/`TileLayer` level, not by asking the OS for
connectivity state directly — a connectivity API can report "online" while the actual
tile fetch still times out (captive portal, degraded connection), and the tile fetch's
own success/failure is the only thing that actually matters here.
- Track a rolling failure count for in-flight tile fetches (cache miss **and** network
fetch failed) over a short window. Crossing a small threshold (e.g. 3 consecutive
failures) flips the map into skeleton mode; a single successful fetch flips it back.
- Skeleton mode replaces the `TileLayer` with a static, non-fetching placeholder — never
a widget that itself keeps trying and failing in a loop.
- Placeholder look: a shimmer/gradient-sweep animation over a neutral grid pattern
(reuse the "technical grid overlay" treatment already present in the Stitch exports'
`map-grid-overlay` CSS — 40px faint grid lines — as the static base under the shimmer),
not a spinner. A map-shaped thing that is clearly still loading, not an error state.
- Recheck periodically (not on every frame) so the map recovers automatically the moment
connectivity returns, without the rider having to do anything.
## Implementation
1. Wrap `CachedTileProvider` (or add a sibling) that reports fetch outcomes to a small
`MapConnectivityState` — rolling window, threshold, debounced flip.
2. `SkeletonMapLayer` widget: the grid + shimmer, sized to fill the same space a
`TileLayer` would.
3. `RideMap`/`PersistentMapBackground` (from UI-01) watches `MapConnectivityState` and
swaps `TileLayer` for `SkeletonMapLayer` when in skeleton mode — markers/polylines
(the rider's own path, planned route) keep rendering on top either way, since those
come from local data, not tiles.
4. A cap on retry frequency while in skeleton mode — this must not turn into a tile-
fetch retry loop that itself violates OSM's usage policy the way V3-11's design
explicitly guards against.
## Acceptance criteria
- [ ] With no cached tiles and no network, the map area shows an animated skeleton, not
blank space or broken-image icons
- [ ] Recovery is automatic: once tiles become fetchable again, the skeleton is replaced
without user action
- [ ] The rider's live position/path and any planned route still render on top of the
skeleton (only the base tiles are missing)
- [ ] Skeleton mode does not itself hammer the tile server with retries
- [ ] Behaves correctly across all four tabs (UI-01's shared background map), not just
the Map tab
## Tests
- Unit: the failure-count/threshold/debounce logic that decides skeleton vs. live,
exercised with fakes (no real network) — mirrors V3-11's `tile_downloader_test.dart`
pattern of injecting fake fetch outcomes
- Widget: `SkeletonMapLayer` renders when connectivity state says offline; `TileLayer`
renders when it says online
- Widget: markers/polylines still render in skeleton mode
## Risks
- Flapping between skeleton and live on a marginal connection would be worse than
either state alone — the debounce window is what prevents this; tune it based on real
behavior, not a guess (echoes V3-13's "measure, don't tune blind" discipline).
- Must not let skeleton-mode retries become the kind of bulk/rapid tile request V3-11's
design explicitly exists to avoid.
## Out of scope
General offline-mode UX beyond the map itself (e.g. graying out map-dependent buttons).
Manual retry controls — automatic recovery is the whole point.
## Outcome
Built exactly to the ticket's Design section. `lib/src/tiles/map_connectivity.dart`'s
`MapConnectivityState` tracks consecutive tile-fetch failures (`skeletonFailureThreshold
= 3`), flips to skeleton mode on the threshold, and recovers instantly on a single
success — either an ordinary fetch succeeding (while still below threshold, resetting
the run) or, once already in skeleton mode, a periodic single-tile probe
(`skeletonProbeInterval = 15s`) succeeding. The probe is a deliberate design choice: once
skeleton mode starts, `RideMap`/the route planner fully unmount their `TileLayer` (no
ongoing requests at all, per the ticket's "never a widget that itself keeps trying and
failing in a loop"), so nothing generates ordinary fetch outcomes to recover from — the
probe exists specifically to test the water on the map's behalf, at a low, fixed
cadence, using one deterministic tile (the zoom-0 whole-world overview) rather than
whatever happened to be in view.
`CachedTileProvider` (`lib/src/tiles/cached_tile_provider.dart`) now takes an optional
`MapConnectivityState? connectivity` and reports outcomes around its network fetch only
— a cache hit is silently skipped since it says nothing about current connectivity
either way, matching the ticket's "cache miss *and* network fetch failed" definition of
a failure exactly. `mapConnectivityProvider` (`app/providers.dart`) is the one shared
instance every map watches, per the class's own reasoning: connectivity is a fact about
the network, not about which map widget happens to be on screen.
`SkeletonMapLayer` (`lib/src/ui/components/skeleton_map_layer.dart`) is the 40px faint
grid (reusing the Stitch exports' `.map-grid-overlay` treatment directly, `rgba(255,255,
255,0.03-0.05)` → `Color(0x0DFFFFFF)`) with a `LinearGradient` shimmer sweeping across it
on a 1800ms repeating cycle, painted via `ShaderMask`/`CustomPainter` rather than a
translated widget so it doesn't need to know its own pixel size. `RideMap` gained a
`skeletonMode` bool (a plain constructor param, not a `ConsumerWidget` watch — consistent
with how `tileProvider` is already handed down rather than looked up) that swaps
`TileLayer` for `SkeletonMapLayer` while leaving `PolylineLayer`/markers untouched, since
those come from local data. The route planner manages its own independent `FlutterMap`
and does the same swap itself, watching `mapConnectivityProvider` directly.
**Refactor along the way:** moved `tileUrlTemplate`/`tileSubdomains`/`tileMaxNativeZoom`/
`tileUserAgent` out of `ride_map.dart` into a new `lib/src/tiles/tile_config.dart`, and
had `ride_map.dart` re-export them for its existing importers. `app/providers.dart` (the
composition root) needed these constants for the connectivity probe's URL, and importing
a `ui/` file from the `app/` layer would have been a real layering violation the tiles
layer itself doesn't have a reason to accept — better to give the constants a home in
the layer they actually describe (tile fetching) than to route around the smell.
**Bug caught by the first real test run:** `mapConnectivityProvider`'s initial
implementation called `ref.onDispose(state.dispose)` in addition to
`ChangeNotifierProvider`'s own automatic disposal of the notifier it returns — a
double-dispose that threw "A MapConnectivityState was used after being disposed" and
failed five `route_planner_screen_test.dart` tests outright. Fixed by removing the
redundant manual dispose call.
**Tests:** `flutter analyze` clean. `flutter test` green at 328 tests (316 + 3 new
`ride_map_test.dart` skeleton-mode cases + 9 new `map_connectivity_test.dart` unit cases
covering threshold behavior, the reset-on-success case, notification-only-on-actual-
change, and the probe loop's recovery and its refusal to run at all while still live).
**Android emulator verification** (`Medium_Phone_API_35`) — a full end-to-end real-
network test, not just widget tests: cut the emulator's actual network
(`svc wifi disable` + `svc data disable`, confirmed via a failing `ping`), cleared the
offline tile cache via Settings, and navigated to an uncached view (the route planner's
default zoom-14 view, which the shell's already-cached Calgary-area background tiles
didn't cover). After the threshold of real failed fetches, the skeleton grid rendered
correctly — clearly a "still loading" placeholder, not blank space or broken-image
icons. Re-enabled the network and confirmed automatic recovery within one probe interval
with no user action, exactly per the acceptance criteria. The shared shell background
map (already displaying previously-cached tiles from memory) was unaffected throughout,
as expected since it never needed a fresh fetch during the test window.

View File

@@ -0,0 +1,137 @@
# UI-03 — Shared floating-glass component kit
**Depends on** UI-08 (theme tokens) · **Size** M · **Status** Done
## Goal
Build once, use everywhere: the small set of visual primitives every Stitch screen
reuses, so UI-04/05/06/07 compose them instead of each reinventing blur-panel styling.
## Context
All four Stitch exports lean on the same handful of visual patterns repeatedly:
glassmorphic panels (`bg-surface/80 backdrop-blur-xl border border-white/10`), a floating
centered pill for a compact stat summary, and a pulsing/radar-style location marker.
Building these once means every consuming screen ticket is "compose these," not
"reimplement blur and border styling a fourth time."
## Design
Three widgets, all pure presentation (no data-fetching, no business logic):
**`GlassPanel`** — the workhorse. `BackdropFilter` + `ImageFilter.blur` inside a
`ClipRRect`, a translucent surface-color fill, a 1px low-opacity border. Takes a `child`
and behaves like a styled `Container`. Every floating card, tooltip, and control in the
redesign is one of these underneath.
**`FloatingPill`** — a `GlassPanel` shaped as a horizontally-centered, rounded-full
capsule holding a row of labeled stat columns (see Route Planning's Distance/Est. Time/
Pins header). Takes a list of `(label, value)` pairs and lays them out with vertical
dividers between them, matching the Stitch export exactly.
**`PulsingLocationMarker`** — the expanding-ring-plus-glow-dot from the Plan/Route
Planning exports. A `CustomPainter` or layered `AnimatedContainer`s driving an
`AnimationController` in a loop (scale 0.8→1.0, opacity 0.8→0, repeating) — respect
`MediaQuery.disableAnimations`/`prefers-reduced-motion` equivalent by falling back to a
static dot when animations are disabled system-wide.
## Implementation
1. `lib/src/ui/components/glass_panel.dart` — `GlassPanel`, with blur radius, fill
opacity, and border opacity as named constants (not magic numbers scattered per call
site), sourced from UI-08's theme tokens.
2. `lib/src/ui/components/floating_pill.dart` — `FloatingPill`, built on `GlassPanel`.
3. `lib/src/ui/components/pulsing_location_marker.dart` — `PulsingLocationMarker`,
wrapping its `AnimationController` lifecycle correctly (dispose on unmount, same
discipline as `RideMap`'s existing lifecycle observer from V3-04).
4. Widget tests render each in isolation against both themes (if UI-08 keeps a light
variant) to catch a black-on-black-style contrast regression early, the same
discipline V3-16 established.
## Acceptance criteria
- [ ] `GlassPanel` renders a blurred, bordered, translucent container matching the
Stitch reference visually
- [ ] `FloatingPill` renders an arbitrary number of stat columns with dividers between
them, not hardcoded to exactly three
- [ ] `PulsingLocationMarker` animates continuously without leaking its
`AnimationController` across widget rebuilds or disposal
- [ ] Reduced-motion setting is respected by `PulsingLocationMarker`
## Tests
- Widget: each component renders with representative content and takes a screenshot-
comparable snapshot of its structure (no golden files per V3-16's precedent — assert
structure/color, not pixels)
- Widget: `PulsingLocationMarker`'s animation controller is disposed when the widget is
removed from the tree (a `flutter_test` pending-timer/ticker check, mirroring the
Drift stream-query keep-alive pattern already documented in `widget_test.dart`)
## Risks
- `BackdropFilter` is one of the more expensive Flutter widgets to composite; stacking
several `GlassPanel`s over a live, animating map (per UI-01/UI-02) could visibly cost
frame time on lower-end devices. Worth a real-device check once UI-05 assembles them
together, not just in isolation.
## Out of scope
The customizable drag/resize telemetry widgets (UI-04) — those consume `GlassPanel` as
their visual shell but the interaction logic is a separate, larger ticket.
## Outcome
All three values (blur radius, opacities, dimensions, colors) were lifted directly from
the Route Planning export's own glass treatment and `pulse-ring` keyframes rather than
guessed — `docs/design/stitch-export/screens/route-planning-dark.html`'s `bg-surface/90
backdrop-blur-xl border border-outline-variant/30` and its `@keyframes pulse-ring`
(scale 0.8→1.0, opacity 0.8→0, eased) map directly onto `GlassPanel.blurSigma`/
`fillOpacity`/`borderOpacity` and `PulsingLocationMarker`'s animation curve.
`GlassPanel` (`lib/src/ui/components/glass_panel.dart`) is `ClipRRect > BackdropFilter >
DecoratedBox`, taking `child`/`borderRadius`/`padding` and reading its fill/border colors
from the theme (`colors.surface`, `colors.outlineVariant` — the latter newly added to
`ripprColors` in `theme.dart` for this ticket, since UI-08 hadn't needed it before now).
`FloatingPill` (`floating_pill.dart`) is a `GlassPanel` shaped as a stadium capsule
around a `Row` of `PillStat` columns with 1px dividers between them — genuinely
arbitrary-length, not hardcoded to three, verified by a 4-stat test case.
`PulsingLocationMarker` (`pulsing_location_marker.dart`) layers a static faint ring, an
animated expanding-and-fading ring, and a glowing center dot; it checks
`MediaQuery.disableAnimations` in `didChangeDependencies` (not `initState`, which
Flutter forbids for inherited-widget lookups) and stops the controller entirely when
reduced motion is requested, falling back to a static dot.
**Test-writing bug caught and fixed:** the first version of the "legible in both
themes" test pumped `ripprTheme()` then `ripprMountedTheme()` sequentially in a single
`testWidgets` loop. Because both produced an identically-shaped widget tree
(`MaterialApp > Scaffold > GlassPanel > Text`), Flutter reused the first pump's elements
across the second `pumpWidget` call rather than rebuilding fresh, so the second
iteration silently re-read the *first* theme's already-resolved paint color — the test
would have falsely failed for a real theme-following bug and, worse, could have
falsely passed one due to reading stale data. Split into two independent `testWidgets`
blocks instead, which is the correct way to exercise two themes against the same
component.
**Tests:** `flutter analyze` clean. `flutter test` green at 336 tests (328 + 8 new in
`test/glass_component_kit_test.dart`): `GlassPanel`'s blur/translucency/border
structure and its resolved-paint-color contrast in both themes; `FloatingPill`'s
divider count scaling with stat count (and the zero-divider single-stat case);
`PulsingLocationMarker`'s continuous animation, correct `AnimationController` disposal
on removal (caught automatically by `flutter_test`'s own ticker-leak check, not a
manual timer assertion), and the reduced-motion fallback.
**Android emulator verification**: no consuming screen exists yet (that's UI-04/05/06),
so verification used a throwaway preview entry point
(`lib/main_ui03_preview.dart`, deleted after use) rendering all three components over a
map-colored gradient background. All three matched the Stitch reference's look: the
`FloatingPill`'s blur/translucency/dividers/blue values rendered crisply; the
`PulsingLocationMarker`'s glow and ring were visible and animating. One red herring
during this check: `GlassPanel`'s content briefly appeared to render illegibly when the
preview used `Theme.of(context).textTheme.headlineSmall` for a heading, despite that
style's `color` property independently verified (via a debug test) to already be the
correct ink value with full alpha. Switching to an explicit inline `TextStyle` (no
ambient text-theme role) rendered crisply instead. This was isolated to the scratch
preview file, not `GlassPanel` itself — the shipped components' own tests all render
content through explicit styles or the already-verified `bodyMedium` role, the same
discipline every real screen in this codebase already follows — but it's worth flagging
as a `headlineSmall`-specific rendering quirk to watch for if UI-05/06 lean on that
particular text-theme role for real headings.
## Risks note (revisited)
The ticket's own risk — `BackdropFilter` compositing cost when several `GlassPanel`s
stack over a live, animating map — was not measurable in this ticket's isolated preview
(no map, no stacking). Deferred to UI-05, as the ticket itself anticipated, where the
Record screen actually assembles multiple glass panels over `RideMap`.

View File

@@ -0,0 +1,197 @@
# UI-04 — Customizable HUD telemetry widgets
**Depends on** UI-03 · **Size** L · **Status** Done
## Goal
Let the rider drag each floating telemetry widget (Speed, Distance, etc.) to wherever
they want it on screen, resize it, and choose in Settings which metrics appear live at
all — a persisted, personal HUD layout rather than a fixed one.
## Context
The Stitch Map HUD mockup has a fixed horizontal row of four telemetry cards with one
interaction: tap a card to temporarily expand it, tap again (or tap another) to collapse
it back. That's a reasonable default, but by explicit instruction the actual goal is
further than that: **the rider owns the layout.** Hold-and-drag to reposition, resize by
dragging a handle, and a Settings screen listing every available metric with a toggle for
whether it's shown at all. This supersedes the mockup's tap-to-expand interaction rather
than coexisting with it — see Design.
## Design
**Data model.** A `HudWidgetLayout` per metric:
```
{ metricId, x, y, width, height, visible }
```
`x`/`y`/`width`/`height` stored as fractions of the available HUD area (0.0-1.0), not
absolute pixels, so a layout saved on one device/orientation still makes sense on
another. `metricId` is a stable enum (`speed`, `avgSpeed`, `distance`, `elapsedTime`,
`maxSpeed`, `elevationGain`, ...) — the same set `RecordUiState`/`Trip` already expose,
not a new data source.
**Available metrics vs. visible metrics.** Every metric the app already tracks is a
candidate; `visible` (toggled in Settings, see below) controls whether it currently has a
HUD widget at all. A metric toggled off entirely has no position/size to speak of until
turned back on, at which point it gets a sane default slot (not wherever it happened to
be last, unless a position was already saved).
**Interaction:**
- **Move:** long-press-and-drag anywhere on a widget's `GlassPanel` body. A brief haptic
and a subtle scale-up on press-start signal "this is now grabbable," matching the
weight of a real physical action rather than an accidental tap.
- **Resize:** a small drag handle in one corner, visible only while a widget is in "edit
mode" (see below), not in every-day use — a resize handle sitting on screen permanently
during a live ride is visual noise the rider doesn't need mid-ride.
- **Edit mode:** entered explicitly (e.g. a long-press anywhere on the HUD area that
isn't a specific widget, or a dedicated "Edit HUD" toggle from Settings/a long-press on
empty HUD space) rather than every telemetry widget always being draggable — an
always-draggable widget risks an accidental drag mid-ride when the rider meant to tap
it for something else. Exiting edit mode (tap "Done", or tap empty space) persists the
layout.
- **Constraints:** every widget clamps to stay fully within the safe HUD area on drag/
resize end — never under the bottom nav bar, never off-screen, never smaller than a
legibility floor (the number must stay readable) or larger than some sane ceiling.
**Settings integration.** A new "Live HUD stats" section: a checkbox/switch per available
metric, controlling `visible`. Order in the list is stable (doesn't reflect current HUD
position) so it's easy to scan.
**Persistence.** One `Config` field (JSON-encoded map of `metricId` → `HudWidgetLayout`),
same pattern as every other `Config` preference in this app — see `mountedMode`,
`crashReportingEnabled` for the shape to follow.
## Implementation
1. `HudWidgetLayout` model + JSON encode/decode, with defaults for every known metric
(a sensible starting grid, e.g. the Stitch mockup's horizontal row) so a fresh install
has a working, if plain, HUD before the rider customizes anything.
2. `Config.hudLayout` getter/setter, following the established `Config` pattern.
3. `HudLayoutController` (or a `Riverpod` `StateNotifier`) holding the current layout in
memory, seeded from `Config`, written back to `Config` on every edit-mode exit — not
on every drag frame, to avoid hammering `SharedPreferences` mid-drag.
4. `DraggableResizableHudWidget`: wraps a telemetry `GlassPanel`, handles the long-press-
to-grab gesture, the resize handle, and the clamp-on-release logic.
5. `HudEditOverlay`: the edit-mode chrome (resize handles, a "Done" affordance, maybe a
subtle grid/snap guide) shown only while editing.
6. Settings section: "Live HUD stats," one row per metric with a switch bound to
`visible`.
## Acceptance criteria
- [ ] A telemetry widget can be dragged to a new position and the new position survives
an app restart
- [ ] A telemetry widget can be resized within sane min/max bounds
- [ ] Dragging or resizing never leaves a widget partially off-screen or behind the nav
bar
- [ ] Toggling a metric off in Settings removes its HUD widget immediately; toggling it
back on restores it (at its last saved position if one exists, otherwise a default)
- [ ] Outside of edit mode, a normal tap on a telemetry widget does not move it
(no accidental drags during a ride)
- [ ] Layout is per-install (`Config`), not per-ride
## Tests
- Unit: `HudWidgetLayout` JSON round-trips exactly; unknown/missing metric ids on load
fall back to defaults rather than crashing
- Unit: clamp logic — a drag/resize ending outside allowed bounds is corrected to the
nearest valid position/size, exercised with fixed geometry inputs (no real gestures
needed to test the math)
- Widget: drag gesture moves a widget and the moved position is what gets persisted
(simulate via `TestGesture`, matching how other drag interactions in this codebase are
tested)
- Widget: toggling a metric's Settings switch adds/removes its HUD widget
- Widget: a tap outside edit mode does not trigger a move
## Risks
- **Scope creep toward a general-purpose layout editor.** Keep the interaction minimal:
drag, resize, toggle visibility. No z-ordering, no custom widget shapes, no per-metric
color customization — those are separate future tickets if wanted, not this one.
- Persisting on every drag frame would thrash `SharedPreferences`; persist only on
edit-mode exit, keep in-memory state authoritative during an active drag.
- This ticket removes the Stitch mockup's tap-to-expand interaction rather than layering
on top of it — a widget the rider has manually resized should not also silently resize
itself on tap, which would fight the rider's own choice.
## Out of scope
Z-ordering/overlap resolution between widgets (widgets simply clamp to stay on-screen;
overlapping each other is the rider's own choice to avoid). Per-metric colour
customization. Sharing/exporting a HUD layout between devices.
## Outcome
Built the full generic infrastructure the ticket scopes, deliberately stopping short of
wiring it into the real Record screen -- that assembly (real metric values from
`RecordUiState`, replacing the fixed stats card) is UI-05's job, per the dependency
graph. `HudMetric` (`lib/src/hud/hud_metric.dart`) is the stable 8-value enum;
critically, the first four are ordered to match the Stitch mockup's fixed row exactly,
since `HudWidgetLayout.defaultFor` derives both default grid position and which metrics
start visible directly from enum index.
**Correction (UI-05):** this ticket originally ordered those first four as
`speed, distance, elapsedTime, maxSpeed` -- a guess made without having read the actual
Map HUD mockup HTML closely yet. Implementing UI-05 against that same HTML directly
showed the real fixed row is Speed/**Avg Speed**/Dist/Time. The enum was reordered to
`speed, avgSpeed, distance, elapsedTime, maxSpeed, movingTime, elevationGain,
pointsCaptured` and every test here that had asserted the old order was updated
alongside it -- see UI-05's own Outcome for the full account.
`HudWidgetLayout` (`hud_widget_layout.dart`) holds fractional `x/y/width/height` +
`visible`, with `clamped()` enforcing a legibility floor and sane ceiling (size clamped
first, then position re-clamped against the now-bounded size, so a resize that would
push a widget off-screen shrinks it rather than silently relocating it) and
`fromJson`/`defaultFor` degrading to sane defaults on any malformed or missing data
rather than crashing Settings on launch. `Config.hudLayout`/`setHudLayout`
(`config/config.dart`) follow the exact JSON-map-of-a-stable-key pattern already
established there. `HudLayoutController` (a `StateNotifier`, `hud_layout_controller.dart`)
holds the authoritative in-memory layout during an edit session and only calls
`Config.setHudLayout` on `persist()` -- never per drag frame, the ticket's own named
risk.
`DraggableResizableHudWidget` and `HudEditOverlay` (`lib/src/ui/components/`) are the
interactive pieces. A widget only attaches a drag/resize `GestureDetector` at all when
`editing` is true -- "a normal tap never moves a widget outside edit mode" is guaranteed
structurally by the absence of a recognizer, not by an internal flag a future edit could
weaken. Edit mode itself is entered by a long-press on *empty* HUD space and exited by
"Done" or a tap on empty space, both handled by `HudEditOverlay`'s own background
`GestureDetector`.
**Bug found and fixed during testing, not anticipated by the plan:** outside edit mode,
`DraggableResizableHudWidget` initially attached no gesture detector at all, meaning a
long-press *on a widget* was free to bubble up through the gesture arena to
`HudEditOverlay`'s background long-press handler and wrongly enter edit mode from a
touch that landed on a specific widget, not the empty area the design explicitly calls
for ("a long-press anywhere on the HUD area *that isn't a specific widget*"). Fixed by
giving the non-editing state a no-op `GestureDetector(onLongPress: () {})` -- an inner
recognizer of the same gesture type wins the arena over the outer one, absorbing the
press instead of letting it propagate. Caught by a widget test that intentionally
dragged directly on a widget while not editing and asserted edit mode never engaged.
**Tests:** `flutter analyze` clean. `flutter test` green at 365 tests (336 + 29 new,
across `hud_widget_layout_test.dart`, `hud_layout_controller_test.dart`,
`hud_edit_overlay_test.dart`, and additions to `config_test.dart`/
`settings_screen_test.dart`) -- covering JSON round-trips and malformed-data fallback,
every clamp edge case with fixed geometry (no gestures needed), the controller's
in-memory-until-persist discipline, `TestGesture`-simulated long-press-drag actually
moving and persisting a widget, and the Settings toggle writing through to `Config`
immediately. One pre-existing-test collateral fix: adding 8 new `SwitchListTile`s
pushed everything after them below the test viewport's initial fold (a plain
`ListView(children:)` still lazily builds via a sliver, same as `.builder` -- an
assumption several existing tests unknowingly depended on). Moved the new section to
the very end of the list (after "About") so no earlier section's position changed, and
added `scrollUntilVisible` to the two new tests that need to reach it.
**Android emulator verification** (`Medium_Phone_API_35`): used a throwaway preview
entry point (`lib/main_ui04_preview.dart`, deleted after use) since no consuming screen
exists yet. Confirmed on-device: the default four-card row renders exactly like the
mockup; a long-press on a widget does nothing (the arena-fix above); a long-press on
empty space enters edit mode, showing every resize handle and a "Done" pill; dragging a
resize handle (a plain pan, not gated by long-press, so directly reproducible via `adb
input swipe`) visibly grows a widget; tapping "Done" hides the edit chrome and keeps the
new size. **Full end-to-end persistence was verified across a real process restart**,
not just via the widget-test's in-memory assertions: resized Speed, tapped Done,
force-stopped the app, relaunched it fresh, and the enlarged Speed widget was still
enlarged. The long-press-*then*-drag move gesture itself could not be reproduced via
`adb input swipe` (its linear interpolation moves throughout the whole gesture rather
than holding still for the ~500ms long-press window first, so Flutter's arena resolves
it as a rejected pan rather than a recognized long-press) -- that exact interaction is
what the `TestGesture`-based widget test (which holds the pointer down, waits out
`kLongPressTimeout`, then moves) verifies precisely, and is trusted as the ground truth
for that specific gesture. Also confirmed via the real (non-preview) app that the new
Settings section renders correctly alongside every existing section and that toggling
"Average speed" flips its switch immediately.

View File

@@ -0,0 +1,169 @@
# UI-05 — Map HUD: Record screen redesign
**Depends on** UI-01, UI-03, UI-04 · **Size** M · **Status** Done
## Goal
Rebuild the Record screen as the fullscreen Map HUD from the Stitch export — this screen
is the **design north star** for the whole redesign (see `README.md`): every other
screen ticket restyles toward what this one establishes, not the other way around.
## Context
Today's Record screen is a scrolling `Column`: a 220px map card, a stats `Card` below it,
full-width Pause/Stop/Resume/Discard buttons, and a "RIPPR" wordmark header with Rides/
Settings/Routes buttons. The redesign inverts this entirely — the map is the full-bleed
canvas (via UI-01's shared background, at full opacity/interactivity on this tab only),
and everything else floats on top of it via `GlassPanel`/HUD widgets.
## Design
- **Header:** none, per UI-01.
- **Telemetry:** `DraggableResizableHudWidget`s from UI-04, not a fixed row — this screen
is where those widgets actually live during a ride. Default layout mirrors the Stitch
mockup's horizontal row (Speed/Avg Speed/Dist/Time) until the rider customizes it.
- **Pause/Stop:** two icon-only, half-width, full-bleed buttons directly above the bottom
nav bar, matching the mockup exactly — `tertiary-container` (pause) and
`error-container` (stop) per UI-08's palette. Resume/Discard: the mockup doesn't show
these explicitly on this screen; keep them reachable (e.g. Discard behind a
confirmation from the paused state, matching today's existing safeguard against a
gloved mis-tap next to Pause) rather than dropping the affordance V3-05/the original
port already earned.
- **Route rendering:** both the planned route (dashed, neutral `outline` color) and the
ridden-so-far path (colored by the existing speed gradient from `RideMap`) drawn
simultaneously, matching the mockup — this is new: today only the ridden path renders
live.
- **Location marker:** `PulsingLocationMarker` (UI-03) replaces the current plain dot.
- **Mounted mode (V3-05) interaction with this redesign:** the high-contrast mounted
theme and larger touch targets still apply, layered on top of this screen's new
layout, not replaced by it — confirm the mounted theme's colors still read correctly
against `GlassPanel`'s blur (a light theme through blurred content behaves differently
than the current dark-on-dark case V3-05 was built against).
## Implementation
1. Rebuild `RecordScreen`'s body against UI-01's full-opacity Map tab background instead
of an embedded `RideMap` card.
2. Replace the current `StatRow`-based stats card with UI-04's HUD widgets, wired to the
same `RecordUiState`/live telemetry stream already powering the old layout — no new
data plumbing, only new presentation.
3. Rebuild `_Controls`/`_PrimaryButton`/`_SecondaryButton` as the two full-bleed icon
buttons; keep the existing state-machine logic (`RecordUiState.isIdle/isRecording/
isPaused`) driving which controls show, per the current `_Controls` widget.
4. Wire both route paths (planned + ridden) into the shared background map's overlay,
sourced from `RoutePlanRepository` (if a route is being followed — V3-09 groundwork,
not required for this ticket if no route is active) and the existing live-points
stream.
5. Re-verify V3-05's mounted-mode contrast/legibility guarantees against the new
`GlassPanel`-based layout specifically, since that's a real visual change from the
opaque `Card` mounted mode was built and tested against.
## Acceptance criteria
- [ ] Map fills the screen behind the HUD widgets and controls, full opacity, on this
tab
- [ ] Existing record/pause/resume/stop/discard state machine behavior is unchanged —
this is a visual rebuild, not a behavior change
- [ ] HUD telemetry widgets are the UI-04 draggable/resizable kind, not a fixed layout
- [ ] Mounted mode (V3-05) still passes its existing contrast tests against the new
layout
- [ ] The black-on-black text-color regression guard (from V3-16/the original port bug)
still passes against `GlassPanel`'s blur background
## Tests
- All existing `record screen` widget tests updated to the new layout and still passing
(state-machine behavior, mounted-mode toggle, wake lock, live-map visibility)
- Widget: HUD widgets render over the map, not replacing it
- Widget: Pause/Stop buttons trigger the same engine calls as today
## Risks
- The biggest visual departure of the whole redesign happens here first — expect this
ticket to surface layout issues (`GlassPanel` blur cost stacked with a live animating
map, HUD widgets overlapping controls on small screens) that UI-06/07 then inherit or
avoid having created themselves.
## Out of scope
Following a planned route turn-by-turn (V3-09). Anything about Plan/Route Planning/Rides
History screens — those are UI-06/UI-07, restyled toward what this ticket establishes.
## Outcome
Rebuilt `RecordScreen`'s body entirely against UI-01's shared background map, keeping
every line of the existing state-machine logic (the ticker, wakelock handling, speed
subscription, `_guard`/error handling) completely untouched -- only the returned widget
tree changed. The idle state keeps its own compact layout (a single `GlassPanel` with
`BigStat` speed + "Ready"/"See Rides", exactly the old content, just re-skinned) rather
than switching to HUD widgets while there's no ride to show numbers for -- deliberately
preserving the pre-existing "a resting screen must not look like a ride going nowhere"
guarantee rather than reinterpreting it. Once a ride exists (recording or paused),
`HudEditOverlay` (UI-04) takes over, wired to a new `_HudMetricValue` that reads
`RecordUiState`/`UnitSystem` and renders through `HudMetric`'s existing set -- no new
data plumbing, exactly as the ticket's Implementation section asked for. Added
`RecordUiState.avgSpeedKmh` (distance-over-moving-time, zero before any movement) since
that metric didn't previously exist anywhere in the app.
**Colour convention decision:** the Map HUD mockup colours its four default metrics
individually (Speed → primary, Distance → secondary, Avg Speed/Time → plain ink) rather
than following this app's usual reference-vs-live split. `_HudMetricValue` matches the
mockup literally rather than inventing a new convention, since the mockup is this
ticket's explicit design source for exactly this screen.
**Correction found and fixed while implementing this ticket:** UI-04's `HudMetric` enum
ordered its "first four, default-visible" metrics as Speed/Distance/Elapsed/Max Speed --
a guess made before the actual Map HUD mockup HTML had been read closely. Reading
`docs/design/stitch-export/screens/map-hud-dark.html`'s own `#telemetry-container`
directly for this ticket showed the real fixed row is Speed/**Avg Speed**/Dist/Time.
Reordered the enum to match and updated every UI-04 test that asserted the old order
(five call sites across `hud_widget_layout_test.dart`, `hud_edit_overlay_test.dart`, and
`settings_screen_test.dart`) -- a real, disclosed correction, not a silent one.
**Control bar:** `_ControlBar`/`_Segment` render the full-bleed, icon-only bar the
mockup specifies for Pause (`tertiaryContainer`/`onTertiaryContainer`) and Stop
(`errorContainer`/`onErrorContainer`) -- both added explicitly to `ripprColors` in this
ticket, since `ColorScheme.dark`'s auto-derived tonal palette wouldn't have matched the
design system's literal hex values otherwise. Idle keeps a labelled "START RECORDING"
segment (an icon alone risked ambiguity for a rider's very first, highest-stakes tap,
a deliberate deviation from strict icon-only fidelity). Paused extends the same visual
language into three segments (Resume 50%, Stop 25%, Discard 25%) since the mockup itself
doesn't depict a paused state — Discard stays behind this dedicated slot, reachable only
while paused, preserving the existing gloved-mis-tap safeguard from V3-05 rather than
dropping it.
**Location marker:** `RideMap` gained a `showLocationMarker` bool; when true and points
exist, a `MarkerLayer` places `PulsingLocationMarker` (UI-03) at the latest point.
`ShellScaffold` passes `showLocationMarker: isMapTab` so the animation only ticks on the
tab where it's actually visible.
**Out of scope, confirmed at implementation time, not just planned:** the ticket allows
skipping planned-route rendering when V3-09 (route-following) doesn't exist yet, and it
doesn't -- there is no "route currently being followed" concept anywhere in the app to
source a planned-route polyline from. Left unimplemented rather than fabricating a data
source; `RideMap`'s existing ridden-path speed-gradient polyline is unaffected and still
renders live.
**Mounted mode re-verified against `GlassPanel` specifically**, per the ticket's own
named risk (mounted mode was built and tested against an opaque `Card`, not a blurred
translucent surface). Added a test asserting the actual contrast ratio of the mounted
speed digit against `GlassPanel`'s mounted-theme surface color (not just "differs from
the wrong, dark-theme ground" the way the pre-existing test checked) -- passes at
roughly 4.5:1+ AA with no colour changes needed.
**Tests:** `flutter analyze` clean. `flutter test` green at 369 tests. Every pre-existing
`record screen` test passed unchanged against the rebuilt screen with zero edits needed
-- the idle-state structural parity was deliberate and it paid off. Added: two tests that
actually tap Start/Pause/Stop and verify the underlying trip state changes (not just key
presence, which the pre-existing tests already covered), one confirming HUD widgets
render over the shared background map rather than replacing it, and the mounted-mode/
GlassPanel contrast test above.
**Android emulator verification** (`Medium_Phone_API_35`) exercised the full state
machine live on-device: Start → Pause → Resume → Stop, confirmed visually correct at
each transition (colours, icon changes, the "Paused" pill appearing/disappearing, the
HUD widgets updating), with the full-bleed live map behind everything on the Map tab
exactly matching the design intent. This surfaced and resolved a real debugging episode
worth recording: initial manual taps on the control bar appeared to do nothing across
many attempts (varied coordinates, map on/off, a full emulator+host reboot, a Gradle-
daemon memory-pressure cleanup, and a `Container`→`Ink` widget refactor along the way).
The eventual root cause was mundane and entirely on the verification side, not the
app: the button's true on-screen position was being mis-estimated from a scaled-down
screenshot preview, roughly 500px off from its actual location. Sampling pixel colours
directly from the raw screenshot file (via PIL) to find the button's exact bounds
resolved it immediately, and every interaction has worked correctly since. The emulator
reboot and Gradle-daemon stop were not the fix, but were a reasonable, low-risk
housekeeping step taken while investigating and are left in place as a genuine
improvement to the session's remaining build performance.

View File

@@ -0,0 +1,170 @@
# UI-06 — Plan & Route Planning redesign
**Depends on** UI-01, UI-03 · **Size** M · **Status** Done
## Goal
Rebuild the Plan tab (empty state) and the route planner (pins dropped) as fullscreen
map canvases with floating controls — correct in principle per the Stitch mockups, but
**restyled to match Map HUD (UI-05), not copied as-is** — see `README.md`'s note on why
these two mockups drifted from the design north star.
## Context
Today's `RoutePlannerScreen` is a `Scaffold` with an `AppBar` (title, rename/delete/
download actions) and the map beneath it. The mockups drop the header entirely (per
UI-01) and float everything — a pin-drop tooltip, a stat summary pill, a Start Route
button — over a fullscreen map, with a bottom nav bar instead of an app bar's back
button (the planner becomes a screen reached *within* the Plan tab, not a separately
titled pushed screen).
**What to take from the mockups as-is:** the fullscreen-map-plus-floating-controls
pattern, the pin-drop ripple micro-interaction, `PulsingLocationMarker` for the rider's
own position, the marching-ants animated route line, the floating stat pill
(Distance/Est. Time/Pins) replacing today's pre-download confirmation dialog as a
persistent readout instead of a one-time modal.
**What to restyle rather than copy:** icon fill-states, the nav bar's exact background/
blur treatment, and any color choice that doesn't match UI-08's actual palette as
established on the Map HUD screen. The Route Planning mockup specifically shows the
"Rides" nav icon active while viewing Plan — a generation error, not a real cross-tab
indicator; the shipped nav bar must correctly highlight whichever tab is actually active,
matching Map HUD's nav bar exactly (same widget, in fact — see UI-01).
## Design
- **Plan tab (no pins yet):** fullscreen map, technical grid overlay, floating "Tap to
drop a pin" tooltip (gentle float animation), `PulsingLocationMarker`.
- **Route Planning (pins dropped):** same canvas, plus the floating stat pill (`FloatingPill`
from UI-03) showing Distance / Est. Time / Pin count, updating live as pins are added/
moved/removed (already true of the underlying `RoutePlanRepository` data — this ticket
is presentation only). "Start Route" as a floating pill-shaped button above the nav
bar, replacing today's app-bar-driven actions.
- **Existing actions (rename, delete, download offline tiles)** need a new home now that
there's no app bar to hang icon buttons off of — a small floating overflow/glass menu
button is the natural fit, styled per Map HUD's `GlassPanel` language, not invented
fresh.
- **Drag-to-move / tap-to-delete pins:** unchanged interaction from today's
`RoutePlannerScreen`; only the chrome around it changes.
## Implementation
1. Rebuild `RoutePlannerScreen`'s `Scaffold`/`AppBar` as a headerless canvas within the
Plan tab's branch navigator (per UI-01), map at full opacity/interactivity here
(unlike the dimmed treatment other tabs get for the *background* map — this screen's
map *is* the primary content, same as Map HUD's).
2. Replace the pre-download confirmation dialog (V3-11) with the persistent `FloatingPill`
stat summary; keep the actual download flow (count/size check, cancellable progress)
as-is underneath — presentation change only.
3. Add the pin-drop ripple micro-interaction and marching-ants route line as new,
currently-nonexistent polish.
4. Rebuild the Plan tab's empty state (no `RoutePlan` selected/created yet) as the
fullscreen tooltip-and-map view, wired to whatever "create a new route" entry point
replaces today's `RoutesListScreen` FAB (see UI-07 — Rides History's tab likely
absorbs or sits alongside a route list; confirm final IA when that ticket lands, since
"Rides" and "Plan" may end up sharing more list/history UI than the current
`RoutesListScreen`/`TripsScreen` split assumes).
5. Move rename/delete/download actions to a floating glass menu, styled per Map HUD.
## Acceptance criteria
- [ ] Both Plan states (empty, pins dropped) render as fullscreen map canvases with no
header
- [ ] The nav bar on this screen is the exact same widget/styling as Map HUD's, with the
Plan tab correctly shown active (not the generation-error "Rides active" state
from the mockup)
- [ ] Existing pin add/move/delete, rename, delete-route, and offline-download behavior
all still work, restyled only
- [ ] Icon fill-states and colors match Map HUD, not the Stitch export's drifted values
## Tests
- All existing `route_planner_screen_test.dart` / `route_plan_repository_test.dart`
behavior tests still pass against the new presentation layer
- Widget: nav bar active-tab indicator is correct on this screen
- Widget: the floating stat pill updates live as pins are added/removed (already covered
conceptually by existing repository tests; add a presentation-level assertion that the
pill reflects it)
## Risks
- The IA question flagged in Implementation step 4 (how "create a new route" is reached
without a `RoutesListScreen`-style list-with-FAB) may turn out to need its own small
design decision once UI-07 is underway — don't block this ticket on it if the existing
entry point can be preserved with new styling in the meantime.
## Out of scope
Road-snapped routing (V3-08/V3-09, deferred). Any change to the underlying route-planning
data model or repository — this is presentation only.
## Outcome
**IA question (Risk section) resolved by deferring, as the ticket explicitly allowed:**
`RoutesListScreen` stays a list-with-`+`-button (the existing entry point), restyled with
`GlassPanel`-wrapped rows instead of plain `Card`s. The mockup's own "fullscreen map +
tooltip" language turned out, on close reading, to describe `RoutePlannerScreen`'s
*own* empty-pins state (the tooltip literally instructs "tap to drop a pin" — an action
that happens on the canvas, not on a list of named routes), not the routes list. Both
screens got the restyle their actual role calls for.
`RoutePlannerScreen` is now a headerless, full-opacity map canvas (its own map, not
UI-01's dimmed shell background — this screen's map *is* the primary content, matching
Map HUD). Implemented: the floating "TAP TO DROP A PIN" tooltip (`GlassPanel`, gentle
3s float) shown only while `waypoints.isEmpty`; `FloatingPill` (UI-03) showing Distance/
Est. Time/Pins once pins exist, replacing the old pre-download confirmation dialog's
role as the primary stat display (the confirmation dialog itself is untouched --
presentation only, per scope); a "START ROUTE" floating pill button; a `GlassPanel`
overflow menu (`PopupMenuButton`) holding Rename/Download offline tiles/Delete route,
replacing the removed `AppBar`'s actions row; and a pin-drop ripple micro-interaction
(a short-lived expanding-and-fading ring at the tap's local screen position).
**"Start Route," given real, working behaviour rather than shipped as a dead button:**
turn-by-turn following (V3-09) doesn't exist anywhere in the app to wire this to, and
inventing that data model here would violate this ticket's own "presentation only"
scope. Tapping it starts an ordinary recording and switches to the Map tab -- a real
action a rider can use today, not a placeholder.
**Est. Time honestly shows "--"** when `RoutePlan.estimatedMillis` is null (always, until
V3-08 adds road-snapped routing) rather than fabricating a straight-line estimate the
model doesn't back.
**Marching-ants route-line animation was deliberately dropped from scope.** It isn't in
this ticket's acceptance criteria (only the pin-drop ripple is named there), and
implementing it properly means animating a dash phase while continuously reprojecting
through `MapCamera.latLngToScreenOffset` under live pan/zoom -- real complexity for a
purely decorative effect. The existing static dashed polyline (already "visibly distinct
from a recorded path," the actual acceptance-relevant property) is unchanged.
**Bug found and fixed, unrelated to this ticket's own changes but discovered while
working in this file:** `_DownloadDialog`'s tile fetch still hardcoded
`https://tile.openstreetmap.org/...` directly -- a leftover from before UI-09 switched
the live map to CARTO's dark tiles. Downloading offline tiles would have silently cached
OSM's tan tiles under the same `(z, x, y)` keys UI-09's cache versioning was specifically
designed to keep separate from CARTO's, meaning a "successful" download would never
actually serve anything to the live dark map. Fixed to build the URL from the same
`tileUrlTemplate`/`tileSubdomains` every other tile fetch in the app already uses.
**Nav bar correctness:** no separate implementation needed -- `RoutePlannerScreen` is
pushed *within* the Plan branch's own navigator (UI-01's `StatefulShellBranch`
architecture), so the shell's persistent nav bar already shows Plan active automatically,
structurally ruling out the mockup's own "Rides active while viewing Plan" generation
error. Added a test asserting this directly rather than trusting the architecture alone.
**Tests:** `flutter analyze` clean. `flutter test` green at 370 tests. Updated every
existing `route_planner_screen_test.dart` assertion that referenced now-removed `AppBar`
structure (the `IconButton` keys are `PopupMenuItem`s behind the overflow menu now; there
is no app-bar title to assert renaming against, so that test now reads the repository
directly instead) plus one real test bug fixed along the way: two close-together
waypoints in the "tapping a pin deletes it" test happened to place one pin directly
under the new floating "Start Route" button in that test's fixed camera geometry, so the
tap intended for the pin actually hit the button underneath it (confirmed via
`WidgetController`'s own hit-test-mismatch warning, not assumed) -- reduced to a single
waypoint, which the camera centres exactly on, clear of the button. `PopupMenuButton`/
`PopupMenuItem` interactions needed bounded pumps rather than `pumpAndSettle`, same
reason the map's own tests already avoid it (a live `TileLayer` never goes idle in this
harness). Also fixed a real, unrelated bug the new `GlassPanel`-wrapped `ListTile` in
`RoutesListScreen` surfaced immediately: Flutter's own framework assertion that a
`ListTile` needs a `Material` ancestor to paint its ink splash correctly, which
`GlassPanel`'s `DecoratedBox` was sitting between it and -- fixed with a
`Material(type: MaterialType.transparency)` wrapper.
**Android emulator verification** (`Medium_Phone_API_35`): confirmed the empty-tooltip
state, dropping a pin (ripple visible, `FloatingPill` and "Start Route" appearing
immediately), and the overflow menu opening with all three actions -- all matching the
mockup and the Design section precisely. The Plan tab's nav-bar pill correctly followed
the active tab (a mid-transition-animation screenshot briefly looked wrong immediately
after tapping the tab; a screenshot taken a moment later confirmed it settles correctly
-- a timing artifact of screenshotting mid-animation, not a real bug).

View File

@@ -0,0 +1,148 @@
# UI-07 — Rides History redesign
**Depends on** UI-01, UI-03 · **Size** M · **Status** Done
## Goal
Rebuild the Trips list as "Rides History": a dimmed live map background (per UI-01, not
a static image), a search bar, and richer ride cards with a real route-thumbnail map —
restyled to Map HUD's (UI-05) established colors/icons, not the Stitch export's mockup
styling.
## Context
Today's `TripsScreen` is a plain `ListView` of `_TripTile`s (icon, name, one-line
subtitle) over a solid background, reached by pushing off Record. The mockup adds a
search bar, a filter button, a summary-stats row, and cards with an actual map-thumbnail
image per ride. By explicit instruction, the background here is the same live map every
other tab has (dimmed), not the mockup's static blurred screenshot — see UI-01.
**Generation artifact to fix, not copy:** the mockup's summary row is four identical
"Total Dist: 482 KM" cards. This ticket defines real, distinct summary stats.
## Design
- **Background:** UI-01's shared dimmed map, same as Settings and Plan get when not the
active-content tab.
- **Search + filter:** a text field over ride name/date, plus a filter affordance (by
activity type — V3-01 already has this data — and/or date range). Filtering logic is
new; nothing today searches or filters the trips list.
- **Summary row:** real distinct stats — total distance, total moving time, ride count,
and one more genuinely useful figure (e.g. average speed across all rides, or longest
ride) rather than repeating one number four times. Computed from existing `Trip`
aggregate columns already stored on every completed ride — no new data needed, only a
query across all of them.
- **Ride cards:** title, date, and a distance/time/avg-speed stat row (matching the
mockup), plus a **real map thumbnail** — a small, non-interactive `RideMap` (or a
lightweight static rendering of the stored path) rather than a stock image, since we
actually have the ride's own path data, unlike the mockup's placeholder photos.
- **Colors/icons:** Map HUD's palette and icon fill-states, per the design north star —
not the mockup's card-specific styling choices (e.g. the third card's grayscale/
reduced-opacity treatment, which reads as an arbitrary "older ride" decoration with no
defined rule — drop it unless a real rule for it is specified).
## Implementation
1. Rebuild `TripsScreen`'s background to sit over UI-01's dimmed shared map instead of a
plain scaffold background.
2. Add search (text query against `Trip.name`/date) and filter (activity type at
minimum, reusing V3-01's `Activity` enum and `activityIcon`/`activityLabel` helpers).
3. Add a summary-stats query/provider computing totals across `watchCompletedTrips()`
(or a dedicated aggregate query if computing it client-side over a large ride history
becomes a real cost — measure before optimizing).
4. Rebuild `_TripTile` as a card with a small live/static `RideMap` thumbnail (reusing
the existing `RideMap` widget at a small size and non-interactive, rather than
building a second map-rendering path) plus the distance/time/avg-speed row.
5. Confirm this screen's tab identity/name in the nav bar — "Rides" (mockup's label) vs.
"History" vs. keeping "Rides" as used elsewhere in the codebase already (V3-07's
Routes screen already uses "Rides" as the trips-tab label on the record screen) —
match whatever UI-01's nav bar ships with.
## Acceptance criteria
- [x] Background is the shared live map (dimmed), not a static image
- [x] Search narrows the list by name/date
- [x] Filter narrows the list by activity type
- [x] Summary row shows four distinct, real statistics, not one number repeated
- [x] Each ride card shows an actual thumbnail of that ride's own path, not a stock photo
- [x] Merge/delete/rename actions from today's `TripsScreen` still work
- [x] Colors and icon fill-states match Map HUD, not the Stitch export's card styling
## Tests
- Widget: search filters the visible list correctly
- Widget: filter-by-activity narrows correctly
- Unit: summary-stats aggregation matches a hand-computed total over a fixed set of
seeded trips
- All existing `trips list` widget tests (empty state, newest-first ordering, active-ride
exclusion, merge-at-exactly-two-selections, delete) updated to the new layout and still
passing
## Risks
- Rendering a small `RideMap` thumbnail per card in a long list could be a real
performance cost (many simultaneous `FlutterMap` instances) — consider a lightweight
static polyline-on-canvas rendering instead of a full interactive map per thumbnail if
this proves too expensive; measure with a real ride history of realistic length before
deciding.
## Out of scope
Any change to trip data, merge, or split logic (V3-10) — presentation and search/filter
only.
## Outcome
`TripsScreen` is now a headerless screen (per UI-01) over the shared dimmed background
map, with a `GlassPanel`-wrapped search bar (`ride-search`) plus a `PopupMenuButton`
activity filter (`activity-filter`), a four-card distinct summary row, and `_TripCard`s
carrying a real 88x88 `RideMap` thumbnail of that ride's own recorded path.
**`RideHistorySummary`** is a plain pure function (`RideHistorySummary.compute(List<Trip>)`),
not a Riverpod provider — folding over an already-fetched list is cheap regardless of ride
count, and being a pure function is what made it directly unit-testable against a
hand-computed total, per the ticket's own Tests section. Its average speed is
distance-weighted across the whole history (not an average of each ride's own average),
so a handful of long rides isn't drowned out by many short ones. The summary reflects the
full unfiltered history, not the currently-searched/filtered subset — it's an overview
stat, not a count of what's visible below it.
**Bug found and fixed while wiring the thumbnails:** the shared `RideMap` widget
unconditionally rendered `TileAttribution` (added in UI-09) inside every `FlutterMap`,
including tiny 88x88 list thumbnails — both a real UX problem (attribution controls
cluttering dozens of small thumbnails) and an actual test collision (`find.byType(Icon)`
scoped to one trip's keyed subtree started matching the attribution button's own icon
too, once thumbnails were added). Fixed by adding a `showAttribution` bool parameter to
`RideMap` (default `true`, so every existing full-size map call site — Map tab
background, Route Planner, Trip Detail — is unaffected) and passing `showAttribution:
false` specifically for `_TripCard`'s thumbnail.
**The same `GlassPanel`-needs-`Material`-ancestor bug UI-06 already hit** recurred
identically in `_TripCard` (rebuilt from `StatelessWidget` to `ConsumerWidget` to watch
the per-trip point/segment streams for its thumbnail) and was fixed the same way:
`Material(type: MaterialType.transparency)` wrapping the `InkWell`.
**Rename is correctly out of scope for this file**: it lives on `TripDetailScreen`
(`_rename`), not the list, and was never touched here — the ticket's "merge/delete/rename
still work" line is satisfied by leaving Trip Detail's own rename alone while restyling
only the list's merge/delete selection toolbar.
**Tests:** `flutter analyze` clean. `flutter test` green at **374 tests** (up from 370),
adding: a widget test that search narrows the list to matching rides, a widget test that
filtering by activity narrows the list, and two unit tests for `RideHistorySummary.compute`
(a hand-computed multi-trip total, and the empty-history zero-division guard) in a new
`test/ride_history_summary_test.dart`. Two pre-existing `widget_test.dart` assertions
needed updating for the new card structure: `'an active ride does not appear in the
list'` asserted `find.byType(Card)` (now `find.byType(RideMap)`, unique to a trip card
since `_SearchAndFilter`/`_SummaryStatCard` don't render one), and the icon-count
collision above resolved once attribution was suppressed on thumbnails.
**Android emulator verification** (`Medium_Phone_API_35`): confirmed end-to-end after
working around significant, unrelated host resource pressure during this session (the
emulator repeatedly hit System UI/app ANRs under low host memory; resolved by freeing
host RAM, a full emulator restart, and a `force-stop`+relaunch of the app — see session
notes, not an app defect). Verified on-device: search field narrows the list correctly
(a query matching neither ride's name/date shows "No rides match your search.", clearing
it restores both); the activity filter's `PopupMenuButton` opens with all seven
activities plus "All activities", correctly highlights the currently-active choice,
narrows to zero rides when filtered to an activity neither seeded ride has (Bicycle),
and shows both again when filtered to the activity they actually have (Motorcycle); the
four summary cards (Total Dist/Total Time/Rides/Avg Speed) render as genuinely distinct
values, not the mockup's four-identical-cards artifact; both ride cards render real,
distinct polyline thumbnails with no attribution icon visible; long-pressing a card
enters selection mode with the Cancel/count/Merge/Delete toolbar, Merge correctly stays
disabled at one selection and enables at exactly two. Colors/icons match Map HUD's
established palette (translucent `GlassPanel` cards, blue accent, same activity icon set)
throughout.

View File

@@ -0,0 +1,160 @@
# UI-08 — Theme migration to Modern Professional Dark
**Depends on** nothing · **Size** S · **Status** Done
## Goal
Replace `theme.dart`'s current safety-orange palette with "Modern Professional Dark" —
the design system actually backing every fetched Stitch screen — so every screen ticket
in this set has one settled palette to build against instead of guessing or patching
colors per screen.
## Context
V3-16 (last shipped) deliberately kept and refined the original safety-orange-on-warm-
charcoal identity, with an instrument-blue tertiary for reference readings. That work is
good and tested, but it is **not** the palette the Stitch mockups use — confirmed by
hex-matching every color in the four fetched screens' HTML against all three design
systems in the Stitch project (`docs/design/stitch-export/README.md`). If this redesign
ships, V3-16's direction is superseded, not extended.
Full spec: `docs/design/stitch-export/design-system-modern-professional-dark.md`.
## Design
- **Ground:** deep dark gray `#0a0a0c` (not pure black — "a true premium feel without
the harshness of pure black," per the design system's own doc), stepped up through
`#131313` (surface) and `#16161a` (cards) for elevation.
- **Primary:** Professional Blue — rendered as `#4090fe` (container) / `#aac7ff`
(on-dark-surface primary) / `#002f64` (on-primary text). This replaces safety-orange as
*the* accent everywhere: live telemetry, active nav indicator, primary buttons,
the user's own location dot.
- **Secondary/tertiary:** muted slate-blue (`#aec7f6`/`#2e476f`) and a warm orange
(`#ffb68c`/`#e3711f`) reserved for tertiary accents (the design system's own spec
doesn't define a strict "reference vs. live" split the way V3-16's tertiary did —
decide during implementation whether to keep that instinct using this palette's
secondary color, or drop it; either is defensible, but pick one and apply it
consistently rather than leaving it ambiguous per screen).
- **Typography:** Inter, exclusively, for every role (headline/body/label) — this drops
the multi-font split V3-16 and the original theme used (monospace for numbers via
`monoDigits` is a separate, orthogonal decision from V3-16 worth keeping regardless of
which color palette wins, since "digits must not jitter" is a real constraint, not a
branding choice — retain `monoDigits` layered under Inter-family theming).
- **Shape:** 8px rounding (`ROUND_EIGHT`), up from the current 4px.
- **Depth:** tonal layering + a soft blue glow on elevated/active elements, no shadows —
matches V3-16's existing "no shadows, translucency for depth" instinct, just with a
different accent color for the glow.
- **Contrast:** the design system's own doc claims AAA-level contrast for functional
text against the dark backdrop — verify this the same way V3-16 did (a
`contrastRatio()` helper and real assertions), don't take the claim on faith.
## Implementation
1. Update `theme.dart`'s `ripprColors`/`ColorScheme` to the Modern Professional Dark
values (ground, surface, cards, primary/secondary/tertiary, outline).
2. Update `bodyFont`/`headlineFont`/`labelFont` to Inter throughout; keep `monoDigits` as
a distinct style applied specifically to numeric displays, unchanged in spirit from
today.
3. Update `roundness` usage (button/card corner radii) from 4px to 8px.
4. Re-run V3-16's `contrastRatio()` assertions against the new palette; adjust any color
that fails AA before shipping, exactly as V3-16 did for the palette it replaces.
5. Decide and document the reference-vs-live color convention (see Design) rather than
leaving V3-16's tertiary-for-reference-readings instinct undecided under the new
palette.
6. **Mounted mode (V3-05) needs its own pass**, not an automatic inheritance — it's a
separate high-contrast daylight theme with its own palette, tuned against a real
sunlight/visor constraint. Confirm it still holds up in spirit (still legible, still
AA-compliant) under the new brand direction; V3-13's real-ride verification is still
the only way to confirm the *actual* sunlight legibility claim, same caveat V3-16
already recorded.
## Acceptance criteria
- [ ] `ripprColors` matches Modern Professional Dark's token values
- [ ] Every screen using `Theme.of(context).colorScheme` picks up the new palette with
no per-screen hardcoded color left over from the old theme
- [ ] `contrastRatio()` assertions pass against the new palette (AA normal/large, same
thresholds V3-16 established)
- [ ] `monoDigits` still applies to every numeric display
- [ ] Mounted mode re-verified against the new brand direction
## Tests
- All of V3-16's `theme_test.dart` contrast assertions, re-pointed at the new color
values, still pass
- The existing black-on-black regression guard (`text is legible against the dark
ground`) still passes
- Widget: a representative sample of screens render with the new palette (no leftover
hardcoded orange/old-tertiary-blue literals)
## Risks
- **Regressing the black-on-black guard is the named risk V3-16 itself called out** —
this ticket touches the same `textTheme`/`bodyColor`/`displayColor` wiring that bug
came from originally. Do not remove the explicit color-naming discipline while
restyling.
- Grep for hardcoded hex literals from the old palette (`0xFFFF5722` and friends) across
the UI layer before considering this done — a few call sites (e.g. `RideMap`'s speed
gradient) reference specific hex values directly rather than through the theme, and
those need a deliberate decision, not an accidental miss.
## Out of scope
Any screen-specific redesign (UI-05/06/07) — this ticket only changes the token layer
those screens then build against.
## Outcome
`lib/src/ui/theme.dart`'s `ripprColors` now carries Modern Professional Dark's values:
ground `#0a0a0c` (the design system's own `surface-main`, not its plain `background`
token — the spec's prose is explicit that `#0a0a0c` is *the* background, with `#131313`
one step up and `#16161a` a second step up for cards, a three-level stack the old
two-level `_ground`/`_surface` pair didn't have room for). Added `ripprCardColor`
(`#16161a`) to carry the third level, since `ColorScheme` has no free surface slot for it
once `surfaceContainerHighest` is spent on the selected/highlighted state. Primary is
Professional Blue (`#4090fe`); tertiary is a warm orange (`#ffb68c`).
**Decision recorded per the ticket's own prompt:** the design system doesn't specify a
reference-vs-live split itself, so one was chosen and applied everywhere consistently —
tertiary orange for `StatRow`'s `reference` values (a max, an average), inverted from
V3-16 where blue held that role, because blue is now the *primary* colour and reusing it
for reference readings would have made the two indistinguishable. `RideMap`'s speed
polyline gradient — flagged explicitly in the ticket's Risks as a hardcoded hex literal —
now lerps `colors.secondary` (the theme's own cool slate-blue) to `colors.primary`
instead of a hardcoded `0xFF4FC3F7`, for the same reason: the old literal was itself
blue, and lerping blue-to-blue under the new primary would have washed the gradient into
one hue.
**Shape:** added `ripprRadiusSmall` (8px) and `ripprRadiusLarge` (16px) constants
matching the spec's small-component/large-container split, and a `CardThemeData` using
the large radius and `ripprCardColor`. `RideMap`'s card-mode `ClipRRect` (previously a
hardcoded 12px) now uses `ripprRadiusLarge`. Deliberately did **not** add a global
`ElevatedButtonTheme`/`OutlinedButtonTheme` shape override — `RecordScreen`'s
Start/Pause/Stop/Discard buttons rely on Material 3's default `StadiumBorder` for their
pill shape, which already matches the Stitch mockups' own pill buttons; forcing an 8px
rectangle there would have been an unrequested regression, not a spec application.
**Inter was not wired in.** The design system calls for it exclusively, but the codebase
has no bundled font asset and no existing `bodyFont`/`headlineFont` abstraction to retarget
— setting `TextTheme.apply(fontFamily: 'Inter')` with nothing registered under that name
would silently fall back to the platform default (Roboto), which is exactly the class of
invisible failure `ripprTheme()`'s own `bodyColor`/`displayColor` fix exists to prevent
(the "Compose `Surface`" comment in the file). Left as a documented, deliberate follow-up
rather than shipping a fontFamily string that resolves to nothing. `monoDigits` is
untouched and still applies to every numeric display, as required.
**Contrast:** re-ran `theme_test.dart`'s `contrastRatio()` assertions against the new
palette (no values needed adjusting — primary-on-ground, tertiary-on-ground, and
onSurface-on-ground all clear their AA thresholds with considerable margin: roughly
6.3:1, 11.6:1, and 15.3:1 respectively, well past the 4.5:1/3:1 bars). Mounted mode
(V3-05) was left unchanged — its own separate high-contrast daylight palette isn't
covered by the Modern Professional Dark spec, its orange accent isn't a brand clash the
way the pocketed theme's safety-orange was, and V3-13's real-ride sunlight-legibility
claim depends on the specific values already tuned there. Its existing contrast tests
were re-run, unchanged, and still pass.
**Tests:** `flutter analyze` clean. `flutter test` green at 315 tests, no count change —
this ticket touched no test files, only re-pointed `ripprColors`'s literals and reused
the same `theme_test.dart`/`ride_map_test.dart` assertions the diff didn't need to alter
because they read `ripprTheme().colorScheme` rather than hardcoding old hex values.
**Android emulator verification** (`Medium_Phone_API_35`): checked Map/Settings/Rides
tabs. Buttons, switches, the segmented Metric/Imperial control, section labels, and the
`Save`/`Clear` text actions all render in Professional Blue; the reference "Max speed"
figure renders in the new tertiary orange, clearly distinct from the live "0" headline
figure (unchanged ink) and from the Pause button's blue; the bottom nav's active-tab
indicator picked up a blue-tinted pill automatically (Material 3 derives it from the
`ColorScheme`, not something this ticket configured directly) rather than the old
neutral grey. No leftover orange/old-instrument-blue literals were visible anywhere.

View File

@@ -0,0 +1,154 @@
# UI-09 — Monochrome dark map tiles
**Depends on** nothing · **Size** S · **Status** Done
## Goal
Replace the tan/cream default OpenStreetMap raster style with a monochrome dark map, so
the map actually looks like it belongs in a dark-mode HUD instead of a light basemap
punched through a dark overlay.
## Context
`RideMap`, the Plan/Route Planning canvas, and every Stitch mockup all assume a dark or
near-monochrome map underneath the HUD. Today's `TileLayer` (`ride_map.dart`) points at
`tile.openstreetmap.org`, OSM's standard light/tan default style — the actual on-device
result is nothing like the mockups, regardless of how dark the chrome on top of it is.
This affects every screen that shows a map (Map HUD, Plan, Route Planning, and UI-01's
dimmed background on every other tab), so it's worth settling once, early, rather than
each screen ticket discovering the same mismatch independently.
## Design
Two real options, not one obvious answer:
**Option A — switch tile provider to CARTO's "Dark Matter" basemap.**
`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png` (subdomains a-d, retina
`{r}` variant available, max native zoom 20). Purpose-built dark, desaturated/monochrome
cartography — closest to the mockups with the least engineering. Free for reasonable
usage same as OSM's own tiles, but it's a **different third party with its own usage
policy and attribution requirement** ("© OpenStreetMap contributors © CARTO", not just
OSM's own attribution) — a real dependency to add, not a style tweak.
**Option B — keep OSM tiles, apply a color filter client-side.** Wrap `TileLayer` in a
`ColorFiltered` widget using a `ColorFilter.matrix` that desaturates and inverts/darkens
the tan basemap into a monochrome dark look. No new third party, no new attribution, and
V3-11's offline tile cache keeps working against the exact tiles it already knows about
— but the result is a filtered light map, not tiles actually designed for dark
presentation, and will read as slightly "off" next to CARTO's purpose-built version
(road/building contrast tuned for light backgrounds doesn't always invert cleanly).
**Recommendation: Option A.** The mockups' whole aesthetic depends on the map itself
being dark, not just tinted dark, and CARTO's dark tiles are a well-established, free,
no-API-key option already widely used in the Flutter/Leaflet ecosystem for exactly this.
The added attribution line and a second host to trust are a small, one-time cost.
**V3-11 interaction:** the offline tile cache (`FileTileCache`/`CachedTileProvider`) is
already keyed by `TileKey(z, x, y)` with no provider-specific data baked in — but that
means a cache built against OSM tiles and a cache built against CARTO tiles are
**indistinguishable to the cache** despite being visually different. Switching providers
without a plan here would silently serve stale, wrong-looking cached OSM tiles once
CARTO's URL is live. This needs a cache-busting or provider-tagging fix as part of this
ticket, not a separate one — see Implementation.
## Implementation
1. Add a `provider`-qualified cache key (or a cache-version bump that invalidates
existing V3-11 cache contents wholesale) so switching tile sources can't silently
mix cached tiles from two visually different sources.
2. Update `RideMap`'s `TileLayer` (and the Route Planner's separate `TileLayer` instance
— see `route_planner_screen.dart`) to CARTO's dark tile URL template, correct
`subdomains`, and `maxNativeZoom: 20`.
3. Update the attribution requirement — check whether `flutter_map`'s
`RichAttributionWidget`/`AttributionWidget` is in use anywhere already, or add one;
OSM-only attribution is no longer sufficient once CARTO tiles are in the mix.
4. Update `tileUserAgent`/any OSM-specific comments in `ride_map.dart` that assumed OSM's
tile servers specifically (the "respect OSM's usage policy" reasoning still applies in
spirit to CARTO's own policy, but the specific server being addressed changes).
5. Re-verify V3-11's tile math/download flow still works end-to-end against the new URL
template (tile enumeration and caching are provider-agnostic already; only the fetch
URL construction needs updating).
## Acceptance criteria
- [ ] The map renders in a dark, desaturated/monochrome style matching the Stitch
mockups, not OSM's default tan basemap
- [ ] Correct attribution for both OpenStreetMap and CARTO is shown wherever the map is
shown
- [ ] Switching tile providers does not silently serve stale, visually-mismatched
cached tiles from before the switch (V3-11's cache is invalidated or
provider-tagged)
- [ ] Both `RideMap` and the Route Planner's independent `TileLayer` are updated
consistently — one dark style everywhere, not just on the screens that happened to
get touched first
## Tests
- Existing `ride_map`/route-planner widget tests updated for the new tile URL and still
passing
- Unit: cache-key/versioning change is exercised the same way V3-11's eviction tests
are — a cache built under the old scheme does not get served for the new provider
## Risks
- **A second third-party tile host is a real dependency, not a free color swap** — its
own usage policy, its own risk of being blocked if misused, and its own attribution
obligation. Treat it with the same care V3-11 already applies to OSM (rate-limit
respecting, no bulk prefetch, real user agent).
- If CARTO's free tier ever proves insufficient or is discontinued, Option B (a color
filter over OSM tiles) is the fallback with no new third-party dependency — worth
keeping in mind as a documented Plan B, not re-deriving from scratch later.
## Out of scope
A fully custom/self-hosted vector tile style — much larger scope, and not needed to hit
"looks like the mockups" today.
## Outcome
Took the recommended Option A. `ride_map.dart` now defines `tileUrlTemplate`
(`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`), `tileSubdomains`
(`a`-`d`), and `tileMaxNativeZoom` (20, kept separate from the app's own `maxTileZoom`
clamp of 19 — raising the app's overall zoom ceiling to match CARTO's native maximum was
deliberately left alone rather than folded into this ticket, since it would mean
re-verifying the fit/follow-zoom logic V3-04/V3-05 already tuned against 19). Both
`RideMap` and `route_planner_screen.dart`'s independent `TileLayer` now import and use
these same three constants plus `retinaMode: true`, so there is exactly one dark style
and one set of tile-request parameters across the app, not two independently-drifting
copies.
**Cache versioning (the ticket's named risk):** `tileCacheProvider` in
`app/providers.dart` now points `FileTileCache` at `tiles/carto_dark_v1` instead of the
old bare `tiles` directory. `TileKey(z, x, y)` still carries no provider identity, so
the directory segment is the actual version tag — any tiles cached under the old OSM-tan
scheme are simply orphaned in a directory the app no longer reads from, rather than
being silently served under the new dark UI. Added a unit test
(`test/tile_cache_test.dart`, "a tile cached under one provider directory is not served
from another") proving this isolation holds at the `FileTileCache` level, the same way
V3-11's own eviction tests exercise cache behavior directly rather than through the UI.
**Attribution:** added a shared `TileAttribution` widget (`ride_map.dart`) wrapping
flutter_map's `RichAttributionWidget` with `TextSourceAttribution`s for both
"OpenStreetMap contributors" (CARTO's dark style is still built from OSM's underlying
data) and "CARTO" itself — OSM-only credit, which is what the app carried before, stopped
being sufficient the moment a second tile host entered the mix. Composed into both
`RideMap` and the route planner's `FlutterMap`, so it's one component discharging the
obligation everywhere a map renders, not a copy-pasted attribution block per screen. Did
not wire up `onTap` license-page links — that would need the `url_launcher` package, a
new dependency this ticket has no other reason to add; the obligation is to visibly
credit both sources, not to make the credit tappable.
**Tests:** `flutter analyze` clean. `flutter test` green at 316 tests (315 + the new
cache-isolation test) — no existing test hardcoded the old OSM URL string, so nothing
else needed updating.
**Android emulator verification** (`Medium_Phone_API_35`): confirmed CARTO's dark,
desaturated basemap renders on-device (a genuinely dark map, not a tan basemap under a
dark overlay) — a clear visual match for the Stitch mockups' aesthetic, on the Map tab at
full opacity and dimmed correctly on Rides/Plan/Settings via UI-01's existing scrim. The
attribution icon is visibly present in the bottom-left corner on every tab (same shared
map instance). Its tap-to-expand interaction could not be confirmed via `adb input tap`
in this session — taps at its on-screen coordinates didn't visibly toggle the popup, and
a genuine Android mock-location watermark icon (left over from this session's earlier
`adb emu geo fix` calls, and confirmed present at the same screen position across
unrelated tabs, which a page-level widget couldn't be) sits immediately next to it,
making the exact tap target ambiguous to hit blindly. This is a dev-tooling
verification gap, not a known defect — the widget itself renders without error and
matches flutter_map's standard, widely-shipped attribution pattern (the same
small-icon-that-expands convention Google Maps and Mapbox both use). Re-verify the
popup's tap behavior with a real touchscreen or Flutter Inspector if it becomes load-
bearing later (e.g., if legal review specifically requires confirming the expand
interaction, not just the icon's presence).

View File

@@ -0,0 +1,80 @@
# v3 tickets
One file per feature, in the shape that worked for v2: Goal · Context · Design ·
Implementation · Acceptance criteria · Tests · Risks · Out of scope. Written before
implementing, so the risks are on paper before they are walked into.
Menu, not a commitment. Nothing here is scheduled.
**Everything in v3 is buildable with no server.** Group rides, accounts and paid cloud
backup are on the ROADMAP (formerly "v4") — see [../BACKLOG.md](../BACKLOG.md).
> **Before starting anything:** [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md).
> The port has never recorded a real ride, and item I3 may still force a change of GPS
> engine — which would land underneath several of these tickets.
## The tickets
| # | Ticket | Size | Depends on | Status |
|---|---|---|---|---|
| [V3-01](V3-01-activity-type.md) | Activity type per ride | M | — | Done |
| [V3-02](V3-02-settings-screen.md) | Settings screen | S | — | Done |
| [V3-03](V3-03-units.md) | Distance and speed units | S | V3-02 | Done |
| [V3-04](V3-04-live-map.md) | Live map on the recording screen | M | — | Done |
| [V3-05](V3-05-mounted-mode.md) | Mounted (handlebar) mode | M | V3-04 | Done |
| [V3-06](V3-06-notification-stats.md) | Live stats in the notification | S | — | Done |
| [V3-07](V3-07-route-drawing.md) | Route drawing (pins, straight lines) | M | — | Done |
| [V3-08](V3-08-road-routing.md) | Road-snapped routing and ETA | L | V3-07, V3-01, **V3-17** | Deferred (needs V3-17) |
| [V3-09](V3-09-route-following.md) | Follow a planned route | M | V3-04, V3-08 | Deferred (needs V3-08) |
| [V3-10](V3-10-trip-splitting.md) | Trip splitting | S | — | Done |
| [V3-11](V3-11-offline-tiles.md) | Offline tile pre-download | M | V3-04 | Partially done (pipeline shipped; needs aeroplane-mode device verification) |
| [V3-12](V3-12-crash-reporting.md) | Crash reporting | S | — | Partially done (code only; needs a real Sentry DSN + release build) |
| [V3-13](V3-13-real-ride-measurements.md) | Real-ride measurements | M | **riding** | Not started |
| [V3-14](V3-14-gpx-interop.md) | GPX interoperability | S | V3-01 | Partially done (code only; needs real-device verification) |
| [V3-15](V3-15-auto-pause.md) | Auto-pause | M | V3-13 *(gated)* | Not started |
| [V3-16](V3-16-visual-identity.md) | Visual identity | M | V3-04, V3-05 | Partially done (token-level identity shipped; needs outdoor device verification) |
| [V3-17](V3-17-osrm-hosting.md) | Self-hosted OSRM: investigate and stand one up | M | — *(needs a server — see the ticket's own note on the v3/ROADMAP boundary)* | Not started |
## Dependencies
```
V3-01 ──┬────────────► V3-08 ──► V3-09
└──► V3-14 ▲
V3-07 ──────► V3-08 │
V3-17 ──────► V3-08 │
V3-02 ──► V3-03 │
V3-04 ──┬──► V3-05 ──┬───────┘
├──► V3-11 └──► V3-16
└──► V3-09
V3-13 ──► V3-15 (gate: may close unbuilt)
no dependencies: V3-01 · V3-02 · V3-04 · V3-06 · V3-07 · V3-10 · V3-12 · V3-17
```
**V3-08/V3-09 are deferred, on request**, pending V3-17 (self-hosted OSRM). See V3-17's
own note on why that also puts them in tension with this document's v3/ROADMAP boundary —
unresolved by design, not an oversight.
## Three that carry more weight than their size suggests
**V3-01** ships the port's **first real migration**. The destructive fallback is gone, so
getting `addColumn` plus a v1-database test right matters more than the feature does —
V3-07 and V3-09 both add migrations behind it.
**V3-08** forces a routing-engine decision with ongoing cost and vendor implications.
Behind a `RoutingService` interface, mirroring what `LocationSource` did for GPS. The
direction is decided (self-hosted OSRM); V3-17 does the actual standing-up, and V3-08 is
deferred until it exists.
**V3-13** is not code. It answers the three questions that have been open since v2, and
**V3-15 may close unbuilt** as a result — a legitimate and probably likely outcome.
## Suggested order, if starting cold
1. **V3-01** — proves the migration path while the stakes are low, and unblocks V3-08/14
2. **V3-02 + V3-03** — small, self-contained, gives V3-01 a home
3. **V3-04** — the most visible change, and the gateway to four other tickets
4. **V3-07** — entirely independent; useful on its own before the routing decision
5. **V3-13** — as soon as there is a ride to measure
V3-12 is a good filler at any point. V3-16 should wait until the screens stop moving.

View File

@@ -0,0 +1,97 @@
# V3-01 — Activity type per ride
**Phase** Foundations · **Depends on** nothing · **Size** M · **Status** Done
## Goal
Every ride records what it was done on — motorcycle, bicycle, scooter, skateboard,
running, walking, other — and that choice drives per-activity defaults rather than just
labelling the row.
## Context
The recording pipeline never cared what you were sitting on: it records positions, speeds
and altitudes. Only the UI says "motorcycle". Dylan rides both bikes and motorcycles.
This is also a **positioning decision** (see [../LAUNCH.md](../LAUNCH.md)): it determines
the store category, the screenshots, and who ever finds the app. Cyclists are a much larger
audience and already pay for ride apps. Cheaper to settle before a store listing exists.
**This ticket ships the port's first real migration.** That matters more than the feature.
## Design
Text enum column on `Trip`, exactly like `TripState`:
`motorcycle · bicycle · scooter · skateboard · running · walking · other`
**Type drives defaults, not labels.** Introduce an `ActivityProfile` rather than scattering
`if (activity == …)`:
| Setting | Today | Why it must vary |
|---|---|---|
| Speed noise floor | 1.5 km/h | Right for a motorcycle; walking lives near it |
| Histogram bucket | 10 km/h | Useless for running — one bucket. Wants ~1 km/h |
| Accuracy gate | 50 m | A bike at speed tolerates looser fixes than a walker |
| Elevation smoothing window | 15 samples | Tuned for 2 Hz at road speed |
| Map fit zoom | — | A 2 km walk and a 200 km ride differ |
**No picker in front of Start.** The founding premise is press-and-go with gloves on.
Default to the last activity used; make it editable on trip detail next to rename.
## Implementation
1. `Activity` enum in `domain/models.dart`; `activity` field on `Trip`
2. Drift column with `.withDefault(Constant('motorcycle'))`
3. **Migration**: `schemaVersion` 1 → 2, `m.addColumn(trips, trips.activity)`
4. `ActivityProfile` in `stats/` holding the constants above; thread it through
`computeSummary`, `speedHistogram`, `Accumulator`, `isUsableFix`
5. Persist last-used activity in `Config`
6. Trip detail: activity row, editable via the same pattern as rename
7. Trips list: show the activity icon on each tile
## Acceptance criteria
- [ ] A new ride records an activity; existing rides read `motorcycle`
- [ ] A v1 database opens, migrates, and **keeps every ride and point**
- [ ] Changing a trip's activity recomputes its aggregates under the new profile
- [ ] Start still takes exactly one tap
- [ ] `flutter analyze` clean, all existing tests still pass
## Tests
- **Migration test** — build a v1 database, migrate, assert rides and points survive.
Drift's `MigrationTestHelper` with a generated v1 schema.
- Profile selection: each activity yields its own noise floor and bucket size
- A running-activity ride produces a histogram with more than one bucket
- Round-trip the enum through the database
## Risks
- **The migration is the risk.** Getting it wrong destroys real rides, and the destructive
fallback is deliberately gone. Write the migration test first.
- Recomputing aggregates on activity change is easy to forget — a ride switched from
motorcycle to walking keeps a wrong moving time otherwise.
## Out of scope
Per-activity totals or a stats screen. GPX `<type>` export (see V3-14).
## Outcome
Shipped as designed, with two deliberate deviations from the ticket text, both
recorded here rather than silently:
- **No `Config.lastActivity` field.** "Default to the last activity used" is instead
derived live from the trips table itself (`AppDatabase.mostRecentTrip()` /
`TripRepository._lastUsedActivity()`) rather than duplicated into a separate
preference. One source of truth, no write path to keep in sync, and it degrades
correctly to `motorcycle` when the database is empty.
- **`ActivityProfile` lives in `domain/activity_profile.dart`**, not folded into
`models.dart` — kept the domain model (`Trip.activity`) separate from the behavioural
defaults built on top of it, and avoided a dependency cycle between the domain layer
and `stats/`/`recording/`.
Map-fit zoom (mentioned in the ticket's defaults table) turned out to need no work:
`RideMap` already fits to the ride's actual recorded bounds, which is activity-agnostic
by construction.
The widget-test pass caught a real overflow bug independent of activity type: the
7-item activity picker sheet overflowed a `Column`-based `showModalBottomSheet` the same
way the record screen once did (see `docs/port/PROGRESS.md`, Phase 4). Fixed with a
scrollable `ListView` + `isScrollControlled: true`, the same shape as that earlier fix.
188 tests total (171 → 188): migration (2), `ActivityProfile` (5), `computeSummary`
profile-threading proof (3), repository (5), widget (2).

View File

@@ -0,0 +1,86 @@
# V3-02 — Settings screen
**Phase** Foundations · **Depends on** nothing (pairs with V3-01, V3-03) · **Size** S · **Status** Done
## Goal
One place for the preferences that currently have nowhere to live.
## Context
`Config` already holds `uploadEndpoint`, `deviceId` and `mapEnabled`, but there is no UI
for any of them. The map toggle sits on trip detail because a single switch did not justify
a screen. V3-01 and V3-03 both add preferences, which finally does justify one.
The upload endpoint has **never** had UI, in the native app or the port. This is where it
stops being a code-only setting.
## Design
Reached from the record screen. Sections:
- **Recording** — default activity (V3-01), units (V3-03)
- **Map** — render maps on trip detail; later, live map (V3-04)
- **Sync** — upload endpoint, with the device id shown read-only and copyable
- **About** — version, a link to the privacy policy, licences
Keep it plain. This is not a screen anyone should spend time in.
## Implementation
1. `SettingsScreen` + a `go_router` route
2. Make `Config` reactive — it is currently read once into a provider; settings need writes
to propagate. A `ConfigNotifier` over `shared_preferences`.
3. Move the map toggle off trip detail
4. Endpoint field validates it parses as a URL and is http(s)
## Acceptance criteria
- [ ] Every `Config` value is viewable and editable
- [ ] Changing the map toggle takes effect without an app restart
- [ ] An invalid endpoint is rejected with a readable message, not silently stored
- [ ] Device id is copyable — it is the only way to identify this install to a server
## Tests
- Widget: each control renders and writes through to `Config`
- Widget: invalid URL rejected
- Changing the map toggle rebuilds trip detail
## Risks
`Config` is currently loaded once at startup into a `StateProvider`. Making it writable
without introducing a second source of truth is the only subtle part.
## Out of scope
Account settings (ROADMAP). Theme selection (V3-16).
## Outcome
"Move the map toggle off trip detail" (implementation step 3) turned out to be moot —
`mapEnabledProvider` already existed and drove `RideMap`'s visibility, but **no widget
anywhere ever offered a control to change it**. There was nothing to move. Settings adds
the first one.
No `ConfigNotifier` was built — see V3-03's outcome for why the existing
`mapEnabledProvider`-style `StateProvider` pattern covers reactivity without it, and
`unitSystemProvider` was added the same way, in `providers.dart`, ahead of this ticket.
Two implementation-step items shipped differently than drafted, both to avoid adding a
dependency disproportionate to an `S`-sized settings screen:
- **No `package_info_plus`.** The About section's version string is a static literal
matching `pubspec.yaml`'s `1.0.0+1`, not a live package lookup. Fine today; would need
revisiting if the version ever needs to be authoritative from inside the running app
rather than copied by hand.
- **No privacy-policy link.** None is published yet (see `docs/LAUNCH.md`) and linking
to one that does not exist would be worse than omitting it. Shows a plain note instead.
Licences are still free: Flutter's built-in `showLicensePage` needed no new dependency.
Endpoint validation accepts `http://` and `https://` with a non-empty host, and treats
an **empty** field as valid — that is how upload gets disabled, not an error state. A
non-empty invalid value is rejected with inline `errorText` and never reaches `Config`;
proven by a widget test that types garbage, taps Save, and asserts `Config.uploadEndpoint`
is still empty afterwards.
`uploaderProvider` needed an explicit `ref.invalidate()` after saving the endpoint, for
the identical reason `unitSystemProvider` needed its own `StateProvider` rather than
reading through `configProvider` directly — a `Config` write never changes the `Config`
instance Riverpod is watching, so nothing downstream rebuilds unless told to.
10 tests: 9 in `settings_screen_test.dart`, 1 confirming the record screen's settings
button is genuinely wired (not just present) in `widget_test.dart`.

View File

@@ -0,0 +1,87 @@
# V3-03 — Distance and speed units
**Phase** Foundations · **Depends on** V3-02 · **Size** S · **Status** Done
## Goal
Imperial as well as metric, chosen once and applied everywhere.
## Context
Everything is hardcoded metric: `formatDistance` switches m/km at 1000, `formatSpeed`
prints km/h, elevation prints metres. Fine in Canada, useless to anyone in the US or UK.
Cheap, and the kind of thing that makes an app feel unfinished when missing.
## Design
A `UnitSystem` enum (`metric`, `imperial`) in `Config`, defaulting from the device locale
on first launch.
**Conversion belongs in formatting only.** Storage stays SI — metres, km/h, metres of
altitude — forever. Converting at the storage layer would corrupt every existing ride and
break the parity harness.
| Value | Metric | Imperial |
|---|---|---|
| Distance | m / km | ft / mi |
| Speed | km/h | mph |
| Elevation | m | ft |
## Implementation
1. `UnitSystem` in `Config`; default from `Platform.localeName`
2. Extend `ui/format.dart` — every formatter takes the unit system
3. Thread it through: record screen, trips list, trip detail, charts, map legend
4. Exports stay SI regardless. GPX is metres by specification; changing that breaks
consumers.
## Acceptance criteria
- [ ] Switching units updates every screen immediately
- [ ] Stored values are unchanged — verified by exporting before and after
- [ ] GPX/GeoJSON output is byte-identical across the two settings
- [ ] First launch picks a sensible default from the locale
## Tests
- Formatter tests for both systems, including the m→km and ft→mi boundaries
- **A test asserting export output does not change with the unit setting**
- Widget test toggling units and checking a rendered label
## Risks
The obvious trap is converting too deep in the stack. Guard it with the export test.
## Out of scope
Temperature, pace (min/km) — pace is arguably right for running, revisit after V3-01.
## Outcome
Built ahead of V3-02 in execution order, despite the ticket table listing it as
depending on V3-02 — the Settings screen needed something real to control, and the
formatting/`Config` plumbing itself has zero dependency on a screen existing. Both are
done; the numbering is unchanged.
`UnitSystem` lives in `domain/models.dart`, not `ui/format.dart` as first drafted —
`Config` (a data/preferences-layer class) needed the enum too, and having it depend on
`ui/` read backwards. Moved to the domain layer alongside `Activity`, which every other
cross-cutting preference-like enum in this codebase already does.
Reactivity reuses the exact pattern `mapEnabledProvider` already established —
`unitSystemProvider`, a `StateProvider<UnitSystem>` seeded from `Config` once and then
read/written directly by the UI — rather than introducing the heavier `ConfigNotifier`
class the ticket's implementation notes proposed. `Config` mutates its own backing
`SharedPreferences` in place, so a widget re-assigning the same `Config` instance to
`configProvider` was never going to notify anything; this sidesteps that without a new
abstraction.
Threaded through all three screens plus the speed histogram's bucket labels, which
convert-and-round for display (`formatSpeedRangeLabel`) without changing how
`speedHistogram` itself bins — binning stays km/h always, matching the invariant that
storage and computation never see the display unit. Export was the one place explicitly
*not* touched: `gpx()`/`geoJson()` take no `UnitSystem` parameter at all, which is a
stronger guarantee than validating one.
One real finding: `config_test.dart`'s locale-default test deliberately does not assert
a specific value, because it cannot know the test runner's own locale — and that caution
was immediately vindicated. `settings_screen_test.dart` first asserted a fresh `Config`
defaults to metric and failed, because this dev machine's own locale resolves to a
region in the imperial set. Fixed by seeding an explicit value before asserting, the
same technique already used elsewhere for exactly this reason.
22 tests: 13 `format_test.dart`, 9 `config_test.dart`.

View File

@@ -0,0 +1,90 @@
# V3-04 — Live map on the recording screen
**Phase** Live map · **Depends on** nothing · **Size** M · **Status** Done
## Goal
While recording, show the path as it is drawn, on the recording screen.
## Context
v2 shipped without one deliberately: the phone rides in a pocket, so a live map would burn
battery for something nobody is looking at. **That decision is reversed** — Dylan wants it
for visual appeal, and because a mounted phone is now a real use case (V3-05).
The map component already exists (`RideMap`) and already handles per-segment polylines,
render-only decimation and the zoom clamp. This ticket is mostly about *lifecycle*, not
drawing.
## Design
Two constraints survive the reversal and are non-negotiable:
- **`RecordingEngine` must never reference a map.** Rendering belongs to a visible screen's
widget lifecycle. The engine already exposes everything needed.
- **No tile fetch or redraw while backgrounded.** A pocketed phone must cost exactly what
it costs today.
Feed the map from a stream of the current trip's points. `watchTripStats` exists but
returns aggregates; this needs the points themselves — add a `watchPointsForTrip` Drift
stream, which updates naturally on each writer flush (~2 s), not per fix.
Follow the rider: keep the latest point centred, with a manual-pan override that stops
auto-follow until re-enabled.
Behind the existing map toggle, off by default while it is unproven on battery.
## Implementation
1. `watchPointsForTrip(tripId)` in `AppDatabase`
2. Hoist the map above the stats card on the record screen, behind the toggle
3. `WidgetsBindingObserver` — on `AppLifecycleState.paused`, stop tile fetching; resume on
`resumed`. This is the load-bearing part.
4. Auto-follow with a pan override
5. Keep the numeric readout visible; the map must not push SPEED off screen (the record
screen already scrolls — see the overflow fix in `port/PROGRESS.md`)
## Acceptance criteria
- [ ] The path appears and extends while recording
- [ ] Backgrounding the app stops all tile activity, verified in a network log
- [ ] `RecordingEngine` still has no map import — grep it
- [ ] With the toggle off, no map widget is constructed at all
- [ ] Speed and elapsed remain visible without scrolling on a common phone size
## Tests
- Widget: map appears only when recording and the toggle is on
- Widget: lifecycle transition to paused stops the tile layer
- The existing map tests still pass
## Risks
- **Battery.** This is the whole reason v2 said no. Measure before defaulting it on
(V3-13).
- Redrawing per fix rather than per flush would be wasteful; drive from the database
stream, which is already batched.
## Out of scope
Mounted mode (V3-05). Other riders on the map (ROADMAP).
## Outcome
Shipped as designed. `AppDatabase.watchPointsForTrip`/`watchSegmentsForTrip` feed two
`autoDispose.family` providers (`livePointsProvider`, `liveSegmentsProvider`) keyed by
trip id; a `_LiveMap` adapter widget on the record screen reads them and hands the result
to the existing `RideMap`, unchanged in shape. `RecordingEngine` was never touched —
verified by `test/architecture_test.dart`, which greps the source rather than trusting a
comment.
`RideMap` itself grew two small, general capabilities rather than a parallel "live"
widget: a `WidgetsBindingObserver` that drops the `TileLayer` entirely (not just visually,
via widget tree omission) outside `AppLifecycleState.resumed`, and an optional `follow`
flag that recentres on the latest point via `didUpdateWidget` + a post-frame
`MapController.move`, cancelled permanently by the first user-gesture pan. Both apply
to the trip-detail map too, which is a free win: a backgrounded detail screen no longer
holds tiles fetching either.
Three existing record-screen tests (`recording swaps to PAUSE and STOP`, `paused offers
RESUME`, `discard asks before destroying anything`) had to gain an explicit `map: false` —
they predate this ticket and would otherwise have started constructing a real
`FlutterMap`/`TileLayer` against an active trip, which is exactly the tile-fetch-in-tests
problem the trip-detail tests already route around.
3 new tests: the grep-based engine-purity check in `architecture_test.dart`, plus two in
`widget_test.dart` — map presence/absence by toggle and trip state, and the lifecycle
transition (paused drops `TileLayer` but keeps `PolylineLayer`; resumed brings it back),
driven via the standard `flutter/lifecycle` platform-message technique rather than a
private binding API. `flutter analyze` clean; full suite green (224 tests, up from 221).

View File

@@ -0,0 +1,101 @@
# V3-05 — Mounted (handlebar) mode
**Phase** Live map · **Depends on** V3-04 · **Size** M · **Status** Done
## Goal
Make the app usable on handlebars in daylight, at speed, with gloves — as an explicit mode
rather than an accident.
## Context
v1 and v2 were built entirely around "start it, pocket it, stop it". **A mounted phone is a
different product.** Treating it as a mode makes the differences deliberate instead of
half-met.
## Design
A toggle that changes several things at once:
- **Keep the screen awake** for the whole ride (`wakelock_plus`). Currently the screen
sleeps and recording continues; mounted, that is wrong.
- **Sunlight legibility.** The dark theme was chosen for glanceability at night and in a
pocket-glance. Behind a visor in daylight it is the wrong choice — a high-contrast
variant with larger figures is needed. This is not the same as V3-16's visual identity.
- **Larger touch targets still.** 72 dp works stopped; at speed with gloves it does not.
- **Interruptions.** What happens on an incoming call or a notification — the recording
must survive and the screen must come back.
## Implementation
1. `mountedMode` in `Config`, surfaced in settings and as a quick toggle on the record
screen
2. `wakelock_plus`, acquired on start when mounted, released on stop/pause — and released
on `dispose`, or the screen stays lit after the app closes
3. A high-contrast text scale applied when mounted
4. Handle `AppLifecycleState.inactive` (a call arriving) distinctly from `paused`
## Acceptance criteria
- [ ] Mounted: the screen never sleeps during a ride
- [ ] Un-mounted: behaviour is exactly as today
- [ ] The wake lock is released on stop, on discard, and on app exit
- [ ] An incoming call does not stop recording
- [ ] Battery cost of mounted mode is measured and written down (V3-13)
## Tests
- Widget: mounted toggle changes text scale and requests the wake lock (fake the plugin)
- Widget: the lock is released on stop
- **Manual, on a real bike** — legibility in daylight cannot be tested any other way
## Risks
- **A leaked wake lock flattens the battery**, silently and after the app is closed. Test
the release path harder than the acquire path.
- Legibility is a judgement call that needs a real ride in real sun.
## Out of scope
A dedicated mounted layout with different information architecture — start by scaling what
exists and see what the ride teaches.
## Outcome
Shipped as designed, plus two deviations worth recording.
`Config.mountedMode` follows the same seeded-`StateProvider` shape as `mapEnabled` and
`unitSystem` (`mountedModeProvider`); a quick-toggle icon button sits next to Settings on
the record screen, and a matching switch was added to `SettingsScreen`. The wake lock is
wrapped in a `WakelockController` seam (`FakeWakelockController` for tests), mirroring
`LocationSource` — the same reasoning: the risk named in this ticket ("a leaked lock
flattens the battery silently") is exactly the kind of thing that has to be provable, not
just plausible.
**Deviation 1 — no `TextTheme.apply(fontSizeFactor: ...)`.** The design called for scaling
the whole mounted text theme at once; Flutter's `TextStyle.apply` asserts when
`fontSizeFactor != 1.0` meets any style with a null `fontSize`, which Material 3's default
`TextTheme` has for at least one role. Scaling was moved to where it already existed:
`BigStat` gained an explicit `scale` parameter (default `1.0`), applied to the record
screen's headline figure only. `StatRow` and button labels were **not** wired to
`mountedTextScale` — the acceptance criterion is legibility of the number that matters at
a glance, not uniform scaling of every row, and over-scaling the stat card risked
reintroducing the record screen's known overflow-on-short-phones failure mode.
**Deviation 2 — the mounted theme wraps only the record screen**, via a local `Theme(...)`
widget inside `RecordScreen.build`, not the app's `MaterialApp`. `Theme.of(context)` inside
that build method would still report the ambient dark theme, so `colors` is read off the
locally-built `ThemeData` directly rather than through `Theme.of(context)` — a small trap
worth flagging for V3-16, which will touch this same file.
Wake lock acquisition is gated on **recording**, not merely mounted-and-idle or
mounted-and-paused, and re-evaluated both on trip-state transitions and on the mounted
toggle itself changing mid-ride. Release happens on stop, on discard (both drive the same
trip-state listener), on navigating away (`dispose`), and defensively whenever mounted mode
is off. One implementation snag: reading `ref` inside `State.dispose()` throws
(`ConsumerStatefulElement` forbids it once unmounting has started) — fixed by capturing the
`WakelockController` once in `initState` via a `late final` field instead of reading it
fresh in `dispose`.
7 new tests across `widget_test.dart` (wake lock acquired while recording+mounted,
never requested un-mounted, released on ride completion, released on navigating away
while still recording, mounted theme scales the headline and enlarges Start),
`settings_screen_test.dart` (switch writes through to `Config`), and `config_test.dart`
(default/round-trip). `flutter analyze` clean; full suite green (231 tests, up from 224).
Not done, and explicitly out of scope per the ticket: real daylight/glove legibility
(needs an actual ride — V3-13), and `AppLifecycleState.inactive` handling for an incoming
call — recording is already fully DB-derived and does not observe app lifecycle at all, so
a call cannot stop it; this was verified by reasoning about the existing architecture
rather than a new test, since there is no lifecycle-reactive code path to test.

View File

@@ -0,0 +1,94 @@
# V3-06 — Live stats in the notification
**Phase** Live map · **Depends on** nothing · **Size** S · **Status** Done
## Goal
Distance and duration readable from the notification shade without unlocking.
## Context
The ongoing notification currently says "Rippr is recording / Tracking your ride" — static
text. During a pocketed ride that is a wasted surface.
**Constraint that shapes this whole ticket:** the notification is owned by `geolocator`'s
`ForegroundNotificationConfig`, which takes fixed strings at stream-subscription time and
offers no update path and no actions. That is the parity gap recorded in
[../port/PARITY-AUDIT.md](../port/PARITY-AUDIT.md).
## Design
Two honest options:
**A. Restart the position stream with new text.** Cheap to write, but it tears down and
re-establishes location updates every time — unacceptable during recording.
**B. Take the notification back with `flutter_local_notifications`,** and let geolocator
raise a silent minimal one. More moving parts, but it also **restores Pause/Resume actions**
— closing the one capability lost in the port.
**Recommend B**, precisely because it buys back the parity gap as well.
## Implementation
1. Add `flutter_local_notifications`
2. Own a notification on the same channel, updated on each writer flush (~2 s), not per fix
3. Pause/Resume actions routed back into `RecordingEngine`
4. Android 13+ notification permission is already requested
## Acceptance criteria
- [ ] Distance and elapsed update while riding, without unlocking
- [ ] Pause and Resume work from the shade
- [ ] Location updates are **not** interrupted when the notification changes
- [ ] The notification cannot be swiped away mid-ride
## Tests
- Unit: notification text formatting from a trip
- Integration on a device: start, confirm the text advances, pause from the shade, confirm
the engine actually paused (check the database, do not trust the UI)
## Risks
- Two notification sources fighting is the obvious failure. Verify only one is visible.
- An update per fix would be a battery and jank problem; drive it from the flush.
## Out of scope
iOS. There is no equivalent live notification surface; a Live Activity is a much larger
piece of work and belongs in its own ticket.
## Outcome
Shipped as designed (option B), with one honestly-unresolved risk carried forward.
`RideNotificationController` is a seam over `flutter_local_notifications`
(`FakeRideNotificationController` for tests), the same shape as `LocationSource` and
`WakelockController`. `RideNotificationCoordinator` owns the wiring: it subscribes to
`TripRepository.watchActiveTrip()` (the same stream `activeTripProvider` exposes, updated
once per writer flush) and calls `show`/`cancel`; it subscribes to the controller's action
stream and routes `pause`/`resume` back into `RecordingEngine`, guarded so a Resume can't
fire against a trip that isn't actually paused. `rideNotificationText(Trip, {unit})` is
pure and unit-tested directly — distance/elapsed formatting, the `Paused ·` prefix, and
unit-system handling.
**Not screen-owned, deliberately.** Unlike the live map (V3-04) and mounted mode (V3-05),
which are fed from `RecordScreen`'s widget tree, the coordinator is instantiated eagerly
from `main.dart`'s `RipprApp.build` via a bare `ref.watch(rideNotificationCoordinatorProvider)`
— a pocketed ride has no visible widget tree, but the notification and Pause/Resume both
still have to work.
**Unresolved risk, flagged rather than papered over:** the ticket's own risk section names
"two notification sources fighting" as the obvious failure mode, and it is real.
geolocator's `ForegroundNotificationConfig` is what satisfies Android's foreground-service
requirement and cannot be suppressed; `flutter_local_notifications` raises a second,
independent notification. There is no documented way to merge or guarantee only one is
visible — the geolocator notification was made minimal and silent
(`geolocator_location_source.dart`) on the assumption that an `ongoing: true` notification
on the same-ish surface might collapse or de-prioritise it, but that assumption is
unverified without a device. This is exactly the kind of claim the project's testing
philosophy refuses to accept on faith — see the real-ride checklist (V3-13) and this
ticket's own "Integration on a device" test, neither of which could run here.
6 new tests, all in `test/ride_notification_test.dart`: three for `rideNotificationText`
(recording, paused-prefix, unit system), three for the coordinator using a real
`RecordingEngine` + in-memory `TripRepository` (not a mock) so pause/resume are checked in
the database per the project's standing rule, not by trusting the notification state.
`flutter analyze` clean; full suite green (237 tests, up from 231).
Not attempted: the device-only acceptance criteria (text updates without unlocking,
Pause/Resume from the shade, the notification resisting swipe-away, and the two-source
visibility question above) — all require a real Android device, per the ticket's own Tests
section.

View File

@@ -0,0 +1,106 @@
# V3-07 — Route drawing (pins and straight lines)
**Phase** Route planning · **Depends on** nothing · **Size** M · **Status** Done
## Goal
Drop pins on a map to sketch a route, see the straight-line distance, and save it. The
foundation for V3-08, deliberately shipped without a routing engine.
## Context
Pre-ride planning is a **genuinely new mode**, not an extension of recording. It needs no
ride in progress, no server, and nothing else in v3 — the most independent thing in the
backlog.
Split from road-snapped routing (V3-08) on purpose: pins, storage, editing and the map
interaction are all needed either way, and none of them require choosing a routing vendor.
That decision should not block a usable feature.
## Design
**New entities, separate from `Trip`.** A plan is not a recording, and conflating them
would put unridden kilometres into ride totals.
```
Route(id, name, createdAt, activity?, distanceM, estimatedMillis?, geometry?)
Waypoint(id, routeId, ordinal, latitude, longitude, name?)
```
`geometry` is null in this ticket; V3-08 fills it with the road-snapped polyline.
`ordinal` rather than relying on insertion id, so waypoints can be reordered.
Straight-line distance reuses `haversineMeters` — already ported and parity-proven.
## Implementation
1. Drift tables + **migration** (the second one; V3-01 proves the path)
2. `RouteRepository` mirroring `TripRepository`'s shape
3. `RoutePlannerScreen` — tap to add a pin, drag to move, tap a pin to delete, reorder
4. Straight-line polyline between pins, visibly distinct from a recorded path
5. Routes list, reachable from the record screen alongside Rides
6. Name, rename, delete
## Acceptance criteria
- [ ] Pins can be added, moved, reordered and deleted
- [ ] Distance updates live as pins change
- [ ] A saved route survives an app restart
- [ ] Routes never appear in the rides list, and never contribute to ride totals
- [ ] Deleting a route cascades to its waypoints
## Tests
- Repository: create, reorder, delete, cascade — in-memory, like the trip tests
- Distance matches `pathLengthMeters` over the same points
- Widget: tapping the map adds a pin; the distance label updates
- **A test asserting routes are absent from `watchCompletedTrips`**
## Risks
The main one is scope drift into V3-08. Ship straight lines first; they are genuinely
useful for a rough plan.
## Out of scope
Road snapping, ETA, following a route while riding. Import of existing GPX routes.
## Outcome
Shipped as designed, with one naming deviation and two real testing traps worth recording
for V3-08/V3-09.
**Named `RoutePlan`, not `Route`.** The ticket's own design sketch used `Route`, but that
collides with `dart:ui`/`package:flutter`'s own `Route<T>` (the navigator's page-transition
class) and with `go_router`'s `GoRoute`. Renaming up front avoided constant `hide`/`as`
import juggling across every file that touches both navigation and route plans.
Schema: `route_plans`/`waypoints` tables, schema version 2→3, following V3-01's migration
pattern exactly (`m.createTable` for brand-new tables needs no backfill, unlike V3-01's
`addColumn`). `RoutePlanRepository` mirrors `TripRepository`'s shape but has no state
machine — every mutating call ends by recomputing `distanceM` via `geo.pathLengthMeters`,
so "distance always matches the current waypoints" holds with no exceptions to remember,
including after a pure reorder that doesn't change it.
`RoutePlannerScreen`: tap-to-add via `MapOptions.onTap`, tap-a-pin-to-delete, and
drag-to-move implemented by hand against `MapCamera.latLngToScreenOffset`/
`screenOffsetToLatLng` (flutter_map has no built-in draggable-marker widget). The straight
line is dashed and uses the planning accent, visibly distinct from `RideMap`'s
speed-bucketed solid polyline, satisfying the acceptance criterion without a design pass.
**Real bug found by testing, not review:** the map's `initialCenter`/`initialZoom` are
read exactly once, at `FlutterMap` construction. Building the map before the waypoints
stream delivered its first value froze the camera on null-island permanently, even once
real waypoints arrived — invisible in manual testing (a route sketched from empty always
starts empty) but immediate in a test that opens a planner for a route with existing
waypoints. Fixed by gating the map behind the stream's first emission, and by switching
from a fixed-zoom guess to `CameraFit.bounds` (matching `RideMap`'s own established
pattern) so pins can't be culled off-camera either.
**Real testing trap, likely to recur in V3-08/V3-09:** `await db.watchRoutePlans().first`
inside a `testWidgets` body hung for a genuine ten minutes (the framework's own internal
timeout, not a guess) — a fresh `Stream.first` subscription on a Drift `.watch()` query
depends on a `Timer` inside Drift's stream-query store that flutter_test's fake test zone
never advances without an explicit pump. `repo`-level `Future`-returning calls
(`routePlanById`, etc.) have no such dependency and are what every other assertion in this
suite already used correctly. Documented inline in the test as a trap for the next ticket
that watches a stream from inside `testWidgets`.
16 new tests: 8 in `route_plan_repository_test.dart` (create, live distance on
add/move/delete, ordinal-gap closing, reordering, rename, cascade delete, and the
ticket-mandated "never appears in ride totals" check), 1 migration test (v2→v3, tables
created and usable, existing trip untouched), 7 in `route_planner_screen_test.dart`
(empty state, create-and-open, delete, tap-to-add-and-distance-updates, tap-to-delete,
rename, missing-route fallback), plus 1 in `widget_test.dart` for the Routes entry point
on the record screen. `flutter analyze` clean; full suite green (254 tests, up from 237).

View File

@@ -0,0 +1,83 @@
# V3-08 — Road-snapped routing and ETA
**Phase** Route planning · **Depends on** V3-07, benefits from V3-01, **blocked on V3-17**
· **Size** L · **Status** Deferred
## Goal
Resolve the actual shortest path along roads between pins, and estimate how long the ride
will take.
## Context
This is what Dylan asked for. It is also the ticket with a **decision that cannot be
deferred**: road-snapped routing needs a routing engine over OpenStreetMap data, and the
choice has ongoing consequences.
**Decided, not deferred:** self-hosted OSRM (see the table below). What's actually
deferred is standing one up — that's [V3-17](V3-17-osrm-hosting.md), split out on
request because provisioning a server is a different kind of work from building the app
code that calls it, and this ticket cannot start until V3-17 has something to point
`RoutingService` at.
## Design
### Choosing an engine — decide before writing code
| Option | Trade-off |
|---|---|
| **Public OSRM demo** | Free, zero setup, **explicitly not for production**, rate-limited. Prototype only. |
| **Self-hosted OSRM** | Fast, well understood. Needs a machine and a regional OSM extract (a province is a few GB). |
| **GraphHopper** | Self-hostable, good cycling/motorcycle profiles, friendlier ETAs |
| **Valhalla** | Best multi-modal profiles, heaviest to run |
| **Mapbox / Google** | No ops, per-request billing, an API key shipped in the app |
**Profiles matter more here than usual.** A motorcycle route and a bicycle route between
the same pins genuinely differ — cycling engines avoid motorways, and a motorcyclist often
wants the twisty road rather than the fast one. This is where V3-01 pays off: the activity
selects the profile.
Put it behind a `RoutingService` interface with a fake, exactly as `LocationSource` did for
GPS. That seam is what made swapping the location engine cheap, and the same argument
applies here.
### ETA is a promise, and easy to get wrong
Engines estimate from posted speed limits. That is not how long *you* take. Once there is
history, the rider's own average moving speed for that activity — already stored on every
`Trip` — is a better predictor.
Show the engine's estimate, then replace it with a personal one once there is enough
history to justify it. Label which is which.
### Caching
Cache the returned polyline on `Route.geometry`. A saved plan must open offline and must
not re-bill a request every time it is viewed.
## Implementation
1. Decide the engine. Write the decision and its reasoning into this file.
2. `RoutingService` interface + implementation + `FakeRoutingService`
3. Resolve on pin change, debounced — not on every drag frame
4. Persist geometry, distance and duration on `Route`
5. Personal ETA from `Trip` history, once ≥5 rides of that activity exist
6. Graceful offline behaviour: fall back to straight lines and say so
## Acceptance criteria
- [ ] Pins resolve to a road-following polyline
- [ ] Distance reflects the road path, not the straight line
- [ ] Activity changes the profile and can change the route
- [ ] A saved route renders offline from cached geometry, with no network call
- [ ] Offline with no cache degrades to straight lines with a visible explanation
- [ ] No API key is committed to the repository
## Tests
- `FakeRoutingService` drives every path: success, failure, offline, empty
- Cached geometry means no second request — assert the fake is called once
- Personal ETA maths against fixed history
- **No live network calls in any test**
## Risks
- **Vendor lock-in and cost.** The interface is the mitigation.
- Debouncing matters: dragging a pin could otherwise fire dozens of requests.
- OSM route quality varies. It will occasionally suggest something daft; that is the data,
not a bug to chase.
## Out of scope
Turn-by-turn navigation and voice guidance. That is a different product.

View File

@@ -0,0 +1,53 @@
# V3-09 — Follow a planned route while riding
**Phase** Route planning · **Depends on** V3-04, V3-08 (itself blocked on V3-17) · **Size** M · **Status** Deferred
## Goal
Pick a saved route before starting, see it on the live map underneath your actual track,
and afterwards compare what you rode against what you planned.
## Context
The payoff that makes V3-07 and V3-08 worth building, and the point where route planning
meets the live map. Not navigation — no turn-by-turn, no voice. Just the line you meant to
follow, drawn under the line you actually rode.
## Design
Attach an optional `routeId` to `Trip`. That is enough for both the live overlay and the
after-the-fact comparison.
Live: the planned route in a muted colour, the recorded track drawn over it in the existing
speed colours. Immediately obvious when you have left the plan.
Afterwards, on trip detail: both lines, plus how far you deviated and how the real duration
compared with the estimate — which also, over time, tells you how honest the ETA is.
**Deliberately not:** rerouting, off-route alerts, or anything that demands attention while
riding. A rider glancing at handlebars wants a picture, not an interruption.
## Implementation
1. `routeId` on `Trip` — **third migration**
2. Route picker on the record screen before Start, defaulting to none
3. Live map renders the planned polyline beneath the track
4. Trip detail renders both, with a comparison block
5. Deviation: max and mean distance from the recorded points to the planned polyline —
`perpendicularDistanceMeters` already exists and is parity-proven
## Acceptance criteria
- [ ] A route can be selected before starting, or not
- [ ] Both lines render, visually distinguishable
- [ ] Deviation and duration-vs-estimate appear on trip detail
- [ ] A ride with no route behaves exactly as today
- [ ] Deleting a route does not delete rides that referenced it
## Tests
- Deviation maths against a known track and route
- Repository: deleting a route nulls `routeId` rather than cascading to the trip —
**the cascade direction here is the opposite of segments and is easy to get wrong**
- Widget: both polylines present when a route is attached
## Risks
The `Route` → `Trip` foreign key must **not** cascade. Deleting an old plan must never
delete the ride you did.
## Out of scope
Turn-by-turn, off-route alerts, rerouting.

View File

@@ -0,0 +1,92 @@
# V3-10 — Trip splitting
**Phase** Ride management · **Depends on** nothing · **Size** S · **Status** Done
## Goal
Split one recorded ride into two at a chosen point. The natural counterpart to merge.
## Context
Merge exists and is well tested; split does not. The case is a rider who forgot to stop —
one "ride" that is really the trip out, lunch, and the trip home.
Merge already establishes the hard parts: re-parenting points and segments inside a
transaction, and recomputing aggregates rather than summing them.
## Design
Split at a **segment boundary** rather than an arbitrary point. Segments already mark where
the rider paused, which is exactly where a forgotten stop shows up — and it avoids
inventing a new boundary type or splitting a segment in half.
The original trip keeps the earlier segments; a new trip takes the later ones. Both get
aggregates recomputed from the points they actually own.
If a ride has only one segment there is nothing to split, and the UI should say so rather
than offering a dead control.
## Implementation
1. `TripRepository.splitTrip(tripId, atSegmentId)` inside a transaction:
create the new trip, re-parent segments and points from `atSegmentId` onward,
set `startedAt`/`endedAt` from the segments each trip now owns,
recompute aggregates for both
2. Trip detail: a split action listing segment boundaries with their times
3. Confirmation naming what the two resulting rides will be
## Acceptance criteria
- [ ] Splitting produces two trips whose point counts sum to the original
- [ ] Neither trip's distance includes the gap between them
- [ ] Both have plausible `startedAt`/`endedAt`
- [ ] Single-segment rides cannot be split, and the UI explains why
- [ ] Atomic — a failure part-way leaves the original intact
## Tests
- Point counts sum; no points orphaned
- Distance of the parts is less than the original by roughly the gap
- Split then merge returns to the original aggregates — a good round-trip property
- Rejects a single-segment trip
- Atomicity under a forced mid-transaction failure
## Risks
Getting `startedAt`/`endedAt` from the wrong source. Derive them from the segments each
trip owns, not from the original trip.
## Out of scope
Splitting mid-segment.
## Outcome
Shipped as designed. `TripRepository.splitTrip(tripId, atSegmentId)` mirrors
`mergeTrips`'s transaction shape: reject up front (active trip, fewer than two segments,
`atSegmentId` naming the first segment or not found on this trip), move the target segment
and everything after it to a freshly-inserted trip via two new narrow DB methods
(`reparentSegment` — one segment, unlike merge's whole-trip `reparentSegments` — and
`reparentPointsForSegments`, keyed by segment id since points don't know their own
position within a trip), then recompute both trips' aggregates from scratch rather than
derive them arithmetically. `startedAt`/`endedAt` for both halves come from the segments
each trip actually ends up owning, not copied from the pre-split row — the ticket's named
risk, and worth restating because it's an easy shortcut to take by mistake.
Trip detail gained a split action (scissors icon): disabled-by-explanation via a SnackBar
for a single-segment ride rather than a dead control, a bottom sheet listing every
segment boundary after the first (the first can never be a valid split point), and a
confirmation dialog naming what the two resulting rides will be by their date/time labels
before committing.
**Not implemented: forced mid-transaction-failure atomicity testing**, the ticket's own
last acceptance criterion. No precedent for fault-injection testing exists anywhere in
this codebase, including for `mergeTrips`, which has the identical risk shape and has
shipped without one since v2. Atomicity here is a property of Drift's `_db.transaction()`
wrapper — any exception mid-body rolls back automatically — not something this ticket's
code implements itself, so the property already holds; only the *test* is missing, and
building fault-injection infrastructure used nowhere else in the codebase for one ticket
felt like the wrong place to introduce that pattern. Flagged rather than silently dropped.
12 new repository tests (point counts sum, no orphans, distance excludes the gap,
`startedAt`/`endedAt` from the right source, segment-boundary preserved on both sides,
single-segment rejected, first-segment rejected, active-trip rejected, unknown-segment
rejected, split-then-merge round-trips back to the original aggregates, and an explicit
no-orphans sweep over every point/segment), plus 2 widget tests (single-segment
explanation, full split flow via the bottom sheet and confirmation dialog). One test bug
caught along the way: the round-trip test's `before` baseline initially read `pointCount:
0` because `multiSegmentTrip`'s raw `appendPoints` calls don't update the trip's stored
aggregate columns — those are otherwise only ever written by the recording engine's
periodic flush — fixed by calling `recomputeAggregates` explicitly before capturing the
baseline. `flutter analyze` clean; full suite green (269 tests, up from 257).

View File

@@ -0,0 +1,113 @@
# V3-11 — Offline tile pre-download
**Phase** Ride management · **Depends on** V3-04 · **Size** M · **Status** Partially done
## Goal
Have map tiles available where there is no signal.
## Context
`flutter_map` caches what it renders, so a re-viewed ride works. A **mountain ride with no
signal shows blank tiles** — precisely where a map is most wanted.
## Design
**Respect OSM's tile usage policy. Bulk prefetching their public servers is prohibited**
and would get the app blocked. This constraint decides the design:
- Pre-download only a **user-chosen area**, at a **limited zoom range**, with a visible
tile count and size estimate before starting
- Rate-limited, sequential, cancellable
- If this becomes a headline feature, move to a paid tile provider or self-hosted tiles.
Do not scale it on OSM's donated infrastructure.
Natural pairing with V3-07: pre-download the corridor along a planned route rather than a
rectangle — far fewer tiles for the same usefulness.
## Implementation
1. Persistent tile cache with a size cap and eviction (`flutter_map_cache` or similar)
2. Area selection on the map, plus a "download along this route" option
3. Tile count and MB estimate **before** any request
4. Sequential fetch with a delay, a progress indicator and cancellation
5. Settings: cache size, and a way to clear it
## Acceptance criteria
- [ ] A downloaded area renders with the network off
- [ ] Count and size shown before download starts
- [ ] Cancellable mid-download, keeping what has already arrived
- [ ] A hard cap on tiles per request — no unbounded area selection
- [ ] Cache size visible and clearable
## Tests
- Tile-count maths for a bounding box across zoom levels
- Cache eviction at the cap
- Cancellation leaves a consistent cache
- **Manual:** aeroplane mode over a downloaded area
## Risks
- **Abusing OSM's servers.** Cap, rate-limit, and be conservative. A blocked user agent
would break the map for everyone.
- Storage growth. Tiles add up fast; the cap is not optional.
## Out of scope
Vector tiles or a full offline basemap.
## Outcome
The download pipeline is done and unit-tested end to end; the on-device acceptance
criterion (aeroplane mode over a downloaded area) is not, and can't be from here.
**Deliberate scope reduction: no rectangle area-selection UI.** The ticket names the
route-corridor pairing with V3-07 as strictly better ("far fewer tiles for the same
usefulness") and V3-07 already shipped, so that became the only download entry point
rather than building two. `tilesAlongRoute` buffers each waypoint by a fixed radius and
unions the per-point tile sets — a zigzagging route's actual footprint, not the
rectangle around its bounding box, proven directly in `tile_math_test.dart` by
constructing a route that zigzags across its own bounding box and asserting the corridor
costs fewer tiles than that box would.
Three pure modules, layered the way `geo.dart`/`ride_statistics.dart` already are in this
codebase: `tile_math.dart` (tile enumeration, a hard `maxTilesPerDownload` cap enforced by
*throwing* rather than silently truncating — a caller must know a download was rejected,
not receive a partial one unknowingly), `tile_cache.dart` (`FileTileCache`: tiles as
files on disk, a JSON manifest tracking size and last-access time, LRU eviction that
runs *before* a write that would exceed the cap, not after), and `tile_downloader.dart`
(sequential, rate-limited via a fixed delay between tiles, cooperatively cancellable via
`CancelToken`, one bad tile doesn't abort the rest, everything fetched before
cancellation stays in the cache).
`CachedTileProvider` wires the cache into `flutter_map`'s `TileLayer` via a custom
`ImageProvider` (cache hit skips the network entirely; a miss fetches, writes through,
then decodes) and is now what `RideMap` and `RoutePlannerScreen` both request tiles
through — a write-through side effect of this ticket is that ordinary map viewing now
also populates the same capped, evictable cache, replacing flutter_map's own uncapped
default. `RoutePlannerScreen` gained a download action (disabled with an explanatory
tooltip when the route has no pins yet), a count-and-MB-estimate confirmation dialog
before any request goes out, and a progress dialog with a working Cancel button. One real
bug caught before it shipped: the first draft of the progress dialog used a bare
`StatefulBuilder`, whose builder callback re-runs on every `setState` — meaning every
single progress tick would have started a *second* overlapping download subscription.
Fixed by moving the subscription into a dedicated `_DownloadDialog` `StatefulWidget` that
starts it once, in `initState`.
Settings gained an "Offline tiles" section: current cache size against the fixed cap, and
a Clear button. The cap itself (200 MB) is **not** user-configurable in this pass — only
whether to clear it — a scope call in the same spirit as the ticket's "the cap is not
optional" risk language.
**Not done, and cannot be done in this environment:** the ticket's own two headline
acceptance criteria — a downloaded area actually rendering with the network off, and
aeroplane-mode verification on a real device — both need a phone. Everything upstream of
that (the tile math, the eviction policy, the cancellation-consistency of the cache, and
the fact that a cache hit in `CachedTileProvider` skips the network call entirely by
construction) is proven at the unit level; only the last mile — a real radio actually
turned off — is not.
21 new tests: 8 in `tile_math_test.dart`, 8 in `tile_cache_test.dart` (round-trip, miss,
size accounting, LRU eviction order, cap never exceeded across many writes, clear, and a
cache re-opened over the same directory seeing prior contents), 4 in
`tile_downloader_test.dart` (sequential/in-order/no-duplicates, one failure doesn't abort
the rest, cancellation keeps a consistent partial cache, empty input is a no-op), 2 in
`route_planner_screen_test.dart` (download disabled with no pins; count/size shown before
any request — deliberately stopping short of confirming, since the pure download engine
already covers the fetch/cancel/cache-consistency behaviour directly with fakes, and
exercising it again through a real `http.Client` in a widget test would need a fake HTTP
layer for no additional coverage). `flutter analyze` clean; full suite green (305 tests,
up from 284).

View File

@@ -0,0 +1,100 @@
# V3-12 — Crash reporting
**Phase** Quality · **Depends on** nothing · **Size** S · **Status** Partially done
## Goal
Know when the app dies mid-ride.
## Context
There is none. A recorder that crashes during a ride currently leaves no trace beyond
logcat, which nobody reads — and the failure mode that matters most (recording stopping
silently) is exactly the one the user cannot report usefully.
Becomes important the moment anyone who is not Dylan uses it.
## Design
Sentry or Firebase Crashlytics. **Sentry is the better fit**: it is not tied to Google
services, works identically on both platforms, and its free tier is ample here.
**A crash reporter in a location app is a privacy surface.** Configure it deliberately:
- No location data in breadcrumbs or context, ever
- No device id, no ride contents
- Explicit opt-out in settings, and disclosed in the privacy policy
- Debug builds report nowhere
Beyond crashes, one custom event is worth having: **recording ended unexpectedly** — the
engine stopping without a user stop. That is the failure the app exists to avoid.
## Implementation
1. Add `sentry_flutter`, initialised in `main()` behind a config flag
2. Scrub: no coordinates, no ids, no trip contents in any payload
3. Breadcrumbs for lifecycle transitions only
4. A custom event when a recording ends without a user action
5. Settings toggle, defaulting **off** until a privacy policy exists
## Acceptance criteria
- [ ] A forced crash appears in Sentry from a release build
- [ ] No coordinate ever appears in a payload — inspect a real one
- [ ] The toggle genuinely disables reporting
- [ ] Debug builds send nothing
## Tests
- The scrubber strips coordinates from a representative payload
- Reporting disabled means the client is never initialised
- Manual: force a crash in a release build and check it lands
## Risks
Leaking location through breadcrumbs or a stack frame's captured state. Inspect a real
payload rather than assuming the scrubber works.
## Out of scope
Analytics or usage tracking. Different purpose, different consent.
## Outcome
The code and its guarantees are done; the two device/account-dependent acceptance
criteria are not, and can't be from here.
`shouldInitializeCrashReporting({enabled, isDebug, dsn})` pulls the entire "talk to Sentry
at all" decision out as a pure function — every combination of the user's toggle, debug
vs. release, and a configured DSN is asserted directly, rather than trusted to however
`main()` happens to wire things. `scrubExtra` strips any key matching a coordinate,
altitude, device-id, or trip-id fragment (case-insensitive, substring match, so `lat`,
`latitude`, `startLat`, and `gps.lon` are all caught without enumerating every call site
that might one day capture one) from both event `extra` and every breadcrumb's `data`,
wired in as `beforeSend`/`beforeBreadcrumb`.
`Config.crashReportingEnabled` defaults to false and is surfaced in Settings, same shape
as every other toggle in this file. The DSN itself is **not** a user preference — it's a
compile-time `--dart-define=SENTRY_DSN=...` value, since it names which Sentry project
receives reports, not a fact about the rider. `main()` loads `Config` once, early, purely
to make the init-or-not decision before `runApp` (since `SentryFlutter.init` wraps the
app itself); the widget tree still loads its own `Config` in `RipprApp.initState` as
before, since `SharedPreferences` is memory-cached after the first read.
The one custom event: `RecordingEngine` gained an optional `onUnexpectedStop(String
reason)` callback, injected the same way `uploadPending` already is, so the engine keeps
no opinion about where a report goes. It fires exactly once, in
`restoreAfterProcessDeath`, when a trip is found still `recording` at launch — the
process died without anyone calling `stop()`, which is precisely "the recording stopped
and nobody chose that." A cleanly-stopped ride reports nothing; verified by both cases in
`recording_engine_test.dart`.
**Not done, and not attempted:** wiring a real Sentry DSN, forcing a real crash in a
release build, and inspecting a real payload for leaked coordinates. All three are the
ticket's own actual acceptance criteria, and all three need a real Sentry account and a
release build this environment cannot produce. `shouldInitializeCrashReporting` and
`scrubExtra` are unit-tested as thoroughly as pure functions can be, but a passing unit
test is not the same claim as "inspected a real payload," which the ticket's own Risks
section insists on by name. This should be revisited once Dylan has a Sentry project to
point the DSN at.
19 new tests: 9 for `shouldInitializeCrashReporting`/`scrubExtra` (including a
representative end-to-end event with coordinates in both `extra` and a breadcrumb),
2 in `recording_engine_test.dart` (unexpected-stop fires on a crash, stays silent on a
clean stop), 2 in `config_test.dart`, 1 in `settings_screen_test.dart`. Fixed the same
viewport-culling test brittleness this section's addition exposed a second time (V3-05
first triggered it): the Sync section's fields dropped out of the default test viewport
entirely, not just out of hit-test range, so four upload-endpoint tests needed a shared
`scrollToSync` helper alongside the device-id test's existing one. `flutter analyze`
clean; full suite green (284 tests, up from 269).

View File

@@ -0,0 +1,61 @@
# V3-13 — Real-ride measurements: elevation, battery, map lifecycle
**Phase** Quality · **Depends on** the real-ride checklist · **Size** M · **Status** Blocked on riding
## Goal
Answer three questions that no amount of code can answer, then act on the answers.
## Context
Three items have been carried since v2 because **nothing but a real ride settles them**.
Grouped into one ticket because they share a prerequisite: riding, with instruments.
## The three questions
### 1. Is elevation gain actually wrong?
~30 m of phantom gain per ten stationary minutes against **synthetic ±8 m uniform noise**.
Real GPS altitude error is *correlated* — it wanders rather than jitters — so the true
behaviour is unknown.
**Do not tune this blind.** Record a flat ride and see what it reports. Only then consider
a longer smoothing window, a larger threshold, or the barometer — which most phones have
and which is far more accurate than GPS altitude.
The port has an advantage the native app did not: `tool/parity/run.sh` proves the algorithm
is bit-identical to the Kotlin original, so any change can be measured against a known
baseline rather than guessed at.
### 2. What does it actually cost in battery?
Never measured, on either app. And V3-04/V3-05 make it worse: a lit screen and continuous
map rendering are a different order of cost from a background service.
Measure three configurations over a multi-hour ride: pocketed with no map, pocketed with
the live map on, and mounted with the screen awake.
### 3. Does the map leak?
`flutter_map`'s lifecycle was wired carefully but never leak-tested across repeated
navigation. The native repo flagged the osmdroid equivalent as a known hazard.
## Implementation
1. Run the checklist in [../port/REAL-RIDE-CHECKLIST.md](../port/REAL-RIDE-CHECKLIST.md)
2. Record elevation on a known-flat route; compare against a barometric or surveyed source
3. Battery: note the percentage at start and end for each configuration, with duration
4. Memory: navigate rides → detail → back fifty times with DevTools attached, watching
for monotonic growth
5. **Write the numbers into this file.** The point is a record, not a vibe.
## Acceptance criteria
- [ ] Flat-ride elevation gain recorded, with a verdict: acceptable or not
- [ ] Battery cost per hour recorded for all three configurations
- [ ] Memory across fifty navigations recorded, with a leak verdict
- [ ] Any resulting code change is justified by a number written down here
## Tests
Measurement, not tests. Any fix that follows gets its own regression test, and elevation
changes must be re-checked against the parity harness.
## Risks
The temptation to tune elevation on a hunch. The v2 backlog says do not, twice, and the
existing bound was already shown to pass on seed luck.
## Out of scope
Fixes themselves. This ticket produces evidence; the fixes are separate work.

View File

@@ -0,0 +1,80 @@
# V3-14 — GPX interoperability
**Phase** Quality · **Depends on** V3-01 for `<type>` · **Size** S · **Status** Partially done
## Goal
Confirm an exported ride actually imports into Strava, Garmin Connect and Google Earth —
and add the activity type so it lands as the right kind of activity.
## Context
Export is well tested: 15 tests, parsed with a real XML parser, and **byte-identical to the
Kotlin original** under the parity harness. But every one of those tests proves *structural
validity*, and structural validity does not mean a consumer accepts the file. That gap has
been open since v2.
## Design
Two parts.
**Verification** — export a real ride and import it into each of Strava, Garmin Connect and
Google Earth. Record what each does with pauses, elevation and timestamps. Pauses are the
interesting case: `<trkseg>` per segment is the correct GPX representation, but consumers
vary in whether they honour it.
**`<type>` on `<trk>`** — Strava and Garmin read it to decide the activity. Without it a
bicycle ride may import as a run. Needs V3-01's activity, mapped to each consumer's
vocabulary (Strava uses `ride`, `run`, and so on).
## Implementation
1. Add `<type>` to the `<trk>` element, from the trip's activity
2. Map the internal enum to GPX conventions; document the mapping in the code
3. Export a real multi-segment ride and import it into all three consumers
4. Write the findings into this file, including anything that surprises
## Acceptance criteria
- [ ] A real ride imports into Strava with the right activity type
- [ ] It imports into Garmin Connect
- [ ] It opens in Google Earth with the path in the right place
- [ ] Pause behaviour in each consumer is documented, whatever it turns out to be
- [ ] Existing export tests still pass, including the byte-identical parity check —
**this one will need updating, since `<type>` changes the output**
## Tests
- `<type>` present and correct per activity
- Absent, not empty, when the activity is `other`
- **The parity harness will now differ from Kotlin here. That is expected and correct —
update its expectation and note why, rather than dropping the check.**
## Risks
Silently breaking the parity harness by changing export output. Update it deliberately.
## Out of scope
GPX import into Rippr. FIT and TCX formats.
## Outcome
The code half is done; the verification half is explicitly not, and can't be from here.
`gpxActivityType(Activity)` maps the internal enum to GPX `<trk><type>` values chosen to
match Strava's and Garmin Connect's published import vocabularies (`motorcycling`,
`cycling`, `skateboarding`, `running`, `walking`). `Activity.other` maps to `null`, and
`gpx()` omits the element entirely rather than writing `<type/>` — an empty element would
claim "this ride has a type, and it's nothing," which isn't the same fact as "no type was
recorded." Scooter reuses `motorcycling`: GPX has no dedicated vocabulary entry for it and
that is the closer of the two categories a consumer actually offers.
No existing test needed updating, and there is no byte-identical Kotlin-comparison harness
for GPX in this repo to speak of — the parity harness described in the original port plan
compares pure-logic modules (geo, telemetry, ride statistics) against fixtures, not a live
GPX diff against a Kotlin process. `<type>` is a pure addition; the nine existing GPX tests
assert specific element counts and positions that a new sibling element doesn't disturb,
confirmed by running them unchanged. Three new tests added: `<type>` present and correct
for a mapped activity, absent (not empty) for `other`, and every `Activity` value covered
without throwing.
**Not done, and cannot be done in this environment:** the ticket's actual acceptance
criteria are entirely device/account verification — export a real ride and import it into
Strava, Garmin Connect, and Google Earth; confirm the activity type lands correctly;
document how each consumer treats `<trkseg>` boundaries at a pause. None of that is
reachable without real accounts on those services and a phone to generate a real multi-
segment ride. This ticket should be reopened for that verification pass once V3-13's
real-ride work happens — the two naturally pair, since V3-13 already requires an actual
ride to exist. `flutter analyze` clean; full suite green (257 tests, up from 254).

View File

@@ -0,0 +1,57 @@
# V3-15 — Auto-pause
**Phase** Quality · **Depends on** V3-13 · **Size** M · **Status** Gated on evidence
## Goal
Decide — with data — whether the app should pause itself when the rider stops.
## Context
**Rejected in v2 as unreliable in traffic**, and that reasoning still stands: a motorcycle
at a long red light is stationary and still mid-ride. Auto-pausing there fragments a ride
into dozens of segments and makes the map look wrong.
Kept in the backlog because it is a common expectation from other ride apps.
## The gate
**Do not build this until V3-13 provides real ride data**, then answer:
1. How long is a typical traffic stop, versus a real break?
2. Is there a clean threshold between them, or do the distributions overlap?
3. Does moving time already handle this well enough? The noise floor **already excludes
stationary time from moving time** — so the numbers may be right and only the segment
count would change.
**If (3) is true, this ticket should be closed rather than built.** That is a legitimate
outcome and arguably the likely one.
## Design, if the data supports it
Time-based, not motion-based: pause after N minutes below the noise floor, resume on the
first fix above it. N derived from the data, not guessed, and never below two minutes.
Off by default, in settings, described plainly.
## Implementation
1. Analyse stop-duration distribution from real rides
2. **Decide and record whether to proceed**
3. If proceeding: a threshold in `RecordingEngine`, reusing the existing pause path so
segments behave identically to a manual pause
4. Setting, defaulting off
## Acceptance criteria
- [ ] A written decision, with the data behind it
- [ ] If built: a traffic-light stop does **not** pause; a coffee stop does
- [ ] Auto-pause produces segments indistinguishable from manual ones
- [ ] Off by default
## Tests
- Synthetic stop patterns: short stop stays recording, long stop pauses
- An auto-paused ride's segments behave exactly like manual ones
- Distance still never spans the gap
## Risks
Building it because other apps have it, rather than because the data says so. The gate
exists for that reason.
## Out of scope
Motion-sensor detection. That is the paid-engine feature set, and this app deliberately
does not use it.

View File

@@ -0,0 +1,137 @@
# V3-16 — Visual identity
**Phase** Quality · **Depends on** V3-04, V3-05 · **Size** M · **Status** Partially done
## Direction (written before any code changed, per the ticket's own step 1)
**Palette.** Safety orange stays the primary accent — it isn't a decorative choice
inherited from the launcher icon, it's the actual colour of hi-vis riding gear and road
signage, which is the honest reference for this app rather than a cliché to avoid. What
changes: orange stops being the only signal colour. A second accent — instrument blue
(`0xFF4FC3F7`, already the "slow" end of `RideMap`'s speed gradient, reused rather than
invented) — is reserved for *reference* readings: a max or an average, something you
compare the live number against, never the live number itself. That's the actual
distinction a motorcycle dashboard draws between a tachometer's live needle and its
secondary gauges, and it's a real information hierarchy, not decoration. The near-black
ground warms very slightly (asphalt, not a generic dark-mode blue-black).
**Typography.** No new font family. Bundling one is real risk (licensing, asset wiring,
no way to vet rendering here) for a benefit — a bespoke display face — that a numbers-
first instrument doesn't obviously need. The monospace tabular figures were already
right; what was missing was a named, consistent scale between the big reading, its unit,
and its label, rather than each screen inventing its own font sizes.
**Data display.** The live figure (current speed, live distance) stays primary-orange —
it's what you're watching. Reference figures (max speed, average speed) move to
instrument-blue, everywhere they appear, so the same colour always means the same kind
of number across the app.
**Motion.** Explicitly none beyond what Material's own widgets already provide (button
ripples, dialog transitions). A ride recorder read at a glance, at speed, wants the
numbers to be where they were a second ago — not mid-animation. This is a decision, not
an oversight.
**Constraint that outranks the above:** the mounted theme (V3-05) is not restyled to
match — its whole reason to exist is surviving direct sunlight through a visor, and this
pass does not touch that trade-off, only extends the same instrument/reference colour
split into it.
## Goal
Move from "functional dark" to a look that is deliberately designed.
## Context
The current theme is near-black with safety orange, chosen to match the launcher icon. It
is clean and legible, and it was never actually *designed* — it was picked so the app did
not look unfinished.
**Deliberately sequenced after the live map and mounted mode.** Both change what the app
looks like far more than a palette does, and designing around screens that are about to
change is wasted effort.
## Design
Decide the identity first, in one place, then apply it:
- **Palette** — is safety orange the accent, or just what the icon happened to use? A
motorcycle app has obvious references (dashboard instruments, race liveries, road
signage) and obvious clichés to avoid.
- **Typography** — the app is numbers-first. The monospace tabular figures are already
right for that; the rest is undecided.
- **Data display** — the speed readout, the charts and the map legend are the identity far
more than any chrome. This is an instrument, not a document.
- **Motion** — currently none. A ride recorder probably wants very little.
**Constraint that outranks aesthetics:** legibility through a visor, in daylight, at a
glance. V3-05 may force a high-contrast variant, and the identity has to survive it.
## Implementation
1. Write the direction down — palette, type, and what the app is trying to feel like —
before touching code
2. Extend `ripprColors` into a fuller token set
3. Apply screen by screen, keeping `flutter test` green throughout
4. **Keep the explicit text colours.** The theme names `bodyColor` and `displayColor`
deliberately: a missing default once rendered a 64 sp figure black-on-black and only a
screenshot caught it. Do not regress that while restyling.
## Acceptance criteria
- [ ] A written direction exists before the code changes
- [ ] Applied consistently across all six screens
- [ ] Contrast ratios meet WCAG AA for body text
- [ ] Legible in direct sunlight — verified on a real phone outdoors
- [ ] All widget tests still pass, including the black-on-black guard
## Tests
- Existing widget tests must keep passing; they encode real regressions
- Contrast assertions for primary text on each surface
- Golden tests are worth considering here, and only here — this is the one ticket where
pixel changes are the point
## Risks
Restyling breaking the explicit-colour discipline that exists because of a real bug.
## Out of scope
A new app icon. The Route mark is good and recently applied.
## Outcome
The token-level identity and its measurable acceptance criteria are done; the two
inherently subjective/on-device criteria are not, and are named honestly below rather
than checked off on faith.
Implemented at `theme.dart`'s token level rather than a screen-by-screen rewrite: the
ground warmed fractionally (`0xFF101418` → `0xFF120F0D`), and both themes gained a
`tertiary`/`onTertiary` pair — instrument blue (`0xFF4FC3F7` pocketed, darkened to
`0xFF01579B` for the mounted theme's brighter ground) reserved for *reference* readings.
`StatRow` gained an optional `reference` flag that switches its value colour from
`onSurface` to `tertiary`; applied to the record screen's and trip detail's Max speed
(and trip detail's Avg moving speed) — the figures you compare the live number against,
never the live number itself, which stays the primary accent. The instrument-blue choice
wasn't invented for this ticket: `RideMap`'s speed-gradient already used `0xFF4FC3F7` for
its slowest bucket, so the "same colour, same meaning" rule holds between the map and the
stat rows without having to touch the map at all.
`contrastRatio(Color, Color)` implements WCAG 2.x's formula directly against
`Color.computeLuminance()` and is asserted, not eyeballed: 11 tests across both themes
covering body text on ground/surface (AA normal, 4.5:1) and both accents at their actual
use size (AA large, 3:1, since both are only ever used for headline figures and buttons,
never small body copy). Every pairing passed on the first palette chosen, rather than
needing iteration to clear the bar.
**Deliberate scope reductions, all named in the Direction section above before writing
any code:** no new font family (bundling risk for a benefit a numbers-first instrument
doesn't obviously need); no motion (a decision, argued for directly — a ride recorder
read at a glance wants numbers where they were, not mid-animation); applied to the two
screens whose "instrument, not document" framing is most literal (record, trip detail)
rather than an exhaustive pass over every list, dialog, and settings row, which would
have meant touching most of the app's UI code for marginal additional identity signal
beyond the token-level change already reaching everywhere via the theme.
**Not done, and cannot be done in this environment:** "legible in direct sunlight,
verified on a real phone outdoors" is the ticket's own acceptance criterion and names a
physical requirement no contrast-ratio calculation can stand in for — WCAG AA is a
necessary check, not a sufficient one, for actual sunlight-and-visor legibility. Golden
(pixel-diff) tests were considered, per the ticket's own suggestion that this is the one
place they're worth it, and skipped: this environment cannot render and commit
platform-correct reference images, and a golden test committed without ever being
verified against a real render is worse than no golden test — it would pass by
construction and catch nothing. `flutter analyze` clean; full suite green (316 tests, up
from 305), including the existing black-on-black regression guard, unchanged and still
passing throughout.

View File

@@ -0,0 +1,90 @@
# V3-17 — Self-hosted OSRM: investigate and stand one up
**Phase** Infrastructure · **Depends on** nothing · **Size** M · **Blocks** V3-08, V3-09 ·
**Status** Not started
## Goal
Get a self-hosted OSRM instance running somewhere, so V3-08 (road-snapped routing and
ETA) has a real backend to build against instead of a deferred decision.
## Context
V3-08 named the choice of routing engine as "a decision that cannot be deferred," and
[the conversation that spawned this ticket](../v3/README.md) picked a direction without
picking a provider: **self-hosted OSRM**, over a hosted API (GraphHopper, Mapbox
Directions) or the public OSRM demo (explicitly not for production use).
**This ticket exists because that decision itself has a wrinkle worth naming up front:**
this whole v3 backlog's organizing principle, stated in `docs/BACKLOG.md`, is *"v3 is
everything that can be built with no server. The ROADMAP (formerly "v4") is everything
that cannot."* A self-hosted OSRM instance is a server. Strictly, that makes V3-08 and
V3-09 — anything that depends on this ticket — ROADMAP work by the project's own
definition, not v3, even though they're filed under `docs/v3/` today and the routing
itself has nothing to do with the group-rides/accounts/backup programme that currently
anchors the ROADMAP. Whether to formally renumber them is a documentation decision for
whoever picks this up next; this ticket does not resolve it, only flags it so it isn't
silently glossed over.
## Design
Two separable questions:
1. **Where does it run?** A small VPS (the same shape of box that would eventually host
the ROADMAP's group-ride server, so this could double as an early step toward that)
versus something serverless/managed. OSRM's own Docker image is the standard path
either way.
2. **What data does it need?** A regional OSM extract, not the planet — start with
whatever region actually gets ridden (per `docs/LAUNCH.md`, this is presently a
friends-and-family app, so the region is small and known). [Geofabrik](https://download.geofabrik.de/)
publishes regional `.osm.pbf` extracts sized for exactly this.
Profiles matter for this app specifically (see V3-08's Design section): a motorcycle
route and a bicycle route between the same two pins should genuinely differ. OSRM ships
car/bike/foot profiles out of the box; a motorcycle profile is closer to car (mostly
avoids the walk-only restrictions bike profiles impose) but might want the twisty-road
preference a stock car profile doesn't have reason to express. Confirming that is part of
this ticket's investigation, not something to guess at now.
## Implementation
1. Pick and provision a host (see Design's first question)
2. Download and preprocess a regional extract with OSRM's own toolchain
(`osrm-extract` → `osrm-partition` → `osrm-customize`, or the older
`osrm-contract` pipeline depending on the OSRM version chosen)
3. Run `osrm-routed` behind whatever the host offers for TLS termination — the app will
be calling this over the public internet from riders' phones, so plain HTTP is not
an option
4. Confirm at least a car-equivalent and a bike profile both return sane routes for a
handful of real local pin pairs, by hand, before writing any app code against it
5. Write the resulting base URL and auth (if any) down for V3-08 to consume — as
configuration, never a hardcoded value, matching V3-08's own "no API key committed to
the repository" acceptance criterion, which applies here too even though there's no
third-party vendor to protect a key from — an open, unauthenticated routing endpoint
is still worth not publishing in a public repo
6. A basic uptime check of some kind — this becomes a real dependency the app relies on,
not a fire-and-forget script
## Acceptance criteria
- [ ] An OSRM instance is reachable over HTTPS from outside the host network
- [ ] Returns a road-following route for a real pin pair in the region actually ridden
- [ ] At least two distinct profiles (car-equivalent, bike) both work
- [ ] The endpoint and any credentials live in configuration, not source
- [ ] Documented: what's running, where, how to update the extract when it goes stale,
and what it costs (if anything) to keep running
## Tests
Infrastructure, not app code — no `flutter test` coverage belongs to this ticket
directly. V3-08's own `FakeRoutingService`-driven tests are what verify the app's
behavior; this ticket's job is only to make the real thing exist for that fake to stand
in for.
## Risks
- **Ongoing hosting cost and maintenance**, however small — this is the first piece of
always-on infrastructure this project has taken on. Worth being honest that "no
server" stopped being true the moment this ticket is picked up, regardless of which
numbering bucket it ends up filed under.
- **OSM extracts go stale.** A road that didn't exist at extract time won't route.
Needs a refresh cadence, not a one-time setup.
- Regional extracts are cheap; do not reach for a planet-wide extract preemptively.
## Out of scope
Actually building V3-08/V3-09 against this once it exists — that's their ticket, not
this one. Turn-by-turn navigation, traffic-aware routing, anything beyond what stock OSRM
gives you.

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:rippr/main.dart' as app;
/// End-to-end tests against the real app on a real device or simulator.
///
/// These exist to cover what widget tests structurally cannot: that Drift actually opens
/// against platform storage, that the plugin registrations resolve, that go_router
/// navigates a real Navigator, and that the app survives a cold start.
///
/// **What these still cannot prove.** Neither simulator produces velocity — `adb emu geo
/// fix` teleports and iOS's simulated locations are no better — so max speed, moving
/// time, average moving speed and speed colouring on the map remain unverifiable here.
/// A green run of this file says nothing about any of them. That is what T25's real ride
/// is for; see `docs/port/PLAN.md` and the native repo's `docs/TESTING.md`.
///
/// Run with:
/// flutter test integration_test -d `<device-id>`
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('cold start', () {
testWidgets('the app launches and lands on the record screen',
(tester) async {
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.text('RIPPR'), findsOneWidget);
expect(find.text('SPEED'), findsOneWidget);
// Idle, because a fresh install has no ride in progress.
expect(find.text('Ready'), findsOneWidget);
});
testWidgets('the database opens against real platform storage',
(tester) async {
// If Drift could not open its file, or the sqlite3 native library were missing,
// the trips list would surface an error rather than an empty state. This is the
// check a widget test with an in-memory database cannot make.
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.tap(find.text('Rides'));
await tester.pumpAndSettle();
expect(find.textContaining('No rides yet'), findsOneWidget);
});
});
group('navigation', () {
testWidgets('record → rides → back', (tester) async {
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.tap(find.text('Rides'));
await tester.pumpAndSettle();
expect(find.text('RIDES'), findsOneWidget);
await tester.tap(find.byKey(const Key('back')));
await tester.pumpAndSettle();
expect(find.text('SPEED'), findsOneWidget);
});
});
group('permissions', () {
testWidgets('starting without location permission explains itself',
(tester) async {
// On a simulator with permission denied, Start must surface a readable message
// rather than silently doing nothing or throwing into the void.
//
// If permission IS granted in the test environment, recording begins instead —
// both are acceptable outcomes, so this asserts only that the app stays alive and
// responsive either way.
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.tap(find.byKey(const Key('start')));
await tester.pumpAndSettle(const Duration(seconds: 3));
expect(tester.takeException(), isNull);
expect(find.byType(MaterialApp), findsOneWidget);
});
});
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:rippr/main.dart' as app;
import 'package:rippr/src/app/providers.dart';
/// Drives a whole recording on a simulator that is genuinely *moving*.
///
/// Run `xcrun simctl location booted start --speed=15 <waypoints>` first. The iOS
/// simulator interpolates between waypoints over time, so the device genuinely moves —
/// but **CoreLocation still reports speed 0.0** throughout. Measured, not assumed: a
/// probe run captured 8 fixes, every one of them zero.
///
/// So position, distance, path shape and segment structure are all verifiable here.
/// Speed, moving time and speed colouring are not, on either platform.
///
/// The app is uninstalled when the run ends, taking its database with it, so anything
/// worth knowing is read back through the provider graph and printed from inside.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('record a ride end to end through the UI', (tester) async {
app.main();
await tester.pumpAndSettle(const Duration(seconds: 5));
await tester.tap(find.byKey(const Key('start')));
await tester.pumpAndSettle(const Duration(seconds: 2));
// Ride for a while, letting the flush timer land batches.
for (var i = 0; i < 15; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(seconds: 1)));
await tester.pump();
}
await tester.tap(find.byKey(const Key('stop')));
await tester.pumpAndSettle(const Duration(seconds: 3));
// Read the stored ride back through the real provider graph. The app is
// uninstalled when the run ends, taking its database with it, so anything worth
// knowing has to be reported from inside the test.
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp)),
listen: false,
);
final repo = container.read(tripRepositoryProvider);
final trips = await repo.watchCompletedTrips().first;
debugPrint('RIPPR-PROBE completedTrips=${trips.length}');
for (final t in trips) {
final points = await repo.pointsForTrip(t.id);
final segments = await repo.segmentsForTrip(t.id);
final lats = [for (final p in points) p.latitude];
debugPrint('RIPPR-PROBE trip=${t.id} points=${t.pointCount} '
'distanceM=${t.distanceM.toStringAsFixed(1)} '
'maxSpeedKmh=${t.maxSpeedKmh.toStringAsFixed(1)} '
'movingMs=${t.movingMillis} segments=${segments.length}');
if (lats.isNotEmpty) {
debugPrint('RIPPR-PROBE latSpan=${(lats.reduce((a, b) => a > b ? a : b) - lats.reduce((a, b) => a < b ? a : b)).toStringAsFixed(5)}');
}
}
// What CAN be proven locally: the ride persisted, moved, and is listed.
expect(trips, isNotEmpty, reason: 'the ride was not saved');
expect(trips.first.pointCount, greaterThan(0));
expect(trips.first.distanceM, greaterThan(0),
reason: 'the simulator moved, so distance must be non-zero');
// Now the list, then the detail screen with its map.
await tester.tap(find.text('Rides'));
await tester.pumpAndSettle(const Duration(seconds: 2));
expect(find.byType(Card), findsWidgets, reason: 'the ride should be listed');
await tester.tap(find.byKey(Key('trip-${trips.first.id}')));
await tester.pumpAndSettle(const Duration(seconds: 4));
expect(find.text('DISTANCE'), findsOneWidget);
debugPrint('RIPPR-PROBE detail screen rendered');
// Hold here so an external `simctl io screenshot` can capture the detail screen and
// its map. Tapping the iOS simulator is not scriptable, so this is how a visual
// check gets taken at all.
for (var i = 0; i < 14; i++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(seconds: 1)));
await tester.pump();
}
});
}

34
rippr-flutter-src/ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

View File

@@ -0,0 +1,16 @@
PODS:
- Flutter (1.0.0)
DEPENDENCIES:
- Flutter (from `Flutter`)
EXTERNAL SOURCES:
Flutter:
:path: Flutter
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
PODFILE CHECKSUM: 3ba3a73b1b12adcf321a227a745625fe0888cc82
COCOAPODS: 1.17.0

View File

@@ -0,0 +1,741 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
87030CDD0819D31F306B448B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 503A8061E9B715651E917D1A /* Pods_RunnerTests.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
F7003408DF00A9D6A6BACAD6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8592E514F54428D238C5AA9F /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
2D08F5EF97D8064AE021BB9A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
40F3634206A0ED63EAF40748 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
503A8061E9B715651E917D1A /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
59C8CAEDBE9FC384E5F45FF0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
59F13F33E5326B3E18021E1D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
5DDA5DD1020470D9C83B1318 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8592E514F54428D238C5AA9F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6D0E4CD94676FA0704BE5D3 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
F7003408DF00A9D6A6BACAD6 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
EEE81055C13D730C7590D94F /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
87030CDD0819D31F306B448B /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
245628F0323E24600B3DEF53 /* Frameworks */ = {
isa = PBXGroup;
children = (
8592E514F54428D238C5AA9F /* Pods_Runner.framework */,
503A8061E9B715651E917D1A /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
638B2B069F5EDF8A38A8C039 /* Pods */ = {
isa = PBXGroup;
children = (
59C8CAEDBE9FC384E5F45FF0 /* Pods-Runner.debug.xcconfig */,
D6D0E4CD94676FA0704BE5D3 /* Pods-Runner.release.xcconfig */,
59F13F33E5326B3E18021E1D /* Pods-Runner.profile.xcconfig */,
5DDA5DD1020470D9C83B1318 /* Pods-RunnerTests.debug.xcconfig */,
40F3634206A0ED63EAF40748 /* Pods-RunnerTests.release.xcconfig */,
2D08F5EF97D8064AE021BB9A /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
638B2B069F5EDF8A38A8C039 /* Pods */,
245628F0323E24600B3DEF53 /* Frameworks */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
56E7784F667350B9E27DE332 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
EEE81055C13D730C7590D94F /* Frameworks */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
BCDD43BFD3ECC518E93D36FB /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
56E7784F667350B9E27DE332 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
BCDD43BFD3ECC518E93D36FB /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5DDA5DD1020470D9C83B1318 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 40F3634206A0ED63EAF40748 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 2D08F5EF97D8064AE021BB9A /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.rippr.port;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

Some files were not shown because too many files have changed in this diff Show More