T22/T24/T26: uploader, integration tests, and release readiness

T22 -- TelemetryUploader on package:http with MockClient standing in for
MockWebServer, tested against a real in-memory Drift database rather than a fake
DAO. Upload runs on its own timer, injected as a callback so the engine has no
opinion about HTTP and tests need no network. Config on shared_preferences with
a hand-rolled UUID v4. Still no UI for the endpoint, exactly as in the native
app. 7 tests.

T24 -- integration_test/app_test.dart, 4 tests passing on the iOS simulator.
These cover what widget tests cannot: Drift opening against real platform
storage, plugin registration, go_router driving a real Navigator, cold start.

T26 -- RELEASE-IOS.md. Usage strings are specific rather than generic, which is
the leading Guideline 5.1.1 rejection cause; privacy-label answers decided; a
pre-submission list covering the bundle-id switch back to com.rippr, the still
default app icon, a release build, and a demo video for review notes.

T25 written up as REAL-RIDE-CHECKLIST.md but outstanding by nature. Two items
decide real things: force-stopping mid-ride on Android confirms the 111 km
crash-gap bug is fixed, and parking 15 minutes mid-recording on iOS decides
whether geolocator is sufficient or the paid engine is needed.

Two prefer_initializing_formals lints suppressed with a reason: Dart forbids a
named parameter beginning with an underscore, so the suggested fix will not
compile.

178 tests passing, analyze clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 22:20:39 -05:00
parent d501528e69
commit a3bcd014c0
10 changed files with 732 additions and 4 deletions

View File

@@ -0,0 +1,61 @@
/// Ported from `com.rippr.Config`.
///
/// Runtime configuration. The upload endpoint is intentionally empty by default —
/// recording must work with no server at all, and uploading is opt-in.
library;
import 'dart:math';
import 'package:shared_preferences/shared_preferences.dart';
const _keyEndpoint = 'upload_endpoint';
const _keyDeviceId = 'device_id';
const _keyMapEnabled = 'map_enabled';
class Config {
Config(this._prefs);
final SharedPreferences _prefs;
static Future<Config> load() async =>
Config(await SharedPreferences.getInstance());
String get uploadEndpoint => _prefs.getString(_keyEndpoint) ?? '';
Future<void> setUploadEndpoint(String url) =>
_prefs.setString(_keyEndpoint, url.trim());
/// Whether trip detail renders a map at all.
///
/// When off the map widget is never created, so no tile is ever requested — a real
/// short-circuit, not a hidden view. The map only ever exists inside a visible screen's
/// lifecycle; the recorder never touches it.
bool get mapEnabled => _prefs.getBool(_keyMapEnabled) ?? true;
Future<void> setMapEnabled(bool enabled) =>
_prefs.setBool(_keyMapEnabled, enabled);
/// Stable per-install id so a server can distinguish riders in a group.
String get deviceId {
final existing = _prefs.getString(_keyDeviceId);
if (existing != null) return existing;
final generated = _randomId();
// Fire-and-forget: the value is returned immediately either way, and a lost write
// only costs a new id next launch.
_prefs.setString(_keyDeviceId, generated);
return generated;
}
/// A UUID v4, without pulling in a package for sixteen bytes.
static String _randomId() {
final rng = Random.secure();
final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
}
}