/// Composition root. /// /// The one place that decides which concrete implementations the app runs with. Tests /// override these rather than reaching for singletons — the mistake the native app made /// with `AppDatabase.getDatabase()` and `TripRepository.get()`, which is how an /// instrumented test once wiped a real device's rides. library; import 'dart:io'; import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift_flutter/drift_flutter.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import '../config/config.dart'; import '../crash/crash_reporter.dart'; import '../data/database.dart'; import '../data/route_plan_repository.dart'; import '../data/trip_repository.dart'; import '../hud/hud_layout_controller.dart'; import '../hud/hud_metric.dart'; import '../hud/hud_widget_layout.dart'; import '../domain/models.dart'; import '../notification/ride_notification_controller.dart'; import '../notification/ride_notification_coordinator.dart'; import '../recording/geolocator_location_source.dart'; import '../recording/location_source.dart'; import '../recording/recording_engine.dart'; import '../recording/wakelock_controller.dart'; import '../telemetry/live_telemetry.dart'; import '../telemetry/telemetry_uploader.dart'; import '../tiles/cached_tile_provider.dart'; import '../tiles/map_connectivity.dart'; import '../tiles/tile_cache.dart'; import '../tiles/tile_config.dart'; /// The Drift database, opened against app-private storage. /// /// Overridden in tests with an in-memory instance. final databaseProvider = Provider((ref) { driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; final db = AppDatabase(driftDatabase(name: 'rippr_db')); ref.onDispose(db.close); return db; }); final tripRepositoryProvider = Provider( (ref) => TripRepository(ref.watch(databaseProvider)), ); /// Overridden in tests with [FakeLocationSource]. final locationSourceProvider = Provider((ref) { final source = GeolocatorLocationSource(); ref.onDispose(source.dispose); return source; }); /// The device's current position when nothing is being recorded — drives the shared /// background map's "look like Google Maps while idle" behavior. Deliberately NOT /// gated through `recordingEngineProvider`/`RecordingEngine.start()` — this must work /// whether or not a ride is ever recorded. Never calls `LocationSource.stop()`: the /// underlying `locationSourceProvider` instance is shared with the recording engine, /// and `stop()` is not reference-counted (see FB-01's ticket for why). final ambientPositionProvider = StreamProvider.autoDispose((ref) async* { if (!ref.watch(mapEnabledProvider)) { yield null; return; } final source = ref.watch(locationSourceProvider); try { await source.start(); // idempotent; safe even if a recording already started it } on LocationException { yield null; // permission denied / service disabled — ambient mode is best-effort return; } yield* source.fixes.map((fix) => fix); // No `stop()` call, ever, on dispose — see the doc comment above. Only this // provider's own subscription to the broadcast `fixes` stream ends; the shared // platform subscription is left exactly as it was. }); /// Loaded once at startup; null until then so nothing blocks the first frame. final configProvider = StateProvider((ref) => null); /// The uploader, or null when no endpoint is configured. /// /// Parity note: there is still **no UI** for setting the endpoint, exactly as in the /// native app. It is reachable only through `Config.setUploadEndpoint`. final uploaderProvider = Provider((ref) { final config = ref.watch(configProvider); if (config == null || config.uploadEndpoint.trim().isEmpty) return null; final uploader = TelemetryUploader( db: ref.watch(databaseProvider), endpoint: config.uploadEndpoint, deviceId: config.deviceId, ); ref.onDispose(uploader.close); return uploader; }); final recordingEngineProvider = Provider((ref) { final engine = RecordingEngine( repository: ref.watch(tripRepositoryProvider), locationSource: ref.watch(locationSourceProvider), uploadPending: () async => ref.read(uploaderProvider)?.uploadPending(), onUnexpectedStop: (reason) => reportUnexpectedRecordingStop(reason: reason), ); ref.onDispose(engine.dispose); return engine; }); /// The live speedo feed, published from the fix callback at GPS rate. final liveTelemetryProvider = Provider( (ref) => LiveTelemetry.instance, ); // --- Reactive state --------------------------------------------------------- /// The in-progress ride, straight from the database. /// /// Recording state is derived, never held as a flag — that is what makes it survive /// process death. final activeTripProvider = StreamProvider( (ref) => ref.watch(tripRepositoryProvider).watchActiveTrip(), ); final completedTripsProvider = StreamProvider>( (ref) => ref.watch(tripRepositoryProvider).watchCompletedTrips(), ); final recorderStateProvider = StreamProvider((ref) { final engine = ref.watch(recordingEngineProvider); return engine.stateStream; }); /// Whether the trip-detail map renders. /// /// Kept as a toggle from v2: the map must only ever be live on a visible screen, and no /// tile is fetched while recording in the background. final mapEnabledProvider = StateProvider( (ref) => ref.watch(configProvider)?.mapEnabled ?? true, ); /// Metric or imperial. Same shape as [mapEnabledProvider]: seeded from [Config] once, /// then read and written directly by the UI so a change is visible immediately without /// waiting on [configProvider]'s identity to change (it never does — `Config` mutates /// its own backing preferences in place, so re-assigning the same instance would not /// notify anything watching it). final unitSystemProvider = StateProvider( (ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric, ); /// V3-05: same seeded-then-directly-written shape as [mapEnabledProvider]. final mountedModeProvider = StateProvider( (ref) => ref.watch(configProvider)?.mountedMode ?? false, ); /// UI-04: the customizable HUD's live layout. A `StateNotifierProvider`, not a plain /// `StateProvider` like the flags above -- editing needs methods (`updatePosition`/ /// `updateSize`/`setVisible`/`persist`), not just a settable value. final hudLayoutControllerProvider = StateNotifierProvider>( (ref) => HudLayoutController(ref.watch(configProvider)), ); /// V3-12: same shape again. Note that flipping this at runtime does not retroactively /// start or stop a Sentry client already initialised at app launch -- see /// `maybeInitCrashReporting`'s doc comment on why that gate is checked once, in /// `main()`, not read reactively. final crashReportingEnabledProvider = StateProvider( (ref) => ref.watch(configProvider)?.crashReportingEnabled ?? false, ); /// Overridden in tests with [FakeWakelockController]. final wakelockControllerProvider = Provider( (ref) => PlusWakelockController(), ); /// Overridden in tests with [FakeRideNotificationController]. final rideNotificationControllerProvider = Provider( (ref) => PlusRideNotificationController(), ); /// V3-06: eager, not screen-owned -- watched once from app root ([main.dart]) purely to /// force creation, so it keeps running (and the notification keeps updating, and /// Pause/Resume keeps working) regardless of which screen is on top or whether the app is /// backgrounded at all. final rideNotificationCoordinatorProvider = Provider((ref) { final coordinator = RideNotificationCoordinator( controller: ref.watch(rideNotificationControllerProvider), engine: ref.watch(recordingEngineProvider), tripStream: ref.watch(tripRepositoryProvider).watchActiveTrip(), ); ref.onDispose(coordinator.dispose); return coordinator; }); /// Live path for the recording screen's map (V3-04). `.family` + `autoDispose` so the /// stream tears down the moment the record screen stops watching it — no leftover /// subscription ticking away once a ride ends or the map toggle goes off. final livePointsProvider = StreamProvider.autoDispose.family, int>( (ref, tripId) => ref.watch(databaseProvider).watchPointsForTrip(tripId), ); final liveSegmentsProvider = StreamProvider.autoDispose.family, int>( (ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId), ); // --- Route plans (V3-07) ----------------------------------------------------- final routePlanRepositoryProvider = Provider( (ref) => RoutePlanRepository(ref.watch(databaseProvider)), ); final routePlansProvider = StreamProvider>( (ref) => ref.watch(routePlanRepositoryProvider).watchRoutePlans(), ); final routePlanProvider = StreamProvider.autoDispose.family( (ref, id) => ref.watch(routePlanRepositoryProvider).watchRoutePlan(id), ); final routeWaypointsProvider = StreamProvider.autoDispose.family, int>( (ref, routeId) => ref.watch(routePlanRepositoryProvider).watchWaypoints(routeId), ); // --- Offline tiles (V3-11) --------------------------------------------------- /// A generous but real cap -- storage growth is explicitly not optional to bound (see /// the ticket's Risks). Not user-configurable in this pass; only whether to clear it. const int defaultTileCacheMaxBytes = 200 * 1024 * 1024; /// A [FutureProvider] rather than a plain [Provider]: the cache directory comes from /// `path_provider`, which is inherently async. Every reader (the map, the download /// screen, settings) treats "not loaded yet" as "behave as if there is no cache" rather /// than blocking on it -- see [RideMap.tileProvider] falling back to plain networked /// tiles while this resolves. final tileCacheProvider = FutureProvider((ref) async { final support = await getApplicationSupportDirectory(); final cache = FileTileCache( // UI-09: the subdirectory name doubles as the cache version. `TileKey(z, x, y)` // carries no provider-specific data, so a cache built against OSM's tan tiles and // one built against CARTO's dark tiles are indistinguishable to `FileTileCache` // despite being visually incompatible -- switching tile URLs without also changing // this path would silently serve stale, wrong-looking cached tiles under the new // dark UI. Bump this suffix again if the tile source ever changes again. directory: Directory('${support.path}/tiles/carto_dark_v1'), maxBytes: defaultTileCacheMaxBytes, ); ref.onDispose(cache.dispose); return cache; }); /// Built fresh from [tileCacheProvider] on every read rather than cached itself -- /// cheap to construct, and it must always wrap whichever [TileCache] instance is /// current. final cachedTileProviderProvider = Provider((ref) { final cache = ref.watch(tileCacheProvider).valueOrNull; if (cache == null) return null; return CachedTileProvider(cache: cache, connectivity: ref.watch(mapConnectivityProvider)); }); /// Settings' "Offline tiles" section reads this rather than [tileCacheProvider] /// directly, so the number on screen updates the moment `Clear cache` invalidates it -- /// see `SettingsScreen`. final tileCacheSizeProvider = FutureProvider((ref) async { final cache = await ref.watch(tileCacheProvider.future); return cache.sizeBytes(); }); // --- Map connectivity / skeleton mode (UI-02) -------------------------------- /// One [MapConnectivityState] shared by every map in the app -- see the class's own /// doc comment for why a single instance, rather than one per map widget, is correct /// here. A [ChangeNotifierProvider] rather than a plain value: widgets need to rebuild /// when `skeletonMode` flips, not just read it once. final mapConnectivityProvider = ChangeNotifierProvider((ref) { final client = http.Client(); ref.onDispose(client.close); // `ChangeNotifierProvider` already disposes the notifier it returns on teardown -- // no separate `ref.onDispose(state.dispose)` here, which would double-dispose it. return MapConnectivityState( // A single, deterministic tile (the whole-world zoom-0 overview, always valid for // any XYZ tile scheme) rather than whatever tile happens to be in view -- the probe // exists to answer "is the tile host reachable at all", not to speculatively refetch // the current viewport. probe: () async { try { final url = tileUrlTemplate .replaceFirst('{s}', tileSubdomains.first) .replaceFirst('{z}', '0') .replaceFirst('{x}', '0') .replaceFirst('{y}', '0') .replaceFirst('{r}', ''); final response = await client .get(Uri.parse(url), headers: {'User-Agent': tileUserAgent}) .timeout(const Duration(seconds: 5)); return response.statusCode == 200; } catch (_) { return false; } }, ); });