/// The seam between the recording pipeline and whatever produces GPS fixes. /// /// This exists for three reasons, in order of importance: /// /// 1. **Testability.** The pipeline is the riskiest code in the app and must be /// verifiable without a device, an emulator, or a real ride. /// 2. **Swappability.** `geolocator` is the free choice. If a real iOS ride shows it /// losing fixes when the OS suspends a stationary app, moving to /// `flutter_background_geolocation` becomes one new implementation of this interface /// rather than a rewrite of the engine. /// 3. **Platform honesty.** Android and iOS keep a process alive by genuinely different /// mechanisms. Confining that difference behind this interface keeps it out of the /// pipeline entirely. library; import 'dart:async'; /// A single platform-neutral GPS fix. /// /// Deliberately **not** `TrackPoint`: a fix has no trip or segment identity. Those ids /// are stamped on by the recorder at the moment of creation, which is what makes a pause /// safe — see the recording engine. /// /// `speedMps` is metres per second, as every platform reports it. Conversion to km/h and /// the noise-floor sanitisation happen once, in the engine, via the ported `Telemetry` /// functions. class LocationFix { const LocationFix({ required this.timestamp, required this.latitude, required this.longitude, required this.speedMps, required this.altitudeM, required this.accuracyM, required this.bearingDeg, }); final int timestamp; final double latitude; final double longitude; final double speedMps; final double altitudeM; final double accuracyM; final double bearingDeg; @override String toString() => 'LocationFix($latitude, $longitude, ${speedMps}m/s, ±${accuracyM}m)'; } /// Why location is unavailable, when it is. enum LocationFailure { /// The user denied the permission, or has not granted it yet. permissionDenied, /// Denied permanently — only a trip to system settings will fix it. permissionDeniedForever, /// Location services are switched off device-wide. serviceDisabled, } class LocationException implements Exception { const LocationException(this.failure, [this.message]); final LocationFailure failure; final String? message; @override String toString() => 'LocationException($failure${message == null ? '' : ': $message'})'; } /// A source of GPS fixes. /// /// Implementations must guarantee that [fixes] never throws mid-stream for a transient /// problem — the recorder treats a closed stream as "recording has stopped", which is a /// user-visible event. Transient errors belong in logs, not in the stream. abstract class LocationSource { /// Fixes, at roughly 1–2 Hz while recording. /// /// Nothing is emitted until [start] has been called. Stream get fixes; /// Begins delivering fixes, requesting permission if needed. /// /// Throws [LocationException] if permission or the device service is unavailable. /// On Android this is also where the foreground service is raised; on iOS it is where /// background updates are enabled. Future start(); /// Stops delivering fixes and releases whatever the platform was holding — the /// foreground service and wake lock on Android, background updates on iOS. /// /// Must be idempotent: the recorder calls it on pause, stop, and discard, and those can /// arrive in any order after a process restart. Future stop(); Future dispose(); } /// An in-memory [LocationSource] for tests. /// /// Lets the whole recording pipeline — batching, segment stamping, accumulation, /// persistence, pause boundaries — be exercised on the Dart VM with no device. class FakeLocationSource implements LocationSource { final _controller = StreamController.broadcast(); var _started = false; var _startCalls = 0; var _stopCalls = 0; bool get isRunning => _started; int get startCalls => _startCalls; int get stopCalls => _stopCalls; /// Set to make [start] throw, so permission handling can be tested. LocationException? failOnStart; @override Stream get fixes => _controller.stream; @override Future start() async { _startCalls++; final failure = failOnStart; if (failure != null) throw failure; _started = true; } @override Future stop() async { _stopCalls++; _started = false; } @override Future dispose() => _controller.close(); /// Emits a fix as though the platform had produced it. /// /// Silently ignored while stopped, mirroring the real thing: a platform that has been /// told to stop does not keep delivering. void emit(LocationFix fix) { if (!_started) return; _controller.add(fix); } /// Convenience for building a plausible fix without spelling out every field. void emitAt({ required int timestamp, double latitude = 51.0, double longitude = -114.0, double speedMps = 11.111111111111112, // 40 km/h double altitudeM = 1000.0, double accuracyM = 5.0, double bearingDeg = 0.0, }) => emit(LocationFix( timestamp: timestamp, latitude: latitude, longitude: longitude, speedMps: speedMps, altitudeM: altitudeM, accuracyM: accuracyM, bearingDeg: bearingDeg, )); }