diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..c1f15f9 --- /dev/null +++ b/INSTALL.md @@ -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 +``` + +### 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 + ``` diff --git a/RIPPR.md b/RIPPR.md index ad2bd47..cead0d3 100644 --- a/RIPPR.md +++ b/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. diff --git a/rippr-flutter-1.0-debug.apk b/rippr-flutter-1.0-debug.apk new file mode 100644 index 0000000..cf3a899 Binary files /dev/null and b/rippr-flutter-1.0-debug.apk differ diff --git a/rippr-flutter-history.bundle b/rippr-flutter-history.bundle new file mode 100644 index 0000000..570afdd Binary files /dev/null and b/rippr-flutter-history.bundle differ diff --git a/rippr-flutter-src/.gitignore b/rippr-flutter-src/.gitignore new file mode 100644 index 0000000..3aad8c4 --- /dev/null +++ b/rippr-flutter-src/.gitignore @@ -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/ diff --git a/rippr-flutter-src/.metadata b/rippr-flutter-src/.metadata new file mode 100644 index 0000000..c344f74 --- /dev/null +++ b/rippr-flutter-src/.metadata @@ -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' diff --git a/rippr-flutter-src/README.md b/rippr-flutter-src/README.md new file mode 100644 index 0000000..4cdf472 --- /dev/null +++ b/rippr-flutter-src/README.md @@ -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 +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= ./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. diff --git a/rippr-flutter-src/analysis_options.yaml b/rippr-flutter-src/analysis_options.yaml new file mode 100644 index 0000000..cedcc10 --- /dev/null +++ b/rippr-flutter-src/analysis_options.yaml @@ -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 diff --git a/rippr-flutter-src/android/.gitignore b/rippr-flutter-src/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/rippr-flutter-src/android/.gitignore @@ -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 diff --git a/rippr-flutter-src/android/app/build.gradle.kts b/rippr-flutter-src/android/app/build.gradle.kts new file mode 100644 index 0000000..ae44eb3 --- /dev/null +++ b/rippr-flutter-src/android/app/build.gradle.kts @@ -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 = "../.." +} diff --git a/rippr-flutter-src/android/app/src/debug/AndroidManifest.xml b/rippr-flutter-src/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/rippr-flutter-src/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/rippr-flutter-src/android/app/src/main/AndroidManifest.xml b/rippr-flutter-src/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..73914d5 --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rippr-flutter-src/android/app/src/main/kotlin/com/rippr/MainActivity.kt b/rippr-flutter-src/android/app/src/main/kotlin/com/rippr/MainActivity.kt new file mode 100644 index 0000000..49a0702 --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/kotlin/com/rippr/MainActivity.kt @@ -0,0 +1,5 @@ +package com.rippr + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/rippr-flutter-src/android/app/src/main/res/drawable-v21/launch_background.xml b/rippr-flutter-src/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/rippr-flutter-src/android/app/src/main/res/drawable/launch_background.xml b/rippr-flutter-src/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/rippr-flutter-src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/rippr-flutter-src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/rippr-flutter-src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/rippr-flutter-src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/rippr-flutter-src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/rippr-flutter-src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/rippr-flutter-src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/rippr-flutter-src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/rippr-flutter-src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/rippr-flutter-src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/rippr-flutter-src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/rippr-flutter-src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/rippr-flutter-src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/rippr-flutter-src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/rippr-flutter-src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/rippr-flutter-src/android/app/src/main/res/values-night/styles.xml b/rippr-flutter-src/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/rippr-flutter-src/android/app/src/main/res/values/styles.xml b/rippr-flutter-src/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/rippr-flutter-src/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/rippr-flutter-src/android/app/src/profile/AndroidManifest.xml b/rippr-flutter-src/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/rippr-flutter-src/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/rippr-flutter-src/android/build.gradle.kts b/rippr-flutter-src/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/rippr-flutter-src/android/build.gradle.kts @@ -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("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/rippr-flutter-src/android/gradle.properties b/rippr-flutter-src/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/rippr-flutter-src/android/gradle.properties @@ -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 diff --git a/rippr-flutter-src/android/gradle/wrapper/gradle-wrapper.properties b/rippr-flutter-src/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /dev/null +++ b/rippr-flutter-src/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/rippr-flutter-src/android/settings.gradle.kts b/rippr-flutter-src/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/rippr-flutter-src/android/settings.gradle.kts @@ -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") diff --git a/rippr-flutter-src/build.yaml b/rippr-flutter-src/build.yaml new file mode 100644 index 0000000..72b188c --- /dev/null +++ b/rippr-flutter-src/build.yaml @@ -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 diff --git a/rippr-flutter-src/docs/ARCHITECTURE.md b/rippr-flutter-src/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1d28fc4 --- /dev/null +++ b/rippr-flutter-src/docs/ARCHITECTURE.md @@ -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 ``. + +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. diff --git a/rippr-flutter-src/docs/PORT_RESEARCH.md b/rippr-flutter-src/docs/PORT_RESEARCH.md new file mode 100644 index 0000000..fe328f8 --- /dev/null +++ b/rippr-flutter-src/docs/PORT_RESEARCH.md @@ -0,0 +1,75 @@ +Strategic Architecture Report: Cross-Platform Migration of Android Telemetry and GPS Applications to FlutterMigration Strategy Framework and Architectural AssessmentMigrating a native Android application to Flutter requires a structured evaluation of existing technical debt, team velocity, and long-term product maintainability. Maintaining dual codebases across Android and iOS doubles engineering effort, duplicates feature implementations, and frequently leads to asynchronous release schedules. Transitioning to a unified Flutter codebase enables engineering teams to consolidate development into a single Dart source while achieving near-native performance through Ahead-Of-Time (AOT) compilation to ARM machine code.Migration Strategy Selection FrameworkSelecting the appropriate migration strategy depends on application complexity, structural health, and business continuity requirements.Migration StrategyTargeted Application ScaleTypical TimelineArchitectural ImpactRisk ProfileFull RewriteUnder 10 screens; high legacy technical debt3–6 MonthsComplete clean slate; unified Clean Architecture across platformsHigh initial investment; temporarily pauses new feature releasesIncremental Migration10 to 50 screens; modular feature sets6–12 MonthsGradual screen-by-screen replacement; hybrid execution modelDual-codebase friction; IPC synchronization overhead during transitionAdd-to-App (Hybrid)50+ screens; enterprise scaleOngoing / PhasedFlutter modules embedded via FlutterActivity or FlutterViewController[cite: 1]Memory footprint expansion; complex asset and navigation lifecycle managementFor legacy applications burdened by technical debt, outdated third-party libraries, or unmaintainable UI patterns, a full rewrite provides a clean start. It eliminates historical cross-platform divergence and facilitates modern software practices from day one.Conversely, enterprise applications with extensive screen counts benefit from an Add-to-App strategy, embedding Flutter views using cached FlutterEngine instances (FlutterEngineCache) to minimize initial execution warm-up latency and memory allocation overhead.The decision process balances app size against architectural technical debt:Applications with fewer than 10 screens or those undergoing significant rebranding, design system updates, or mergers are optimal candidates for a complete rebuild.Medium-complexity applications containing 10 to 50 screens benefit from incremental migration, where low-risk modules—such as user onboarding or settings dashboards—are replaced first to build team fluency before tackling business-critical features.Enterprise platforms exceeding 50 screens require an Add-to-App pattern to maintain operational continuity, embedding Flutter submodules directly into host view controllers.Translating Native Android Architecture to DartTranslating native Android paradigms (Java/Kotlin) to Dart is straightforward due to object-oriented similarities in class inheritance, abstract interfaces, and mixins. However, structural application components must be systematically mapped to fit the reactive Flutter lifecycle:Dependency Injection: Android dependency frameworks like Dagger or Hilt map to Dart service locators such as get_it paired with injectable for compile-time dependency injection. This abstracts platform context lifecycles from core business objects.State Management: Legacy Android ViewModel and LiveData patterns translate cleanly to event-driven architectures such as BLoC (Business Logic Component) or reactive providers like Riverpod. Riverpod offers compile-safe dependency overrides and state caching, isolating business rules from UI re-renders.Navigation: Imperative Android navigation controllers map to declarative routing frameworks such as go_router. Declarative routing supports deep-linking, route guards, and stateful nested navigation required for cross-platform visual parity.Layered Architecture: Implementing Clean Architecture separates data layers (repositories, local SQLite caches, HTTP clients) from domain logic (use cases) and presentation layers (Flutter widgets). Repositories abstract platform-specific differences, exposing unified interfaces to presentation widgets.Cross-Platform Location Infrastructure and Hardware TelemetryPorting an application centered on continuous GPS tracking requires reconciling fundamental differences between Android and iOS background execution models. While Android relies on persistent foreground services linked to visible notifications, iOS enforces strict process suspensions to maximize energy efficiency.Platform Execution Divergence: Android 14 vs. iOS CoreLocationEngineering a continuous, high-precision telemetry engine across mobile OS environments requires handling opposing platform constraints:Android 14 (API Level 34+) Background Execution: Android 14 enforces explicit foreground service type declarations (android:foregroundServiceType="location") bound to a non-dismissible persistent user notification. Launching background location services without these declarations results in immediate OS process termination. Furthermore, background tracking requires explicit runtime approvals for ACCESS_FINE_LOCATION and ACCESS_BACKGROUND_LOCATION, as well as configuration adjustments to handle manufacturer-specific battery optimization engines.iOS CoreLocation Process Lifecycle: iOS restricts long-running background tasks. When a device becomes stationary, the OS transitions the application into a stationary state and completely suspends process execution. In this state, Dart execution pauses. CoreLocation establishes a hardware-monitored "stationary geofence" (typically $\sim 200\text{ meters}$). Only when hardware sensors register a boundary breach does iOS wake the application process, re-engage location services, and resume active tracking based on the configured distanceFilter.Geolocation Plugin Ecosystem ComparisonChoosing the appropriate geolocation plugin determines whether the migrated application achieves enterprise-grade tracking reliability or suffers from frequent process termination and excessive battery drain.Feature / CapabilityThin Wrappers (geolocator, location)flutter_background_geolocation (Transistor)tracelet (Federated Engine)Primary ArchitectureDirect channel wrappers around basic platform location APIsNative C/Kotlin/Swift engine with battery intelligenceFederated architecture with independent native SDKsMotion-Detection IntelligenceNone; relies on unfiltered system location updatesTri-axial sensor fusion (accelerometer, gyro, magnetometer)Accelerometer-based motion activity classificationBattery OptimizationHigh consumption; continuous GPS chip engagementAutomated state toggling between Moving and Stationary modesDynamic Battery Budget Engine targeting fixed %/hr drainGeofencing EngineOS system-level limits (maximum 20 regions)Enterprise geofencing engine with polygon supportUnlimited hardware-abstracted polygon geofencesData Integrity & SecurityManual custom implementation requiredSQLite persistence with automatic HTTP server syncSHA-256 hash chains, attestation, 3-level spoof detectionLicensing ModelOpen Source (MIT)Commercial license ($500+/yr per app for release)Open SourceHardware Motion Detection and Adaptive Sampling LogicTo minimize battery consumption during continuous background tracking, modern mobile telemetry engines rely on hardware motion activity sensors. Rather than keeping the power-hungry GPS receiver continuously energized, the system utilizes low-power hardware accelerometers (~0.5mW) to determine movement states.When the system enters a stationary state, location services are disabled, and a stationary geofence is established. The system initiates a stop-detection timer (stopTimeout). If no movement is detected before the timer expires, location hardware shuts down entirely.When motion activity sensors register physical displacement or a geofence breach occurs, the tracking engine transitions back to a moving state. It re-energizes the GPS receiver and samples fixes based on an adaptive distance filter.Advanced engines implement a Battery Budget Engine that dynamically adjusts sensor sampling frequencies based on real-time power consumption. If actual battery drain exceeds defined thresholds (e.g., $2\%$ per hour), the engine automatically increases the distanceFilter and adjusts GPS accuracy parameters down from high to medium or passive tracking modes.Low-Level Interoperability and Platform Channel EngineeringWhen existing community plugins do not cover custom native Android logic—such as proprietary enterprise SDKs or specialized telemetry hardware—developers must construct custom platform interoperation bridges.Platform Communication PatternsFlutter offers three primary channel mechanisms for communicating between the Dart execution layer and host platform code:MethodChannel: Functions as an asynchronous Remote Procedure Call (RPC) mechanism. It transmits single method requests and returns discrete responses, making it ideal for standard configuration calls, permission checks, or triggering one-time hardware tasks.EventChannel: Designed specifically for continuous data streaming. Host platform event listeners emit continuous data streams—such as live accelerometer readings, barometer changes, or real-time GPS location updates—directly into Dart Stream instances.BasicMessageChannel: Handles unstructured, lightweight message passing using customizable binary, string, or JSON codecs. It provides raw message passing capabilities when method-call semantics are not required.Data flows seamlessly across these channels: Dart presentation widgets invoke requests through state controllers, which route through typed IPC interfaces. The underlying BinaryMessenger serializes messages using the StandardMessageCodec across the platform boundary to host native implementations (Kotlin/Swift).Type-Safe Interoperability via PigeonStandard string-based MethodChannel implementations carry runtime risks, such as typos in channel names or message payloads failing silent deserialization. Flutter Pigeon addresses this by generating compile-time type-safe code interfaces for Dart, Kotlin, and Swift from a single contract definition.Contract Definition (pigeons/location_api.dart)Dartimport 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/location_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/example/app/LocationApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.example.app'), + swiftOut: 'ios/Runner/LocationApi.g.swift', +)) + +class LocationDataPayload { + double? latitude; + double? longitude; + double? accuracy; + double? altitude; + double? speed; + int? timestamp; +} + +@HostApi() +abstract class NativeLocationHostApi { + void startTrackingService(); + void stopTrackingService(); + @async + LocationDataPayload getCurrentSingleFix(); +} + +@FlutterApi() +abstract class NativeLocationFlutterApi { + void onLocationUpdated(LocationDataPayload location); +} +Executing Code GenerationExecuting the code generator compiles host bindings directly into the respective platform directories:Bashdart run pigeon --input pigeons/location_api.dart +Native Host Implementation (Kotlin - Android)Kotlinpackage com.example.app + +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.BinaryMessenger + +class LocationApiImpl(private val messenger: BinaryMessenger) : NativeLocationHostApi { + override fun startTrackingService() { + // Implementation for starting Android Foreground Service + } + + override fun stopTrackingService() { + // Implementation for stopping Foreground Service + } + + override fun getCurrentSingleFix(callback: (Result) -> Unit) { + val payload = LocationDataPayload( + latitude = 37.7749, + longitude = -122.4194, + accuracy = 5.0, + altitude = 10.0, + speed = 1.2, + timestamp = System.currentTimeMillis() + ) + callback(Result.success(payload)) + } +} +Quality Assurance, Performance Profiling, and Security ComplianceTransitioning mission-critical telemetry applications requires rigorous validation of battery consumption, offline resilience, and location data integrity.Cryptographic Audit Trails and Spoof PreventionIn commercial telemetry applications, location data often serves as legal proof of presence or regulatory compliance. Securing location payloads against tampering, device root modifications, or mock GPS applications requires a defense-in-depth security design:Cryptographic Hash Chains: Every persisted location point is appended to an immutable SHA-256 hash chain stored in local SQLite databases: +$$\text{hash}_n = \text{SHA-256}(\text{hash}_{n-1} + \text{canonical}(\text{location}_n))$$ +The genesis hash ($\text{hash}_0$) is bound to hardware-derived metrics (Build.FINGERPRINT on Android or identifierForVendor on iOS). Any manual modification, insertion, or deletion of historical database records breaks the checksum chain.Multi-Tiered Mock Location Detection:Level 1 (System): Checking platform API flags (isMock()).Level 2 (Heuristic): Analyzing satellite signal noise ratios, elapsed real-time clock drift against monotonic hardware clocks, and detecting unnatural velocity jumps.Level 3 (Hardware Attestation): Utilizing Google Play Integrity API tokens on Android and Apple App Attest on iOS to verify binary authenticity and hardware integrity before accepting location fixes.Offline Persistence and Delta Encoding: Network loss must not result in telemetry gaps. Location fixes are buffered locally in SQLite queues. Upon re-establishing connectivity, batched payloads undergo delta encoding—compressing byte streams by up to $80\%$ by transmitting raw values for initial fixes followed solely by differential updates.Testing Methodology and Continuous IntegrationEnsuring production reliability across mobile platforms involves a multi-tiered testing methodology:Unit Testing: Validates business logic, state providers, and repository layers using Dart unit tests and mocking frameworks like mockito or mocktail. Target code coverage should meet or exceed 80% on domain and data layers.Widget Testing: Verifies UI component rendering, user interactions, layout responsiveness, and state updates in isolation without launching a physical device.Integration Testing: Executes end-to-end user flows on physical hardware to verify native channel execution, location permission prompts, and background process resumes.CI/CD Pipeline Integration: Deployment services like Codemagic or GitHub Actions automate build verification, code shrinking via Android R8, upload key signing, and deployment to test tracks on Google Play Console and Apple App Store Connect.Store Compliance and Review Navigational GuidelinesDeploying background location applications to the Apple App Store involves stricter guidelines than Google Play. Unclear privacy disclosures, missing permission keys, or improper background configuration represent primary causes for App Store rejections.App Store Review Guidelines Compliance MatrixGuideline SectionCore Policy RequirementPrimary Rejection CauseTechnical MitigationGuideline 5.1.1 (Data Privacy & Transparency)Explicit justifications for sensitive permissions; accurate privacy labels.Generic usage strings (e.g., "App needs location").Provide explicit, context-rich Info.plist usage descriptions.Guideline 2.1 (App Completeness & Performance)System stability; zero crashes on launch or background resume.Background location fixes crashing while process is suspended.Implement robust error boundaries, graceful headless handling, and offline fallback queues.Guideline 4.2 (Minimum Functionality)Must provide native utility beyond a packaged website.Application feels like a wrapped web view.Utilize Flutter native UI widgets, smooth animations, and rich background interaction flows.App Privacy Nutrition LabelsDeclared practices must match runtime data behavior.Undisclosed background location tracking in third-party SDKs.Audit all third-party SDK dependencies for undisclosed data collection.iOS Permission Strings Configuration (Info.plist)To comply with Apple Guideline 5.1.1, applications utilizing background geolocation must explicitly define usage strings in ios/Runner/Info.plist:XMLNSLocationWhenInUseUsageDescription +This application tracks your route during active delivery tasks to provide real-time status updates to consumers. + +NSLocationAlwaysAndWhenInUseUsageDescription +Background location access is required to maintain fleet telemetry and log mileage automatically while driving, even when the application is closed. + +NSLocationAlwaysUsageDescription +Background location access is required to deliver real-time arrival alerts while driving. + +UIBackgroundModes + + location + fetch + processing + +Synthesis and Strategic RecommendationsSuccessfully porting a native Android GPS tracking application to Flutter requires balancing cross-platform development efficiency against platform-specific hardware constraints. Addressing background lifecycle differences early in the migration process is critical to long-term success.Engineering teams should structure their migration roadmap across four distinct phases:Phase 1: Assessment and Strategy Selection: Conduct a feature inventory and audit existing code complexity. Choose a Full Rewrite for applications with fewer than 10 screens or heavy technical debt, Incremental Migration for medium-scale modular apps, or an Add-to-App approach for enterprise codebases.Phase 2: Core Platform Infrastructure: Configure a motion-aware background geolocation engine (flutter_background_geolocation or tracelet). Implement motion activity triggers and stationary geofences to ensure high tracking accuracy while preserving battery life.Phase 3: Migration and Interoperability: Rebuild presentation layouts using Flutter widgets and implement Clean Architecture using Riverpod or BLoC. Interface with custom native Android logic or specialized hardware using type-safe Pigeon platform channels.Phase 4: Security Verification and Release: Implement cryptographic hash chains for location data persistence and integrate Play Integrity and App Attest hardware attestation checks. Audit Info.plist usage descriptions and App Store metadata to ensure full compliance with Apple review guidelines prior to submission. diff --git a/rippr-flutter-src/docs/V3-BACKLOG.md b/rippr-flutter-src/docs/V3-BACKLOG.md new file mode 100644 index 0000000..ed13aea --- /dev/null +++ b/rippr-flutter-src/docs/V3-BACKLOG.md @@ -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. diff --git a/rippr-flutter-src/docs/port/PARITY-AUDIT.md b/rippr-flutter-src/docs/port/PARITY-AUDIT.md new file mode 100644 index 0000000..4a0a610 --- /dev/null +++ b/rippr-flutter-src/docs/port/PARITY-AUDIT.md @@ -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). diff --git a/rippr-flutter-src/docs/port/PLAN.md b/rippr-flutter-src/docs/port/PLAN.md new file mode 100644 index 0000000..c76c7bc --- /dev/null +++ b/rippr-flutter-src/docs/port/PLAN.md @@ -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(UNLIMITED) ──► StreamController (unbounded) + single writer coroutine ──► single async drain loop (batch 25 / 2s) + Mutex ──► package:synchronized Lock + Room + @Transaction ──► Drift + transaction() + Flow ──► Stream (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 +flutter test integration_test -d +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*. diff --git a/rippr-flutter-src/docs/port/PROGRESS.md b/rippr-flutter-src/docs/port/PROGRESS.md new file mode 100644 index 0000000..d0b0132 --- /dev/null +++ b/rippr-flutter-src/docs/port/PROGRESS.md @@ -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=` — 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 → 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). diff --git a/rippr-flutter-src/docs/port/REAL-RIDE-CHECKLIST.md b/rippr-flutter-src/docs/port/REAL-RIDE-CHECKLIST.md new file mode 100644 index 0000000..12cd847 --- /dev/null +++ b/rippr-flutter-src/docs/port/REAL-RIDE-CHECKLIST.md @@ -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. diff --git a/rippr-flutter-src/docs/port/RELEASE-IOS.md b/rippr-flutter-src/docs/port/RELEASE-IOS.md new file mode 100644 index 0000000..a231498 --- /dev/null +++ b/rippr-flutter-src/docs/port/RELEASE-IOS.md @@ -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. diff --git a/rippr-flutter-src/integration_test/app_test.dart b/rippr-flutter-src/integration_test/app_test.dart new file mode 100644 index 0000000..861784b --- /dev/null +++ b/rippr-flutter-src/integration_test/app_test.dart @@ -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 `` +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); + }); + }); +} diff --git a/rippr-flutter-src/ios/.gitignore b/rippr-flutter-src/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/rippr-flutter-src/ios/.gitignore @@ -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 diff --git a/rippr-flutter-src/ios/Flutter/AppFrameworkInfo.plist b/rippr-flutter-src/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/rippr-flutter-src/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/rippr-flutter-src/ios/Flutter/Debug.xcconfig b/rippr-flutter-src/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/rippr-flutter-src/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/rippr-flutter-src/ios/Flutter/Release.xcconfig b/rippr-flutter-src/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/rippr-flutter-src/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/rippr-flutter-src/ios/Podfile b/rippr-flutter-src/ios/Podfile new file mode 100644 index 0000000..9d7ef08 --- /dev/null +++ b/rippr-flutter-src/ios/Podfile @@ -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 diff --git a/rippr-flutter-src/ios/Podfile.lock b/rippr-flutter-src/ios/Podfile.lock new file mode 100644 index 0000000..91d3527 --- /dev/null +++ b/rippr-flutter-src/ios/Podfile.lock @@ -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 diff --git a/rippr-flutter-src/ios/Runner.xcodeproj/project.pbxproj b/rippr-flutter-src/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..7eccc46 --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 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 = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; +/* 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 = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 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 = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 638B2B069F5EDF8A38A8C039 /* Pods */, + 245628F0323E24600B3DEF53 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 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 = ""; + }; +/* 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 = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/rippr-flutter-src/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/rippr-flutter-src/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rippr-flutter-src/ios/Runner.xcworkspace/contents.xcworkspacedata b/rippr-flutter-src/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/rippr-flutter-src/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/rippr-flutter-src/ios/Runner/AppDelegate.swift b/rippr-flutter-src/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/rippr-flutter-src/ios/Runner/AppDelegate.swift @@ -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) + } +} diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" + } +} diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -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" + } +} diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -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. \ No newline at end of file diff --git a/rippr-flutter-src/ios/Runner/Base.lproj/LaunchScreen.storyboard b/rippr-flutter-src/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rippr-flutter-src/ios/Runner/Base.lproj/Main.storyboard b/rippr-flutter-src/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rippr-flutter-src/ios/Runner/Info.plist b/rippr-flutter-src/ios/Runner/Info.plist new file mode 100644 index 0000000..077970f --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Info.plist @@ -0,0 +1,90 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Rippr + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + rippr + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + NSLocationWhenInUseUsageDescription + 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. + NSLocationAlwaysAndWhenInUseUsageDescription + 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. + + + UIBackgroundModes + + location + + + diff --git a/rippr-flutter-src/ios/Runner/Runner-Bridging-Header.h b/rippr-flutter-src/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/rippr-flutter-src/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/rippr-flutter-src/ios/Runner/SceneDelegate.swift b/rippr-flutter-src/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/rippr-flutter-src/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/rippr-flutter-src/ios/RunnerTests/RunnerTests.swift b/rippr-flutter-src/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/rippr-flutter-src/ios/RunnerTests/RunnerTests.swift @@ -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. + } + +} diff --git a/rippr-flutter-src/pubspec.lock b/rippr-flutter-src/pubspec.lock new file mode 100644 index 0000000..e05d8c9 --- /dev/null +++ b/rippr-flutter-src/pubspec.lock @@ -0,0 +1,1225 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "1b0e6a07425a3e460666e88bf1c949ccc7bb0116ad562ce94a1eca60fe820725" + url: "https://pub.dev" + source: hosted + version: "103.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "61c04d0c1bfed555c681ea079519933f071a5a026578ff73c4ff0df2d3462e5e" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: b94f5da9ed3d081fd4ecc426c260998b5f4f4eb2f6d89b7e5472edef0b2b2a1b + url: "https://pub.dev" + source: hosted + version: "4.0.10" + build_config: + dependency: transitive + description: + name: build_config + sha256: "94eaf6708fe64408c632ef2689ca3777b112f9421306ccf4f8c84d7c5c9f83f8" + url: "https://pub.dev" + source: hosted + version: "1.3.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" + url: "https://pub.dev" + source: hosted + version: "4.1.5" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "90363d438bd9f84a4d5bbb83352251f57d2d9d771bc95a44e6a33fe25fa52774" + url: "https://pub.dev" + source: hosted + version: "2.16.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" + url: "https://pub.dev" + source: hosted + version: "8.12.7" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a + url: "https://pub.dev" + source: hosted + version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: "71e43f1976c3eae2d9468a5b4d6600bf8cae61508b87f27fa1799228935d48ab" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "3f88fc9c96c568d631356507355a2d1e983ee000c9ac008bdcb39b5bf53ce777" + url: "https://pub.dev" + source: hosted + version: "3.1.12" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.dev" + source: hosted + version: "0.7.14" + drift: + dependency: "direct main" + description: + name: drift + sha256: "3a3f1f6f905037d7426e4c445854139fd6a3d592135f7c96d7931682b73d16f4" + url: "https://pub.dev" + source: hosted + version: "2.34.3" + drift_dev: + dependency: "direct dev" + description: + name: drift_dev + sha256: "735aad3c34215805c66bd518c8812fa4f83b5534b2c13c4092c970c04a7e9983" + url: "https://pub.dev" + source: hosted + version: "2.34.5" + drift_flutter: + dependency: "direct main" + description: + name: drift_flutter + sha256: "91acf4bee7c3c84467cba46455aa70e5292a3b889f4582645d74f2e5a8c106f2" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: ce1debbb29cade61964334b2a19c7c76e5bb2ef7dacf126c6424ee44036232f8 + url: "https://pub.dev" + source: hosted + version: "8.3.1" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f + url: "https://pub.dev" + source: hosted + version: "14.0.3" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" + url: "https://pub.dev" + source: hosted + version: "2.3.14" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" + url: "https://pub.dev" + source: hosted + version: "0.2.6" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 + url: "https://pub.dev" + source: hosted + version: "4.2.8" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: "5e1bf53cc7baa8062a33b84424deb61513858ea05c601b8509e683815b5914aa" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.dev" + source: hosted + version: "0.19.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" + url: "https://pub.dev" + source: hosted + version: "10.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: e7317eb2eb611d1bf0386b6b974be9c50449f975f19a90a7b8ea013550302b30 + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d7676c6fcf2f0b92537ec41476a6ead45a00b0d8bbb852395a6f9f33f49d6242 + url: "https://pub.dev" + source: hosted + version: "14.0.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + recase: + dependency: transitive + description: + name: recase + sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213 + url: "https://pub.dev" + source: hosted + version: "4.1.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "34f00f9becd2743c1fb05363d624f9f70d37f7ccdcdda47450bc0b8c9d327b8c" + url: "https://pub.dev" + source: hosted + version: "13.3.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.dev" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: a603f1fb984a7391ae5978d1b92bfaaa08b350dca5c825256f925818f7943bf5 + url: "https://pub.dev" + source: hosted + version: "4.2.4" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlcipher_flutter_libs: + dependency: transitive + description: + name: sqlcipher_flutter_libs + sha256: "38d62d659d2fb8739bf25a42c9a350d1fdd6c29a5a61f13a946778ec75d27929" + url: "https://pub.dev" + source: hosted + version: "0.7.0+eol" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "64b2c63c8232dd20d14b34105a81ebfd74320442e8451f836179ec89986aa478" + url: "https://pub.dev" + source: hosted + version: "3.5.1" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + sqlparser: + dependency: transitive + description: + name: sqlparser + sha256: "772bb2f6f5bce0631a60f26b57d6e8882e1105c22345b05c163ee6ee5d7ba32d" + url: "https://pub.dev" + source: hosted + version: "0.45.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + synchronized: + dependency: "direct main" + description: + name: synchronized + sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" + url: "https://pub.dev" + source: hosted + version: "3.4.1+1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + win32: + dependency: transitive + description: + name: win32 + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" + source: hosted + version: "6.4.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: "direct dev" + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/rippr-flutter-src/pubspec.yaml b/rippr-flutter-src/pubspec.yaml new file mode 100644 index 0000000..c480fcb --- /dev/null +++ b/rippr-flutter-src/pubspec.yaml @@ -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 diff --git a/rippr-flutter-src/test/database_test.dart b/rippr-flutter-src/test/database_test.dart new file mode 100644 index 0000000..8304605 --- /dev/null +++ b/rippr-flutter-src/test/database_test.dart @@ -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); + }); + }); +} diff --git a/rippr-flutter-src/test/geo_test.dart b/rippr-flutter-src/test/geo_test.dart new file mode 100644 index 0000000..46bb975 --- /dev/null +++ b/rippr-flutter-src/test/geo_test.dart @@ -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 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 [], 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 []), 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 []), closeTo(0.0, 1e-9)); + expect(pathLengthMeters(const [calgary]), closeTo(0.0, 1e-9)); + }); + }); +} diff --git a/rippr-flutter-src/test/recording_engine_test.dart b/rippr-flutter-src/test/recording_engine_test.dart new file mode 100644 index 0000000..19bddeb --- /dev/null +++ b/rippr-flutter-src/test/recording_engine_test.dart @@ -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 settle() => Future.delayed(Duration.zero); + + /// Emits [n] fixes a second apart, moving ~11 m north each time. + Future 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 = []; + final sub = engine.stateStream.listen(seen.add); + + await engine.start(); + await engine.pause(); + await engine.start(); + await engine.stop(); + await Future.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); + }); + }); +} diff --git a/rippr-flutter-src/test/ride_accumulator_test.dart b/rippr-flutter-src/test/ride_accumulator_test.dart new file mode 100644 index 0000000..b9e402f --- /dev/null +++ b/rippr-flutter-src/test/ride_accumulator_test.dart @@ -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 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)); + }); +} diff --git a/rippr-flutter-src/test/ride_export_test.dart b/rippr-flutter-src/test/ride_export_test.dart new file mode 100644 index 0000000..061f616 --- /dev/null +++ b/rippr-flutter-src/test/ride_export_test.dart @@ -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 segments, List 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 = [ + 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 "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; + final coords = ((((json['features'] as List).first + as Map)['geometry'] + as Map)['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; + final features = json['features'] as List; + + expect(features.length, 3); + expect( + ((features.first as Map)['geometry'] + as Map)['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; + var exported = 0; + for (final f in json['features'] as List) { + exported += (((f as Map)['geometry'] + as Map)['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; + + expect((json['properties'] as Map)['name'], + r'He said "go" \ fast'); + }); + + test('geojson of an empty ride parses', () { + final json = jsonDecode(geoJson(trip, const [], const [], 'Empty')) + as Map; + expect((json['features'] as List).length, 0); + }); + }); +} diff --git a/rippr-flutter-src/test/ride_map_test.dart b/rippr-flutter-src/test/ride_map_test.dart new file mode 100644 index 0000000..2ff907f --- /dev/null +++ b/rippr-flutter-src/test/ride_map_test.dart @@ -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> render( + WidgetTester tester, { + required List points, + required List segments, + }) async { + await tester.pumpWidget(MaterialApp( + theme: ripprTheme(), + home: Scaffold(body: RideMap(points: points, segments: segments)), + )); + await tester.pump(); + final layers = tester + .widgetList(find.byType(PolylineLayer)) + .toList(); + return layers.isEmpty ? const [] : layers.first.polylines.cast(); + } + + 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(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(find.byType(FlutterMap)); + expect(map.options.initialZoom, shortRideZoom); + expect(map.options.maxZoom, maxTileZoom, + reason: 'exceeding OSM max tile zoom renders an empty grid'); + }); +} diff --git a/rippr-flutter-src/test/ride_statistics_test.dart b/rippr-flutter-src/test/ride_statistics_test.dart new file mode 100644 index 0000000..3fba41e --- /dev/null +++ b/rippr-flutter-src/test/ride_statistics_test.dart @@ -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 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); + }); + }); +} diff --git a/rippr-flutter-src/test/telemetry_test.dart b/rippr-flutter-src/test/telemetry_test.dart new file mode 100644 index 0000000..5154a3c --- /dev/null +++ b/rippr-flutter-src/test/telemetry_test.dart @@ -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; + + expect(json['device_id'], 'device-abc'); + final first = (json['points'] as List).first as Map; + 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)['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; + expect((json['points'] as List).length, 0); + }); +} diff --git a/rippr-flutter-src/test/telemetry_uploader_test.dart b/rippr-flutter-src/test/telemetry_uploader_test.dart new file mode 100644 index 0000000..b877371 --- /dev/null +++ b/rippr-flutter-src/test/telemetry_uploader_test.dart @@ -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 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()); + 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()); + 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()); + 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()); + 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; + expect(json['device_id'], 'device-abc'); + final first = (json['points'] as List).first as Map; + // 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()); + expect((result as UploadPartial).uploaded, uploadBatchSize); + expect(await db.countUnsynced(), 10, + reason: 'the first batch landed; the rest must survive for retry'); + }); +} diff --git a/rippr-flutter-src/test/trip_repository_test.dart b/rippr-flutter-src/test/trip_repository_test.dart new file mode 100644 index 0000000..727c69f --- /dev/null +++ b/rippr-flutter-src/test/trip_repository_test.dart @@ -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 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 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); + }); + }); +} diff --git a/rippr-flutter-src/test/widget_test.dart b/rippr-flutter-src/test/widget_test.dart new file mode 100644 index 0000000..5c532b2 --- /dev/null +++ b/rippr-flutter-src/test/widget_test.dart @@ -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 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 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 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( + 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(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(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(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'); + }); + }); +} diff --git a/rippr-flutter-src/tool/parity/main.kt b/rippr-flutter-src/tool/parity/main.kt new file mode 100644 index 0000000..bfd6eed --- /dev/null +++ b/rippr-flutter-src/tool/parity/main.kt @@ -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 ") + val j = RideExport.geoJson(etrip, esegs, epts, "Sam & Dave's ") + 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) +} diff --git a/rippr-flutter-src/tool/parity/probe.dart b/rippr-flutter-src/tool/parity/probe.dart new file mode 100644 index 0000000..9f83d05 --- /dev/null +++ b/rippr-flutter-src/tool/parity/probe.dart @@ -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 = [ + 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 "); + final j = geoJson(etrip, esegs, epts, "Sam & Dave's "); + out('gpx_len', g.length); + out('gpx_fnv', fnv1a(g)); + out('geojson_len', j.length); + out('geojson_fnv', fnv1a(j)); +} diff --git a/rippr-flutter-src/tool/parity/run.sh b/rippr-flutter-src/tool/parity/run.sh new file mode 100755 index 0000000..b744f23 --- /dev/null +++ b/rippr-flutter-src/tool/parity/run.sh @@ -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 diff --git a/rippr-flutter-src/tool/parity/stubs.kt b/rippr-flutter-src/tool/parity/stubs.kt new file mode 100644 index 0000000..8e66a39 --- /dev/null +++ b/rippr-flutter-src/tool/parity/stubs.kt @@ -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, +) diff --git a/rippr-flutter-src/tool/parity/telemetry_stub.kt b/rippr-flutter-src/tool/parity/telemetry_stub.kt new file mode 100644 index 0000000..5a83374 --- /dev/null +++ b/rippr-flutter-src/tool/parity/telemetry_stub.kt @@ -0,0 +1,2 @@ +package com.rippr +object Telemetry { const val SPEED_NOISE_FLOOR_KMH = 1.5f } diff --git a/rippr-src/docs/PORT_RESEARCH.md b/rippr-src/docs/PORT_RESEARCH.md new file mode 100644 index 0000000..fe328f8 --- /dev/null +++ b/rippr-src/docs/PORT_RESEARCH.md @@ -0,0 +1,75 @@ +Strategic Architecture Report: Cross-Platform Migration of Android Telemetry and GPS Applications to FlutterMigration Strategy Framework and Architectural AssessmentMigrating a native Android application to Flutter requires a structured evaluation of existing technical debt, team velocity, and long-term product maintainability. Maintaining dual codebases across Android and iOS doubles engineering effort, duplicates feature implementations, and frequently leads to asynchronous release schedules. Transitioning to a unified Flutter codebase enables engineering teams to consolidate development into a single Dart source while achieving near-native performance through Ahead-Of-Time (AOT) compilation to ARM machine code.Migration Strategy Selection FrameworkSelecting the appropriate migration strategy depends on application complexity, structural health, and business continuity requirements.Migration StrategyTargeted Application ScaleTypical TimelineArchitectural ImpactRisk ProfileFull RewriteUnder 10 screens; high legacy technical debt3–6 MonthsComplete clean slate; unified Clean Architecture across platformsHigh initial investment; temporarily pauses new feature releasesIncremental Migration10 to 50 screens; modular feature sets6–12 MonthsGradual screen-by-screen replacement; hybrid execution modelDual-codebase friction; IPC synchronization overhead during transitionAdd-to-App (Hybrid)50+ screens; enterprise scaleOngoing / PhasedFlutter modules embedded via FlutterActivity or FlutterViewController[cite: 1]Memory footprint expansion; complex asset and navigation lifecycle managementFor legacy applications burdened by technical debt, outdated third-party libraries, or unmaintainable UI patterns, a full rewrite provides a clean start. It eliminates historical cross-platform divergence and facilitates modern software practices from day one.Conversely, enterprise applications with extensive screen counts benefit from an Add-to-App strategy, embedding Flutter views using cached FlutterEngine instances (FlutterEngineCache) to minimize initial execution warm-up latency and memory allocation overhead.The decision process balances app size against architectural technical debt:Applications with fewer than 10 screens or those undergoing significant rebranding, design system updates, or mergers are optimal candidates for a complete rebuild.Medium-complexity applications containing 10 to 50 screens benefit from incremental migration, where low-risk modules—such as user onboarding or settings dashboards—are replaced first to build team fluency before tackling business-critical features.Enterprise platforms exceeding 50 screens require an Add-to-App pattern to maintain operational continuity, embedding Flutter submodules directly into host view controllers.Translating Native Android Architecture to DartTranslating native Android paradigms (Java/Kotlin) to Dart is straightforward due to object-oriented similarities in class inheritance, abstract interfaces, and mixins. However, structural application components must be systematically mapped to fit the reactive Flutter lifecycle:Dependency Injection: Android dependency frameworks like Dagger or Hilt map to Dart service locators such as get_it paired with injectable for compile-time dependency injection. This abstracts platform context lifecycles from core business objects.State Management: Legacy Android ViewModel and LiveData patterns translate cleanly to event-driven architectures such as BLoC (Business Logic Component) or reactive providers like Riverpod. Riverpod offers compile-safe dependency overrides and state caching, isolating business rules from UI re-renders.Navigation: Imperative Android navigation controllers map to declarative routing frameworks such as go_router. Declarative routing supports deep-linking, route guards, and stateful nested navigation required for cross-platform visual parity.Layered Architecture: Implementing Clean Architecture separates data layers (repositories, local SQLite caches, HTTP clients) from domain logic (use cases) and presentation layers (Flutter widgets). Repositories abstract platform-specific differences, exposing unified interfaces to presentation widgets.Cross-Platform Location Infrastructure and Hardware TelemetryPorting an application centered on continuous GPS tracking requires reconciling fundamental differences between Android and iOS background execution models. While Android relies on persistent foreground services linked to visible notifications, iOS enforces strict process suspensions to maximize energy efficiency.Platform Execution Divergence: Android 14 vs. iOS CoreLocationEngineering a continuous, high-precision telemetry engine across mobile OS environments requires handling opposing platform constraints:Android 14 (API Level 34+) Background Execution: Android 14 enforces explicit foreground service type declarations (android:foregroundServiceType="location") bound to a non-dismissible persistent user notification. Launching background location services without these declarations results in immediate OS process termination. Furthermore, background tracking requires explicit runtime approvals for ACCESS_FINE_LOCATION and ACCESS_BACKGROUND_LOCATION, as well as configuration adjustments to handle manufacturer-specific battery optimization engines.iOS CoreLocation Process Lifecycle: iOS restricts long-running background tasks. When a device becomes stationary, the OS transitions the application into a stationary state and completely suspends process execution. In this state, Dart execution pauses. CoreLocation establishes a hardware-monitored "stationary geofence" (typically $\sim 200\text{ meters}$). Only when hardware sensors register a boundary breach does iOS wake the application process, re-engage location services, and resume active tracking based on the configured distanceFilter.Geolocation Plugin Ecosystem ComparisonChoosing the appropriate geolocation plugin determines whether the migrated application achieves enterprise-grade tracking reliability or suffers from frequent process termination and excessive battery drain.Feature / CapabilityThin Wrappers (geolocator, location)flutter_background_geolocation (Transistor)tracelet (Federated Engine)Primary ArchitectureDirect channel wrappers around basic platform location APIsNative C/Kotlin/Swift engine with battery intelligenceFederated architecture with independent native SDKsMotion-Detection IntelligenceNone; relies on unfiltered system location updatesTri-axial sensor fusion (accelerometer, gyro, magnetometer)Accelerometer-based motion activity classificationBattery OptimizationHigh consumption; continuous GPS chip engagementAutomated state toggling between Moving and Stationary modesDynamic Battery Budget Engine targeting fixed %/hr drainGeofencing EngineOS system-level limits (maximum 20 regions)Enterprise geofencing engine with polygon supportUnlimited hardware-abstracted polygon geofencesData Integrity & SecurityManual custom implementation requiredSQLite persistence with automatic HTTP server syncSHA-256 hash chains, attestation, 3-level spoof detectionLicensing ModelOpen Source (MIT)Commercial license ($500+/yr per app for release)Open SourceHardware Motion Detection and Adaptive Sampling LogicTo minimize battery consumption during continuous background tracking, modern mobile telemetry engines rely on hardware motion activity sensors. Rather than keeping the power-hungry GPS receiver continuously energized, the system utilizes low-power hardware accelerometers (~0.5mW) to determine movement states.When the system enters a stationary state, location services are disabled, and a stationary geofence is established. The system initiates a stop-detection timer (stopTimeout). If no movement is detected before the timer expires, location hardware shuts down entirely.When motion activity sensors register physical displacement or a geofence breach occurs, the tracking engine transitions back to a moving state. It re-energizes the GPS receiver and samples fixes based on an adaptive distance filter.Advanced engines implement a Battery Budget Engine that dynamically adjusts sensor sampling frequencies based on real-time power consumption. If actual battery drain exceeds defined thresholds (e.g., $2\%$ per hour), the engine automatically increases the distanceFilter and adjusts GPS accuracy parameters down from high to medium or passive tracking modes.Low-Level Interoperability and Platform Channel EngineeringWhen existing community plugins do not cover custom native Android logic—such as proprietary enterprise SDKs or specialized telemetry hardware—developers must construct custom platform interoperation bridges.Platform Communication PatternsFlutter offers three primary channel mechanisms for communicating between the Dart execution layer and host platform code:MethodChannel: Functions as an asynchronous Remote Procedure Call (RPC) mechanism. It transmits single method requests and returns discrete responses, making it ideal for standard configuration calls, permission checks, or triggering one-time hardware tasks.EventChannel: Designed specifically for continuous data streaming. Host platform event listeners emit continuous data streams—such as live accelerometer readings, barometer changes, or real-time GPS location updates—directly into Dart Stream instances.BasicMessageChannel: Handles unstructured, lightweight message passing using customizable binary, string, or JSON codecs. It provides raw message passing capabilities when method-call semantics are not required.Data flows seamlessly across these channels: Dart presentation widgets invoke requests through state controllers, which route through typed IPC interfaces. The underlying BinaryMessenger serializes messages using the StandardMessageCodec across the platform boundary to host native implementations (Kotlin/Swift).Type-Safe Interoperability via PigeonStandard string-based MethodChannel implementations carry runtime risks, such as typos in channel names or message payloads failing silent deserialization. Flutter Pigeon addresses this by generating compile-time type-safe code interfaces for Dart, Kotlin, and Swift from a single contract definition.Contract Definition (pigeons/location_api.dart)Dartimport 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/location_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/com/example/app/LocationApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.example.app'), + swiftOut: 'ios/Runner/LocationApi.g.swift', +)) + +class LocationDataPayload { + double? latitude; + double? longitude; + double? accuracy; + double? altitude; + double? speed; + int? timestamp; +} + +@HostApi() +abstract class NativeLocationHostApi { + void startTrackingService(); + void stopTrackingService(); + @async + LocationDataPayload getCurrentSingleFix(); +} + +@FlutterApi() +abstract class NativeLocationFlutterApi { + void onLocationUpdated(LocationDataPayload location); +} +Executing Code GenerationExecuting the code generator compiles host bindings directly into the respective platform directories:Bashdart run pigeon --input pigeons/location_api.dart +Native Host Implementation (Kotlin - Android)Kotlinpackage com.example.app + +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.BinaryMessenger + +class LocationApiImpl(private val messenger: BinaryMessenger) : NativeLocationHostApi { + override fun startTrackingService() { + // Implementation for starting Android Foreground Service + } + + override fun stopTrackingService() { + // Implementation for stopping Foreground Service + } + + override fun getCurrentSingleFix(callback: (Result) -> Unit) { + val payload = LocationDataPayload( + latitude = 37.7749, + longitude = -122.4194, + accuracy = 5.0, + altitude = 10.0, + speed = 1.2, + timestamp = System.currentTimeMillis() + ) + callback(Result.success(payload)) + } +} +Quality Assurance, Performance Profiling, and Security ComplianceTransitioning mission-critical telemetry applications requires rigorous validation of battery consumption, offline resilience, and location data integrity.Cryptographic Audit Trails and Spoof PreventionIn commercial telemetry applications, location data often serves as legal proof of presence or regulatory compliance. Securing location payloads against tampering, device root modifications, or mock GPS applications requires a defense-in-depth security design:Cryptographic Hash Chains: Every persisted location point is appended to an immutable SHA-256 hash chain stored in local SQLite databases: +$$\text{hash}_n = \text{SHA-256}(\text{hash}_{n-1} + \text{canonical}(\text{location}_n))$$ +The genesis hash ($\text{hash}_0$) is bound to hardware-derived metrics (Build.FINGERPRINT on Android or identifierForVendor on iOS). Any manual modification, insertion, or deletion of historical database records breaks the checksum chain.Multi-Tiered Mock Location Detection:Level 1 (System): Checking platform API flags (isMock()).Level 2 (Heuristic): Analyzing satellite signal noise ratios, elapsed real-time clock drift against monotonic hardware clocks, and detecting unnatural velocity jumps.Level 3 (Hardware Attestation): Utilizing Google Play Integrity API tokens on Android and Apple App Attest on iOS to verify binary authenticity and hardware integrity before accepting location fixes.Offline Persistence and Delta Encoding: Network loss must not result in telemetry gaps. Location fixes are buffered locally in SQLite queues. Upon re-establishing connectivity, batched payloads undergo delta encoding—compressing byte streams by up to $80\%$ by transmitting raw values for initial fixes followed solely by differential updates.Testing Methodology and Continuous IntegrationEnsuring production reliability across mobile platforms involves a multi-tiered testing methodology:Unit Testing: Validates business logic, state providers, and repository layers using Dart unit tests and mocking frameworks like mockito or mocktail. Target code coverage should meet or exceed 80% on domain and data layers.Widget Testing: Verifies UI component rendering, user interactions, layout responsiveness, and state updates in isolation without launching a physical device.Integration Testing: Executes end-to-end user flows on physical hardware to verify native channel execution, location permission prompts, and background process resumes.CI/CD Pipeline Integration: Deployment services like Codemagic or GitHub Actions automate build verification, code shrinking via Android R8, upload key signing, and deployment to test tracks on Google Play Console and Apple App Store Connect.Store Compliance and Review Navigational GuidelinesDeploying background location applications to the Apple App Store involves stricter guidelines than Google Play. Unclear privacy disclosures, missing permission keys, or improper background configuration represent primary causes for App Store rejections.App Store Review Guidelines Compliance MatrixGuideline SectionCore Policy RequirementPrimary Rejection CauseTechnical MitigationGuideline 5.1.1 (Data Privacy & Transparency)Explicit justifications for sensitive permissions; accurate privacy labels.Generic usage strings (e.g., "App needs location").Provide explicit, context-rich Info.plist usage descriptions.Guideline 2.1 (App Completeness & Performance)System stability; zero crashes on launch or background resume.Background location fixes crashing while process is suspended.Implement robust error boundaries, graceful headless handling, and offline fallback queues.Guideline 4.2 (Minimum Functionality)Must provide native utility beyond a packaged website.Application feels like a wrapped web view.Utilize Flutter native UI widgets, smooth animations, and rich background interaction flows.App Privacy Nutrition LabelsDeclared practices must match runtime data behavior.Undisclosed background location tracking in third-party SDKs.Audit all third-party SDK dependencies for undisclosed data collection.iOS Permission Strings Configuration (Info.plist)To comply with Apple Guideline 5.1.1, applications utilizing background geolocation must explicitly define usage strings in ios/Runner/Info.plist:XMLNSLocationWhenInUseUsageDescription +This application tracks your route during active delivery tasks to provide real-time status updates to consumers. + +NSLocationAlwaysAndWhenInUseUsageDescription +Background location access is required to maintain fleet telemetry and log mileage automatically while driving, even when the application is closed. + +NSLocationAlwaysUsageDescription +Background location access is required to deliver real-time arrival alerts while driving. + +UIBackgroundModes + + location + fetch + processing + +Synthesis and Strategic RecommendationsSuccessfully porting a native Android GPS tracking application to Flutter requires balancing cross-platform development efficiency against platform-specific hardware constraints. Addressing background lifecycle differences early in the migration process is critical to long-term success.Engineering teams should structure their migration roadmap across four distinct phases:Phase 1: Assessment and Strategy Selection: Conduct a feature inventory and audit existing code complexity. Choose a Full Rewrite for applications with fewer than 10 screens or heavy technical debt, Incremental Migration for medium-scale modular apps, or an Add-to-App approach for enterprise codebases.Phase 2: Core Platform Infrastructure: Configure a motion-aware background geolocation engine (flutter_background_geolocation or tracelet). Implement motion activity triggers and stationary geofences to ensure high tracking accuracy while preserving battery life.Phase 3: Migration and Interoperability: Rebuild presentation layouts using Flutter widgets and implement Clean Architecture using Riverpod or BLoC. Interface with custom native Android logic or specialized hardware using type-safe Pigeon platform channels.Phase 4: Security Verification and Release: Implement cryptographic hash chains for location data persistence and integrate Play Integrity and App Attest hardware attestation checks. Audit Info.plist usage descriptions and App Store metadata to ensure full compliance with Apple review guidelines prior to submission.