Compare commits
2 Commits
64f5dff30b
...
3c801e9114
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c801e9114 | |||
| 0bc42b2e5a |
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 — copies held here
|
||||||
|
|
||||||
Rippr is a native Android GPS ride recorder for motorcycles. Its canonical repo is
|
Rippr is a GPS ride recorder for motorcycles. There are now **two** codebases, and
|
||||||
`~/dojo/rippr`, which **has no git remote** — it exists only on that machine, so the
|
neither has a git remote — they exist only on that machine, so the bundles below are the
|
||||||
bundle below is the only off-machine copy of its history.
|
only off-machine copies of their history.
|
||||||
|
|
||||||
**Snapshot taken at:** `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 `fc6d8f9`.
|
||||||
|
|
||||||
## What is here
|
## What is here
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `rippr-src/` | Browsable source, exported with `git archive HEAD`. No history; will drift. |
|
| `rippr-flutter-src/` | Flutter port, browsable source (`git archive HEAD`) |
|
||||||
| `rippr-full-history.bundle` | Full git history. The actual backup. |
|
| `rippr-flutter-history.bundle` | Flutter port, full git history — the actual backup |
|
||||||
| `rippr-2.0.1-debug.apk` | Latest installable build (debug-signed) |
|
| `rippr-flutter-1.0-debug.apk` | **Installable Flutter build** (`com.rippr.port`) |
|
||||||
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Previous builds |
|
| `rippr-src/` | Native app, browsable source |
|
||||||
|
| `rippr-full-history.bundle` | Native app, full git history |
|
||||||
|
| `rippr-2.0.1-debug.apk` | Native app, latest installable build (`com.rippr`) |
|
||||||
|
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Older native builds |
|
||||||
|
|
||||||
`rippr-src/` contains tracked files only — no build outputs, no `local.properties`, no
|
**The two APKs use different application ids on purpose** (`com.rippr` vs
|
||||||
nested `.git`.
|
`com.rippr.port`), so both install side by side. That is not an accident — validating the
|
||||||
|
port depends on recording the same ride on both at once.
|
||||||
|
|
||||||
## Restoring the full repo
|
## Installing
|
||||||
|
|
||||||
|
See **[INSTALL.md](INSTALL.md)** for Android and iOS, including the sideloading route for
|
||||||
|
iPhone (which needs a Mac and takes about ten minutes).
|
||||||
|
|
||||||
|
## Restoring either repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone rippr-flutter-history.bundle rippr-flutter
|
||||||
|
cd rippr-flutter && flutter pub get && dart run build_runner build
|
||||||
|
flutter test
|
||||||
|
|
||||||
|
# or the native app
|
||||||
git clone rippr-full-history.bundle rippr
|
git clone rippr-full-history.bundle rippr
|
||||||
cd rippr
|
cd rippr && echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||||
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
|
||||||
./gradlew assembleDebug
|
./gradlew assembleDebug
|
||||||
```
|
```
|
||||||
|
|
||||||
Needs **JDK 17–21** — AGP does not support 25 — and the Android SDK with platform 35 and
|
Both need **JDK 17–21** for Android — AGP does not support 25.
|
||||||
build-tools 35.
|
|
||||||
|
|
||||||
## Where to start reading
|
## Where to start reading
|
||||||
|
|
||||||
Inside `rippr-src/`:
|
Inside `rippr-flutter-src/`:
|
||||||
|
|
||||||
- `README.md` — what the app is and its current state
|
- `README.md` — status, and the two bugs the port found in the native app
|
||||||
- `docs/ARCHITECTURE.md` — the design decisions and why
|
- `docs/port/PARITY-AUDIT.md` — feature by feature, with the evidence behind each claim
|
||||||
- `docs/v3/BACKLOG.md` — known gaps, deferred work, what must not regress
|
- `docs/port/REAL-RIDE-CHECKLIST.md` — **the outstanding work**
|
||||||
- `docs/TESTING.md` — especially "What the emulator cannot verify"
|
- `docs/ARCHITECTURE.md` — why it is built this way
|
||||||
- `docs/v2/PROGRESS.md` — every bug found during v2 development
|
- `docs/port/PROGRESS.md` — everything that went wrong during the port
|
||||||
|
|
||||||
|
Inside `rippr-src/` (native): `docs/v2/PROGRESS.md` and `docs/TESTING.md` remain the
|
||||||
|
record of how the app got here.
|
||||||
|
|
||||||
## Keeping these current
|
## Keeping these current
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/dojo/samplez
|
cd ~/dojo/samplez
|
||||||
|
|
||||||
|
rm -rf rippr-flutter-src && mkdir rippr-flutter-src
|
||||||
|
git -C ~/dojo/rippr-flutter archive HEAD | tar -x -C rippr-flutter-src
|
||||||
|
(cd ~/dojo/rippr-flutter && git bundle create /tmp/fb.bundle --all) \
|
||||||
|
&& mv /tmp/fb.bundle rippr-flutter-history.bundle
|
||||||
|
|
||||||
rm -rf rippr-src && mkdir rippr-src
|
rm -rf rippr-src && mkdir rippr-src
|
||||||
git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src
|
git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src
|
||||||
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) && mv /tmp/rb.bundle rippr-full-history.bundle
|
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) \
|
||||||
|
&& mv /tmp/rb.bundle rippr-full-history.bundle
|
||||||
|
|
||||||
|
git bundle verify rippr-flutter-history.bundle
|
||||||
git bundle verify rippr-full-history.bundle
|
git bundle verify rippr-full-history.bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
Note the export wipes `rippr-src/` wholesale, which is why this file lives at the repo
|
The exports wipe those directories wholesale, which is why this file and `INSTALL.md`
|
||||||
root rather than inside it.
|
live at the repo root rather than inside them.
|
||||||
|
|||||||
BIN
rippr-flutter-1.0-debug.apk
Normal file
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'
|
||||||
102
rippr-flutter-src/README.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# 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/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
|
||||||
49
rippr-flutter-src/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 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>
|
||||||
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.
|
||||||
75
rippr-flutter-src/docs/PORT_RESEARCH.md
Normal file
196
rippr-flutter-src/docs/V3-BACKLOG.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
> **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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v3 backlog
|
||||||
|
|
||||||
|
Everything known-outstanding as of v2.0.1, with enough context to pick up cold.
|
||||||
|
Nothing here is committed to — it is a menu, roughly ordered by value.
|
||||||
|
|
||||||
|
**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. The original v3 candidate — live group ride view
|
||||||
|
|
||||||
|
Deferred from v2 as "needs real server work". This was in the **v1** brief's goal
|
||||||
|
statement, so it has been the intended destination all along.
|
||||||
|
|
||||||
|
Already in place:
|
||||||
|
- `TelemetryUploader` — batched POST, retry, offline-safe, cannot stall recording
|
||||||
|
- `synced` column and backlog semantics
|
||||||
|
- `trip_id` / `segment_id` per point, so a server can reconstruct rides and pauses
|
||||||
|
- `Config.deviceId` — stable per-install id to distinguish riders
|
||||||
|
|
||||||
|
Missing:
|
||||||
|
- **A server.** Nothing exists. This is the actual work.
|
||||||
|
- **UI for the endpoint** — currently only reachable via `Config.setUploadEndpoint()`
|
||||||
|
- Other riders' positions on a map, and a live map at all (see below)
|
||||||
|
- Auth, rider identity, group membership
|
||||||
|
|
||||||
|
**Worth deciding early:** this is the point where Rippr stops being a local-only app. That
|
||||||
|
brings hosting, privacy, and location-sharing consent into scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 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. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 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 3 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.
|
||||||
|
- **User sign-up and accounts.** Register people, give their data somewhere to live.
|
||||||
|
Prerequisite for anything cloud-side, and pairs with the group-ride server in section 2.
|
||||||
|
- **Activity type per ride.** Motorcycle, bicycle, skateboard, running, other. The app is
|
||||||
|
not inherently motorcycle-only — the recording pipeline is activity-agnostic already.
|
||||||
|
Note: adds a column to `Trip`, so it needs a real `Migration` (the destructive fallback
|
||||||
|
is gone). Type could also drive sensible defaults — speed noise floor, map zoom,
|
||||||
|
elevation smoothing.
|
||||||
|
- **Paid cloud backup.** Ongoing storage of rides over time. Needs accounts first, plus a
|
||||||
|
decision on hosting, pricing, and what happens to data when someone stops paying.
|
||||||
|
- **Waypoint route planning.** Drop a series of pins on the map to "draw" a route, get
|
||||||
|
distance and estimates back, and save it to ride later. This is *pre*-ride planning —
|
||||||
|
a genuinely new mode alongside recording, not an extension of it. Needs its own entity
|
||||||
|
(`Route` + `Waypoint`), separate from `Trip`, since a plan is not a recording.
|
||||||
|
Straight-line pin-to-pin distance is easy and reuses `Geo.haversineMeters`; snapping to
|
||||||
|
actual roads needs a routing service (OSRM, GraphHopper, Valhalla — self-hostable) and is
|
||||||
|
a much larger step. Natural follow-ons: follow a planned route on the live map, and
|
||||||
|
compare a recorded ride against the plan afterwards.
|
||||||
|
|
||||||
|
### 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. Worth scoping together rather than separately.
|
||||||
|
|
||||||
|
Activity type and theming are independent and much cheaper — either could ship alone.
|
||||||
|
|
||||||
|
Live map, handlebar mounting and waypoint following also cluster: all three assume a
|
||||||
|
visible screen during the ride, and all three want the same map component. Route planning
|
||||||
|
is the odd one out — it needs no ride in progress at all and could be built entirely
|
||||||
|
standalone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Things that must not regress
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 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.
|
||||||
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*.
|
||||||
799
rippr-flutter-src/docs/port/PROGRESS.md
Normal file
@@ -0,0 +1,799 @@
|
|||||||
|
# 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/V3-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).
|
||||||
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.
|
||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -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>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1510"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Prepare Flutter Framework Script"
|
||||||
|
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
||||||
|
BuildableName = "RunnerTests.xctest"
|
||||||
|
BlueprintName = "RunnerTests"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
enableGPUValidationMode = "1"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Profile"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
10
rippr-flutter-src/ios/Runner.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "group:Runner.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</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>
|
||||||
@@ -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>PreviewsEnabled</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
16
rippr-flutter-src/ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
|
) -> Bool {
|
||||||
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-20x20@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-29x29@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-40x40@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "60x60",
|
||||||
|
"idiom" : "iphone",
|
||||||
|
"filename" : "Icon-App-60x60@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "20x20",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-20x20@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "29x29",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-29x29@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "40x40",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-40x40@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "76x76",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-76x76@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "83.5x83.5",
|
||||||
|
"idiom" : "ipad",
|
||||||
|
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"size" : "1024x1024",
|
||||||
|
"idiom" : "ios-marketing",
|
||||||
|
"filename" : "Icon-App-1024x1024@1x.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 295 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 282 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 406 B |
|
After Width: | Height: | Size: 586 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 862 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
23
rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage.png",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@2x.png",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"filename" : "LaunchImage@3x.png",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"version" : 1,
|
||||||
|
"author" : "xcode"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 68 B |
5
rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Launch Screen Assets
|
||||||
|
|
||||||
|
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
|
||||||
|
|
||||||
|
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--View Controller-->
|
||||||
|
<scene sceneID="EHf-IW-A2E">
|
||||||
|
<objects>
|
||||||
|
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<subviews>
|
||||||
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
|
</imageView>
|
||||||
|
</subviews>
|
||||||
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
|
<constraints>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
</constraints>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
<point key="canvasLocation" x="53" y="375"/>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="LaunchImage" width="168" height="185"/>
|
||||||
|
</resources>
|
||||||
|
</document>
|
||||||
26
rippr-flutter-src/ios/Runner/Base.lproj/Main.storyboard
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<dependencies>
|
||||||
|
<deployment identifier="iOS"/>
|
||||||
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||||
|
</dependencies>
|
||||||
|
<scenes>
|
||||||
|
<!--Flutter View Controller-->
|
||||||
|
<scene sceneID="tne-QT-ifu">
|
||||||
|
<objects>
|
||||||
|
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
|
||||||
|
<layoutGuides>
|
||||||
|
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
|
||||||
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
|
</layoutGuides>
|
||||||
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
|
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||||
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
|
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||||
|
</view>
|
||||||
|
</viewController>
|
||||||
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
|
</objects>
|
||||||
|
</scene>
|
||||||
|
</scenes>
|
||||||
|
</document>
|
||||||
90
rippr-flutter-src/ios/Runner/Info.plist
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?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>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Rippr</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>rippr</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UIApplicationSceneManifest</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
<false/>
|
||||||
|
<key>UISceneConfigurations</key>
|
||||||
|
<dict>
|
||||||
|
<key>UIWindowSceneSessionRoleApplication</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UISceneClassName</key>
|
||||||
|
<string>UIWindowScene</string>
|
||||||
|
<key>UISceneConfigurationName</key>
|
||||||
|
<string>flutter</string>
|
||||||
|
<key>UISceneDelegateClassName</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
|
||||||
|
<key>UISceneStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchStoryboardName</key>
|
||||||
|
<string>LaunchScreen</string>
|
||||||
|
<key>UIMainStoryboardFile</key>
|
||||||
|
<string>Main</string>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
|
</array>
|
||||||
|
<!-- Location usage strings.
|
||||||
|
|
||||||
|
Apple Guideline 5.1.1 rejections are most often caused by generic text like
|
||||||
|
"This app needs location". These say specifically what is collected, why, and
|
||||||
|
what the user gets for it. -->
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Rippr records the GPS track of your ride so you can see your route, speed and distance afterwards. Nothing is recorded until you press Start.</string>
|
||||||
|
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||||
|
<string>Rippr keeps recording your route while your phone is in your pocket or the screen is off, so a ride you started is captured from beginning to end. Recording stops the moment you press Stop.</string>
|
||||||
|
|
||||||
|
<!-- Keeps the process, the Dart isolate and the writer timer alive while location
|
||||||
|
updates are being delivered. Without this the OS suspends the app the moment the
|
||||||
|
screen locks and the ride simply stops being recorded.
|
||||||
|
|
||||||
|
Paired in code with allowBackgroundLocationUpdates and
|
||||||
|
pauseLocationUpdatesAutomatically = false; see geolocator_location_source.dart. -->
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>location</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
rippr-flutter-src/ios/Runner/Runner-Bridging-Header.h
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#import "GeneratedPluginRegistrant.h"
|
||||||
6
rippr-flutter-src/ios/Runner/SceneDelegate.swift
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class SceneDelegate: FlutterSceneDelegate {
|
||||||
|
|
||||||
|
}
|
||||||
12
rippr-flutter-src/ios/RunnerTests/RunnerTests.swift
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Flutter
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
class RunnerTests: XCTestCase {
|
||||||
|
|
||||||
|
func testExample() {
|
||||||
|
// If you add code to the Runner application, consider adding tests here.
|
||||||
|
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
1225
rippr-flutter-src/pubspec.lock
Normal file
109
rippr-flutter-src/pubspec.yaml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
name: rippr
|
||||||
|
description: "GPS ride recorder for motorcycles"
|
||||||
|
# The following line prevents the package from being accidentally published to
|
||||||
|
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||||
|
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||||
|
|
||||||
|
# The following defines the version and build number for your application.
|
||||||
|
# A version number is three numbers separated by dots, like 1.2.43
|
||||||
|
# followed by an optional build number separated by a +.
|
||||||
|
# Both the version and the builder number may be overridden in flutter
|
||||||
|
# build by specifying --build-name and --build-number, respectively.
|
||||||
|
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||||
|
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||||
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||||
|
# Read more about iOS versioning at
|
||||||
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ^3.13.0
|
||||||
|
|
||||||
|
# Dependencies specify other packages that your package needs in order to work.
|
||||||
|
# To automatically upgrade your package dependencies to the latest versions
|
||||||
|
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||||
|
# dependencies can be manually updated by changing the version numbers below to
|
||||||
|
# the latest version available on pub.dev. To see which dependencies have newer
|
||||||
|
# versions available, run `flutter pub outdated`.
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The following adds the Cupertino Icons font to your application.
|
||||||
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
cupertino_icons: ^1.0.8
|
||||||
|
flutter_riverpod: ^2.6.1
|
||||||
|
go_router: ^17.5.0
|
||||||
|
drift: ^2.34.3
|
||||||
|
drift_flutter: ^0.3.1
|
||||||
|
path_provider: ^2.1.6
|
||||||
|
geolocator: ^14.0.3
|
||||||
|
permission_handler: ^13.0.1
|
||||||
|
flutter_map: ^8.3.1
|
||||||
|
latlong2: ^0.10.1
|
||||||
|
share_plus: ^13.3.0
|
||||||
|
shared_preferences: ^2.5.5
|
||||||
|
http: ^1.6.0
|
||||||
|
synchronized: ^3.4.1+1
|
||||||
|
intl: ^0.20.3
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
|
||||||
|
# The "flutter_lints" package below contains a set of recommended lints to
|
||||||
|
# encourage good coding practices. The lint set provided by the package is
|
||||||
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
|
# package. See that file for information about deactivating specific lint
|
||||||
|
# rules and activating additional ones.
|
||||||
|
flutter_lints: ^6.0.0
|
||||||
|
drift_dev: ^2.34.5
|
||||||
|
build_runner: ^2.16.0
|
||||||
|
mocktail: ^1.0.5
|
||||||
|
xml: ^6.6.1
|
||||||
|
|
||||||
|
# For information on the generic Dart part of this file, see the
|
||||||
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
||||||
|
# The following section is specific to Flutter packages.
|
||||||
|
flutter:
|
||||||
|
|
||||||
|
# The following line ensures that the Material Icons font is
|
||||||
|
# included with your application, so that you can use the icons in
|
||||||
|
# the material Icons class.
|
||||||
|
uses-material-design: true
|
||||||
|
|
||||||
|
# To add assets to your application, add an assets section, like this:
|
||||||
|
# assets:
|
||||||
|
# - images/a_dot_burr.jpeg
|
||||||
|
# - images/a_dot_ham.jpeg
|
||||||
|
|
||||||
|
# An image asset can refer to one or more resolution-specific "variants", see
|
||||||
|
# https://flutter.dev/to/resolution-aware-images
|
||||||
|
|
||||||
|
# For details regarding adding assets from package dependencies, see
|
||||||
|
# https://flutter.dev/to/asset-from-package
|
||||||
|
|
||||||
|
# To add custom fonts to your application, add a fonts section here,
|
||||||
|
# in this "flutter" section. Each entry in this list should have a
|
||||||
|
# "family" key with the font family name, and a "fonts" key with a
|
||||||
|
# list giving the asset and other descriptors for the font. For
|
||||||
|
# example:
|
||||||
|
# fonts:
|
||||||
|
# - family: Schyler
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/Schyler-Regular.ttf
|
||||||
|
# - asset: fonts/Schyler-Italic.ttf
|
||||||
|
# style: italic
|
||||||
|
# - family: Trajan Pro
|
||||||
|
# fonts:
|
||||||
|
# - asset: fonts/TrajanPro.ttf
|
||||||
|
# - asset: fonts/TrajanPro_Bold.ttf
|
||||||
|
# weight: 700
|
||||||
|
#
|
||||||
|
# For details regarding fonts from package dependencies,
|
||||||
|
# see https://flutter.dev/to/font-from-package
|
||||||
248
rippr-flutter-src/test/database_test.dart
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.data.SchemaTest`.
|
||||||
|
///
|
||||||
|
/// **These were instrumented tests requiring a device.** Against Drift on the Dart VM
|
||||||
|
/// they are plain unit tests: faster, and runnable with no emulator booted. That is one
|
||||||
|
/// of the concrete wins of the port.
|
||||||
|
///
|
||||||
|
/// Every test uses an in-memory database. The native suite once wiped a real device's
|
||||||
|
/// rides by running against the production singleton in `setUp`, so this is not
|
||||||
|
/// negotiable — see `docs/TESTING.md` in the native repo.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
|
Future<(int tripId, int segmentId)> seedTrip({int startedAt = 1000}) async {
|
||||||
|
final tripId =
|
||||||
|
await db.insertTrip(Trip(startedAt: startedAt, state: TripState.recording));
|
||||||
|
final segmentId = await db.insertSegment(tripId, startedAt);
|
||||||
|
return (tripId, segmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackPoint point(int tripId, int segmentId, int ts, {double speed = 50.0}) =>
|
||||||
|
TrackPoint(
|
||||||
|
tripId: tripId,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: ts,
|
||||||
|
latitude: 51.0447,
|
||||||
|
longitude: -114.0719,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: 1045.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
group('cascade', () {
|
||||||
|
test('deleting a trip cascades to segments and points', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
expect(await db.countPointsForTrip(tripId), 5);
|
||||||
|
expect(await db.countSegmentsForTrip(tripId), 1);
|
||||||
|
|
||||||
|
await db.deleteTrip(tripId);
|
||||||
|
|
||||||
|
expect(await db.countPointsForTrip(tripId), 0);
|
||||||
|
expect(await db.countSegmentsForTrip(tripId), 0);
|
||||||
|
expect(await db.getTrip(tripId), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foreign keys are actually enforced', () async {
|
||||||
|
// SQLite defaults foreign_keys to OFF. Room enabled it; Drift does not, so the
|
||||||
|
// pragma in beforeOpen is load-bearing. Without it the CASCADE above is
|
||||||
|
// decorative and every delete silently orphans its points.
|
||||||
|
final result =
|
||||||
|
await db.customSelect('PRAGMA foreign_keys').getSingle();
|
||||||
|
expect(result.data.values.first, 1,
|
||||||
|
reason: 'foreign_keys pragma is off — CASCADE would do nothing');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point cannot reference a trip that does not exist', () async {
|
||||||
|
await expectLater(
|
||||||
|
db.insertPoint(point(9999, 9999, 1000)),
|
||||||
|
throwsA(anything),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('active trip', () {
|
||||||
|
test('an open trip is the active one and a closed trip is not', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
expect((await db.getActiveTrip())?.id, tripId);
|
||||||
|
|
||||||
|
await db.closeTrip(tripId, 5000);
|
||||||
|
|
||||||
|
expect(await db.getActiveTrip(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchActiveTrip emits the open trip', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
expect((await db.watchActiveTrip().first)?.id, tripId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed trips are ordered newest first', () async {
|
||||||
|
final a = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 1000, state: TripState.recording));
|
||||||
|
final b = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 3000, state: TripState.recording));
|
||||||
|
final c = await db.insertTrip(
|
||||||
|
const Trip(startedAt: 2000, state: TripState.recording));
|
||||||
|
for (final id in [a, b, c]) {
|
||||||
|
await db.closeTrip(id, 9000);
|
||||||
|
}
|
||||||
|
|
||||||
|
final listed = await db.watchCompletedTrips().first;
|
||||||
|
expect(listed.map((t) => t.startedAt).toList(), [3000, 2000, 1000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('point ordering', () {
|
||||||
|
test('points come back by segment then id, not by timestamp', () async {
|
||||||
|
// Timestamps are GPS-derived and can jump; id is monotonic in write order. The
|
||||||
|
// ordering contract is what keeps a ride walkable with pauses intact.
|
||||||
|
final (tripId, seg1) = await seedTrip();
|
||||||
|
final seg2 = await db.insertSegment(tripId, 2000);
|
||||||
|
|
||||||
|
await db.insertPoints([
|
||||||
|
point(tripId, seg2, 500), // deliberately the earliest timestamp
|
||||||
|
point(tripId, seg1, 9000),
|
||||||
|
point(tripId, seg1, 1000),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final ordered = await db.pointsForTrip(tripId);
|
||||||
|
expect(ordered.map((p) => p.segmentId).toList(), [seg1, seg1, seg2]);
|
||||||
|
expect(ordered.first.timestamp, 9000,
|
||||||
|
reason: 'must not be re-sorted by timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lastInSegment returns the highest id, the recorder anchor', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(5, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
final last = await db.lastInSegment(segmentId);
|
||||||
|
expect(last!.timestamp, 1004);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openSegment finds the one still being recorded into', () async {
|
||||||
|
final (tripId, seg1) = await seedTrip();
|
||||||
|
await db.closeSegment(seg1, 2000);
|
||||||
|
final seg2 = await db.insertSegment(tripId, 3000);
|
||||||
|
|
||||||
|
expect((await db.openSegment(tripId))?.id, seg2);
|
||||||
|
|
||||||
|
await db.closeSegment(seg2, 4000);
|
||||||
|
expect(await db.openSegment(tripId), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('stats guards', () {
|
||||||
|
test('a trip with no points reports zeros rather than failing', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
final stats = await db.watchTripStats(tripId).first;
|
||||||
|
|
||||||
|
expect(stats.pointCount, 0);
|
||||||
|
expect(stats.maxSpeedKmh, 0.0);
|
||||||
|
expect(stats.avgSpeedKmh, 0.0);
|
||||||
|
expect(stats.firstTimestamp, 0);
|
||||||
|
expect(stats.durationMillis, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats aggregate over the trip', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints([
|
||||||
|
point(tripId, segmentId, 1000, speed: 10),
|
||||||
|
point(tripId, segmentId, 2000, speed: 50),
|
||||||
|
point(tripId, segmentId, 3000, speed: 30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final stats = await db.watchTripStats(tripId).first;
|
||||||
|
|
||||||
|
expect(stats.pointCount, 3);
|
||||||
|
expect(stats.maxSpeedKmh, 50.0);
|
||||||
|
expect(stats.avgSpeedKmh, closeTo(30.0, 1e-9));
|
||||||
|
expect(stats.durationMillis, 2000);
|
||||||
|
expect(stats.pendingUpload, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('upload backlog', () {
|
||||||
|
test('unsynced points come back oldest first and mark cleanly', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints(
|
||||||
|
List.generate(10, (i) => point(tripId, segmentId, 1000 + i)));
|
||||||
|
|
||||||
|
expect(await db.countUnsynced(), 10);
|
||||||
|
|
||||||
|
final batch = await db.unsyncedPoints(4);
|
||||||
|
expect(batch.length, 4);
|
||||||
|
expect(batch.first.timestamp, 1000);
|
||||||
|
|
||||||
|
await db.markSynced(batch.map((p) => p.id).toList());
|
||||||
|
|
||||||
|
expect(await db.countUnsynced(), 6);
|
||||||
|
expect((await db.unsyncedPoints(1)).first.timestamp, 1004);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marking an empty list is a no-op', () async {
|
||||||
|
final (tripId, segmentId) = await seedTrip();
|
||||||
|
await db.insertPoints([point(tripId, segmentId, 1000)]);
|
||||||
|
await db.markSynced(const []);
|
||||||
|
expect(await db.countUnsynced(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('aggregates', () {
|
||||||
|
test('updateAggregates writes only the running totals', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
await db.renameTrip(tripId, 'Morning loop');
|
||||||
|
|
||||||
|
await db.updateAggregates(
|
||||||
|
id: tripId,
|
||||||
|
distanceM: 1234.5,
|
||||||
|
movingMillis: 60000,
|
||||||
|
maxSpeedKmh: 88.5,
|
||||||
|
elevationGainM: 42.0,
|
||||||
|
pointCount: 99,
|
||||||
|
);
|
||||||
|
|
||||||
|
final trip = (await db.getTrip(tripId))!;
|
||||||
|
expect(trip.distanceM, 1234.5);
|
||||||
|
expect(trip.movingMillis, 60000);
|
||||||
|
expect(trip.maxSpeedKmh, 88.5);
|
||||||
|
expect(trip.elevationGainM, 42.0);
|
||||||
|
expect(trip.pointCount, 99);
|
||||||
|
expect(trip.name, 'Morning loop', reason: 'unrelated columns must survive');
|
||||||
|
expect(trip.endedAt, isNull, reason: 'must not close the trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state round-trips through the enum column', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
|
||||||
|
await db.setTripState(tripId, TripState.paused);
|
||||||
|
expect((await db.getTrip(tripId))!.state, TripState.paused);
|
||||||
|
|
||||||
|
await db.closeTrip(tripId, 5000);
|
||||||
|
expect((await db.getTrip(tripId))!.state, TripState.completed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming to null clears the name rather than storing empty', () async {
|
||||||
|
final (tripId, _) = await seedTrip();
|
||||||
|
await db.renameTrip(tripId, 'Something');
|
||||||
|
await db.renameTrip(tripId, null);
|
||||||
|
expect((await db.getTrip(tripId))!.name, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
204
rippr-flutter-src/test/geo_test.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/geo/geo.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.geo.GeoTest` (native Android v2.0.1).
|
||||||
|
///
|
||||||
|
/// Every case and every tolerance is carried over unchanged. Where the Kotlin
|
||||||
|
/// suite encoded a hard-won correction — notably the Calgary–Edmonton
|
||||||
|
/// great-circle figure, which was verified by hand after the *test* turned out to
|
||||||
|
/// be wrong rather than the code — that correction travels with it.
|
||||||
|
void main() {
|
||||||
|
const calgary = LatLon(51.0447, -114.0719);
|
||||||
|
const edmonton = LatLon(53.5461, -113.4938);
|
||||||
|
|
||||||
|
List<LatLon> line(int n) =>
|
||||||
|
List.generate(n, (i) => LatLon(51.0 + i * 0.001, -114.0));
|
||||||
|
|
||||||
|
group('haversine', () {
|
||||||
|
test('matches a known long-distance reference', () {
|
||||||
|
// Calgary to Edmonton, great-circle. Cross-checked by hand: 2.5014° of
|
||||||
|
// latitude is ~278.1 km, and 0.5781° of longitude at ~52.3° is ~39.3 km,
|
||||||
|
// giving sqrt(278.1² + 39.3²) ≈ 280.9 km. (Road distance is ~300 km — not
|
||||||
|
// the same thing.)
|
||||||
|
final d = haversineBetween(calgary, edmonton);
|
||||||
|
expect(d, closeTo(280900.0, 280900.0 * 0.005));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one degree of latitude is about 111 km anywhere', () {
|
||||||
|
final atEquator = haversineMeters(0.0, 0.0, 1.0, 0.0);
|
||||||
|
final atLatitude60 = haversineMeters(60.0, 0.0, 61.0, 0.0);
|
||||||
|
expect(atEquator, closeTo(111195.0, 200.0));
|
||||||
|
expect(atLatitude60, closeTo(atEquator, 200.0),
|
||||||
|
reason: 'latitude spacing does not vary with longitude');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a degree of longitude shrinks with latitude', () {
|
||||||
|
final atEquator = haversineMeters(0.0, 0.0, 0.0, 1.0);
|
||||||
|
final atLatitude60 = haversineMeters(60.0, 0.0, 60.0, 1.0);
|
||||||
|
// cos(60°) = 0.5, so it should be about half.
|
||||||
|
expect(atLatitude60, closeTo(atEquator / 2, 500.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('identical points are zero distance', () {
|
||||||
|
expect(haversineBetween(calgary, calgary), closeTo(0.0, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('short hops between consecutive fixes stay accurate', () {
|
||||||
|
// ~11 m north, the scale of a 2 Hz fix at road speed.
|
||||||
|
final d = haversineMeters(51.0447, -114.0719, 51.04480, -114.0719);
|
||||||
|
expect(d, closeTo(11.1, 0.5));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distance is symmetric', () {
|
||||||
|
expect(
|
||||||
|
haversineBetween(calgary, edmonton),
|
||||||
|
closeTo(haversineBetween(edmonton, calgary), 1e-6),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles an antimeridian crossing without exploding', () {
|
||||||
|
// 0.02° apart in longitude, straddling +/-180.
|
||||||
|
final d = haversineMeters(0.0, 179.99, 0.0, -179.99);
|
||||||
|
expect(d, lessThan(3000), reason: 'expected a short hop, got $d m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('douglas-peucker', () {
|
||||||
|
test('a straight line collapses to its endpoints', () {
|
||||||
|
final simplified = simplify(line(50), 5.0);
|
||||||
|
expect(simplified.length, 2);
|
||||||
|
expect(simplified.first, line(50).first);
|
||||||
|
expect(simplified.last, line(50).last);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endpoints always survive', () {
|
||||||
|
final zigzag = List.generate(
|
||||||
|
30,
|
||||||
|
(i) => LatLon(51.0 + i * 0.001, -114.0 + (i % 2 == 0 ? 0.0 : 0.002)),
|
||||||
|
);
|
||||||
|
final simplified = simplify(zigzag, 5.0);
|
||||||
|
expect(simplified.first, zigzag.first);
|
||||||
|
expect(simplified.last, zigzag.last);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deviation above epsilon is preserved', () {
|
||||||
|
// Middle point sits ~110 m off the line between its neighbours.
|
||||||
|
const points = [
|
||||||
|
LatLon(51.000, -114.0),
|
||||||
|
LatLon(51.001, -113.999),
|
||||||
|
LatLon(51.002, -114.0),
|
||||||
|
];
|
||||||
|
expect(simplify(points, 5.0).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deviation below epsilon is dropped', () {
|
||||||
|
const points = [
|
||||||
|
LatLon(51.000, -114.0),
|
||||||
|
LatLon(51.001, -114.00001), // under a metre off the line
|
||||||
|
LatLon(51.002, -114.0),
|
||||||
|
];
|
||||||
|
expect(simplify(points, 5.0).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('epsilon of zero returns the input untouched', () {
|
||||||
|
final input = line(20);
|
||||||
|
expect(simplify(input, 0.0), input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inputs of two or fewer are returned as-is', () {
|
||||||
|
expect(simplify(const <LatLon>[], 5.0).length, 0);
|
||||||
|
expect(simplify(line(1), 5.0).length, 1);
|
||||||
|
expect(simplify(line(2), 5.0).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles a full ride without stack overflow and stays fast', () {
|
||||||
|
// Three hours at 2 Hz, the real scale this has to survive.
|
||||||
|
final ride = List.generate(
|
||||||
|
21600,
|
||||||
|
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001),
|
||||||
|
);
|
||||||
|
final started = DateTime.now();
|
||||||
|
final simplified = simplify(ride, 5.0);
|
||||||
|
final elapsedMs = DateTime.now().difference(started).inMilliseconds;
|
||||||
|
|
||||||
|
expect(simplified.length, lessThan(ride.length),
|
||||||
|
reason: 'simplification should reduce the point count');
|
||||||
|
expect(simplified.first, ride.first);
|
||||||
|
expect(simplified.last, ride.last);
|
||||||
|
expect(elapsedMs, lessThan(1000),
|
||||||
|
reason: 'took ${elapsedMs}ms, expected well under a second');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('perpendicular distance', () {
|
||||||
|
test('a point on the segment has zero perpendicular distance', () {
|
||||||
|
final d = perpendicularDistanceMeters(
|
||||||
|
const LatLon(51.001, -114.0),
|
||||||
|
const LatLon(51.000, -114.0),
|
||||||
|
const LatLon(51.002, -114.0),
|
||||||
|
);
|
||||||
|
expect(d.abs(), lessThan(0.01), reason: 'expected ~0, got $d');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a point beyond the end measures to the endpoint, not the infinite line',
|
||||||
|
() {
|
||||||
|
// Directly past the segment's end along the same bearing.
|
||||||
|
final d = perpendicularDistanceMeters(
|
||||||
|
const LatLon(51.003, -114.0),
|
||||||
|
const LatLon(51.000, -114.0),
|
||||||
|
const LatLon(51.002, -114.0),
|
||||||
|
);
|
||||||
|
expect(d, closeTo(111.0, 5.0),
|
||||||
|
reason: 'should be the ~111 m to the endpoint');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a degenerate segment falls back to point distance', () {
|
||||||
|
const a = LatLon(51.0, -114.0);
|
||||||
|
final d = perpendicularDistanceMeters(const LatLon(51.001, -114.0), a, a);
|
||||||
|
expect(d, closeTo(111.0, 5.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('bounds', () {
|
||||||
|
test('bounds is null for an empty path', () {
|
||||||
|
expect(bounds(const <LatLon>[]), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bounds spans mixed-sign coordinates', () {
|
||||||
|
final b = bounds(const [
|
||||||
|
LatLon(-10.0, -20.0),
|
||||||
|
LatLon(30.0, 40.0),
|
||||||
|
LatLon(5.0, 0.0),
|
||||||
|
])!;
|
||||||
|
expect(b.minLat, closeTo(-10.0, 1e-9));
|
||||||
|
expect(b.maxLat, closeTo(30.0, 1e-9));
|
||||||
|
expect(b.minLon, closeTo(-20.0, 1e-9));
|
||||||
|
expect(b.maxLon, closeTo(40.0, 1e-9));
|
||||||
|
expect(b.centerLat, closeTo(10.0, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stationary ride reports degenerate bounds', () {
|
||||||
|
final b = bounds(List.filled(10, calgary))!;
|
||||||
|
expect(b.isDegenerate, isTrue,
|
||||||
|
reason: 'map auto-fit must special-case this');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real path is not degenerate', () {
|
||||||
|
expect(bounds(line(10))!.isDegenerate, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('path length', () {
|
||||||
|
test('path length sums consecutive hops', () {
|
||||||
|
final points = line(11); // ten hops of 0.001 degrees latitude
|
||||||
|
expect(pathLengthMeters(points), closeTo(10 * 111.19, 20.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path length of fewer than two points is zero', () {
|
||||||
|
expect(pathLengthMeters(const <LatLon>[]), closeTo(0.0, 1e-9));
|
||||||
|
expect(pathLengthMeters(const [calgary]), closeTo(0.0, 1e-9));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
440
rippr-flutter-src/test/recording_engine_test.dart
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/recording/location_source.dart';
|
||||||
|
import 'package:rippr/src/recording/recording_engine.dart';
|
||||||
|
|
||||||
|
/// Covers `TrackingServiceLifecycleTest`'s ground, but on the Dart VM.
|
||||||
|
///
|
||||||
|
/// The Kotlin equivalent drove a real Android `Service` through all five actions on a
|
||||||
|
/// device, polled the database with a 25 s timeout, and was flaky enough that the timeout
|
||||||
|
/// was once raised to hide what turned out to be a real `stopSelf()` race. None of that
|
||||||
|
/// applies here: the pipeline is plain Dart behind a fake location source, so these run
|
||||||
|
/// deterministically in milliseconds.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late TripRepository repo;
|
||||||
|
late FakeLocationSource source;
|
||||||
|
late RecordingEngine engine;
|
||||||
|
late int fakeNow;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
repo = TripRepository(db);
|
||||||
|
source = FakeLocationSource();
|
||||||
|
fakeNow = 1000;
|
||||||
|
engine = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await engine.dispose();
|
||||||
|
await source.dispose();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Lets the broadcast stream deliver. Fixes reach the engine on a microtask, not
|
||||||
|
/// synchronously, so every emission must be followed by this before asserting.
|
||||||
|
Future<void> settle() => Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
/// Emits [n] fixes a second apart, moving ~11 m north each time.
|
||||||
|
Future<void> ride(int n,
|
||||||
|
{int startTs = 1000, double startLat = 51.0, double speedMps = 11.11}) async {
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
source.emitAt(
|
||||||
|
timestamp: startTs + i * 1000,
|
||||||
|
latitude: startLat + i * 0.0001,
|
||||||
|
speedMps: speedMps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await settle();
|
||||||
|
}
|
||||||
|
|
||||||
|
group('the fix path', () {
|
||||||
|
test('a started ride buffers fixes without writing immediately', () async {
|
||||||
|
await engine.start();
|
||||||
|
await ride(5);
|
||||||
|
|
||||||
|
expect(engine.pendingCount, 5,
|
||||||
|
reason: 'the fix path must not touch the database');
|
||||||
|
expect(await db.countPointsForTrip(engine.currentTripId), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixes are ignored before start and after stop', () async {
|
||||||
|
await ride(3);
|
||||||
|
expect(engine.pendingCount, 0, reason: 'source is not running yet');
|
||||||
|
|
||||||
|
await engine.start();
|
||||||
|
await engine.stop();
|
||||||
|
await ride(3);
|
||||||
|
expect(engine.pendingCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixes worse than the accuracy budget are dropped', () async {
|
||||||
|
await engine.start();
|
||||||
|
source.emitAt(timestamp: 1000, accuracyM: 500);
|
||||||
|
await settle();
|
||||||
|
expect(engine.pendingCount, 0);
|
||||||
|
|
||||||
|
source.emitAt(timestamp: 2000, accuracyM: 5);
|
||||||
|
await settle();
|
||||||
|
expect(engine.pendingCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('speed is converted and the noise floor applied', () async {
|
||||||
|
await engine.start();
|
||||||
|
source.emitAt(timestamp: 1000, speedMps: 10.0); // 36 km/h
|
||||||
|
source.emitAt(timestamp: 2000, speedMps: 0.1); // 0.36 km/h, under the floor
|
||||||
|
await settle();
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
final points = await db.allPoints();
|
||||||
|
expect(points[0].speedKmh, closeTo(36.0, 1e-9));
|
||||||
|
expect(points[1].speedKmh, 0.0,
|
||||||
|
reason: 'jitter under the noise floor must read as zero');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live telemetry updates at fix rate', () async {
|
||||||
|
await engine.start();
|
||||||
|
source.emitAt(timestamp: 1000, speedMps: 20.0);
|
||||||
|
await settle();
|
||||||
|
expect(engine.pendingCount, 1);
|
||||||
|
// 20 m/s is 72 km/h.
|
||||||
|
// LiveTelemetry is a singleton; the value is what the record screen reads.
|
||||||
|
await engine.stop();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('persistence', () {
|
||||||
|
test('stop drains everything buffered', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(7);
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
expect(await db.countPointsForTrip(tripId), 7);
|
||||||
|
expect(engine.pendingCount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregates are persisted and then reconciled on stop', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(11); // ten hops of ~11.12 m
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
final trip = (await repo.tripById(tripId))!;
|
||||||
|
expect(trip.pointCount, 11);
|
||||||
|
expect(trip.distanceM, closeTo(111.2, 3.0));
|
||||||
|
expect(trip.state, TripState.completed);
|
||||||
|
expect(trip.endedAt, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a ride that captured nothing is discarded, not saved', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
|
||||||
|
final result = await engine.stop();
|
||||||
|
|
||||||
|
expect(result, isNull);
|
||||||
|
expect(await repo.tripById(tripId), isNull,
|
||||||
|
reason: 'an empty trip is noise in the history list');
|
||||||
|
expect(await db.countTrips(), 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('pause and segments', () {
|
||||||
|
test('the full lifecycle produces one trip and two segments', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(3, startTs: 1000);
|
||||||
|
|
||||||
|
await engine.pause();
|
||||||
|
await ride(3, startTs: 5000); // ignored: the source is stopped
|
||||||
|
|
||||||
|
fakeNow = 6000;
|
||||||
|
await engine.start(); // resume
|
||||||
|
await ride(3, startTs: 6000, startLat: 51.001);
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
final segments = await repo.segmentsForTrip(tripId);
|
||||||
|
expect(segments.length, 2);
|
||||||
|
expect(segments.every((s) => !s.isOpen), isTrue);
|
||||||
|
expect(await db.countPointsForTrip(tripId), 6,
|
||||||
|
reason: 'fixes emitted while paused must not be recorded');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fix in flight at pause lands in the segment it belongs to', () async {
|
||||||
|
// This is the guarantee that stamping ids at creation exists for. The fix is
|
||||||
|
// buffered before the pause and written after it; it must carry the OLD segment id.
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
final firstSegment =
|
||||||
|
(await repo.segmentsForTrip(tripId)).single.id;
|
||||||
|
|
||||||
|
await ride(2); // buffered, not yet written
|
||||||
|
expect(engine.pendingCount, 2);
|
||||||
|
|
||||||
|
await engine.pause();
|
||||||
|
|
||||||
|
final points = await db.pointsForTrip(tripId);
|
||||||
|
expect(points.length, 2);
|
||||||
|
expect(points.every((p) => p.segmentId == firstSegment), isTrue,
|
||||||
|
reason: 'a queued fix must not be re-homed into the next segment');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distance does not span the pause', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(3, startLat: 51.0);
|
||||||
|
|
||||||
|
await engine.pause();
|
||||||
|
fakeNow = 6000;
|
||||||
|
await engine.start();
|
||||||
|
// Resumed a full degree of latitude away — a trailered gap of ~111 km.
|
||||||
|
await ride(3, startTs: 6000, startLat: 52.0);
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
final trip = (await repo.tripById(tripId))!;
|
||||||
|
expect(trip.distanceM, lessThan(200.0),
|
||||||
|
reason: 'the pause gap leaked into distance: ${trip.distanceM} m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause then stop completes the trip normally', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(3);
|
||||||
|
await engine.pause();
|
||||||
|
|
||||||
|
final result = await engine.stop();
|
||||||
|
|
||||||
|
expect(result, tripId);
|
||||||
|
expect((await repo.tripById(tripId))!.state, TripState.completed);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('discard', () {
|
||||||
|
test('discard deletes the trip and everything buffered', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(5);
|
||||||
|
|
||||||
|
await engine.discard();
|
||||||
|
|
||||||
|
expect(await repo.tripById(tripId), isNull);
|
||||||
|
expect(await db.countTrips(), 0);
|
||||||
|
expect(engine.pendingCount, 0);
|
||||||
|
expect(engine.state, RecorderState.idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discard leaves earlier completed rides alone', () async {
|
||||||
|
await engine.start();
|
||||||
|
final keep = engine.currentTripId;
|
||||||
|
await ride(3);
|
||||||
|
await engine.stop();
|
||||||
|
|
||||||
|
fakeNow = 10000;
|
||||||
|
await engine.start();
|
||||||
|
await ride(3, startTs: 10000);
|
||||||
|
await engine.discard();
|
||||||
|
|
||||||
|
expect(await repo.tripById(keep), isNotNull);
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('idempotency and state', () {
|
||||||
|
test('start twice adopts rather than duplicating', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await engine.start();
|
||||||
|
|
||||||
|
expect(engine.currentTripId, tripId);
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
expect(await db.countSegmentsForTrip(tripId), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause twice is safe', () async {
|
||||||
|
await engine.start();
|
||||||
|
await engine.pause();
|
||||||
|
await engine.pause();
|
||||||
|
expect(engine.state, RecorderState.paused);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop with nothing running is a safe no-op', () async {
|
||||||
|
expect(await engine.stop(), isNull);
|
||||||
|
expect(engine.state, RecorderState.idle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the source is started and stopped in step with the lifecycle', () async {
|
||||||
|
await engine.start();
|
||||||
|
expect(source.isRunning, isTrue);
|
||||||
|
|
||||||
|
await engine.pause();
|
||||||
|
expect(source.isRunning, isFalse);
|
||||||
|
|
||||||
|
await engine.start();
|
||||||
|
expect(source.isRunning, isTrue);
|
||||||
|
|
||||||
|
await engine.stop();
|
||||||
|
expect(source.isRunning, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state transitions are observable', () async {
|
||||||
|
final seen = <RecorderState>[];
|
||||||
|
final sub = engine.stateStream.listen(seen.add);
|
||||||
|
|
||||||
|
await engine.start();
|
||||||
|
await engine.pause();
|
||||||
|
await engine.start();
|
||||||
|
await engine.stop();
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
await sub.cancel();
|
||||||
|
|
||||||
|
expect(seen, [
|
||||||
|
RecorderState.recording,
|
||||||
|
RecorderState.paused,
|
||||||
|
RecorderState.recording,
|
||||||
|
RecorderState.idle,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('process death', () {
|
||||||
|
test('a recording trip is resumed into a new segment', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(5);
|
||||||
|
// Deliberately no stop(): dispose without completing simulates a process kill.
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
// A brand new engine over the same database, as after a process restart.
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
fakeNow = 20000;
|
||||||
|
final state = await revived.restoreAfterProcessDeath();
|
||||||
|
|
||||||
|
expect(state, RecorderState.recording);
|
||||||
|
expect(revived.currentTripId, tripId);
|
||||||
|
final segments = await repo.segmentsForTrip(tripId);
|
||||||
|
expect(segments.length, 2,
|
||||||
|
reason: 'the dead time is a real gap and must render as one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the dead time is never measured as distance', () async {
|
||||||
|
// The bug this guards is in the native app: after a crash the open segment is
|
||||||
|
// adopted rather than closed, so computeSummary -- the authoritative pass at trip
|
||||||
|
// completion -- measures straight across the gap. A rider who crashes in town and
|
||||||
|
// relaunches downtown would have those kilometres added to their ride.
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
final crashedSegment = (await repo.segmentsForTrip(tripId)).single.id;
|
||||||
|
|
||||||
|
// Write points the way the flush loop would have, *without* going through pause or
|
||||||
|
// stop — so the segment is left OPEN, which is exactly what a crash leaves behind.
|
||||||
|
// Using pause() here would close the segment and quietly stop reproducing the bug.
|
||||||
|
await repo.appendPoints([
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
TrackPoint(
|
||||||
|
tripId: tripId,
|
||||||
|
segmentId: crashedSegment,
|
||||||
|
timestamp: 1000 + i * 1000,
|
||||||
|
latitude: 51.0 + i * 0.0001,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40,
|
||||||
|
altitudeM: 1000,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
fakeNow = 900000;
|
||||||
|
await revived.restoreAfterProcessDeath();
|
||||||
|
// Relaunched a full degree of latitude away — ~111 km from where it died.
|
||||||
|
await ride(3, startTs: 900000, startLat: 52.0);
|
||||||
|
await revived.stop();
|
||||||
|
|
||||||
|
final trip = (await repo.tripById(tripId))!;
|
||||||
|
expect(trip.distanceM, lessThan(200.0),
|
||||||
|
reason:
|
||||||
|
'the dead time leaked into distance: ${trip.distanceM} m — the crash gap '
|
||||||
|
'was measured as if it had been ridden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restored totals continue rather than restarting from zero', () async {
|
||||||
|
await engine.start();
|
||||||
|
final tripId = engine.currentTripId;
|
||||||
|
await ride(11);
|
||||||
|
// Force the buffered points and their aggregates to disk without completing.
|
||||||
|
await engine.pause();
|
||||||
|
|
||||||
|
final before = (await repo.tripById(tripId))!;
|
||||||
|
expect(before.distanceM, greaterThan(100.0));
|
||||||
|
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
fakeNow = 30000;
|
||||||
|
await revived.start(); // resumes the paused trip
|
||||||
|
await ride(3, startTs: 30000, startLat: 53.0);
|
||||||
|
await revived.stop();
|
||||||
|
|
||||||
|
final after = (await repo.tripById(tripId))!;
|
||||||
|
expect(after.pointCount, 14, reason: 'earlier points must be kept');
|
||||||
|
expect(after.distanceM, greaterThanOrEqualTo(before.distanceM),
|
||||||
|
reason: 'distance must not restart from zero');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a paused trip is restored as paused, not resumed', () async {
|
||||||
|
await engine.start();
|
||||||
|
await ride(3);
|
||||||
|
await engine.pause();
|
||||||
|
await engine.dispose();
|
||||||
|
|
||||||
|
final revived = RecordingEngine(
|
||||||
|
repository: repo,
|
||||||
|
locationSource: source,
|
||||||
|
clock: () => fakeNow,
|
||||||
|
);
|
||||||
|
addTearDown(revived.dispose);
|
||||||
|
|
||||||
|
final state = await revived.restoreAfterProcessDeath();
|
||||||
|
|
||||||
|
expect(state, RecorderState.paused);
|
||||||
|
expect(source.isRunning, isFalse,
|
||||||
|
reason: 'a paused ride must not silently start consuming GPS');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no active trip restores to idle', () async {
|
||||||
|
final state = await engine.restoreAfterProcessDeath();
|
||||||
|
expect(state, RecorderState.idle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
204
rippr-flutter-src/test/ride_accumulator_test.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/recording/ride_accumulator.dart';
|
||||||
|
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.AccumulatorTest`.
|
||||||
|
///
|
||||||
|
/// As in `ride_statistics_test.dart`, the one RNG-driven case uses the shared LCG rather
|
||||||
|
/// than either language's `Random`, so the fixture is identical across implementations.
|
||||||
|
class _Lcg {
|
||||||
|
_Lcg(this._s);
|
||||||
|
int _s;
|
||||||
|
double nextDouble() {
|
||||||
|
_s = _s * 6364136223846793005 + 1442695040888963407;
|
||||||
|
final bits = (_s >>> 11) & ((1 << 53) - 1);
|
||||||
|
return bits / (1 << 53);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
var nextId = 1;
|
||||||
|
|
||||||
|
TrackPoint point({
|
||||||
|
int segmentId = 1,
|
||||||
|
required int ts,
|
||||||
|
double lat = 51.0,
|
||||||
|
double speed = 40.0,
|
||||||
|
double alt = 1000.0,
|
||||||
|
}) =>
|
||||||
|
TrackPoint(
|
||||||
|
id: nextId++,
|
||||||
|
tripId: 1,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: ts,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: alt,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<TrackPoint> run(int n,
|
||||||
|
{int segmentId = 1, int startTs = 0, double startLat = 51.0}) =>
|
||||||
|
List.generate(
|
||||||
|
n,
|
||||||
|
(i) => point(
|
||||||
|
segmentId: segmentId,
|
||||||
|
ts: startTs + i * 1000,
|
||||||
|
lat: startLat + i * 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
setUp(() => nextId = 1);
|
||||||
|
|
||||||
|
group('the cross-batch anchor — the subtle bug this class exists to avoid', () {
|
||||||
|
test('distance across many small batches matches one big batch', () {
|
||||||
|
final points = run(100);
|
||||||
|
|
||||||
|
final batched = Accumulator();
|
||||||
|
for (var i = 0; i < points.length; i += 7) {
|
||||||
|
batched.fold(points.sublist(i, (i + 7).clamp(0, points.length)));
|
||||||
|
}
|
||||||
|
final single = Accumulator()..fold(points);
|
||||||
|
|
||||||
|
expect(batched.distanceM, closeTo(single.distanceM, 1e-6),
|
||||||
|
reason:
|
||||||
|
'losing the anchor between flushes silently under-reports distance');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accumulated distance agrees with the batch statistics', () {
|
||||||
|
final points = run(100);
|
||||||
|
|
||||||
|
final acc = Accumulator();
|
||||||
|
for (var i = 0; i < points.length; i += 25) {
|
||||||
|
acc.fold(points.sublist(i, (i + 25).clamp(0, points.length)));
|
||||||
|
}
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
|
||||||
|
expect(acc.distanceM, closeTo(summary.distanceM, 0.5));
|
||||||
|
expect(acc.movingMillis, summary.movingMillis);
|
||||||
|
expect(acc.maxSpeedKmh, closeTo(summary.maxSpeedKmh, 1e-6));
|
||||||
|
expect(acc.pointCount, summary.pointCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('segment boundaries', () {
|
||||||
|
test('distance does not span a segment boundary', () {
|
||||||
|
// Second segment starts a full degree of latitude away — a trailered gap.
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(run(5, segmentId: 1, startLat: 51.0));
|
||||||
|
acc.fold(run(5, segmentId: 2, startTs: 600000, startLat: 52.0));
|
||||||
|
|
||||||
|
expect(acc.distanceM, lessThan(200.0),
|
||||||
|
reason: 'the ~111 km gap leaked into distance: ${acc.distanceM} m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onSegmentChanged clears the anchor even within one batch stream', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(run(5, segmentId: 1, startLat: 51.0));
|
||||||
|
acc.onSegmentChanged();
|
||||||
|
acc.fold(run(5, segmentId: 1, startTs: 600000, startLat: 52.0));
|
||||||
|
|
||||||
|
expect(acc.distanceM, lessThan(200.0),
|
||||||
|
reason: 'anchor was not cleared: ${acc.distanceM} m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('moving time', () {
|
||||||
|
test('moving time ignores samples below the speed noise floor', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(List.generate(30, (i) => point(ts: i * 1000, speed: 40.0)));
|
||||||
|
acc.fold(
|
||||||
|
List.generate(30, (i) => point(ts: (30 + i) * 1000, speed: 0.0)));
|
||||||
|
|
||||||
|
expect(acc.movingMillis, 29000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long gap between fixes does not inject phantom moving time', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold([
|
||||||
|
point(ts: 0, speed: 90.0),
|
||||||
|
point(ts: 120000, lat: 51.001, speed: 90.0),
|
||||||
|
]);
|
||||||
|
expect(acc.movingMillis, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('elevation', () {
|
||||||
|
test('elevation gain is visible before the climb ends', () {
|
||||||
|
// A steady ascent that never turns back down. Reading the committed total alone
|
||||||
|
// would report zero, so the live figure has to include the pending run.
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(List.generate(101, (i) => point(ts: i * 1000, alt: 1000.0 + i)));
|
||||||
|
expect(acc.elevationGain(), closeTo(100.0, 6.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a parked bike does not accumulate phantom climbing', () {
|
||||||
|
final rng = _Lcg(7);
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(List.generate(
|
||||||
|
600,
|
||||||
|
(i) => point(
|
||||||
|
ts: i * 1000,
|
||||||
|
speed: 0.0,
|
||||||
|
alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
|
||||||
|
));
|
||||||
|
expect(acc.elevationGain(), lessThan(60.0),
|
||||||
|
reason: 'phantom climbing: ${acc.elevationGain()} m');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('restore', () {
|
||||||
|
test('restore continues from persisted totals rather than starting over', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.restore(
|
||||||
|
distanceM: 5000.0,
|
||||||
|
movingMillis: 300000,
|
||||||
|
maxSpeedKmh: 95.0,
|
||||||
|
elevationGainM: 120.0,
|
||||||
|
pointCount: 600,
|
||||||
|
lastPoint: null,
|
||||||
|
);
|
||||||
|
acc.fold(run(11));
|
||||||
|
|
||||||
|
expect(acc.distanceM, greaterThan(5000.0), reason: 'distance went backwards');
|
||||||
|
expect(acc.distanceM, closeTo(5111.2, 5.0));
|
||||||
|
expect(acc.pointCount, 611);
|
||||||
|
expect(acc.maxSpeedKmh, closeTo(95.0, 1e-6));
|
||||||
|
expect(acc.elevationGain(), greaterThanOrEqualTo(120.0),
|
||||||
|
reason: 'persisted elevation was lost');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restore keeps a higher persisted max speed', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.restore(
|
||||||
|
distanceM: 0.0,
|
||||||
|
movingMillis: 0,
|
||||||
|
maxSpeedKmh: 150.0,
|
||||||
|
elevationGainM: 0.0,
|
||||||
|
pointCount: 0,
|
||||||
|
lastPoint: null,
|
||||||
|
);
|
||||||
|
acc.fold(run(5));
|
||||||
|
expect(acc.maxSpeedKmh, closeTo(150.0, 1e-6));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset clears everything', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(run(20));
|
||||||
|
acc.reset();
|
||||||
|
|
||||||
|
expect(acc.distanceM, closeTo(0.0, 1e-9));
|
||||||
|
expect(acc.movingMillis, 0);
|
||||||
|
expect(acc.maxSpeedKmh, closeTo(0.0, 1e-9));
|
||||||
|
expect(acc.pointCount, 0);
|
||||||
|
expect(acc.elevationGain(), closeTo(0.0, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('folding an empty batch is a no-op', () {
|
||||||
|
final acc = Accumulator();
|
||||||
|
acc.fold(const []);
|
||||||
|
expect(acc.pointCount, 0);
|
||||||
|
expect(acc.distanceM, closeTo(0.0, 1e-9));
|
||||||
|
});
|
||||||
|
}
|
||||||
207
rippr-flutter-src/test/ride_export_test.dart
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/export/ride_export.dart';
|
||||||
|
import 'package:xml/xml.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.export.RideExportTest`.
|
||||||
|
///
|
||||||
|
/// Like the Kotlin original, this parses the output with a **real** parser rather than
|
||||||
|
/// matching substrings. Structural validity is the whole point: a malformed export fails
|
||||||
|
/// invisibly until some other tool rejects the file.
|
||||||
|
void main() {
|
||||||
|
var nextId = 1;
|
||||||
|
|
||||||
|
const trip = Trip(
|
||||||
|
id: 1,
|
||||||
|
startedAt: 1700000000000,
|
||||||
|
endedAt: 1700000600000,
|
||||||
|
state: TripState.completed,
|
||||||
|
distanceM: 12345.6,
|
||||||
|
maxSpeedKmh: 118.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
TrackPoint point(int segmentId, int i) => TrackPoint(
|
||||||
|
id: nextId++,
|
||||||
|
tripId: 1,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: 1700000000000 + i * 1000,
|
||||||
|
latitude: 51.0447 + i * 0.0001,
|
||||||
|
longitude: -114.0719,
|
||||||
|
speedKmh: 72.0,
|
||||||
|
altitudeM: 1045.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
({List<Segment> segments, List<TrackPoint> points, int total}) ride(
|
||||||
|
{required int segmentCount, required int perSegment}) {
|
||||||
|
final segments = List.generate(
|
||||||
|
segmentCount,
|
||||||
|
(i) => Segment(id: i + 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||||
|
);
|
||||||
|
final points = <TrackPoint>[
|
||||||
|
for (final s in segments)
|
||||||
|
for (var i = 0; i < perSegment; i++) point(s.id, i),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
segments: segments,
|
||||||
|
points: points,
|
||||||
|
total: segmentCount * perSegment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() => nextId = 1);
|
||||||
|
|
||||||
|
group('gpx', () {
|
||||||
|
test('gpx is well-formed and carries the expected structure', () {
|
||||||
|
final r = ride(segmentCount: 2, perSegment: 5);
|
||||||
|
|
||||||
|
final doc =
|
||||||
|
XmlDocument.parse(gpx(trip, r.segments, r.points, 'Morning loop'));
|
||||||
|
|
||||||
|
expect(doc.rootElement.name.local, 'gpx');
|
||||||
|
expect(doc.rootElement.getAttribute('version'), '1.1');
|
||||||
|
expect(doc.findAllElements('trk').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one trkseg per segment, so pauses survive the round trip', () {
|
||||||
|
final r = ride(segmentCount: 3, perSegment: 4);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('trkseg').length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every raw point is exported with no decimation', () {
|
||||||
|
final r = ride(segmentCount: 2, perSegment: 250);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('trkpt').length, r.total,
|
||||||
|
reason: 'decimation must never reach an export');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('timestamps are ISO 8601 in UTC', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||||
|
final time = doc.findAllElements('time').elementAt(1).innerText;
|
||||||
|
|
||||||
|
expect(time, '2023-11-14T22:13:20Z');
|
||||||
|
expect(time.endsWith('Z'), isTrue, reason: 'must be UTC with a Z suffix');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coordinates and elevation land on the right attributes', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||||
|
final trkpt = doc.findAllElements('trkpt').first;
|
||||||
|
|
||||||
|
expect(trkpt.getAttribute('lat'), '51.0447000');
|
||||||
|
expect(trkpt.getAttribute('lon'), '-114.0719000');
|
||||||
|
expect(doc.findAllElements('ele').first.innerText, '1045.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('speed is exported in metres per second', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, 'Ride'));
|
||||||
|
|
||||||
|
// 72 km/h is exactly 20 m/s.
|
||||||
|
expect(doc.findAllElements('speed').first.innerText, '20.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hostile trip name still produces valid xml', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 2);
|
||||||
|
const nasty = 'Sam & Dave\'s <ride> "fast" ';
|
||||||
|
|
||||||
|
// Would throw if the name were interpolated unescaped.
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, r.segments, r.points, nasty));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('name').first.innerText, nasty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('escaping covers every xml metacharacter', () {
|
||||||
|
expect(escapeXml('&<>"\''), '&<>"'');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty ride still produces a valid document', () {
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, const [], const [], 'Empty'));
|
||||||
|
|
||||||
|
expect(doc.rootElement.name.local, 'gpx');
|
||||||
|
expect(doc.findAllElements('trkpt').length, 0);
|
||||||
|
expect(doc.findAllElements('trkseg').length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('points whose segment is missing are still exported', () {
|
||||||
|
final orphan = [point(99, 0)];
|
||||||
|
|
||||||
|
final doc = XmlDocument.parse(gpx(trip, const [], orphan, 'Ride'));
|
||||||
|
|
||||||
|
expect(doc.findAllElements('trkpt').length, 1,
|
||||||
|
reason: 'no point may be silently dropped');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('geojson', () {
|
||||||
|
test('geojson coordinates are longitude first', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
|
||||||
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
final coords = ((((json['features'] as List).first
|
||||||
|
as Map<String, dynamic>)['geometry']
|
||||||
|
as Map<String, dynamic>)['coordinates'] as List).first as List;
|
||||||
|
|
||||||
|
expect(coords[0] as double, closeTo(-114.0719, 1e-6),
|
||||||
|
reason: 'longitude must come first');
|
||||||
|
expect(coords[1] as double, closeTo(51.0447, 1e-6));
|
||||||
|
expect(coords[2] as double, closeTo(1045.0, 1e-6));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geojson has one LineString feature per segment', () {
|
||||||
|
final r = ride(segmentCount: 3, perSegment: 4);
|
||||||
|
|
||||||
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
final features = json['features'] as List;
|
||||||
|
|
||||||
|
expect(features.length, 3);
|
||||||
|
expect(
|
||||||
|
((features.first as Map<String, dynamic>)['geometry']
|
||||||
|
as Map<String, dynamic>)['type'],
|
||||||
|
'LineString');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geojson exports every raw point', () {
|
||||||
|
final r = ride(segmentCount: 2, perSegment: 120);
|
||||||
|
|
||||||
|
final json = jsonDecode(geoJson(trip, r.segments, r.points, 'Ride'))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
var exported = 0;
|
||||||
|
for (final f in json['features'] as List) {
|
||||||
|
exported += (((f as Map<String, dynamic>)['geometry']
|
||||||
|
as Map<String, dynamic>)['coordinates'] as List).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(exported, r.total);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a hostile name is escaped in json too', () {
|
||||||
|
final r = ride(segmentCount: 1, perSegment: 1);
|
||||||
|
|
||||||
|
final json = jsonDecode(
|
||||||
|
geoJson(trip, r.segments, r.points, r'He said "go" \ fast'))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect((json['properties'] as Map<String, dynamic>)['name'],
|
||||||
|
r'He said "go" \ fast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('geojson of an empty ride parses', () {
|
||||||
|
final json = jsonDecode(geoJson(trip, const [], const [], 'Empty'))
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect((json['features'] as List).length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
120
rippr-flutter-src/test/ride_map_test.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/ui/components/ride_map.dart';
|
||||||
|
import 'package:rippr/src/ui/theme.dart';
|
||||||
|
|
||||||
|
/// Tiles are never fetched here — a widget test cannot serve them — but the polyline
|
||||||
|
/// geometry, which is where every real map bug in this project has lived, is fully
|
||||||
|
/// exercisable.
|
||||||
|
void main() {
|
||||||
|
TrackPoint p(int segmentId, int i, {double speed = 40, double lat = 51.0}) =>
|
||||||
|
TrackPoint(
|
||||||
|
id: i + 1,
|
||||||
|
tripId: 1,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: 1000 + i * 1000,
|
||||||
|
latitude: lat + i * 0.0005,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<List<Polyline>> render(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required List<TrackPoint> points,
|
||||||
|
required List<Segment> segments,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(body: RideMap(points: points, segments: segments)),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
final layers = tester
|
||||||
|
.widgetList<PolylineLayer>(find.byType(PolylineLayer))
|
||||||
|
.toList();
|
||||||
|
return layers.isEmpty ? const [] : layers.first.polylines.cast<Polyline>();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('an empty ride says so instead of drawing a blank map',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: const Scaffold(
|
||||||
|
body: RideMap(points: [], segments: []),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('No path recorded'), findsOneWidget);
|
||||||
|
expect(find.byType(FlutterMap), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a pause leaves a gap: segments are never joined', (tester) async {
|
||||||
|
// Two segments a degree apart. If they were joined, one polyline would span ~111 km.
|
||||||
|
final points = [
|
||||||
|
for (var i = 0; i < 6; i++) p(1, i, lat: 51.0),
|
||||||
|
for (var i = 0; i < 6; i++) p(2, i, lat: 52.0),
|
||||||
|
];
|
||||||
|
const segments = [
|
||||||
|
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||||
|
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
final polylines = await render(tester, points: points, segments: segments);
|
||||||
|
|
||||||
|
expect(polylines, isNotEmpty);
|
||||||
|
for (final line in polylines) {
|
||||||
|
final lats = line.points.map((c) => c.latitude);
|
||||||
|
// No single polyline may straddle the gap between the two rides.
|
||||||
|
expect(lats.every((l) => l < 51.5) || lats.every((l) => l > 51.5), isTrue,
|
||||||
|
reason: 'a polyline spanned the pause');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('decimation reduces rendered vertices but keeps the shape',
|
||||||
|
(tester) async {
|
||||||
|
// A dense straight run: Douglas-Peucker should collapse it hard.
|
||||||
|
final points = [for (var i = 0; i < 400; i++) p(1, i)];
|
||||||
|
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
|
||||||
|
|
||||||
|
final polylines = await render(tester, points: points, segments: segments);
|
||||||
|
|
||||||
|
final rendered =
|
||||||
|
polylines.fold<int>(0, (a, line) => a + line.points.length);
|
||||||
|
expect(rendered, lessThan(points.length),
|
||||||
|
reason: 'render-only decimation should reduce vertex count');
|
||||||
|
expect(rendered, greaterThan(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a stationary ride still renders without zooming to infinity',
|
||||||
|
(tester) async {
|
||||||
|
// Every point at one spot — degenerate bounds. Fitting these would zoom past the
|
||||||
|
// tile server's maximum and render an empty grid, the v2.0 short-ride bug.
|
||||||
|
final points = [
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
TrackPoint(
|
||||||
|
id: i + 1,
|
||||||
|
tripId: 1,
|
||||||
|
segmentId: 1,
|
||||||
|
timestamp: 1000 + i * 1000,
|
||||||
|
latitude: 51.0,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 0,
|
||||||
|
altitudeM: 1000,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1)];
|
||||||
|
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
theme: ripprTheme(),
|
||||||
|
home: Scaffold(body: RideMap(points: points, segments: segments)),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
final map = tester.widget<FlutterMap>(find.byType(FlutterMap));
|
||||||
|
expect(map.options.initialZoom, shortRideZoom);
|
||||||
|
expect(map.options.maxZoom, maxTileZoom,
|
||||||
|
reason: 'exceeding OSM max tile zoom renders an empty grid');
|
||||||
|
});
|
||||||
|
}
|
||||||
302
rippr-flutter-src/test/ride_statistics_test.dart
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.stats.RideStatisticsTest`.
|
||||||
|
///
|
||||||
|
/// ## One case cannot be a literal port: the noise fixture
|
||||||
|
///
|
||||||
|
/// The Kotlin original seeds `kotlin.random.Random(42)`. Dart's `Random(42)` is a
|
||||||
|
/// different generator and produces a different sequence, so this suite cannot assert
|
||||||
|
/// the same *number* — only the same *bound*. That is fine here, because the assertion
|
||||||
|
/// was always a regression guard rather than an accuracy claim: a naive implementation
|
||||||
|
/// reported 1498 m over a parked bike, and anything in that neighbourhood must fail.
|
||||||
|
///
|
||||||
|
/// T07's cross-language parity harness must therefore drive elevation from a shared,
|
||||||
|
/// language-independent fixture rather than from either language's RNG.
|
||||||
|
/// A deterministic linear congruential generator, implemented identically in Kotlin and
|
||||||
|
/// Dart so both languages can be driven by the *same* noise sequence. Neither language's
|
||||||
|
/// built-in `Random` can do this — that is the whole reason this exists.
|
||||||
|
///
|
||||||
|
/// Dart ints are 64-bit two's complement on the VM and multiplication wraps, matching
|
||||||
|
/// Kotlin's `Long`. The Kotlin twin lives in `tool/parity/main.kt`.
|
||||||
|
class _Lcg {
|
||||||
|
_Lcg(this._s);
|
||||||
|
int _s;
|
||||||
|
double nextDouble() {
|
||||||
|
_s = _s * 6364136223846793005 + 1442695040888963407;
|
||||||
|
final bits = (_s >>> 11) & ((1 << 53) - 1);
|
||||||
|
return bits / (1 << 53);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
var nextId = 1;
|
||||||
|
|
||||||
|
TrackPoint point({
|
||||||
|
int segmentId = 1,
|
||||||
|
required int ts,
|
||||||
|
double lat = 51.0,
|
||||||
|
double lon = -114.0,
|
||||||
|
double speed = 50.0,
|
||||||
|
double alt = 1000.0,
|
||||||
|
}) =>
|
||||||
|
TrackPoint(
|
||||||
|
id: nextId++,
|
||||||
|
tripId: 1,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: ts,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: alt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// A straight northward run: [n] fixes one second apart, 0.0001° (~11 m) each.
|
||||||
|
List<TrackPoint> straightRun(int n,
|
||||||
|
{int segmentId = 1, int startTs = 0, double speed = 40.0}) =>
|
||||||
|
List.generate(
|
||||||
|
n,
|
||||||
|
(i) => point(
|
||||||
|
segmentId: segmentId,
|
||||||
|
ts: startTs + i * 1000,
|
||||||
|
lat: 51.0 + i * 0.0001,
|
||||||
|
speed: speed),
|
||||||
|
);
|
||||||
|
|
||||||
|
setUp(() => nextId = 1);
|
||||||
|
|
||||||
|
group('distance', () {
|
||||||
|
test('distance matches the summed haversine hops', () {
|
||||||
|
final summary = computeSummary(straightRun(11));
|
||||||
|
// Ten hops of 0.0001 degrees latitude, ~11.12 m each.
|
||||||
|
expect(summary.distanceM, closeTo(111.2, 3.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('distance never spans a pause', () {
|
||||||
|
// Two segments 100 km apart — a rider who trailered between them.
|
||||||
|
final first = straightRun(5, segmentId: 1, startTs: 0);
|
||||||
|
final second = List.generate(
|
||||||
|
5,
|
||||||
|
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
|
||||||
|
);
|
||||||
|
|
||||||
|
final summary = computeSummary([...first, ...second]);
|
||||||
|
|
||||||
|
// Two runs of ~44.5 m each; the ~111 km gap must not appear.
|
||||||
|
expect(summary.distanceM, lessThan(200.0),
|
||||||
|
reason: 'gap leaked into distance: ${summary.distanceM} m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('single point has zero distance and no NaN', () {
|
||||||
|
final summary = computeSummary([point(ts: 0)]);
|
||||||
|
expect(summary.distanceM, closeTo(0.0, 1e-9));
|
||||||
|
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
|
||||||
|
expect(summary.pointCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty input returns the zero summary', () {
|
||||||
|
expect(computeSummary(const []), RideSummary.empty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('elevation — the regression that matters most', () {
|
||||||
|
test('stationary noisy altitude yields near-zero elevation gain', () {
|
||||||
|
// A parked bike for ten minutes with realistic +/-8 m GPS altitude wander.
|
||||||
|
//
|
||||||
|
// A naive "sum every delta over the threshold" implementation reported **1498 m**
|
||||||
|
// of climbing on this shape of input. Smoothing plus reversal hysteresis is what
|
||||||
|
// brings it down. This bound is a regression guard against that class of failure,
|
||||||
|
// not an accuracy claim.
|
||||||
|
//
|
||||||
|
// ## Why a hand-rolled LCG instead of Random(42)
|
||||||
|
//
|
||||||
|
// The Kotlin original seeded `kotlin.random.Random(42)`, which produces a
|
||||||
|
// different stream from Dart's `Random(42)` — so the two suites could never be
|
||||||
|
// compared, only vaguely trusted. Driving both from the identical LCG in
|
||||||
|
// `tool/parity/` proved the port is exact: **38.959594555022136 m in both
|
||||||
|
// languages, to the last digit.**
|
||||||
|
//
|
||||||
|
// That also exposed something about the native app: on a shared fixture this
|
||||||
|
// algorithm yields ~39 m, which would **fail Kotlin's own 35 m bound**. The
|
||||||
|
// Kotlin test passes on seed luck, not on a property of the algorithm. A sweep of
|
||||||
|
// 25 Dart seeds ranged 24.7–46.7 m (median 36). The bound below is therefore set
|
||||||
|
// from measured behaviour with headroom, rather than inherited from a lucky draw.
|
||||||
|
final rng = _Lcg(42);
|
||||||
|
final points = List.generate(
|
||||||
|
600,
|
||||||
|
(i) => point(
|
||||||
|
ts: i * 1000, speed: 0.0, alt: 1000.0 + (rng.nextDouble() * 16.0 - 8.0)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
|
||||||
|
expect(summary.elevationGainM, closeTo(38.959594555022136, 1e-9),
|
||||||
|
reason: 'must stay bit-identical to the Kotlin implementation');
|
||||||
|
expect(summary.elevationGainM, lessThan(60.0),
|
||||||
|
reason:
|
||||||
|
'phantom climbing: ${summary.elevationGainM} m over a parked bike');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a genuine climb is recorded', () {
|
||||||
|
final points = List.generate(
|
||||||
|
101,
|
||||||
|
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i),
|
||||||
|
);
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
expect(summary.elevationGainM, closeTo(100.0, 5.0));
|
||||||
|
expect(summary.elevationLossM, closeTo(0.0, 5.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a descent counts as loss, not gain', () {
|
||||||
|
final points = List.generate(
|
||||||
|
101,
|
||||||
|
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1100.0 - i),
|
||||||
|
);
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
expect(summary.elevationLossM, closeTo(100.0, 5.0));
|
||||||
|
expect(summary.elevationGainM, closeTo(0.0, 5.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an out-and-back records both gain and loss', () {
|
||||||
|
final up = List.generate(51, (i) => point(ts: i * 1000, alt: 1000.0 + i));
|
||||||
|
final down =
|
||||||
|
List.generate(51, (i) => point(ts: 51000 + i * 1000, alt: 1050.0 - i));
|
||||||
|
final summary = computeSummary([...up, ...down]);
|
||||||
|
expect(summary.elevationGainM, closeTo(50.0, 5.0));
|
||||||
|
expect(summary.elevationLossM, closeTo(50.0, 5.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('moving vs elapsed time', () {
|
||||||
|
test('moving time excludes time below the speed noise floor', () {
|
||||||
|
final moving = List.generate(60, (i) => point(ts: i * 1000, speed: 40.0));
|
||||||
|
final stopped =
|
||||||
|
List.generate(60, (i) => point(ts: (60 + i) * 1000, speed: 0.0));
|
||||||
|
|
||||||
|
final summary = computeSummary([...moving, ...stopped]);
|
||||||
|
|
||||||
|
expect(summary.movingMillis.toDouble(), closeTo(59000.0, 2000.0),
|
||||||
|
reason: '~59 s of movement');
|
||||||
|
expect(summary.elapsedMillis, 119000);
|
||||||
|
expect(summary.stoppedMillis, greaterThan(55000));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long gap between fixes does not inject phantom moving time', () {
|
||||||
|
// Two fixes two minutes apart — a tunnel. Without the cap this would count as
|
||||||
|
// 120 s of movement at the last known speed.
|
||||||
|
final points = [
|
||||||
|
point(ts: 0, speed: 90.0),
|
||||||
|
point(ts: 120000, lat: 51.001, speed: 90.0),
|
||||||
|
];
|
||||||
|
expect(computeSummary(points).movingMillis, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('elapsed time prefers closed segment spans when available', () {
|
||||||
|
final points = straightRun(5, startTs: 1000);
|
||||||
|
const segments = [Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 10000)];
|
||||||
|
expect(computeSummary(points, segments: segments).elapsedMillis, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('elapsed falls back to point timestamps when a segment is still open', () {
|
||||||
|
final points = straightRun(5, startTs: 1000); // 1000..5000
|
||||||
|
const segments = [Segment(id: 1, tripId: 1, startedAt: 0)];
|
||||||
|
expect(computeSummary(points, segments: segments).elapsedMillis, 4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('average speed', () {
|
||||||
|
test('average speed uses distance over moving time, not the sample mean', () {
|
||||||
|
// 100 fixes 1 s apart, each 0.0001 deg (~11.12 m) => ~1101 m over ~99 s
|
||||||
|
// => ~40 km/h.
|
||||||
|
final points = List.generate(
|
||||||
|
100,
|
||||||
|
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, speed: 40.0),
|
||||||
|
);
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
expect(summary.avgMovingSpeedKmh, closeTo(40.0, 2.0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('average speed is zero rather than NaN when nothing moved', () {
|
||||||
|
final points = List.generate(10, (i) => point(ts: i * 1000, speed: 0.0));
|
||||||
|
final summary = computeSummary(points);
|
||||||
|
expect(summary.avgMovingSpeedKmh, closeTo(0.0, 1e-9));
|
||||||
|
expect(summary.avgMovingSpeedKmh.isNaN, isFalse,
|
||||||
|
reason: 'NaN would render literally on screen');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('max speed is the highest sample', () {
|
||||||
|
final points = [
|
||||||
|
point(ts: 0, speed: 40.0),
|
||||||
|
point(ts: 1000, speed: 118.4),
|
||||||
|
point(ts: 2000, speed: 60.0),
|
||||||
|
];
|
||||||
|
expect(computeSummary(points).maxSpeedKmh, closeTo(118.4, 0.001));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('histogram', () {
|
||||||
|
test('histogram measures time in band, not sample count', () {
|
||||||
|
final points = [
|
||||||
|
point(ts: 0, speed: 5.0),
|
||||||
|
point(ts: 1000, speed: 15.0), // 1 s in 10-20
|
||||||
|
point(ts: 2000, speed: 15.0), // 1 s in 10-20
|
||||||
|
point(ts: 3000, speed: 95.0), // 1 s in 90-100
|
||||||
|
];
|
||||||
|
final buckets = speedHistogram(points, bucketKmh: 10);
|
||||||
|
|
||||||
|
expect(buckets.firstWhere((b) => b.fromKmh == 10).millis, 2000);
|
||||||
|
expect(buckets.firstWhere((b) => b.fromKmh == 90).millis, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('histogram is empty for degenerate input', () {
|
||||||
|
expect(speedHistogram(const []), isEmpty);
|
||||||
|
expect(speedHistogram([point(ts: 0)]), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('elevation profile', () {
|
||||||
|
test('profile plots altitude against cumulative distance', () {
|
||||||
|
final points = List.generate(
|
||||||
|
5,
|
||||||
|
(i) => point(ts: i * 1000, lat: 51.0 + i * 0.0001, alt: 1000.0 + i * 10),
|
||||||
|
);
|
||||||
|
final profile = elevationProfile(points);
|
||||||
|
|
||||||
|
expect(profile.length, 5);
|
||||||
|
expect(profile.first.distanceM, closeTo(0.0, 1e-9));
|
||||||
|
expect(profile.last.altitudeM, closeTo(1040.0, 1e-9));
|
||||||
|
expect(profile.last.distanceM, greaterThan(profile.first.distanceM),
|
||||||
|
reason: 'distance must increase');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile does not accrue distance across a pause', () {
|
||||||
|
final first = List.generate(
|
||||||
|
3, (i) => point(segmentId: 1, ts: i * 1000, lat: 51.0 + i * 0.0001));
|
||||||
|
final second = List.generate(
|
||||||
|
3,
|
||||||
|
(i) => point(segmentId: 2, ts: 600000 + i * 1000, lat: 52.0 + i * 0.0001),
|
||||||
|
);
|
||||||
|
final profile = elevationProfile([...first, ...second]);
|
||||||
|
expect(profile.last.distanceM, lessThan(200.0),
|
||||||
|
reason:
|
||||||
|
'the 111 km gap leaked into the profile: ${profile.last.distanceM}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile downsamples a long ride', () {
|
||||||
|
final points = List.generate(
|
||||||
|
21600,
|
||||||
|
(i) => point(ts: i * 500, lat: 51.0 + i * 0.00001, alt: 1000.0),
|
||||||
|
);
|
||||||
|
final profile = elevationProfile(points, maxSamples: 200);
|
||||||
|
expect(profile.length, lessThanOrEqualTo(201),
|
||||||
|
reason: 'expected ~200 samples, got ${profile.length}');
|
||||||
|
expect(profile.last.altitudeM, closeTo(points.last.altitudeM, 1e-9));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('profile handles empty and single-point input', () {
|
||||||
|
expect(elevationProfile(const []), isEmpty);
|
||||||
|
expect(elevationProfile([point(ts: 0)]).length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
111
rippr-flutter-src/test/telemetry_test.dart
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/telemetry/telemetry.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.TelemetryTest`.
|
||||||
|
///
|
||||||
|
/// Tolerances are carried over as-is. Note the speed assertions: Kotlin's `Float`
|
||||||
|
/// widened to `Double` yields 88.5 only within 1e-4, which is exactly why the original
|
||||||
|
/// used that tolerance. Dart's uniform binary64 is exact here, but the tolerance is kept
|
||||||
|
/// so the two suites stay comparable line for line.
|
||||||
|
void main() {
|
||||||
|
test('converts meters per second to kmh', () {
|
||||||
|
expect(msToKmh(10), closeTo(36, 0.001));
|
||||||
|
expect(msToKmh(0), closeTo(0, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gps jitter below the noise floor reports as zero', () {
|
||||||
|
expect(sanitizeSpeedKmh(0.9), closeTo(0, 0.001));
|
||||||
|
expect(sanitizeSpeedKmh(double.nan), closeTo(0, 0.001));
|
||||||
|
expect(sanitizeSpeedKmh(-5), closeTo(0, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real speeds pass through untouched', () {
|
||||||
|
expect(sanitizeSpeedKmh(97.3), closeTo(97.3, 0.001));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects fixes worse than the accuracy budget', () {
|
||||||
|
expect(isUsableFix(12), isTrue);
|
||||||
|
expect(isUsableFix(0), isTrue,
|
||||||
|
reason: 'unknown accuracy must not be discarded');
|
||||||
|
expect(isUsableFix(120), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats duration as hh mm ss', () {
|
||||||
|
expect(formatDuration(0), '00:00:00');
|
||||||
|
expect(formatDuration(-1), '00:00:00');
|
||||||
|
expect(formatDuration(65000), '00:01:05');
|
||||||
|
expect(formatDuration(7384000), '02:03:04');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes a batch as the documented payload shape', () {
|
||||||
|
final points = [
|
||||||
|
const TrackPoint(
|
||||||
|
id: 7,
|
||||||
|
tripId: 3,
|
||||||
|
segmentId: 5,
|
||||||
|
timestamp: 1700000000000,
|
||||||
|
latitude: 51.0447,
|
||||||
|
longitude: -114.0719,
|
||||||
|
speedKmh: 88.5,
|
||||||
|
altitudeM: 1045.0,
|
||||||
|
accuracyM: 4.2,
|
||||||
|
bearingDeg: 271.5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final json =
|
||||||
|
jsonDecode(encodeBatch('device-abc', points)) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(json['device_id'], 'device-abc');
|
||||||
|
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||||
|
expect(first['id'], 7);
|
||||||
|
expect(first['trip_id'], 3);
|
||||||
|
expect(first['segment_id'], 5);
|
||||||
|
expect(first['ts'], 1700000000000);
|
||||||
|
expect(first['lat'] as double, closeTo(51.0447, 1e-6));
|
||||||
|
expect(first['lon'] as double, closeTo(-114.0719, 1e-6));
|
||||||
|
expect(first['speed_kmh'] as double, closeTo(88.5, 1e-4));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a batch spanning two segments labels each point individually', () {
|
||||||
|
// A batch is drawn by id and can cross a boundary, so identity must travel with
|
||||||
|
// the point rather than the batch.
|
||||||
|
final points = [
|
||||||
|
const TrackPoint(
|
||||||
|
id: 1,
|
||||||
|
tripId: 3,
|
||||||
|
segmentId: 5,
|
||||||
|
timestamp: 1,
|
||||||
|
latitude: 51.0,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40,
|
||||||
|
altitudeM: 1000.0),
|
||||||
|
const TrackPoint(
|
||||||
|
id: 2,
|
||||||
|
tripId: 3,
|
||||||
|
segmentId: 6,
|
||||||
|
timestamp: 2,
|
||||||
|
latitude: 51.1,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40,
|
||||||
|
altitudeM: 1000.0),
|
||||||
|
];
|
||||||
|
|
||||||
|
final array =
|
||||||
|
(jsonDecode(encodeBatch('d', points)) as Map<String, dynamic>)['points']
|
||||||
|
as List;
|
||||||
|
|
||||||
|
expect((array[0] as Map)['segment_id'], 5);
|
||||||
|
expect((array[1] as Map)['segment_id'], 6);
|
||||||
|
expect((array[0] as Map)['trip_id'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encodes an empty batch without failing', () {
|
||||||
|
final json =
|
||||||
|
jsonDecode(encodeBatch('d', const [])) as Map<String, dynamic>;
|
||||||
|
expect((json['points'] as List).length, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
152
rippr-flutter-src/test/telemetry_uploader_test.dart
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/telemetry/telemetry_uploader.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.TelemetryUploaderTest`.
|
||||||
|
///
|
||||||
|
/// The Kotlin suite used MockWebServer plus an in-memory fake DAO. Here a `MockClient`
|
||||||
|
/// stands in for the network and a real in-memory Drift database stands in for the DAO —
|
||||||
|
/// closer to production, and still no device required.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
|
Future<void> seed(int count) async {
|
||||||
|
final tripId =
|
||||||
|
await db.insertTrip(const Trip(startedAt: 1000, state: TripState.recording));
|
||||||
|
final segmentId = await db.insertSegment(tripId, 1000);
|
||||||
|
await db.insertPoints([
|
||||||
|
for (var i = 0; i < count; i++)
|
||||||
|
TrackPoint(
|
||||||
|
tripId: tripId,
|
||||||
|
segmentId: segmentId,
|
||||||
|
timestamp: 1000 + i,
|
||||||
|
latitude: 51.0,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40,
|
||||||
|
altitudeM: 1000,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
TelemetryUploader uploader({
|
||||||
|
required http.Client client,
|
||||||
|
String endpoint = 'https://example.test/ingest',
|
||||||
|
}) =>
|
||||||
|
TelemetryUploader(
|
||||||
|
db: db,
|
||||||
|
endpoint: endpoint,
|
||||||
|
deviceId: 'device-abc',
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
|
||||||
|
test('an empty endpoint disables upload entirely', () async {
|
||||||
|
var called = false;
|
||||||
|
final client = MockClient((_) async {
|
||||||
|
called = true;
|
||||||
|
return http.Response('', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await uploader(client: client, endpoint: '').uploadPending();
|
||||||
|
|
||||||
|
expect(result, isA<UploadDisabled>());
|
||||||
|
expect(called, isFalse, reason: 'recording must work with no server at all');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful run marks points synced', () async {
|
||||||
|
await seed(5);
|
||||||
|
final client = MockClient((_) async => http.Response('{}', 200));
|
||||||
|
|
||||||
|
final result = await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
expect(result, isA<UploadSuccess>());
|
||||||
|
expect((result as UploadSuccess).uploaded, 5);
|
||||||
|
expect(await db.countUnsynced(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected batch leaves the backlog intact for retry', () async {
|
||||||
|
await seed(5);
|
||||||
|
final client = MockClient((_) async => http.Response('nope', 500));
|
||||||
|
|
||||||
|
final result = await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
expect(result, isA<UploadFailed>());
|
||||||
|
expect(await db.countUnsynced(), 5,
|
||||||
|
reason: 'nothing may be marked synced when the server refused it');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a network error is swallowed and retried later', () async {
|
||||||
|
await seed(3);
|
||||||
|
final client = MockClient((_) async => throw http.ClientException('offline'));
|
||||||
|
|
||||||
|
final result = await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
expect(result, isA<UploadFailed>());
|
||||||
|
expect(await db.countUnsynced(), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the payload carries trip and segment identity per point', () async {
|
||||||
|
await seed(2);
|
||||||
|
String? captured;
|
||||||
|
final client = MockClient((req) async {
|
||||||
|
captured = req.body;
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
final json = jsonDecode(captured!) as Map<String, dynamic>;
|
||||||
|
expect(json['device_id'], 'device-abc');
|
||||||
|
final first = (json['points'] as List).first as Map<String, dynamic>;
|
||||||
|
// Batches are drawn by id and can straddle a boundary, so identity travels with the
|
||||||
|
// point rather than the batch.
|
||||||
|
expect(first.containsKey('trip_id'), isTrue);
|
||||||
|
expect(first.containsKey('segment_id'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a backlog larger than one batch is sent across several requests',
|
||||||
|
() async {
|
||||||
|
// Two full batches plus a remainder.
|
||||||
|
await seed(uploadBatchSize * 2 + 7);
|
||||||
|
var requests = 0;
|
||||||
|
final client = MockClient((_) async {
|
||||||
|
requests++;
|
||||||
|
return http.Response('{}', 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
expect(requests, 3);
|
||||||
|
expect((result as UploadSuccess).uploaded, uploadBatchSize * 2 + 7);
|
||||||
|
expect(await db.countUnsynced(), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failure partway through reports what did land', () async {
|
||||||
|
await seed(uploadBatchSize + 10);
|
||||||
|
var requests = 0;
|
||||||
|
final client = MockClient((_) async {
|
||||||
|
requests++;
|
||||||
|
return http.Response('{}', requests == 1 ? 200 : 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
final result = await uploader(client: client).uploadPending();
|
||||||
|
|
||||||
|
expect(result, isA<UploadPartial>());
|
||||||
|
expect((result as UploadPartial).uploaded, uploadBatchSize);
|
||||||
|
expect(await db.countUnsynced(), 10,
|
||||||
|
reason: 'the first batch landed; the rest must survive for retry');
|
||||||
|
});
|
||||||
|
}
|
||||||
352
rippr-flutter-src/test/trip_repository_test.dart
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
|
||||||
|
/// Ported from `com.rippr.data.TripRepositoryTest` and `MergeTest`.
|
||||||
|
///
|
||||||
|
/// Both were **instrumented** suites needing a device and a booted emulator. Against
|
||||||
|
/// Drift on the Dart VM they are ordinary unit tests.
|
||||||
|
///
|
||||||
|
/// In-memory databases only — the native suite once wiped a real device's rides by
|
||||||
|
/// running against the production singleton.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late TripRepository repo;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
repo = TripRepository(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async => db.close());
|
||||||
|
|
||||||
|
Future<void> addPoint(TripHandle handle, int ts,
|
||||||
|
{double lat = 51.0, double speed = 40.0, double alt = 1000.0}) =>
|
||||||
|
repo.appendPoints([
|
||||||
|
TrackPoint(
|
||||||
|
tripId: handle.tripId,
|
||||||
|
segmentId: handle.segmentId,
|
||||||
|
timestamp: ts,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: speed,
|
||||||
|
altitudeM: alt,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
group('lifecycle', () {
|
||||||
|
test('full lifecycle produces one trip and two segments', () async {
|
||||||
|
final h1 = await repo.startTrip(1000);
|
||||||
|
await addPoint(h1, 1000);
|
||||||
|
await addPoint(h1, 2000);
|
||||||
|
|
||||||
|
await repo.pauseTrip(3000);
|
||||||
|
final h2 = (await repo.resumeTrip(4000))!;
|
||||||
|
await addPoint(h2, 5000);
|
||||||
|
|
||||||
|
final tripId = await repo.completeTrip(6000);
|
||||||
|
|
||||||
|
expect(tripId, h1.tripId);
|
||||||
|
expect(h2.segmentId, isNot(h1.segmentId),
|
||||||
|
reason: 'resume must open a fresh segment');
|
||||||
|
|
||||||
|
final segments = await repo.segmentsForTrip(h1.tripId);
|
||||||
|
expect(segments.length, 2);
|
||||||
|
expect(segments.every((s) => !s.isOpen), isTrue,
|
||||||
|
reason: 'completing must close the open segment');
|
||||||
|
expect((await repo.tripById(h1.tripId))!.state, TripState.completed);
|
||||||
|
expect(await repo.activeTrip(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pause closes the segment but leaves the trip open', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
|
||||||
|
final trip = (await repo.activeTrip())!;
|
||||||
|
expect(trip.state, TripState.paused);
|
||||||
|
expect(trip.endedAt, isNull, reason: 'pause must not end the trip');
|
||||||
|
expect(await db.openSegment(h.tripId), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startTrip adopts an already active trip instead of creating a second',
|
||||||
|
() async {
|
||||||
|
final first = await repo.startTrip(1000);
|
||||||
|
final second = await repo.startTrip(2000);
|
||||||
|
|
||||||
|
expect(second.tripId, first.tripId);
|
||||||
|
expect(second.segmentId, first.segmentId);
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resume while already recording is a no-op', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
final resumed = await repo.resumeTrip(2000);
|
||||||
|
|
||||||
|
expect(resumed!.segmentId, h.segmentId,
|
||||||
|
reason: 'must not open a duplicate segment');
|
||||||
|
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('double pause is a no-op', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
expect(await repo.pauseTrip(2000), isTrue);
|
||||||
|
expect(await repo.pauseTrip(3000), isTrue);
|
||||||
|
expect(await db.countSegmentsForTrip(h.tripId), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transitions with no active trip are safe no-ops', () async {
|
||||||
|
expect(await repo.pauseTrip(1000), isFalse);
|
||||||
|
expect(await repo.resumeTrip(1000), isNull);
|
||||||
|
expect(await repo.completeTrip(1000), isNull);
|
||||||
|
expect(await repo.discardTrip(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start after complete begins a fresh trip', () async {
|
||||||
|
final first = await repo.startTrip(1000);
|
||||||
|
await repo.completeTrip(2000);
|
||||||
|
final second = await repo.startTrip(3000);
|
||||||
|
|
||||||
|
expect(second.tripId, isNot(first.tripId));
|
||||||
|
expect(await db.countTrips(), 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('discard', () {
|
||||||
|
test('discard removes the trip and all its points', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
await addPoint(h, 1000);
|
||||||
|
await addPoint(h, 2000);
|
||||||
|
|
||||||
|
expect(await repo.discardTrip(), isTrue);
|
||||||
|
|
||||||
|
expect(await repo.tripById(h.tripId), isNull);
|
||||||
|
expect(await db.countPointsForTrip(h.tripId), 0);
|
||||||
|
expect(await db.countSegmentsForTrip(h.tripId), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discard leaves earlier completed trips alone', () async {
|
||||||
|
final keep = await repo.startTrip(1000);
|
||||||
|
await addPoint(keep, 1000);
|
||||||
|
await repo.completeTrip(2000);
|
||||||
|
|
||||||
|
final throwaway = await repo.startTrip(3000);
|
||||||
|
await addPoint(throwaway, 3000);
|
||||||
|
await repo.discardTrip();
|
||||||
|
|
||||||
|
expect(await repo.tripById(keep.tripId), isNotNull);
|
||||||
|
expect(await db.countPointsForTrip(keep.tripId), 1);
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('streams', () {
|
||||||
|
test('active trip stream tracks the lifecycle', () async {
|
||||||
|
expect(await repo.watchActiveTrip().first, isNull);
|
||||||
|
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
expect((await repo.watchActiveTrip().first)?.id, h.tripId);
|
||||||
|
|
||||||
|
await repo.completeTrip(2000);
|
||||||
|
expect(await repo.watchActiveTrip().first, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completed trips stream excludes the active trip', () async {
|
||||||
|
final done = await repo.startTrip(1000);
|
||||||
|
await repo.completeTrip(2000);
|
||||||
|
await repo.startTrip(3000);
|
||||||
|
|
||||||
|
final completed = await repo.watchCompletedTrips().first;
|
||||||
|
expect(completed.length, 1);
|
||||||
|
expect(completed.single.id, done.tripId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('rename', () {
|
||||||
|
test('rename stores null rather than an empty string', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
|
||||||
|
await repo.renameTrip(h.tripId, 'Morning loop');
|
||||||
|
expect((await repo.tripById(h.tripId))!.name, 'Morning loop');
|
||||||
|
|
||||||
|
await repo.renameTrip(h.tripId, '');
|
||||||
|
expect((await repo.tripById(h.tripId))!.name, isNull,
|
||||||
|
reason: 'an empty string would diverge from the date-label branch');
|
||||||
|
|
||||||
|
await repo.renameTrip(h.tripId, ' ');
|
||||||
|
expect((await repo.tripById(h.tripId))!.name, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rename trims surrounding whitespace', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
await repo.renameTrip(h.tripId, ' Sunday blast ');
|
||||||
|
expect((await repo.tripById(h.tripId))!.name, 'Sunday blast');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('process death', () {
|
||||||
|
test('a fresh repository over the same database sees the active trip',
|
||||||
|
() async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
await addPoint(h, 1000);
|
||||||
|
|
||||||
|
// Simulates the process being killed and rebuilt: new repository, same file.
|
||||||
|
final revived = TripRepository(db);
|
||||||
|
|
||||||
|
final active = await revived.activeTrip();
|
||||||
|
expect(active, isNotNull,
|
||||||
|
reason: 'recording state must survive process death');
|
||||||
|
expect(active!.id, h.tripId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restart after pause resumes into a new segment', () async {
|
||||||
|
final h = await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
|
||||||
|
final revived = TripRepository(db);
|
||||||
|
final resumed = (await revived.resumeTrip(3000))!;
|
||||||
|
|
||||||
|
expect(resumed.tripId, h.tripId);
|
||||||
|
expect(resumed.segmentId, isNot(h.segmentId));
|
||||||
|
expect((await revived.activeTrip())!.state, TripState.recording);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Merge — ported from MergeTest
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
group('merge', () {
|
||||||
|
Future<int> completedTrip({
|
||||||
|
required int startedAt,
|
||||||
|
required int endedAt,
|
||||||
|
int points = 3,
|
||||||
|
String? name,
|
||||||
|
double lat = 51.0,
|
||||||
|
}) async {
|
||||||
|
final h = await repo.startTrip(startedAt);
|
||||||
|
for (var i = 0; i < points; i++) {
|
||||||
|
await addPoint(h, startedAt + i * 1000, lat: lat + i * 0.0001);
|
||||||
|
}
|
||||||
|
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||||
|
await repo.completeTrip(endedAt);
|
||||||
|
return h.tripId;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('merge reparents everything and deletes the absorbed row', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
|
||||||
|
final survivor = await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
expect(survivor, a);
|
||||||
|
expect(await repo.tripById(b), isNull);
|
||||||
|
expect(await db.countPointsForTrip(a), 6);
|
||||||
|
expect(await db.countSegmentsForTrip(a), 2);
|
||||||
|
expect(await db.countTrips(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selection order does not decide the survivor', () async {
|
||||||
|
final earlier = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final later = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
|
||||||
|
// Passed later-first on purpose.
|
||||||
|
expect(await repo.mergeTrips(later, earlier), earlier);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merged aggregates are recomputed, not summed', () async {
|
||||||
|
// The two rides are a degree of latitude apart — ~111 km. If aggregates were
|
||||||
|
// summed, or if the segments were joined, that gap would appear as distance.
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000, lat: 51.0);
|
||||||
|
final b =
|
||||||
|
await completedTrip(startedAt: 10000, endedAt: 15000, lat: 52.0);
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
final merged = (await repo.tripById(a))!;
|
||||||
|
expect(merged.pointCount, 6);
|
||||||
|
expect(merged.distanceM, lessThan(200.0),
|
||||||
|
reason:
|
||||||
|
'the ~111 km gap leaked into distance: ${merged.distanceM} m');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the join remains a segment boundary', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
final segments = await repo.segmentsForTrip(a);
|
||||||
|
expect(segments.length, 2,
|
||||||
|
reason: 'segments must never be joined by a merge');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endedAt becomes the later of the two', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
expect((await repo.tripById(a))!.endedAt, 15000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unnamed survivor inherits the other name', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b =
|
||||||
|
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
expect((await repo.tripById(a))!.name, 'Part two');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an existing survivor name is kept', () async {
|
||||||
|
final a = await completedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'The good one');
|
||||||
|
final b =
|
||||||
|
await completedTrip(startedAt: 10000, endedAt: 15000, name: 'Part two');
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
expect((await repo.tripById(a))!.name, 'The good one');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging a trip with itself is rejected', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
expect(await repo.mergeTrips(a, a), isNull);
|
||||||
|
expect(await db.countPointsForTrip(a), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging an active trip is rejected', () async {
|
||||||
|
final done = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final active = await repo.startTrip(10000);
|
||||||
|
|
||||||
|
expect(await repo.mergeTrips(done, active.tripId), isNull);
|
||||||
|
expect(await db.countTrips(), 2, reason: 'nothing may be deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merging a missing trip is rejected', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
expect(await repo.mergeTrips(a, 9999), isNull);
|
||||||
|
expect(await repo.tripById(a), isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merge is atomic and leaves no orphans', () async {
|
||||||
|
final a = await completedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await completedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
|
||||||
|
await repo.mergeTrips(a, b);
|
||||||
|
|
||||||
|
// Every surviving point and segment must belong to the survivor.
|
||||||
|
final allPoints = await db.allPoints();
|
||||||
|
expect(allPoints.every((p) => p.tripId == a), isTrue);
|
||||||
|
expect(allPoints.length, 6);
|
||||||
|
|
||||||
|
final segments = await repo.segmentsForTrip(a);
|
||||||
|
expect(segments.every((s) => s.tripId == a), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
344
rippr-flutter-src/test/widget_test.dart
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import 'package:drift/drift.dart' show driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:rippr/src/app/providers.dart';
|
||||||
|
import 'package:rippr/src/data/database.dart';
|
||||||
|
import 'package:rippr/src/data/trip_repository.dart';
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/recording/location_source.dart';
|
||||||
|
import 'package:rippr/src/ui/detail/trip_detail_screen.dart';
|
||||||
|
import 'package:rippr/src/ui/record/record_screen.dart';
|
||||||
|
import 'package:rippr/src/ui/theme.dart';
|
||||||
|
import 'package:rippr/src/ui/trips/trips_screen.dart';
|
||||||
|
|
||||||
|
/// **The first UI tests this project has ever had.**
|
||||||
|
///
|
||||||
|
/// `docs/v3/BACKLOG.md` names "zero UI tests across six screens" as v2's largest coverage
|
||||||
|
/// gap: everything was verified by manual screenshot, which is how a 64 sp speed figure
|
||||||
|
/// once shipped rendering black-on-black. Flutter makes these cheap enough that carrying
|
||||||
|
/// that debt into the port would be indefensible.
|
||||||
|
void main() {
|
||||||
|
late AppDatabase db;
|
||||||
|
late TripRepository repo;
|
||||||
|
late FakeLocationSource source;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
db = AppDatabase(NativeDatabase.memory());
|
||||||
|
repo = TripRepository(db);
|
||||||
|
source = FakeLocationSource();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await source.dispose();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Runs a widget test and then tears the tree down cleanly.
|
||||||
|
///
|
||||||
|
/// `flutter_test` asserts that no `Timer` is pending once the tree is disposed. Two
|
||||||
|
/// things here legitimately hold one: the elapsed clock on the record screen, and
|
||||||
|
/// Drift's stream-query cache, which keeps a query alive briefly after its last
|
||||||
|
/// listener leaves so re-subscribing is cheap. Removing the tree cancels the first;
|
||||||
|
/// pumping past the keep-alive window clears the second.
|
||||||
|
void screenTest(String description, Future<void> Function(WidgetTester) body) {
|
||||||
|
testWidgets(description, (tester) async {
|
||||||
|
await body(tester);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pumps a screen that has a live ride on it.
|
||||||
|
///
|
||||||
|
/// `pumpAndSettle` cannot be used once a ride is active: the elapsed clock ticks every
|
||||||
|
/// second forever, so "settled" never arrives. Two explicit frames are enough to build
|
||||||
|
/// and then reflect the streamed trip.
|
||||||
|
Future<void> pumpLive(WidgetTester tester, Widget widget) async {
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps a screen with the real theme and an in-memory stack.
|
||||||
|
///
|
||||||
|
/// The theme is deliberately included: the black-on-black bug was invisible to logic
|
||||||
|
/// tests and only a rendered widget can catch its equivalent.
|
||||||
|
Widget host(Widget child, {bool map = true}) => ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
databaseProvider.overrideWithValue(db),
|
||||||
|
locationSourceProvider.overrideWithValue(source),
|
||||||
|
// The map is off by default in tests that are not about the map: it fetches
|
||||||
|
// tiles, which a widget test cannot serve, and it changes scroll geometry.
|
||||||
|
mapEnabledProvider.overrideWith((ref) => map),
|
||||||
|
],
|
||||||
|
child: MaterialApp(theme: ripprTheme(), home: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<int> seedCompletedTrip({
|
||||||
|
required int startedAt,
|
||||||
|
required int endedAt,
|
||||||
|
String? name,
|
||||||
|
int points = 5,
|
||||||
|
double startLat = 51.0,
|
||||||
|
}) async {
|
||||||
|
final h = await repo.startTrip(startedAt);
|
||||||
|
await repo.appendPoints([
|
||||||
|
for (var i = 0; i < points; i++)
|
||||||
|
TrackPoint(
|
||||||
|
tripId: h.tripId,
|
||||||
|
segmentId: h.segmentId,
|
||||||
|
timestamp: startedAt + i * 1000,
|
||||||
|
latitude: startLat + i * 0.0001,
|
||||||
|
longitude: -114.0,
|
||||||
|
speedKmh: 40.0 + i,
|
||||||
|
altitudeM: 1000.0 + i,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (name != null) await repo.renameTrip(h.tripId, name);
|
||||||
|
await repo.completeTrip(endedAt);
|
||||||
|
await repo.recomputeAggregates(h.tripId);
|
||||||
|
return h.tripId;
|
||||||
|
}
|
||||||
|
|
||||||
|
group('record screen', () {
|
||||||
|
screenTest('idle shows Ready and only START', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Ready'), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('start')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('pause')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('stop')), findsNothing);
|
||||||
|
// A resting screen must not look like a ride going nowhere.
|
||||||
|
expect(find.text('Distance'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('the headline is live speed, not max speed', (tester) async {
|
||||||
|
// v2.0 shipped max speed as the headline and it read as a frozen, broken screen on
|
||||||
|
// a real ride, because a max figure only moves when you beat it. This guards the
|
||||||
|
// 2.0.1 fix.
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('SPEED'), findsOneWidget);
|
||||||
|
expect(find.text('MAX SPEED'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('recording swaps to PAUSE and STOP, with no DISCARD',
|
||||||
|
(tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('pause')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||||
|
// Destructive actions must not sit next to Pause during a live ride — a gloved
|
||||||
|
// mis-tap at speed would lose the whole recording.
|
||||||
|
expect(find.byKey(const Key('discard')), findsNothing);
|
||||||
|
expect(find.byKey(const Key('start')), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('paused offers RESUME, STOP and DISCARD', (tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('resume')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('stop')), findsOneWidget);
|
||||||
|
expect(find.byKey(const Key('discard')), findsOneWidget);
|
||||||
|
expect(find.text('Paused'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('discard asks before destroying anything', (tester) async {
|
||||||
|
await repo.startTrip(1000);
|
||||||
|
await repo.pauseTrip(2000);
|
||||||
|
await pumpLive(tester, host(const RecordScreen()));
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('discard')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Discard this ride?'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(await repo.activeTrip(), isNotNull,
|
||||||
|
reason: 'cancelling must not delete the ride');
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('text is legible against the dark ground', (tester) async {
|
||||||
|
// The regression this exists for: removing Compose's Surface left LocalContentColor
|
||||||
|
// black, and a 64sp figure rendered invisibly on a near-black background. No logic
|
||||||
|
// test could catch it. This asserts the rendered colour differs from the ground.
|
||||||
|
await tester.pumpWidget(host(const RecordScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final speed = tester.widget<Text>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('speed')),
|
||||||
|
matching: find.text('0'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final colour = speed.style?.color;
|
||||||
|
expect(colour, isNotNull, reason: 'the headline must name its colour');
|
||||||
|
expect(colour, isNot(ripprBackground));
|
||||||
|
expect(colour, isNot(Colors.black));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('trips list', () {
|
||||||
|
screenTest('empty state explains what to do', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('No rides yet'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('completed rides are listed newest first', (tester) async {
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Older ride');
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 100000, endedAt: 200000, name: 'Newer ride');
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final newer = tester.getTopLeft(find.text('Newer ride')).dy;
|
||||||
|
final older = tester.getTopLeft(find.text('Older ride')).dy;
|
||||||
|
expect(newer, lessThan(older));
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('an active ride does not appear in the list', (tester) async {
|
||||||
|
await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Finished');
|
||||||
|
await repo.startTrip(100000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 50));
|
||||||
|
|
||||||
|
expect(find.byKey(const Key('trip-1')), findsOneWidget);
|
||||||
|
expect(find.text('Finished'), findsOneWidget);
|
||||||
|
// Only the completed one.
|
||||||
|
expect(find.byType(Card), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('Merge enables at exactly two selections', (tester) async {
|
||||||
|
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
final b = await seedCompletedTrip(startedAt: 10000, endedAt: 15000);
|
||||||
|
final c = await seedCompletedTrip(startedAt: 20000, endedAt: 25000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// One selected: merge is present but disabled.
|
||||||
|
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNull,
|
||||||
|
reason: 'merging one ride is meaningless',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Two: enabled.
|
||||||
|
await tester.tap(find.byKey(Key('trip-$b')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNotNull,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Three: ambiguous, so disabled again.
|
||||||
|
await tester.tap(find.byKey(Key('trip-$c')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
tester.widget<TextButton>(find.byKey(const Key('merge'))).onPressed,
|
||||||
|
isNull,
|
||||||
|
reason: 'merge combines exactly two rides',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('deleting asks first and then removes the ride',
|
||||||
|
(tester) async {
|
||||||
|
final a = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(const TripsScreen()));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.longPress(find.byKey(Key('trip-$a')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.byKey(const Key('delete')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('Delete 1 ride'), findsOneWidget);
|
||||||
|
// "Delete" appears both in the toolbar and in the dialog; target the dialog's.
|
||||||
|
await tester.tap(find.descendant(
|
||||||
|
of: find.byType(AlertDialog),
|
||||||
|
matching: find.text('Delete'),
|
||||||
|
));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(await repo.tripById(a), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('trip detail', () {
|
||||||
|
screenTest('shows the authoritative summary', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 5000, name: 'Sunday blast', points: 11);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Sunday blast'), findsOneWidget);
|
||||||
|
expect(find.text('DISTANCE'), findsOneWidget);
|
||||||
|
expect(find.text('Max speed'), findsOneWidget);
|
||||||
|
expect(find.text('Segments'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('a missing ride says so instead of rendering blank',
|
||||||
|
(tester) async {
|
||||||
|
await tester.pumpWidget(host(const TripDetailScreen(tripId: 9999), map: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('no longer exists'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('charts degrade gracefully below two points', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(
|
||||||
|
startedAt: 1000, endedAt: 2000, points: 1);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Both charts need intervals; one point has none. An unexplained blank box would
|
||||||
|
// read as a bug.
|
||||||
|
//
|
||||||
|
// The detail body is a ListView, so the second chart is not built until it is
|
||||||
|
// scrolled into view -- asserting without scrolling would silently check only one.
|
||||||
|
expect(find.textContaining('Not enough points'), findsOneWidget);
|
||||||
|
await tester.drag(find.byType(ListView), const Offset(0, -600));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('Not enough points'), findsWidgets);
|
||||||
|
expect(find.text('Elevation profile'.toUpperCase()), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
screenTest('renaming updates the title', (tester) async {
|
||||||
|
final id = await seedCompletedTrip(startedAt: 1000, endedAt: 5000);
|
||||||
|
|
||||||
|
await tester.pumpWidget(host(TripDetailScreen(tripId: id), map: false));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('rename')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.enterText(find.byKey(const Key('name-field')), 'Coast run');
|
||||||
|
await tester.tap(find.text('Save'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Coast run'), findsOneWidget);
|
||||||
|
expect((await repo.tripById(id))!.name, 'Coast run');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
84
rippr-flutter-src/tool/parity/main.kt
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import com.rippr.data.TrackPoint
|
||||||
|
import com.rippr.data.Segment
|
||||||
|
import com.rippr.geo.Geo
|
||||||
|
import com.rippr.geo.LatLon
|
||||||
|
import com.rippr.stats.RideStatistics
|
||||||
|
import com.rippr.data.Trip
|
||||||
|
import com.rippr.data.TripState
|
||||||
|
import com.rippr.export.RideExport
|
||||||
|
|
||||||
|
// A deterministic LCG implemented identically in Kotlin and Dart, so both languages
|
||||||
|
// see the SAME noise sequence. Neither language's built-in Random can do this.
|
||||||
|
class Lcg(private var s: Long) {
|
||||||
|
fun nextDouble(): Double {
|
||||||
|
s = s * 6364136223846793005L + 1442695040888963407L
|
||||||
|
val bits = (s ushr 11) and ((1L shl 53) - 1L)
|
||||||
|
return bits.toDouble() / (1L shl 53).toDouble()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FNV-1a 32-bit. String.hashCode() differs between Java and Dart, so it cannot be used
|
||||||
|
// to compare text across languages -- this can.
|
||||||
|
fun fnv1a(s: String): Long {
|
||||||
|
var h = 2166136261L
|
||||||
|
for (ch in s) { h = h xor ch.code.toLong(); h = (h * 16777619L) and 0xFFFFFFFFL }
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
fun p(seg: Long, ts: Long, lat: Double, lon: Double, sp: Float, alt: Double, id: Long) =
|
||||||
|
TrackPoint(id = id, tripId = 1, segmentId = seg, timestamp = ts,
|
||||||
|
latitude = lat, longitude = lon, speedKmh = sp, altitudeM = alt)
|
||||||
|
|
||||||
|
fun main() {
|
||||||
|
fun out(k: String, v: Any) = println("$k=$v")
|
||||||
|
|
||||||
|
out("haversine_calgary_edmonton", Geo.haversineMeters(51.0447, -114.0719, 53.5461, -113.4938))
|
||||||
|
out("haversine_short_hop", Geo.haversineMeters(51.0447, -114.0719, 51.04480, -114.0719))
|
||||||
|
out("perp_beyond_end", Geo.perpendicularDistanceMeters(
|
||||||
|
LatLon(51.003, -114.0), LatLon(51.000, -114.0), LatLon(51.002, -114.0)))
|
||||||
|
|
||||||
|
val ride = (0 until 21_600).map { LatLon(51.0 + it * 0.00001, -114.0 + Math.sin(it / 100.0) * 0.001) }
|
||||||
|
val simplified = Geo.simplify(ride, 5.0)
|
||||||
|
out("simplify_count", simplified.size)
|
||||||
|
out("simplify_last_lat", simplified.last().lat)
|
||||||
|
out("path_length_ride", Geo.pathLengthMeters(ride))
|
||||||
|
|
||||||
|
// The disputed fixture: 600 stationary fixes with shared LCG +/-8 m altitude noise.
|
||||||
|
val rng = Lcg(42L)
|
||||||
|
val noisy = (0 until 600).map {
|
||||||
|
p(1, it * 1000L, 51.0, -114.0, 0f, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), (it + 1).toLong())
|
||||||
|
}
|
||||||
|
out("noisy_gain", RideStatistics.compute(noisy).elevationGainM)
|
||||||
|
out("noisy_loss", RideStatistics.compute(noisy).elevationLossM)
|
||||||
|
|
||||||
|
val climb = (0 until 101).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 50f, 1000.0 + it, (it + 1).toLong()) }
|
||||||
|
out("climb_gain", RideStatistics.compute(climb).elevationGainM)
|
||||||
|
|
||||||
|
val run = (0 until 100).map { p(1, it * 1000L, 51.0 + it * 0.0001, -114.0, 40f, 1000.0, (it + 1).toLong()) }
|
||||||
|
val s = RideStatistics.compute(run)
|
||||||
|
out("run_distance", s.distanceM)
|
||||||
|
out("run_moving_millis", s.movingMillis)
|
||||||
|
out("run_avg_speed", s.avgMovingSpeedKmh)
|
||||||
|
|
||||||
|
val prof = RideStatistics.elevationProfile(run, 200)
|
||||||
|
out("profile_size", prof.size)
|
||||||
|
out("profile_last_distance", prof.last().distanceM)
|
||||||
|
|
||||||
|
val hist = RideStatistics.speedHistogram(run, 10)
|
||||||
|
out("hist_buckets", hist.size)
|
||||||
|
|
||||||
|
// --- export byte output -------------------------------------------------
|
||||||
|
val etrip = Trip(id = 1, startedAt = 1_700_000_000_000L, endedAt = 1_700_000_600_000L,
|
||||||
|
state = TripState.COMPLETED, distanceM = 12_345.6, maxSpeedKmh = 118.4f)
|
||||||
|
val esegs = listOf(Segment(id = 1, tripId = 1, startedAt = 0, endedAt = 1),
|
||||||
|
Segment(id = 2, tripId = 1, startedAt = 2, endedAt = 3))
|
||||||
|
val epts = esegs.flatMap { sg -> (0 until 5).map {
|
||||||
|
p(sg.id, 1_700_000_000_000L + it * 1000L, 51.0447 + it * 0.0001, -114.0719, 72f, 1045.0, it.toLong() + 1) } }
|
||||||
|
val g = RideExport.gpx(etrip, esegs, epts, "Sam & Dave's <ride>")
|
||||||
|
val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's <ride>")
|
||||||
|
out("gpx_len", g.length)
|
||||||
|
out("gpx_fnv", fnv1a(g))
|
||||||
|
out("geojson_len", j.length)
|
||||||
|
out("geojson_fnv", fnv1a(j))
|
||||||
|
out("hist_first_millis", hist.first().millis)
|
||||||
|
}
|
||||||
102
rippr-flutter-src/tool/parity/probe.dart
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// ignore_for_file: avoid_print -- this is a command-line harness; stdout is the output.
|
||||||
|
// Dart side of the cross-language parity harness (T07).
|
||||||
|
// Must stay fixture-for-fixture identical to tool/parity/main.kt.
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:rippr/src/domain/models.dart';
|
||||||
|
import 'package:rippr/src/export/ride_export.dart';
|
||||||
|
import 'package:rippr/src/geo/geo.dart';
|
||||||
|
import 'package:rippr/src/stats/ride_statistics.dart';
|
||||||
|
|
||||||
|
/// Same LCG as the Kotlin oracle. Dart ints are 64-bit two's complement on the VM and
|
||||||
|
/// multiplication wraps, matching Kotlin's Long.
|
||||||
|
class Lcg {
|
||||||
|
Lcg(this._s);
|
||||||
|
int _s;
|
||||||
|
double nextDouble() {
|
||||||
|
_s = _s * 6364136223846793005 + 1442695040888963407;
|
||||||
|
final bits = (_s >>> 11) & ((1 << 53) - 1);
|
||||||
|
return bits / (1 << 53);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FNV-1a 32-bit. Dart's String.hashCode differs from Java's, so it cannot be used to
|
||||||
|
/// compare text across languages -- this can.
|
||||||
|
int fnv1a(String s) {
|
||||||
|
var h = 2166136261;
|
||||||
|
for (final c in s.codeUnits) {
|
||||||
|
h ^= c;
|
||||||
|
h = (h * 16777619) & 0xFFFFFFFF;
|
||||||
|
}
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrackPoint p(int seg, int ts, double lat, double lon, double sp, double alt, int id) =>
|
||||||
|
TrackPoint(id: id, tripId: 1, segmentId: seg, timestamp: ts,
|
||||||
|
latitude: lat, longitude: lon, speedKmh: sp, altitudeM: alt);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
void out(String k, Object v) => print('$k=$v');
|
||||||
|
|
||||||
|
out('haversine_calgary_edmonton', haversineMeters(51.0447, -114.0719, 53.5461, -113.4938));
|
||||||
|
out('haversine_short_hop', haversineMeters(51.0447, -114.0719, 51.04480, -114.0719));
|
||||||
|
out('perp_beyond_end', perpendicularDistanceMeters(
|
||||||
|
const LatLon(51.003, -114.0), const LatLon(51.000, -114.0), const LatLon(51.002, -114.0)));
|
||||||
|
|
||||||
|
final ride = List.generate(21600,
|
||||||
|
(i) => LatLon(51.0 + i * 0.00001, -114.0 + math.sin(i / 100.0) * 0.001));
|
||||||
|
final simplified = simplify(ride, 5.0);
|
||||||
|
out('simplify_count', simplified.length);
|
||||||
|
out('simplify_last_lat', simplified.last.lat);
|
||||||
|
out('path_length_ride', pathLengthMeters(ride));
|
||||||
|
|
||||||
|
final rng = Lcg(42);
|
||||||
|
final noisy = List.generate(600,
|
||||||
|
(i) => p(1, i * 1000, 51.0, -114.0, 0.0, 1000.0 + (rng.nextDouble() * 16.0 - 8.0), i + 1));
|
||||||
|
out('noisy_gain', computeSummary(noisy).elevationGainM);
|
||||||
|
out('noisy_loss', computeSummary(noisy).elevationLossM);
|
||||||
|
|
||||||
|
final climb = List.generate(101,
|
||||||
|
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 50.0, 1000.0 + i, i + 1));
|
||||||
|
out('climb_gain', computeSummary(climb).elevationGainM);
|
||||||
|
|
||||||
|
final run = List.generate(100,
|
||||||
|
(i) => p(1, i * 1000, 51.0 + i * 0.0001, -114.0, 40.0, 1000.0, i + 1));
|
||||||
|
final s = computeSummary(run);
|
||||||
|
out('run_distance', s.distanceM);
|
||||||
|
out('run_moving_millis', s.movingMillis);
|
||||||
|
out('run_avg_speed', s.avgMovingSpeedKmh);
|
||||||
|
|
||||||
|
final prof = elevationProfile(run, maxSamples: 200);
|
||||||
|
out('profile_size', prof.length);
|
||||||
|
out('profile_last_distance', prof.last.distanceM);
|
||||||
|
|
||||||
|
final hist = speedHistogram(run, bucketKmh: 10);
|
||||||
|
out('hist_buckets', hist.length);
|
||||||
|
out('hist_first_millis', hist.first.millis);
|
||||||
|
|
||||||
|
// --- export byte output -------------------------------------------------
|
||||||
|
const etrip = Trip(
|
||||||
|
id: 1,
|
||||||
|
startedAt: 1700000000000,
|
||||||
|
endedAt: 1700000600000,
|
||||||
|
state: TripState.completed,
|
||||||
|
distanceM: 12345.6,
|
||||||
|
maxSpeedKmh: 118.4);
|
||||||
|
const esegs = [
|
||||||
|
Segment(id: 1, tripId: 1, startedAt: 0, endedAt: 1),
|
||||||
|
Segment(id: 2, tripId: 1, startedAt: 2, endedAt: 3),
|
||||||
|
];
|
||||||
|
final epts = <TrackPoint>[
|
||||||
|
for (final sg in esegs)
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
p(sg.id, 1700000000000 + i * 1000, 51.0447 + i * 0.0001, -114.0719, 72.0,
|
||||||
|
1045.0, i + 1),
|
||||||
|
];
|
||||||
|
final g = gpx(etrip, esegs, epts, "Sam & Dave's <ride>");
|
||||||
|
final j = geoJson(etrip, esegs, epts, "Sam & Dave's <ride>");
|
||||||
|
out('gpx_len', g.length);
|
||||||
|
out('gpx_fnv', fnv1a(g));
|
||||||
|
out('geojson_len', j.length);
|
||||||
|
out('geojson_fnv', fnv1a(j));
|
||||||
|
}
|
||||||
60
rippr-flutter-src/tool/parity/run.sh
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cross-language parity harness (T07).
|
||||||
|
#
|
||||||
|
# Drives the SAME fixtures through the native Kotlin implementation and the Dart port,
|
||||||
|
# and diffs the output. This is the port's strongest correctness guarantee: it proved the
|
||||||
|
# elevation accumulator is bit-identical across both languages.
|
||||||
|
#
|
||||||
|
# Requires: kotlinc (brew install kotlin) and a JDK 17-21.
|
||||||
|
#
|
||||||
|
# NOTE: never redirect the build to /dev/null. A v2 sweep once returned four identical
|
||||||
|
# results because a quoting bug corrupted the source while the compile error hid behind
|
||||||
|
# a redirect. If every value looks suspiciously equal, suspect the harness first.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
NATIVE="${RIPPR_NATIVE:-$HOME/dojo/rippr}"
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
if [ ! -d "$NATIVE" ]; then
|
||||||
|
echo "Native repo not found at $NATIVE. Set RIPPR_NATIVE." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The files actually under test are copied verbatim from the native repo — never
|
||||||
|
# reimplemented. Only the Room-annotated holders and one Telemetry constant are stubbed,
|
||||||
|
# so the algorithm files compile without androidx or org.json on the classpath.
|
||||||
|
cp "$NATIVE/app/src/main/java/com/rippr/geo/Geo.kt" "$WORK/"
|
||||||
|
cp "$NATIVE/app/src/main/java/com/rippr/stats/RideStatistics.kt" "$WORK/"
|
||||||
|
cp "$NATIVE/app/src/main/java/com/rippr/export/RideExport.kt" "$WORK/"
|
||||||
|
cp "$HERE/stubs.kt" "$HERE/telemetry_stub.kt" "$HERE/main.kt" "$WORK/"
|
||||||
|
|
||||||
|
echo "== compiling Kotlin oracle =="
|
||||||
|
( cd "$WORK" && kotlinc Geo.kt RideStatistics.kt RideExport.kt stubs.kt \
|
||||||
|
telemetry_stub.kt main.kt -include-runtime -d parity.jar 2>&1 \
|
||||||
|
| grep -v '^warning:' || true )
|
||||||
|
|
||||||
|
# A missing jar means compilation failed. Never let that pass as a green run -- a v2
|
||||||
|
# sweep hid exactly this behind a redirect and reported four identical results.
|
||||||
|
if [ ! -f "$WORK/parity.jar" ]; then
|
||||||
|
echo "Kotlin oracle failed to compile (see errors above)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
JAVA_BIN="${JAVA_HOME:+$JAVA_HOME/bin/}java"
|
||||||
|
"$JAVA_BIN" -jar "$WORK/parity.jar" | grep '=' | sort > "$WORK/kotlin.txt"
|
||||||
|
|
||||||
|
echo "== running Dart side =="
|
||||||
|
( cd "$HERE/../.." && dart run tool/parity/probe.dart ) | grep '=' | sort > "$WORK/dart.txt"
|
||||||
|
|
||||||
|
echo "== diff =="
|
||||||
|
if diff -u "$WORK/kotlin.txt" "$WORK/dart.txt"; then
|
||||||
|
echo "PARITY OK - byte-identical on every key"
|
||||||
|
else
|
||||||
|
echo
|
||||||
|
echo "Differences above. Expected and accepted: run_avg_speed and any other"
|
||||||
|
echo "speed-derived value, where Kotlin's 32-bit Float widens to double with"
|
||||||
|
echo "artefacts Dart's uniform binary64 does not reproduce. Anything else is a bug."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
19
rippr-flutter-src/tool/parity/stubs.kt
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package com.rippr.data
|
||||||
|
data class TrackPoint(
|
||||||
|
val id: Long = 0, val tripId: Long, val segmentId: Long, val timestamp: Long,
|
||||||
|
val latitude: Double, val longitude: Double, val speedKmh: Float,
|
||||||
|
val altitudeM: Double, val accuracyM: Float = 0f, val bearingDeg: Float = 0f,
|
||||||
|
val synced: Boolean = false,
|
||||||
|
)
|
||||||
|
data class Segment(
|
||||||
|
val id: Long = 0, val tripId: Long, val startedAt: Long, val endedAt: Long? = null,
|
||||||
|
) { val isOpen: Boolean get() = endedAt == null }
|
||||||
|
|
||||||
|
enum class TripState { RECORDING, PAUSED, COMPLETED }
|
||||||
|
data class Trip(
|
||||||
|
val id: Long = 0, val startedAt: Long, val endedAt: Long? = null,
|
||||||
|
val name: String? = null, val state: TripState = TripState.RECORDING,
|
||||||
|
val distanceM: Double = 0.0, val movingMillis: Long = 0,
|
||||||
|
val maxSpeedKmh: Float = 0f, val elevationGainM: Double = 0.0,
|
||||||
|
val pointCount: Int = 0,
|
||||||
|
)
|
||||||
2
rippr-flutter-src/tool/parity/telemetry_stub.kt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
package com.rippr
|
||||||
|
object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f }
|
||||||
@@ -1,4 +1,32 @@
|
|||||||
# Rippr
|
# Rippr — native Android (superseded)
|
||||||
|
|
||||||
|
> ## ⚠ This app has been ported to Flutter
|
||||||
|
>
|
||||||
|
> Active development moved to **`~/dojo/rippr-flutter`**, which runs on Android *and*
|
||||||
|
> iOS. That port is feature-complete and better tested than this app; see its
|
||||||
|
> `docs/port/PARITY-AUDIT.md` for a row-by-row comparison.
|
||||||
|
>
|
||||||
|
> **This repository is kept as a reference and a fallback**, not archived. It is still the
|
||||||
|
> only version that has recorded real rides, and the port's validation depends on running
|
||||||
|
> both apps side by side — they use different application ids (`com.rippr` here,
|
||||||
|
> `com.rippr.port` there) specifically so they can be installed together.
|
||||||
|
>
|
||||||
|
> **Two bugs in this code were found by porting it.** Both are fixed in the port and
|
||||||
|
> still present here:
|
||||||
|
>
|
||||||
|
> 1. **`TrackingService.restoreAfterProcessDeath` measures the dead time as distance.**
|
||||||
|
> Its comment says it resumes into a new segment; it calls `resumeTrip`, which adopts
|
||||||
|
> the segment a crash left open. `RideStatistics.compute` then measures straight
|
||||||
|
> through the gap — reproduced at **111 km** of phantom distance. See
|
||||||
|
> `TripRepository.resumeIntoNewSegment` in the port.
|
||||||
|
> 2. **The elevation regression guard passes on seed luck.** On a fixture shared between
|
||||||
|
> both languages the algorithm yields ~39 m, which would fail this repo's own 35 m
|
||||||
|
> bound in `RideStatisticsTest`. It passes only because `kotlin.random.Random(42)`
|
||||||
|
> draws a benign sequence.
|
||||||
|
>
|
||||||
|
> Everything below describes this app as it stands, and remains accurate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
A native Android GPS ride recorder for motorcycles. Records telemetry from an
|
A native Android GPS ride recorder for motorcycles. Records telemetry from an
|
||||||
un-killable foreground service into a local SQLite database, renders the traversed path
|
un-killable foreground service into a local SQLite database, renders the traversed path
|
||||||
|
|||||||