/// ───────────────────────────────────────────────────────────────────────── /// THE PROXIMITY FENCE — one radius, one policy, one answer /// /// A rider may not tell the hub he is at a door he is not at. Every /// location-sensitive rung — arrived, picked, picked up, delivery arrived, /// delivered, handed over — is measured here and nowhere else. /// /// ── Why this is its own file ── /// /// The measurement used to live inside `PickupsController`, which cost three /// things: /// /// * it could only be reached from a `GetxController`, so Home's bulk gate /// grew a second copy with a different radius (500 m against the /// controller's 100, later 10); /// * it could not be tested without a real `Geolocator`, so the arithmetic was /// never exercised at the values that decide a rider's day — 99 m and 101 m; /// * it returned a bare `bool`, so a screen could not tell "he is 240 m away" /// from "his GPS is off", and showed one sentence for both. /// /// This returns a [GeofenceDecision] carrying `allowed`, `distanceMetres`, /// `reason`, `accuracyMetres` and `timestamp`, and takes its position through /// an injectable seam ([positionProvider]) so the whole table is a unit test /// rather than a walk around a car park. /// /// ── THE RULE ── /// /// distance > kGeofenceRadiusMetres → BLOCKED, and no API call is made. /// /// Blocked means blocked. A caller that receives `allowed == false` must return /// before it builds a payload — not warn and continue. `geofence_test.dart` /// asserts that with a `MockClient` that fails the test if any request is sent. /// /// ── Fail CLOSED ── /// /// Every failure to *measure* is a refusal: no permission, no fix, a stale one, /// a vague one, a throw from the platform channel, and a booking carrying no /// coordinates. The previous implementation allowed on four of those six, which /// made the fence decorative — switching location off opened every rung in the /// app. /// ───────────────────────────────────────────────────────────────────────── library; import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; /// The one switch, and it is **ON**. /// /// ── The default changed on 2026-09-16, and it is not to change back ── /// /// This was `defaultValue: false`, so every release build shipped with the /// fence off and `_checkGeofence` returned `true` on its third line. "Picked /// up" meant the rider pressed a button, not that he was there. /// /// The define survives as an **ops kill switch**, not a development /// convenience: if the fence starts refusing real work at real doors, a build /// with `--dart-define=ENFORCE_GEOFENCE=false` gets the fleet moving again /// within one release rather than one sprint. Every bypassed check logs, in /// every build mode, so a build's own log says which way it was compiled. /// /// Raise [kGeofenceRadiusMetres] before reaching for this. const bool kGeofenceEnforced = bool.fromEnvironment( 'ENFORCE_GEOFENCE', defaultValue: true, ); /// How close "at the stop" is, in metres. /// /// ── Why 100 and not 10 ── /// /// This was 10 m for one day. Ten metres is at or inside the error radius of /// consumer GPS: a phone reporting ±15 m in the open and ±40 m between /// buildings cannot resolve a 10 m fence at all, so the control stopped /// measuring proximity and started measuring whether the satellites were kind. /// That is the exact failure that got the fence switched off twice before. /// /// 100 m is the operational radius: comfortably outside normal GPS error, and /// tight enough that a rider two streets away is refused. It is one number, /// here, and it is deliberately **not** read from the server's `pickupradius` — /// that made the app's one honesty control a per-tenant value nobody on this /// side could see, and it silently disagreed with a hardcoded 500 in Home's own /// gate. const double kGeofenceRadiusMetres = 100; /// How stale a fix may be before it is not a measurement. /// /// On a round, a stale position is reliably the *previous* stop. Anything much /// longer than this and the fence measures from the last door. const Duration kGeofenceFixMaxAge = Duration(seconds: 30); /// The worst fix the fence will decide on, in metres. /// /// ── Rejected, not credited ── /// /// The previous implementation credited the phone's error to the rider — /// `distance - accuracy > radius` — so a ±250 m fix bought 250 m of slack and a /// rider a quarter-kilometre away was waved through. Against a 100 m fence that /// inverts the control: the worse the fix, the easier it is to pass. /// /// A fix too vague to decide with is a fix the app must not decide with. At or /// under this it is used raw; over it the rung is refused with "step into the /// open", which is a thing the rider can act on. 50 m is generous against a /// 100 m fence — typical urban Android fixes are 5–30 m — and it leaves the /// fence measuring distance rather than luck. const double kGeofenceMaxAccuracyMetres = 50; /// How long to wait for the GPS to settle before giving up on a live fix. const Duration kGeofenceFixTimeout = Duration(seconds: 8); /// How long one live fix may serve several checks. /// /// ── Why this exists: a bulk action is ONE press at ONE place ── /// /// A rider ticking twenty bags at a counter and pressing "Picked up" produces /// twenty calls to [Geofence.check], and without this each one took its own /// `LocationAccuracy.best` fix with an 8-second ceiling. Twenty GPS settles for /// a man who has not moved: 40–160 seconds of a rider standing at a counter /// watching a spinner, which is most of the "status updates take two minutes" /// report. /// /// Worse, the bulk arrival gate on Home worked around it with a 3-second /// `.timeout(..., onTimeout: () => everything is inside the fence)`. Twenty /// sequential fixes can never finish in three seconds, so that timeout fired /// essentially every time and waved every order through — a hole straight /// through the 100 m rule. One fix closes the hole by making the checks /// instant, which is the better fix in both senses. /// /// ── Why fifteen seconds ── /// /// It is the window in which "where the rider is" has not meaningfully changed /// against a 100 m fence. Walking pace is ~1.4 m/s, so fifteen seconds is about /// 20 m of possible drift — a fifth of the radius, and well inside /// [kGeofenceFixMaxAge], which already governs how old a position may be before /// it stops being a measurement. Riding away from a stop at 30 km/h covers /// 125 m in the same window, so a rider who genuinely leaves is outside the /// fence on the next fix rather than the cached one: the cache is short enough /// that it cannot carry him to the next door. const Duration kGeofenceFixReuse = Duration(seconds: 15); /// Why the fence answered the way it did. enum GeofenceOutcome { /// Inside the radius. The only outcome that allows. inside, /// Measured, and too far. [GeofenceDecision.distanceMetres] says how far. outside, /// The booking carries no usable pin, so proximity cannot be checked. /// /// **Refused.** This used to be allowed-and-logged on the argument that the /// rider cannot fix the hub's data from a doorstep. True, and it still made /// the fence optional: a booking with a blank latitude opened every rung on /// that stop. The office can fix a missing pin in a minute and the rider is /// told to ring them. noTarget, /// Location services are switched off on the handset. serviceDisabled, /// The rider has not granted location permission. permissionDenied, /// Permission is denied permanently; only Settings can restore it. permissionDeniedForever, /// The GPS did not produce a fix in [kGeofenceFixTimeout] and there is no /// recent cached one. timeout, /// No fix at all, and not specifically a timeout. noFix, /// The only fix available is older than [kGeofenceFixMaxAge]. staleFix, /// The fix is worse than [kGeofenceMaxAccuracyMetres]. poorAccuracy, /// The platform threw. Refused — see the class note on failing closed. error, } /// The fence's answer about one rung at one stop. @immutable class GeofenceDecision { const GeofenceDecision({ required this.allowed, required this.outcome, required this.timestamp, this.distanceMetres, this.accuracyMetres, this.reason, this.riderLat, this.riderLng, }); /// May the caller proceed to the API call? /// /// True for exactly one outcome — [GeofenceOutcome.inside]. Everything else /// is a refusal, including every way of failing to measure. final bool allowed; final GeofenceOutcome outcome; /// Metres from the rider to the stop, or null when it could not be measured. final double? distanceMetres; /// The error radius the phone reported on the fix used, in metres. final double? accuracyMetres; /// When the decision was taken. final DateTime timestamp; /// The fix the decision was taken on, when there was one. /// /// Carried so a caller that has just been allowed through can stamp the same /// position onto its payload instead of asking the GPS a second time. Two /// fixes seconds apart are two different answers, and the one the fence /// judged is the one the hub should be told about. final double? riderLat; final double? riderLng; /// One sentence, in the rider's words, naming what he must do. Null only when /// [allowed] and there is nothing to say. /// /// Never a developer error, a status code, or a coordinate pair. final String? reason; bool get isBlocked => !allowed; @override String toString() => 'GeofenceDecision(${outcome.name}, allowed=$allowed, ' 'distance=${distanceMetres?.toStringAsFixed(1)}m, ' 'accuracy=${accuracyMetres?.toStringAsFixed(0)}m)'; } /// A position reading, or the reason there is not one. @immutable class GeofenceFix { const GeofenceFix({this.position, this.failure}); /// A reading good enough to hand to the fence. final Position? position; /// Why there is none. Ignored when [position] is set. final GeofenceOutcome? failure; bool get hasPosition => position != null; } /// Measures one rung against one stop. /// /// Stateless and static on purpose: Home's bulk gate and the per-stop rung in /// `PickupsController` are different code paths that must never answer /// differently, and the surest way to guarantee that is for there to be nothing /// to construct differently. abstract final class Geofence { /// How the fence gets the rider's position. Swapped in tests. /// /// Kept as a settable function rather than a constructor argument because the /// call sites are spread across controllers and widgets with no shared owner /// to thread an instance through — and the alternative on offer was another /// copy of the arithmetic. static Future Function() positionProvider = _devicePosition; /// Restores the real device provider and drops any cached fix. /// Call in `tearDown`. static void useDevice() { positionProvider = _devicePosition; resetCache(); } /// The last live fix, and when it was taken. See [kGeofenceFixReuse]. static Position? _cachedFix; static DateTime? _cachedAt; /// Forgets the cached fix. /// /// Called on sign-out and from tests. Also worth calling if a screen knows /// the rider has travelled — though it should rarely be needed, because the /// window is shorter than any journey between two stops. static void resetCache() { _cachedFix = null; _cachedAt = null; } /// True when a coordinate pair is a usable point on Earth. /// /// `0,0` is in the Gulf of Guinea and is what every unset latitude in this /// codebase decays to, so it is treated as absent rather than as a place. static bool isUsable(double? lat, double? lng) => lat != null && lng != null && lat != 0 && lng != 0 && lat.abs() <= 90 && lng.abs() <= 180; /// Metres between two points on the WGS-84 ellipsoid. /// /// Delegates to [Geolocator.distanceBetween] — never a latitude/longitude /// comparison and never a flat-plane subtraction, both of which have been /// tried in this app's history and are wrong by a factor that varies with /// where the rider is standing. static double distanceBetween( double targetLat, double targetLng, double riderLat, double riderLng, ) => Geolocator.distanceBetween(targetLat, targetLng, riderLat, riderLng); /// The fence, for one [action] at one target. /// /// [action] is the rung's name in the rider's vocabulary — "Arrived", /// "Delivered", "Handed over" — and is quoted back in /// [GeofenceDecision.reason] so one sentence serves every rung. static Future check({ required double? targetLat, required double? targetLng, required String action, double radiusMetres = kGeofenceRadiusMetres, }) async { final now = DateTime.now(); final verb = action.toLowerCase(); // Logged with `debugPrint` in *every* build mode, deliberately. The whole // risk of this switch is a build going out with proximity silently off, so // the one thing it must never be is quiet. if (!kGeofenceEnforced) { debugPrint( '[GEOFENCE] OFF for $action — this build was compiled with ' 'ENFORCE_GEOFENCE=false. Stop completion is NOT proximity-verified.', ); return GeofenceDecision( allowed: true, outcome: GeofenceOutcome.inside, timestamp: now, ); } // ── No pin on the booking: REFUSED ── // // Checked before the phone is asked for anything, because no fix can answer // a question with no target and the rider should not wait 8s to be told so. if (!isUsable(targetLat, targetLng)) { final decision = GeofenceDecision( allowed: false, outcome: GeofenceOutcome.noTarget, timestamp: now, reason: 'This stop has no map location on it, so Miler cannot confirm you ' 'are there. Ring your office and ask them to add the address ' 'location.', ); debugPrint( '[GEOFENCE] $action refused: stop carries no coordinates ' '($targetLat, $targetLng) — data gap on the booking', ); return decision; } final GeofenceFix fix; try { fix = await positionProvider(); } catch (e) { // ── A throw is a refusal ── // // The old implementation caught here and returned `true` with the comment // "allow on error (fail-safe)". That is fail-*open*: any throw from the // location stack — and the permission plugin throws rather than returning // on several OEM builds — opened every rung in the app. debugPrint('[GEOFENCE] $action refused: position provider threw: $e'); return GeofenceDecision( allowed: false, outcome: GeofenceOutcome.error, timestamp: now, reason: _reasonForFixFailure(GeofenceOutcome.error, verb), ); } if (!fix.hasPosition) { final outcome = fix.failure ?? GeofenceOutcome.noFix; final decision = GeofenceDecision( allowed: false, outcome: outcome, timestamp: now, reason: _reasonForFixFailure(outcome, verb), ); debugPrint('[GEOFENCE] $action refused: $decision'); return decision; } final pos = fix.position!; final accuracy = pos.accuracy; // ── The rider's own position must be a place ── // // A plugin that answers `0,0` rather than throwing is what this catches; // without it the fence measures from the Gulf of Guinea and refuses every // stop in India with a distance in the thousands of kilometres. if (!isUsable(pos.latitude, pos.longitude)) { final decision = GeofenceDecision( allowed: false, outcome: GeofenceOutcome.noFix, timestamp: now, accuracyMetres: accuracy, reason: _reasonForFixFailure(GeofenceOutcome.noFix, verb), ); debugPrint('[GEOFENCE] $action refused: $decision'); return decision; } final distance = distanceBetween( targetLat!, targetLng!, pos.latitude, pos.longitude, ); // ── A fix too vague to decide with ── // // Refused rather than credited. Checked *after* the distance is computed so // the decision still carries the measurement for the log, but before the // radius comparison so a vague fix can never pass one. See // [kGeofenceMaxAccuracyMetres]. if (accuracy > kGeofenceMaxAccuracyMetres) { final decision = GeofenceDecision( allowed: false, outcome: GeofenceOutcome.poorAccuracy, timestamp: now, distanceMetres: distance, accuracyMetres: accuracy, riderLat: pos.latitude, riderLng: pos.longitude, reason: 'Your phone is not sure where you are yet. Step into the open for a ' 'moment, then mark this stop $verb.', ); debugPrint('[GEOFENCE] $action refused: $decision'); return decision; } // ── Raw distance against the radius ── // // Not `distance - accuracy`. The slack that arithmetic granted grew with // the phone's own error, so the worse the fix the further out a rider could // stand — the control running backwards. The error is handled once, above, // by refusing to decide on a fix that cannot resolve 100 m. final inside = distance <= radiusMetres; final decision = GeofenceDecision( allowed: inside, outcome: inside ? GeofenceOutcome.inside : GeofenceOutcome.outside, timestamp: now, distanceMetres: distance, accuracyMetres: accuracy, riderLat: pos.latitude, riderLng: pos.longitude, reason: inside ? null : _tooFarSentence(distance, radiusMetres, verb), ); debugPrint( '[GEOFENCE] $action | target ($targetLat, $targetLng) ' '| rider (${pos.latitude}, ${pos.longitude}) | $decision', ); return decision; } /// "You're 240 m away. Move within 100 m to mark this stop arrived." /// /// Distance first, because that is the number the rider acts on, and in the /// unit he thinks in — metres under a kilometre, kilometres over it. static String _tooFarSentence(double distance, double radius, String verb) { final away = distance >= 1000 ? '${(distance / 1000).toStringAsFixed(1)} km' : '${distance.round()} m'; return "You're $away away. Move within ${radius.round()} m to mark this " 'stop $verb.'; } static String _reasonForFixFailure(GeofenceOutcome outcome, String verb) => switch (outcome) { GeofenceOutcome.serviceDisabled => 'Location is switched off on your phone. Turn it on, then mark this ' 'stop $verb.', GeofenceOutcome.permissionDenied => 'Miler needs your location to confirm you are at this stop. Allow ' 'location access, then mark it $verb.', GeofenceOutcome.permissionDeniedForever => 'Location access is blocked for Miler. Open Settings → Permissions → ' 'Location and allow it, then mark this stop $verb.', GeofenceOutcome.staleFix => 'Your phone last found you a while ago. Wait a moment for it to catch ' 'up, then mark this stop $verb.', GeofenceOutcome.timeout => 'Your phone is still looking for your location. Step into the open, ' 'wait a moment, then mark this stop $verb.', _ => 'Your phone could not find your location, so this stop cannot be ' 'marked $verb. Turn location on, allow it for Miler, and step ' 'outside if you can.', }; /// The real device fix: service, permission, then the best reading the /// hardware will give inside [kGeofenceFixTimeout]. /// /// Falls back to a cached position **only** when it is fresher than /// [kGeofenceFixMaxAge]; a last-known fix has no age limit of its own and on /// a round it is reliably the previous stop. static Future _devicePosition() async { // ── One press at one place is one fix ── // // Checked before the service and permission calls, because those are // platform-channel round trips too and a batch of twenty pays them twenty // times for an answer that cannot have changed. A cached fix is only ever // stored after a *successful* live read, so this can never serve a stale or // vague one — the age check below is the only way in. final cached = _cachedFix; final at = _cachedAt; if (cached != null && at != null && DateTime.now().difference(at) <= kGeofenceFixReuse) { return GeofenceFix(position: cached); } try { if (!await Geolocator.isLocationServiceEnabled()) { return const GeofenceFix(failure: GeofenceOutcome.serviceDisabled); } var permission = await Geolocator.checkPermission(); if (permission == LocationPermission.denied) { permission = await Geolocator.requestPermission(); } if (permission == LocationPermission.deniedForever) { return const GeofenceFix( failure: GeofenceOutcome.permissionDeniedForever, ); } if (permission == LocationPermission.denied) { return const GeofenceFix(failure: GeofenceOutcome.permissionDenied); } try { final pos = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( // `best`, not `low`. A `low` fix is a ~1 km hint on Android; against // a 100 m fence that is not a degraded measurement, it is noise // being treated as evidence. accuracy: LocationAccuracy.best, timeLimit: kGeofenceFixTimeout, ), ); _cachedFix = pos; _cachedAt = DateTime.now(); return GeofenceFix(position: pos); } catch (_) { // No live fix. A recent cached one is a measurement; a stale one is not. final cached = await Geolocator.getLastKnownPosition(); if (cached == null) { return const GeofenceFix(failure: GeofenceOutcome.timeout); } final age = DateTime.now().difference(cached.timestamp); if (age > kGeofenceFixMaxAge) { debugPrint( '[GEOFENCE] cached fix is ${age.inSeconds}s old — too stale to ' 'measure a ${kGeofenceRadiusMetres.round()}m fence', ); return const GeofenceFix(failure: GeofenceOutcome.staleFix); } return GeofenceFix(position: cached); } } catch (e) { debugPrint('[GEOFENCE] position lookup threw: $e'); return const GeofenceFix(failure: GeofenceOutcome.error); } } }