Three fixes found by running the app on a real handset against production. 1. An expired token left the app looking signed in and unable to work. MilerApi.onUnauthorized was declared and called on every 401 but never assigned, so the token was dropped and nothing else happened: the profile stayed on disk, logged_out stayed false, and the rider saw his own name over a dashboard whose every call returned 401. He reads that as "no work today". The teardown now lives in endSession() and both ways out of a session — the Log out button and the 401 path — use it. 2. Arrived was written locally even when the rider was not there. updateArrivedStatus answers false for three different things and the caller treated all of them as "the write did not land", which is only true of one. A geofence refusal and a server refusal now stop the rung and hand back the reason; a dead network still advances, as it should. 3. A multi-destination customer pickup collapsed onto one stop. GET /miler/bookings returns a row per destination once collected, all with the same bookingid and reference. Every local store keys on that id, so the accepted store deduped two of three drops away and their consignment ids were unrecoverable. orderid is now the stop key; bookingreference stays the booking's name. Cards show "Stop 2 of 3" and the receiver's own name and number rather than the sender's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
579 lines
23 KiB
Dart
579 lines
23 KiB
Dart
/// ─────────────────────────────────────────────────────────────────────────
|
||
/// 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<GeofenceFix> 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<GeofenceDecision> 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<GeofenceFix> _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);
|
||
}
|
||
}
|
||
}
|