Adds ambientPositionProvider (never calls LocationSource.stop(), only the idempotent start()) and wires it into ShellScaffold whenever no trip is active, so the persistent background map centers and follows the device's live GPS position at street-level zoom instead of sitting at (0,0)/zoom 2 until a recording starts. RideMap gains an ambientPosition param that the existing chase-camera/pan-cancel/location-marker logic falls back to whenever there are no recorded points, with recorded points always taking priority. Also brings in the docs/feedback ticket set (FB-01..FB-05, README, FEEDBACK.md) that this worktree's branch point predated. Adds 10 tests (374 -> 384): RideMap-level ambient centering/chase/pan-cancel/ marker coverage in test/ride_map_test.dart, plus shell-wiring and provider-level (never-calls-stop, mapEnabledProvider-off) coverage in test/widget_test.dart. flutter analyze remains clean.
309 lines
13 KiB
Dart
309 lines
13 KiB
Dart
/// 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<AppDatabase>((ref) {
|
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
|
final db = AppDatabase(driftDatabase(name: 'rippr_db'));
|
|
ref.onDispose(db.close);
|
|
return db;
|
|
});
|
|
|
|
final tripRepositoryProvider = Provider<TripRepository>(
|
|
(ref) => TripRepository(ref.watch(databaseProvider)),
|
|
);
|
|
|
|
/// Overridden in tests with [FakeLocationSource].
|
|
final locationSourceProvider = Provider<LocationSource>((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<LocationFix?>((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<LocationFix?>((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<Config?>((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<TelemetryUploader?>((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<RecordingEngine>((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<LiveTelemetry>(
|
|
(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<Trip?>(
|
|
(ref) => ref.watch(tripRepositoryProvider).watchActiveTrip(),
|
|
);
|
|
|
|
final completedTripsProvider = StreamProvider<List<Trip>>(
|
|
(ref) => ref.watch(tripRepositoryProvider).watchCompletedTrips(),
|
|
);
|
|
|
|
final recorderStateProvider = StreamProvider<RecorderState>((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<bool>(
|
|
(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<UnitSystem>(
|
|
(ref) => ref.watch(configProvider)?.unitSystem ?? UnitSystem.metric,
|
|
);
|
|
|
|
/// V3-05: same seeded-then-directly-written shape as [mapEnabledProvider].
|
|
final mountedModeProvider = StateProvider<bool>(
|
|
(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<HudLayoutController, Map<HudMetric, HudWidgetLayout>>(
|
|
(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<bool>(
|
|
(ref) => ref.watch(configProvider)?.crashReportingEnabled ?? false,
|
|
);
|
|
|
|
/// Overridden in tests with [FakeWakelockController].
|
|
final wakelockControllerProvider = Provider<WakelockController>(
|
|
(ref) => PlusWakelockController(),
|
|
);
|
|
|
|
/// Overridden in tests with [FakeRideNotificationController].
|
|
final rideNotificationControllerProvider = Provider<RideNotificationController>(
|
|
(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<RideNotificationCoordinator>((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<List<TrackPoint>, int>(
|
|
(ref, tripId) => ref.watch(databaseProvider).watchPointsForTrip(tripId),
|
|
);
|
|
|
|
final liveSegmentsProvider = StreamProvider.autoDispose.family<List<Segment>, int>(
|
|
(ref, tripId) => ref.watch(databaseProvider).watchSegmentsForTrip(tripId),
|
|
);
|
|
|
|
// --- Route plans (V3-07) -----------------------------------------------------
|
|
|
|
final routePlanRepositoryProvider = Provider<RoutePlanRepository>(
|
|
(ref) => RoutePlanRepository(ref.watch(databaseProvider)),
|
|
);
|
|
|
|
final routePlansProvider = StreamProvider<List<RoutePlan>>(
|
|
(ref) => ref.watch(routePlanRepositoryProvider).watchRoutePlans(),
|
|
);
|
|
|
|
final routePlanProvider = StreamProvider.autoDispose.family<RoutePlan?, int>(
|
|
(ref, id) => ref.watch(routePlanRepositoryProvider).watchRoutePlan(id),
|
|
);
|
|
|
|
final routeWaypointsProvider = StreamProvider.autoDispose.family<List<Waypoint>, 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<TileCache>((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<CachedTileProvider?>((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<int>((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<MapConnectivityState>((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;
|
|
}
|
|
},
|
|
);
|
|
});
|