Compare commits
12 Commits
64f5dff30b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1920b2b822 | |||
| f830f8937d | |||
| 7ec28c7b5f | |||
| 2ad5fab677 | |||
| b1b81a6dc6 | |||
| 4aa5da53d2 | |||
| 587f75eab4 | |||
| 4c634354bd | |||
| 14289befb0 | |||
| e8d8bcacda | |||
| 3c801e9114 | |||
| 0bc42b2e5a |
6
.gitignore
vendored
@@ -177,9 +177,3 @@ cython_debug/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# proprietary TuneECU/Triumph map binaries — do not redistribute
|
||||
*.hex
|
||||
*.dec.bin
|
||||
maps_cache/
|
||||
tunie/**/table_map.json
|
||||
|
||||
141
INSTALL.md
Normal 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
|
||||
```
|
||||
77
RIPPR.md
@@ -1,54 +1,83 @@
|
||||
# Rippr — copies held here
|
||||
|
||||
Rippr is a native Android GPS ride recorder for motorcycles. Its canonical repo is
|
||||
`~/dojo/rippr`, which **has no git remote** — it exists only on that machine, so the
|
||||
bundle below is the only off-machine copy of its history.
|
||||
Rippr is a GPS ride recorder for motorcycles. There are now **two** codebases, and
|
||||
neither has a git remote — they exist only on that machine, so the bundles below are the
|
||||
only off-machine copies of their history.
|
||||
|
||||
**Snapshot taken at:** `d418920` — "v3: add waypoint route planning; record that handlebar mounting changes the premise" (21 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
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `rippr-src/` | Browsable source, exported with `git archive HEAD`. No history; will drift. |
|
||||
| `rippr-full-history.bundle` | Full git history. The actual backup. |
|
||||
| `rippr-2.0.1-debug.apk` | Latest installable build (debug-signed) |
|
||||
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Previous builds |
|
||||
| `rippr-flutter-src/` | Flutter port, browsable source (`git archive HEAD`) |
|
||||
| `rippr-flutter-history.bundle` | Flutter port, full git history — the actual backup |
|
||||
| `rippr-flutter-1.0-debug.apk` | **Installable Flutter build** (`com.rippr.port`) |
|
||||
| `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
|
||||
nested `.git`.
|
||||
**The two APKs use different application ids on purpose** (`com.rippr` vs
|
||||
`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
|
||||
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
|
||||
cd rippr
|
||||
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||
cd rippr && echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Needs **JDK 17–21** — AGP does not support 25 — and the Android SDK with platform 35 and
|
||||
build-tools 35.
|
||||
Both need **JDK 17–21** for Android — AGP does not support 25.
|
||||
|
||||
## Where to start reading
|
||||
|
||||
Inside `rippr-src/`:
|
||||
Inside `rippr-flutter-src/`:
|
||||
|
||||
- `README.md` — what the app is and its current state
|
||||
- `docs/ARCHITECTURE.md` — the design decisions and why
|
||||
- `docs/v3/BACKLOG.md` — known gaps, deferred work, what must not regress
|
||||
- `docs/TESTING.md` — especially "What the emulator cannot verify"
|
||||
- `docs/v2/PROGRESS.md` — every bug found during v2 development
|
||||
- `README.md` — status, and the two bugs the port found in the native app
|
||||
- `docs/port/PARITY-AUDIT.md` — feature by feature, with the evidence behind each claim
|
||||
- `docs/port/REAL-RIDE-CHECKLIST.md` — **the outstanding work**
|
||||
- `docs/ARCHITECTURE.md` — why it is built this way
|
||||
- `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
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Note the export wipes `rippr-src/` wholesale, which is why this file lives at the repo
|
||||
root rather than inside it.
|
||||
The exports wipe those directories wholesale, which is why this file and `INSTALL.md`
|
||||
live at the repo root rather than inside them.
|
||||
|
||||
BIN
rippr-flutter-1.0-debug.apk
Normal file
BIN
rippr-flutter-history.bundle
Normal file
52
rippr-flutter-src/.gitignore
vendored
Normal 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/
|
||||
33
rippr-flutter-src/.metadata
Normal 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
@@ -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.
|
||||
38
rippr-flutter-src/analysis_options.yaml
Normal 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
@@ -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
|
||||
55
rippr-flutter-src/android/app/build.gradle.kts
Normal 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 = "../.."
|
||||
}
|
||||
@@ -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>
|
||||
64
rippr-flutter-src/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.rippr
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
18
rippr-flutter-src/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
@@ -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>
|
||||
24
rippr-flutter-src/android/build.gradle.kts
Normal 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)
|
||||
}
|
||||
6
rippr-flutter-src/android/gradle.properties
Normal 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
|
||||
5
rippr-flutter-src/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
|
||||
26
rippr-flutter-src/android/settings.gradle.kts
Normal 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")
|
||||
13
rippr-flutter-src/build.yaml
Normal 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
|
||||
141
rippr-flutter-src/docs/ARCHITECTURE.md
Normal 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.
|
||||
360
rippr-flutter-src/docs/BACKLOG.md
Normal 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.
|
||||
195
rippr-flutter-src/docs/LAUNCH.md
Normal 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.
|
||||
75
rippr-flutter-src/docs/PORT_RESEARCH.md
Normal file
62
rippr-flutter-src/docs/design/stitch-export/README.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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&family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@400;600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 89 KiB |
@@ -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&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@600;700&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&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>
|
||||
|
After Width: | Height: | Size: 138 KiB |
@@ -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&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@400;600;700&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>
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -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&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&family=Sora:wght@600;700&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>
|
||||
|
After Width: | Height: | Size: 83 KiB |
89
rippr-flutter-src/docs/port/IOS-VERIFICATION.md
Normal 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.
|
||||
168
rippr-flutter-src/docs/port/PARITY-AUDIT.md
Normal 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).
|
||||
349
rippr-flutter-src/docs/port/PLAN.md
Normal 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*.
|
||||
832
rippr-flutter-src/docs/port/PROGRESS.md
Normal 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.
|
||||
100
rippr-flutter-src/docs/port/REAL-RIDE-CHECKLIST.md
Normal 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.
|
||||
78
rippr-flutter-src/docs/port/RELEASE-IOS.md
Normal 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.
|
||||
112
rippr-flutter-src/docs/ui-redesign/README.md
Normal 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.
|
||||
@@ -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.
|
||||
148
rippr-flutter-src/docs/ui-redesign/UI-02-offline-skeleton-map.md
Normal 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.
|
||||
137
rippr-flutter-src/docs/ui-redesign/UI-03-glass-component-kit.md
Normal 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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
148
rippr-flutter-src/docs/ui-redesign/UI-07-rides-history.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
80
rippr-flutter-src/docs/v3/README.md
Normal 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.
|
||||
97
rippr-flutter-src/docs/v3/V3-01-activity-type.md
Normal 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).
|
||||
86
rippr-flutter-src/docs/v3/V3-02-settings-screen.md
Normal 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`.
|
||||
87
rippr-flutter-src/docs/v3/V3-03-units.md
Normal 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`.
|
||||
90
rippr-flutter-src/docs/v3/V3-04-live-map.md
Normal 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).
|
||||
101
rippr-flutter-src/docs/v3/V3-05-mounted-mode.md
Normal 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.
|
||||
94
rippr-flutter-src/docs/v3/V3-06-notification-stats.md
Normal 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.
|
||||
106
rippr-flutter-src/docs/v3/V3-07-route-drawing.md
Normal 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).
|
||||
83
rippr-flutter-src/docs/v3/V3-08-road-routing.md
Normal 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.
|
||||
53
rippr-flutter-src/docs/v3/V3-09-route-following.md
Normal 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.
|
||||
92
rippr-flutter-src/docs/v3/V3-10-trip-splitting.md
Normal 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).
|
||||
113
rippr-flutter-src/docs/v3/V3-11-offline-tiles.md
Normal 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).
|
||||
100
rippr-flutter-src/docs/v3/V3-12-crash-reporting.md
Normal 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).
|
||||
61
rippr-flutter-src/docs/v3/V3-13-real-ride-measurements.md
Normal 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.
|
||||
80
rippr-flutter-src/docs/v3/V3-14-gpx-interop.md
Normal 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).
|
||||
57
rippr-flutter-src/docs/v3/V3-15-auto-pause.md
Normal 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.
|
||||
137
rippr-flutter-src/docs/v3/V3-16-visual-identity.md
Normal 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.
|
||||
90
rippr-flutter-src/docs/v3/V3-17-osrm-hosting.md
Normal 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.
|
||||
84
rippr-flutter-src/integration_test/app_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
89
rippr-flutter-src/integration_test/ride_simulation_test.dart
Normal 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
@@ -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
|
||||
24
rippr-flutter-src/ios/Flutter/AppFrameworkInfo.plist
Normal 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>
|
||||
2
rippr-flutter-src/ios/Flutter/Debug.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
2
rippr-flutter-src/ios/Flutter/Release.xcconfig
Normal file
@@ -0,0 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
43
rippr-flutter-src/ios/Podfile
Normal 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
|
||||
16
rippr-flutter-src/ios/Podfile.lock
Normal 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
|
||||
741
rippr-flutter-src/ios/Runner.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -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>
|
||||