Verify the iPhone build locally, and scope what still needs a phone

Added integration_test/ride_simulation_test.dart, which drives a whole recording
against a simulator that is genuinely moving, then reads the stored ride back
through the provider graph -- the app is uninstalled when a run ends, so anything
worth knowing has to be reported from inside the test.

Attacked an assumption and it survived. simctl location start --speed=15
interpolates between waypoints over time, unlike Android's teleporting geo fix,
so it looked like speed might finally be testable locally. A probe captured 8
fixes, every one 0.0 m/s. The recorded ride shows the consequence plainly: 245.5m
of distance with zero moving time, because every sample sits under the 1.5 km/h
noise floor.

What that leaves verified locally: the app builds and launches, the real UI
renders, Drift opens against iOS storage, plugins register, CoreLocation
delivers fixes, distance accumulates from real movement, trips persist and list,
the detail screen and its map render, and navigation works. Also confirmed
visually that the permission dialog shows the specific usage string written for
Guideline 5.1.1.

What still needs a phone: speed, moving time, speed colouring, elevation against
correlated GPS error, battery, and above all whether iOS suspends a stationary
app mid-ride -- the item that decides whether geolocator is sufficient.

Named gap: no screenshot of the iOS trips or detail screens. Verified
functionally, but tapping the iOS simulator is not scriptable and a fresh install
re-prompts for location over the UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 09:36:52 -05:00
parent 82f07a2d68
commit 131dca3241
3 changed files with 211 additions and 0 deletions

View File

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

View File

@@ -797,3 +797,36 @@ tests the project has ever had.
**What remains is not code.** It is a rider, two phones, and
[REAL-RIDE-CHECKLIST.md](REAL-RIDE-CHECKLIST.md).
---
## iPhone verification pass
Scoped and executed: [IOS-VERIFICATION.md](IOS-VERIFICATION.md).
**An assumption was attacked and survived.** `simctl location start --speed=15`
interpolates between waypoints over time — unlike Android's teleporting `geo fix` — which
looked like it might make speed testable locally at last. A probe captured 8 fixes, every
one reporting **0.0 m/s**. So "no simulator produces velocity" holds on iOS too, and the
real-ride checklist remains mandatory.
A full ride was recorded on the simulator through the real UI:
```
trip=1 points=18 distanceM=245.5 maxSpeedKmh=0.0 movingMs=0 segments=1
```
245 metres of travel with **zero moving time** — correct behaviour given speed is pinned
at zero, and a compact illustration of exactly what local testing cannot reach.
Also confirmed visually on iOS: the record screen renders correctly, and the location
permission dialog displays the specific usage string written for Guideline 5.1.1.
`integration_test/ride_simulation_test.dart` is kept — it is a reusable local
verification, not a throwaway.
**Named gap:** no screenshot of the iOS trips list or detail screen. Both are verified
functionally, but tapping the iOS simulator is not scriptable the way `adb shell input
tap` is, and a fresh install re-prompts for location over the UI. Android's equivalents
were checked visually.

View File

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