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 is a native Android GPS ride recorder for motorcycles. Its canonical repo is
|
||||
`~/dojo/rippr`, which **has no git remote** — it exists only on that machine, so the
|
||||
bundle below is the only off-machine copy of its history.
|
||||
Rippr is a GPS ride recorder for motorcycles. There are now **two** codebases, and
|
||||
neither has a git remote — they exist only on that machine, so the bundles below are the
|
||||
only off-machine copies of their history.
|
||||
|
||||
**Snapshot taken at:** `d418920` — "v3: add waypoint route planning; record that handlebar mounting changes the premise" (21 commits)
|
||||
| Repo | What it is |
|
||||
|---|---|
|
||||
| `~/dojo/rippr` | The original **native Android** app (Kotlin, v1 → v2.0.1). **Superseded**, but still the only version that has recorded real rides. |
|
||||
| `~/dojo/rippr-flutter` | The **Flutter port** — Android *and* iOS. Feature-complete, better tested, never yet ridden. |
|
||||
|
||||
**Snapshots taken at:** native `ba57a92`, Flutter `fc6d8f9`.
|
||||
|
||||
## What is here
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `rippr-src/` | Browsable source, exported with `git archive HEAD`. No history; will drift. |
|
||||
| `rippr-full-history.bundle` | Full git history. The actual backup. |
|
||||
| `rippr-2.0.1-debug.apk` | Latest installable build (debug-signed) |
|
||||
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Previous builds |
|
||||
| `rippr-flutter-src/` | Flutter port, browsable source (`git archive HEAD`) |
|
||||
| `rippr-flutter-history.bundle` | Flutter port, full git history — the actual backup |
|
||||
| `rippr-flutter-1.0-debug.apk` | **Installable Flutter build** (`com.rippr.port`) |
|
||||
| `rippr-src/` | Native app, browsable source |
|
||||
| `rippr-full-history.bundle` | Native app, full git history |
|
||||
| `rippr-2.0.1-debug.apk` | Native app, latest installable build (`com.rippr`) |
|
||||
| `rippr-2.0-debug.apk`, `rippr-1.0-debug.apk` | Older native builds |
|
||||
|
||||
`rippr-src/` contains tracked files only — no build outputs, no `local.properties`, no
|
||||
nested `.git`.
|
||||
**The two APKs use different application ids on purpose** (`com.rippr` vs
|
||||
`com.rippr.port`), so both install side by side. That is not an accident — validating the
|
||||
port depends on recording the same ride on both at once.
|
||||
|
||||
## Restoring the full repo
|
||||
## Installing
|
||||
|
||||
See **[INSTALL.md](INSTALL.md)** for Android and iOS, including the sideloading route for
|
||||
iPhone (which needs a Mac and takes about ten minutes).
|
||||
|
||||
## Restoring either repo
|
||||
|
||||
```bash
|
||||
git clone rippr-flutter-history.bundle rippr-flutter
|
||||
cd rippr-flutter && flutter pub get && dart run build_runner build
|
||||
flutter test
|
||||
|
||||
# or the native app
|
||||
git clone rippr-full-history.bundle rippr
|
||||
cd rippr
|
||||
echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||
cd rippr && echo "sdk.dir=$HOME/Library/Android/sdk" > local.properties
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Needs **JDK 17–21** — AGP does not support 25 — and the Android SDK with platform 35 and
|
||||
build-tools 35.
|
||||
Both need **JDK 17–21** for Android — AGP does not support 25.
|
||||
|
||||
## Where to start reading
|
||||
|
||||
Inside `rippr-src/`:
|
||||
Inside `rippr-flutter-src/`:
|
||||
|
||||
- `README.md` — what the app is and its current state
|
||||
- `docs/ARCHITECTURE.md` — the design decisions and why
|
||||
- `docs/v3/BACKLOG.md` — known gaps, deferred work, what must not regress
|
||||
- `docs/TESTING.md` — especially "What the emulator cannot verify"
|
||||
- `docs/v2/PROGRESS.md` — every bug found during v2 development
|
||||
- `README.md` — status, and the two bugs the port found in the native app
|
||||
- `docs/port/PARITY-AUDIT.md` — feature by feature, with the evidence behind each claim
|
||||
- `docs/port/REAL-RIDE-CHECKLIST.md` — **the outstanding work**
|
||||
- `docs/ARCHITECTURE.md` — why it is built this way
|
||||
- `docs/port/PROGRESS.md` — everything that went wrong during the port
|
||||
|
||||
Inside `rippr-src/` (native): `docs/v2/PROGRESS.md` and `docs/TESTING.md` remain the
|
||||
record of how the app got here.
|
||||
|
||||
## Keeping these current
|
||||
|
||||
```bash
|
||||
cd ~/dojo/samplez
|
||||
|
||||
rm -rf rippr-flutter-src && mkdir rippr-flutter-src
|
||||
git -C ~/dojo/rippr-flutter archive HEAD | tar -x -C rippr-flutter-src
|
||||
(cd ~/dojo/rippr-flutter && git bundle create /tmp/fb.bundle --all) \
|
||||
&& mv /tmp/fb.bundle rippr-flutter-history.bundle
|
||||
|
||||
rm -rf rippr-src && mkdir rippr-src
|
||||
git -C ~/dojo/rippr archive HEAD | tar -x -C rippr-src
|
||||
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) && mv /tmp/rb.bundle rippr-full-history.bundle
|
||||
(cd ~/dojo/rippr && git bundle create /tmp/rb.bundle --all) \
|
||||
&& mv /tmp/rb.bundle rippr-full-history.bundle
|
||||
|
||||
git bundle verify rippr-flutter-history.bundle
|
||||
git bundle verify rippr-full-history.bundle
|
||||
```
|
||||
|
||||
Note the export wipes `rippr-src/` wholesale, which is why this file lives at the repo
|
||||
root rather than inside it.
|
||||
The exports wipe those directories wholesale, which is why this file and `INSTALL.md`
|
||||
live at the repo root rather than inside them.
|
||||
|
||||
BIN
rippr-flutter-1.0-debug.apk
Normal file
BIN
rippr-flutter-history.bundle
Normal file
52
rippr-flutter-src/.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
|
||||
# Build artifacts (large; regenerable)
|
||||
build/
|
||||
.dart_tool/
|
||||
33
rippr-flutter-src/.metadata
Normal file
@@ -0,0 +1,33 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
- platform: android
|
||||
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
- platform: ios
|
||||
create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
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
|
||||
un-killable foreground service into a local SQLite database, renders the traversed path
|
||||
|
||||