diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 5d8bfec..86aa4f8 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,9 +4,17 @@
-
-
-
+
+
+
@@ -19,15 +27,43 @@
-
-
-
-
+
+
+
-
@@ -135,10 +171,6 @@
-
-
-
-
diff --git a/android/key.properties b/android/key.properties
deleted file mode 100644
index 8a16a8a..0000000
--- a/android/key.properties
+++ /dev/null
@@ -1,4 +0,0 @@
-storePassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
-keyPassword=W8UzA5NVaDTvix79rekmn8834m81tcKX
-keyAlias=doormile
-storeFile=doormilerider-keystore.jks
diff --git a/lib/controllers/auth.dart b/lib/controllers/auth.dart
index 88f5f54..e84d858 100644
--- a/lib/controllers/auth.dart
+++ b/lib/controllers/auth.dart
@@ -10,9 +10,37 @@ import 'package:miler/utils/device.dart';
import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/Models/login/login.dart';
import 'package:miler/data/api_config.dart';
+import 'package:miler/data/miler_api.dart';
import 'package:miler/views/helpers/widgets/miler_sheet_kit.dart';
-enum AuthNext { verifyPin, otp, notRegistered, error }
+/// Which screen the phone number on the sign-in form has earned.
+///
+/// ── `otp` is gone, and it was never real ──
+///
+/// There is no OTP route on the miler side — `MilerApi` carries the whole auth
+/// surface and it is login / set-pin / verify-pin / device-token. The old `otp`
+/// branch fired when the directory said "no such account", sent the rider to a
+/// code screen that verified nothing (`verifyOtp` returned `true` without
+/// checking), and dead-ended at a Create-MPIN screen that could not write a
+/// PIN. A rider who took it could not come back.
+///
+/// The server answers this question directly now — see [MilerApi.pinSetOf].
+enum AuthNext {
+ /// `pin_set: true` — he has a PIN. Enter-PIN, exactly as before.
+ verifyPin,
+
+ /// `pin_set: false` — a rider who has never signed in. Set-PIN.
+ setPin,
+
+ /// 404. No miler account on this number.
+ notRegistered,
+
+ /// 403. The row exists but is not an active miler.
+ inactive,
+
+ /// The directory could not be reached. Not evidence about the rider.
+ error,
+}
class AuthController extends GetxController {
final RxBool sendingOtp = false.obs;
@@ -41,11 +69,18 @@ class AuthController extends GetxController {
static const String _prefsUserEmailKey = 'user_email';
static const String _prefsContactNoKey = 'contactno';
static const String _prefsAddressKey = 'user_address';
- static const String _prefsForceMasterPinKey = 'force_master_pin';
- static const String _masterPinValue = '1234';
- static const String forceMasterPinPrefKey = _prefsForceMasterPinKey;
- static const String masterPinValue = _masterPinValue;
- bool _forceMasterPinFlow = false;
+ // ── The master-PIN constants are gone ──
+ //
+ // `_masterPinValue = '1234'`, `masterPinValue`, `forceMasterPinPrefKey` and
+ // `_forceMasterPinFlow` were declared here and read by nothing — the feature
+ // they belonged to was removed and its constants were not. A public constant
+ // named `masterPinValue` holding a four-digit PIN is an invitation to the
+ // next person looking for a shortcut, and it read as though the app still had
+ // a back door. Removed with the set-PIN work rather than left to be
+ // rediscovered.
+ //
+ // Riders set their own PIN now; `Creat_mpin.dart` refuses `1234` and `1111`
+ // along with every other trivial sequence.
Future _notifyProfileController() async {
try {
if (Get.isRegistered()) {
@@ -103,6 +138,26 @@ class AuthController extends GetxController {
);
}
+ /// Which screen this phone number has earned, asked of the server.
+ ///
+ /// ── One call, one boolean, no guessing ──
+ ///
+ /// This used to ask `milerAccountExists`, which read *only the status code*
+ /// of `POST /miler/login` and threw the body away. From "an account exists"
+ /// it inferred Enter-PIN, and from "it does not" it inferred an OTP branch
+ /// that verified nothing and dead-ended at a screen which could not write a
+ /// PIN. A `null` — the directory unreachable — was read as "he has an
+ /// account", because the OTP direction was the worse place to be wrong.
+ ///
+ /// The server answers directly now. `pin_set` is the whole decision, and it
+ /// is read as a boolean rather than off the message beside it, which is prose
+ /// and will be reworded.
+ ///
+ /// The fallback when the field is absent — an older server, or a body that
+ /// did not parse — is **Enter-PIN**, for the same reason the old `null` case
+ /// chose it: a rider who does have a PIN can sign in, and one who does not
+ /// gets a refusal he can report. Sending him to Set-PIN on a guess earns a
+ /// 409 and a screen he cannot leave.
Future precheckPhone(String phone) async {
try {
final normalized = _normalizePhone(phone);
@@ -111,31 +166,34 @@ class AuthController extends GetxController {
// The mocked "Demo Rider" (userid 9999) that used to be written here is
// gone. It bypassed the server entirely and left a fake identity in prefs
- // that outlived the session it was created for — every screen reading
- // 'userid' got 9999 until the app was reinstalled. The real user is
- // established by verify-pin and nowhere else.
+ // that outlived the session it was created for. The real user is
+ // established by verify-pin / set-pin and nowhere else.
await prefs.setString(_prefsContactNoKey, normalized);
- // On the live backend, ask whether this phone already belongs to an
- // active miler account with a PIN on file. If it does, go straight to the
- // MPIN screen: OTP delivery isn't live yet, and the OTP path ends at
- // Create-MPIN, which would overwrite the PIN the account was issued.
- // Seeded development accounts take exactly this branch — enter the phone,
- // enter the seeded MPIN, done.
- // Null means the directory could not be reached — see
- // [AuthProvider.milerAccountExists]. Treat it as "he has an account",
- // because that is true of every rider who gets this far and because the
- // MPIN screen is the only one that can tell him what went wrong. The OTP
- // branch is the dead end: it ends at Create-MPIN, which cannot write a
- // PIN, so guessing wrong in that direction locks a rider out.
- final exists = await _api.milerAccountExists(normalized);
- if (exists ?? true) {
- lastDecision = AuthNext.verifyPin;
+ final res = await MilerApi.login(normalized);
+ debugPrint(
+ '[AUTH][PRECHECK] $normalized -> ${res.status} '
+ 'pin_set=${MilerApi.pinSetOf(res)} raw=${res.raw}',
+ );
+
+ if (res.status == 404) {
+ lastDecision = AuthNext.notRegistered;
+ return lastDecision!;
+ }
+ if (res.status == 403 || res.status == 401) {
+ lastDecision = AuthNext.inactive;
+ return lastDecision!;
+ }
+ if (!res.ok) {
+ // 5xx, a timeout, a body that did not parse. Not a fact about the
+ // rider, and not a reason to send him anywhere final.
+ lastDecision = AuthNext.error;
return lastDecision!;
}
- // The directory answered, and said there is no such account.
- lastDecision = AuthNext.otp;
+ lastDecision = MilerApi.pinSetOf(res) == false
+ ? AuthNext.setPin
+ : AuthNext.verifyPin;
return lastDecision!;
} catch (e) {
debugPrint('Precheck phone error: $e');
@@ -144,6 +202,13 @@ class AuthController extends GetxController {
}
}
+ /// Why the last [setPin] failed, in the rider's words. Null on success.
+ String? lastSetPinFailure;
+
+ /// True when [setPin] was refused because the account already has a PIN —
+ /// the caller sends the rider to Enter-PIN rather than showing an error.
+ bool lastSetPinWasAlreadySet = false;
+
Future sendOtp([String? phoneArg]) async {
if (sendingOtp.value) return false;
if (phoneArg != null && phoneArg.isNotEmpty) {
@@ -179,52 +244,129 @@ class AuthController extends GetxController {
return true;
}
+ /// Creates this rider's PIN and signs him in. `POST /miler/set-pin`.
+ ///
+ /// ── What this replaces ──
+ ///
+ /// It called `AuthProvider.updatePin`, which had no route to call and
+ /// returned a manufactured `403 "Your MPIN is issued by your office and
+ /// cannot be changed from the app."` — correct while `reset-pin` was the only
+ /// PIN write and it needed an admin token, and a dead end for the rider
+ /// standing on the Create-MPIN screen.
+ ///
+ /// Riders set their own PIN on first sign-in now. The call returns a **full
+ /// session**, so this lands the rider logged in — there is no verify-pin
+ /// afterwards and no second screen.
+ ///
+ /// Returns true when the session is real. On a `409` — the account already
+ /// has a PIN — [lastSetPinWasAlreadySet] is set and the caller sends him to
+ /// Enter-PIN rather than showing him an error he cannot act on.
Future setPin(String newPin) async {
+ lastSetPinFailure = null;
+ lastSetPinWasAlreadySet = false;
+
+ final phone = currentPhone;
+ if (phone == null || phone.isEmpty) {
+ lastSetPinFailure =
+ 'We lost your number. Go back and enter it again.';
+ return false;
+ }
+ if (newPin.length != 4 || int.tryParse(newPin) == null) {
+ lastSetPinFailure = 'Enter a 4-digit PIN.';
+ return false;
+ }
+
try {
final prefs = await SharedPreferences.getInstance();
- int? userId =
- prefs.getInt(_prefsPendingPinUserIdKey) ??
- prefs.getInt(_prefsUserIdKey);
- if (newPin.length != 4 || int.tryParse(newPin) == null) {
- _showBottomSheet(
- title: 'Invalid PIN',
- message: 'Please enter a valid 4-digit PIN.',
- );
+ String deviceId = '';
+ String fcmToken = '';
+ try {
+ deviceId = await DeviceUtils.ensureDeviceId(prefs);
+ } catch (e) {
+ debugPrint('[AUTH] device id unavailable, continuing: $e');
+ }
+ try {
+ fcmToken = await DeviceUtils.ensureFcmToken(prefs);
+ } catch (e) {
+ debugPrint('[AUTH] fcm token unavailable, continuing: $e');
+ }
+
+ // Same rule as verify-pin: only THIS attempt may grant a session, so a
+ // stale token cannot make a refused set-pin look accepted.
+ await ApiConfig.clearToken();
+
+ final Login res = await _api.loginParsed(
+ contactNo: phone,
+ deviceType: Platform.operatingSystem,
+ configId: 6,
+ deviceId: deviceId,
+ fcmToken: fcmToken,
+ pinRaw: newPin,
+ firstTime: true,
+ );
+
+ // `_loginNew` normalises the envelope as `code: ok ? 200 : httpStatus`,
+ // so on a refusal this IS the server's status line. `httpstatus` is on
+ // the envelope too but `Login` does not parse it.
+ final int http = res.code ?? 0;
+
+ // ── 409 is not a failure the rider can fix by trying again ──
+ //
+ // It means the account already has a PIN — he is not a first-time rider
+ // after all, or he set one on another handset. The caller sends him to
+ // Enter-PIN; telling him "could not save your PIN" would leave him
+ // retyping a PIN the server will never accept.
+ if (http == 409) {
+ lastSetPinWasAlreadySet = true;
+ lastSetPinFailure =
+ 'You already have a PIN on this number. Enter it to sign in.';
return false;
}
- if (userId == null) {
- _showBottomSheet(
- title: 'Error',
- message: 'User ID not found. Please try again.',
- );
+ if (http == 404) {
+ lastSetPinFailure =
+ 'That number is not registered as a Miler. Contact your manager.';
+ return false;
+ }
+ if (http == 403 || http == 401) {
+ lastSetPinFailure =
+ 'This account is not active. Contact your manager.';
return false;
}
- final int pinNum = int.parse(newPin);
- final res = await _api.updatePin(userId: userId, pin: pinNum);
- if (res.statusCode >= 200 && res.statusCode < 300) {
+ final bool serverAccepted = res.status == true;
+ final String? token = await ApiConfig.getToken();
+ final bool haveSession = token != null && token.isNotEmpty;
+
+ if (serverAccepted && !haveSession) {
+ // The PIN was created and there is nothing to sign in with. An
+ // integration fault, and it must never be reported as the rider's
+ // mistake — see the same branch in [verifyPinWithServer].
+ debugPrint('[AUTH] set-pin succeeded but returned no usable token');
+ lastSetPinFailure =
+ 'Your PIN was saved, but the server did not return a session. '
+ 'Sign in with your new PIN.';
+ lastSetPinWasAlreadySet = true;
+ return false;
+ }
+
+ if (serverAccepted && haveSession) {
await prefs.setString('dbPin', newPin);
+ await prefs.setBool('logged_out', false);
+ await prefs.setString(_prefsContactNoKey, phone);
await prefs.remove(_prefsPendingPinUserIdKey);
+ await _notifyProfileController();
return true;
}
- // The server's own sentence, not a slice of its JSON. A rider reading
- // `{"status":false,"code":403,...}` learns nothing he can act on.
- String reason = '';
- try {
- final decoded = json.decode(res.body);
- if (decoded is Map) reason = (decoded['message'] ?? '').toString();
- } catch (_) {}
- if (reason.trim().isEmpty) {
- reason = 'Could not set your MPIN. Please contact your manager.';
- }
- _showBottomSheet(title: 'MPIN not changed', message: reason);
+
+ final String serverMsg = (res.message ?? '').trim();
+ lastSetPinFailure = serverMsg.isNotEmpty && serverMsg.length < 140
+ ? serverMsg
+ : 'Could not set your PIN. Check your connection and try again.';
return false;
} catch (e) {
debugPrint('setPin error: $e');
- _showBottomSheet(
- title: 'Error',
- message: 'Something went wrong while setting the PIN.',
- );
+ lastSetPinFailure =
+ 'Something went wrong while setting your PIN. Try again.';
return false;
}
}
diff --git a/lib/controllers/pickups_controller.dart b/lib/controllers/pickups_controller.dart
index 06d8922..e9776e5 100644
--- a/lib/controllers/pickups_controller.dart
+++ b/lib/controllers/pickups_controller.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
+import 'package:miler/data/geofence.dart';
import 'package:miler/data/work_repository.dart';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart' show LatLng;
@@ -19,133 +20,38 @@ import 'package:minio/io.dart';
import 'package:miler/utils/kalman_filter.dart';
import 'package:miler/utils/mqtt_service.dart';
import 'package:miler/controllers/connectivity_mixin.dart';
-import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/data/meal_run_mock.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
-/// ── PROXIMITY ENFORCEMENT IS OFF ──
+/// ── PROXIMITY ENFORCEMENT IS ON ──
///
-/// Off again on 2026-08-25, the same day it was turned on, at the founder's
-/// call. Nothing was found wrong with it — this is a decision about when to
-/// switch it on, not a retraction of the work.
+/// The fence itself moved to `lib/data/geofence.dart` on 2026-09-16. What used
+/// to live here — four constants, a `_freshFix` ladder and a 130-line
+/// `_checkGeofence` — could only be reached from a `GetxController`, so Home's
+/// bulk gate grew a second copy of the arithmetic with a different radius, and
+/// none of it could be tested without a real handset.
///
-/// It is off for **every** status — arrived, picked, picked up, delivery
-/// arrived, delivered, cancelled — and on both gates, this one and the bulk
-/// check on Home. Half a fence is worse than none: it would block one route
-/// into a rung and wave through another, with two different messages and no
-/// explanation of why one worked.
+/// [Geofence] is that logic, extracted and corrected. Four things changed with
+/// it, and each one was a way a rider could mark a stop he was not standing at:
///
-/// ── What it costs while it is off ──
+/// * **It is on.** `kGeofenceEnforced` defaulted to `false`, so
+/// `_checkGeofence` returned `true` on its third line in every shipped
+/// build.
+/// * **The radius is 100 m, not 10.** Ten metres is inside consumer-GPS error,
+/// which is what got the fence switched off twice before.
+/// * **It fails closed.** The old `catch` returned `true` with the comment
+/// "allow on error (fail-safe)". Any throw from the location stack opened
+/// every rung in the app.
+/// * **The phone's error is no longer credited to the rider.** The comparison
+/// was `distance - accuracy > radius`, so slack grew with inaccuracy and a
+/// ±250 m fix bought 250 m of it. A fix too vague to resolve the fence is
+/// refused instead — see `kGeofenceMaxAccuracyMetres`.
///
-/// This is the one control that decides whether the app is telling the truth
-/// about where a rider was standing when he said a stop was done. With it off,
-/// "Picked up" means he pressed a button, not that he was there — the
-/// timestamps and the GPS still ride along on every status write, so the office
-/// can audit after the fact, but nothing is refused at the moment of the press.
-///
-/// ── What is ready for the day it goes back on ──
-///
-/// Recorded here because the work is done and the flag is the only thing
-/// holding it, so nobody has to rediscover any of it. The fence was off from
-/// 2026-08-19 because it refused real work — riders pressing **Picked up** at a
-/// counter were told "You're 4.2 km from this stop" — and that was a
-/// measurement problem rather than a strictness one. Three causes, all fixed:
-///
-/// • **The fix was not worth measuring with.** Home handed the fence a
-/// `LocationAccuracy.low` position — a ~1 km hint on Android — or a cached
-/// one of any age. See [_freshFix] and [kGeofenceFixMaxAge].
-/// • **The phone's own error was charged to the rider.** The comparison is
-/// `distance - accuracy > radius`, so a rider 12 m out on a ±20 m fix is not
-/// refused for a precision the hardware never provided.
-/// • **There were three radii.** A configured `pickupradius` defaulting to
-/// 100 here, a hardcoded 500 in Home's bulk gate, and no agreement between
-/// them. There is one now: [kGeofenceRadiusMeters], set to 10.
-///
-/// All three are live in the code below and simply do not run while this is
-/// false. The fourth cause is real and none of this fixes it: **the booking's
-/// own coordinates are often wrong**, because they come from wherever the
-/// customer dropped a pin. A stop carrying *no* coordinates is allowed through
-/// — that is the hub's data, not something a rider can resolve from a doorstep
-/// — but a stop carrying wrong ones will still refuse him.
-///
-/// ── Turning it on ──
-///
-/// flutter run --dart-define=ENFORCE_GEOFENCE=true
-/// flutter build apk --dart-define=ENFORCE_GEOFENCE=true
-///
-/// Or flip the default. Ten metres is tight — at or inside consumer GPS
-/// accuracy — so if the first reports are riders blocked at doors, raise
-/// [kGeofenceRadiusMeters] before reaching for this switch again.
-///
-/// Every bypass is logged in every build mode (see `_checkGeofence`), so a
-/// build's own log says which way it was compiled.
-///
-/// ── The history, so none of the old mistakes come back ──
-///
-/// This was once `kDebugMode`, which meant the fence was off in exactly the
-/// builds anyone tested with — so it was never really tested. It was then
-/// pinned to a hard `false`, which left no way to walk the flow at a desk. The
-/// shape below survives both: one named constant, one define, one default, and
-/// the default is a product decision rather than a side effect of how the app
-/// was built.
-const bool kGeofenceEnforced = bool.fromEnvironment(
- 'ENFORCE_GEOFENCE',
- defaultValue: false,
-);
-
-/// The name every call site reads. Derived, so there is one switch and not two.
-const bool kBypassGeofenceForTesting = !kGeofenceEnforced;
-
-/// ─────────────────────────────────────────────────────────────────────────
-/// HOW CLOSE IS "AT THE STOP" — 10 metres
-///
-/// A product decision, taken deliberately and tight: the rider must be *at the
-/// door*, not on the street outside it, before he can mark a stop arrived,
-/// picked or delivered.
-///
-/// ── Why this is a constant and no longer the server's `pickupradius` ──
-///
-/// The fence used to read `pickupradius` out of prefs, which login writes from
-/// the profile and defaults to 100. That made the strictness of the app's one
-/// honesty control a per-tenant configuration value nobody on this side could
-/// see, and it silently disagreed with the second gate on Home, which was
-/// hardcoded to 500. Three numbers for one rule. This is the rule.
-///
-/// ── What 10 metres actually demands, stated plainly ──
-///
-/// This is at or inside the accuracy of consumer GPS. A phone reports a fix
-/// with an error radius, and 5–15 m in the open is normal while 30–50 m
-/// between buildings is ordinary rather than exceptional. A fence smaller than
-/// the error it is measured with will refuse a rider who is genuinely standing
-/// at the door — which is the exact failure that got this whole control turned
-/// off once before, and the reason two things below are not optional:
-///
-/// • **The fix must be worth 10 m.** [LocationAccuracy.best], not the `low`
-/// the callers were passing — `low` is a ~1 km hint on Android and against
-/// a 10 m fence it is not a measurement, it is a coin toss. See
-/// [_freshFix].
-///
-/// • **The phone's own error is credited to the rider.** The check is
-/// `distance - accuracy > radius`, not `distance > radius`: a rider 12 m
-/// away on a fix that says ±20 m has not been shown to be outside the
-/// fence, and refusing him is asserting a precision the hardware did not
-/// provide. He is refused when the *phone* says he is outside, not when the
-/// arithmetic does.
-///
-/// Together those keep 10 m meaning "at the door" without it meaning "when the
-/// satellites are kind". If riders still report being blocked at a door, this
-/// number is the knob — raise it here, in one place, rather than turning the
-/// fence off again.
-const double kGeofenceRadiusMeters = 10;
-
-/// How stale a cached fix may be before the fence refuses to measure with it.
-///
-/// A last-known position is instant and free and can be an hour old. Against a
-/// 100 m fence that was survivable; against 10 m it is how a rider marks a
-/// delivery arrived from the previous street because that is where the phone
-/// last looked.
-const Duration kGeofenceFixMaxAge = Duration(seconds: 30);
-
+/// What is left in this file is the wiring: [_checkGeofence] adapts [Geofence]
+/// to the eight call sites below, and every one of them returns **before**
+/// building a payload when it answers false. That is asserted end-to-end in
+/// `geofence_test.dart` with an HTTP client that fails the test if any request
+/// is sent.
class PickupsController extends GetxController
with ConnectivityControllerMixin {
MilerKalmanFilter? _kf;
@@ -332,12 +238,12 @@ class PickupsController extends GetxController
// ---------------- Location helpers ----------------
/// The error radius, in metres, of the fix [_ensureLatLng] last obtained.
///
- /// Read by [_checkGeofence], which credits it to the rider — see
- /// [kGeofenceRadiusMeters]. Starts at zero so a fence measured before any fix
- /// has been taken is strict rather than accidentally generous.
+ /// Telemetry only. The fence no longer reads this — [Geofence] takes its own
+ /// fix and rejects one it cannot trust rather than crediting its error to the
+ /// rider. Kept because the payload builders log it.
double _lastFixAccuracy = 0;
- /// A position good enough to measure a [kGeofenceRadiusMeters] fence with.
+ /// A position good enough to put on a status payload.
///
/// ── What this replaced, and why it had to go ──
///
@@ -380,7 +286,7 @@ class PickupsController extends GetxController
} else if (cached != null) {
debugPrint(
'[GEOFENCE] cached fix is ${age?.inSeconds}s old — too stale to '
- 'measure a ${kGeofenceRadiusMeters.toStringAsFixed(0)}m fence',
+ 'measure a ${kGeofenceRadiusMetres.toStringAsFixed(0)}m fence',
);
}
} catch (_) {}
@@ -398,34 +304,28 @@ class PickupsController extends GetxController
final needsFetch =
(lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty);
- // ── The two shortcuts below are disabled while the fence is on ──
+ // ── These shortcuts are safe again, because nothing is decided on them ──
//
- // Every caller of this method feeds its answer to [_checkGeofence] and
- // then puts the same pair on the payload. Both shortcuts hand back a
- // position of unknown provenance: the first trusts whatever the screen
- // passed in — Home passes a `LocationAccuracy.low` fix, which is a ~1 km
- // hint — and the second reuses a cached value with no age on it at all.
- // Neither carries an accuracy, so [_lastFixAccuracy] would be stale too
- // and the fence would measure a 10 m rule with a number it cannot
- // characterise.
+ // They used to be gated on the fence being off, and rightly: the fence
+ // measured with whatever this returned, and both shortcuts hand back a
+ // position of unknown provenance — the first trusts whatever the screen
+ // passed in (Home passes a `LocationAccuracy.low` fix, a ~1 km hint on
+ // Android), the second reuses a cached value with no age on it at all.
//
- // With the fence enforced this takes one real fix per status write. That
- // is a few seconds, once, at a door the rider is standing still at — and
- // it is the whole basis on which the app is about to refuse or allow his
- // press. With the fence off the shortcuts stand: nothing is being decided
- // on the answer, it is telemetry.
- if (kBypassGeofenceForTesting) {
- // Fast path: if we have valid coordinates, use them immediately
- if (!needsFetch) return {'lat': outLat, 'lng': outLng};
+ // [Geofence] takes its own `best` fix now and characterises it before
+ // deciding, so this method is back to what its name says: the coordinates
+ // that go **on the payload**. Telemetry, not evidence. Taking a second
+ // 8-second fix for it would cost the rider time at every door to improve
+ // a number nobody adjudicates.
+ // Fast path: if we have valid coordinates, use them immediately
+ if (!needsFetch) return {'lat': outLat, 'lng': outLng};
- // Reuse recently cached coordinates first if fresh (e.g. within 30s)
- // For now just check if they exist to save time
- if (currentLat.value.isNotEmpty &&
- currentLat.value != '0' &&
- currentLng.value.isNotEmpty &&
- currentLng.value != '0') {
- return {'lat': currentLat.value, 'lng': currentLng.value};
- }
+ // Reuse recently cached coordinates first if fresh (e.g. within 30s)
+ if (currentLat.value.isNotEmpty &&
+ currentLat.value != '0' &&
+ currentLng.value.isNotEmpty &&
+ currentLng.value != '0') {
+ return {'lat': currentLat.value, 'lng': currentLng.value};
}
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
@@ -1212,12 +1112,15 @@ class PickupsController extends GetxController
// ---------------- Geofencing helpers ----------------
//
- // The radius is [kGeofenceRadiusMeters] and nothing else. It read the
- // server's `pickupradius` out of prefs, which meant the app's one honesty
- // control was a per-tenant number nobody here could see — and it disagreed
- // with Home's own hardcoded 500. `pickupradius` is still stored at login;
- // it simply no longer decides this.
- double get _geofenceRadius => kGeofenceRadiusMeters;
+ // The radius is [kGeofenceRadiusMetres], in `lib/data/geofence.dart`, and
+ // nothing else. It read the server's `pickupradius` out of prefs, which made
+ // the app's one honesty control a per-tenant number nobody here could see —
+ // and it disagreed with Home's own hardcoded 500. `pickupradius` is still
+ // stored at login; it simply no longer decides this.
+
+ /// The fence's answer to the last rung that asked, for screens that want to
+ /// show the live distance rather than only the refusal.
+ GeofenceDecision? lastGeofence;
// Helper method to show snackbar reliably in both debug and release builds
//
@@ -1304,6 +1207,18 @@ class PickupsController extends GetxController
/// a stop the hub keeps rejecting. See [updatePickedStatus].
String? lastPickupRefusal;
+ /// The proximity gate for one rung. **False means do not call the API.**
+ ///
+ /// Adapts [Geofence] to the call sites below. The rider's own coordinates are
+ /// no longer taken as arguments: the fence takes its *own* fix, at
+ /// [LocationAccuracy.best], because the pair the screens pass in comes from a
+ /// `low`-accuracy Home poll or an unaged cache and carries no accuracy at all
+ /// — so the fence could not tell how much to trust the number it was
+ /// measuring a 100 m rule with. The parameters stay for the eight call sites
+ /// that build a payload from the same values; they are ignored here.
+ ///
+ /// Sets [lastBlockedReason] to the rider-facing sentence on every refusal, so
+ /// a caller can say what happened instead of reporting a network failure.
Future _checkGeofence(
double targetLat,
double targetLng,
@@ -1313,132 +1228,20 @@ class PickupsController extends GetxController
) async {
lastBlockedReason = null;
- // Opt-in via `--dart-define=BYPASS_GEOFENCE=true`; enforced otherwise. See
- // the declaration for why it is a define rather than a constant.
- //
- // Logged with `debugPrint` in *every* build mode, deliberately — not behind
- // `kDebugMode` like the diagnostics below. The whole risk of this flag is a
- // build going out with proximity silently off, so the one thing it must
- // never be is quiet.
- if (kBypassGeofenceForTesting) {
- debugPrint(
- '[GEOFENCE] OFF for $action — enforcement is disabled in this build. '
- 'Stop completion is NOT proximity-verified. '
- 'See kGeofenceEnforced.',
- );
- return true;
- }
-
- // Validate coordinates - ensure they are valid GPS coordinates
- final bool hasValidTarget =
- targetLat != 0 &&
- targetLng != 0 &&
- targetLat.abs() <= 90 &&
- targetLng.abs() <= 180;
- final bool hasValidCurrent =
- currentLat != 0 &&
- currentLng != 0 &&
- currentLat.abs() <= 90 &&
- currentLng.abs() <= 180;
-
- // ── Two ways to have no coordinates, and only one of them is the rider's ──
- //
- // This used to treat both the same and wave both through: "Missing
- // coordinates. Proceeding with update." That is the bypass that makes a
- // fence decorative — turn location off and every rung opens — and it was
- // survivable only because the fence itself was off.
- //
- // **No target.** The booking carries no pin. That is the hub's data, the
- // rider cannot fix it from a doorstep, and blocking him leaves the stop
- // unworkable by anyone. Allowed, and logged, exactly as before.
- if (!hasValidTarget) {
- debugPrint(
- '[GEOFENCE] $action allowed: the stop carries no coordinates '
- '($targetLat, $targetLng), so proximity cannot be checked. This is a '
- 'data gap on the booking, not a rider who is somewhere else.',
- );
- return true;
- }
-
- // **No fix.** Location is off, permission is denied, or the GPS did not
- // settle in time. This one the rider *can* fix, and it is the difference
- // between a fence and a suggestion — so it is refused, and the message
- // says which of the three to go and change.
- if (!hasValidCurrent) {
- debugPrint(
- '[GEOFENCE] $action refused: no usable fix '
- '($currentLat, $currentLng)',
- );
- lastBlockedReason =
- 'Your phone could not find your location, so this stop cannot be '
- 'marked ${action.toLowerCase()}. Turn location on, allow it for '
- 'Miler, and step outside if you can.';
- _showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
- return false;
- }
-
- try {
- final radius = _geofenceRadius;
- final distance = Geolocator.distanceBetween(
- targetLat,
- targetLng,
- currentLat,
- currentLng,
- );
- final distanceKm = distance / 1000.0;
- final distanceMeters = distance;
-
- // ── The phone's own error is credited to the rider ──
- //
- // A fix carries an accuracy in metres, and at a 10 m fence that number
- // is the same size as the thing being measured. Comparing a raw distance
- // against 10 m asserts a precision the hardware did not provide, and the
- // rider standing at the door on a ±25 m fix is the one it refuses.
- //
- // So the fence is measured against the *nearest point the phone allows*:
- // 12 m away on a ±20 m fix has not been shown to be outside it. He is
- // blocked when the phone says he is outside, not when the arithmetic
- // does. See [kGeofenceRadiusMeters].
- final slack = _lastFixAccuracy;
- final effective = (distance - slack).clamp(0.0, double.infinity);
-
- debugPrint(
- '[GEOFENCE] $action | target ($targetLat, $targetLng) '
- '| rider ($currentLat, $currentLng) '
- '| ${distanceMeters.toStringAsFixed(1)}m ±${slack.toStringAsFixed(0)}m '
- '→ ${effective.toStringAsFixed(1)}m vs ${radius.toStringAsFixed(0)}m',
- );
-
- if (effective > radius) {
- // ── One sentence, in metres he can act on ──
- //
- // Was three lines of "Distance: 4213 m (4.21 km) / Required: Within
- // 100 m" — a readout, in a snackbar, on a phone in a jacket pocket.
- // What the rider needs is how far he still has to go.
- final String away = distanceMeters >= 1000
- ? '${distanceKm.toStringAsFixed(1)} km'
- : '${distanceMeters.toStringAsFixed(0)} m';
- lastBlockedReason =
- "You're $away from this stop — get within "
- '${radius.toStringAsFixed(0)} m to mark it '
- '${action.toLowerCase()}';
- _showErrorSnackbar('Location Error', lastBlockedReason!, seconds: 5);
- return false;
- }
- return true;
- } catch (e) {
- if (kDebugMode) {
- debugPrint('[GEOFENCE] Error calculating distance: $e');
- }
- // On error, show warning but allow (fail-safe)
- _showErrorSnackbar(
- 'Location Warning',
- 'Unable to verify distance. Proceeding with caution.',
- bgColor: ColorConstants.warning,
- seconds: 3,
- );
- return true; // Allow on error (fail-safe)
+ final decision = await Geofence.check(
+ targetLat: targetLat,
+ targetLng: targetLng,
+ action: action,
+ );
+
+ lastGeofence = decision;
+ if (decision.allowed) return true;
+
+ lastBlockedReason = decision.reason;
+ if (decision.reason != null) {
+ _showErrorSnackbar('Location Error', decision.reason!, seconds: 5);
}
+ return false;
}
// Get last pickup location (for calculating riderkms between pickup)
@@ -1824,6 +1627,14 @@ class PickupsController extends GetxController
final mock = _mockStatus(pickupId, 'arrived');
if (mock != null) return mock;
+ // Cleared on entry, not only on the paths that set them. The caller now
+ // reads these to decide whether a failed arrival may still advance the rung
+ // locally, and a value left over from an earlier stop would answer for this
+ // one — refusing an arrival because a different door refused twenty minutes
+ // ago. `lastBlockedReason` gets the same treatment inside `_checkGeofence`.
+ lastArrivalRefusal = null;
+ lastArrivalNotice = null;
+
// START LOADING IMMEDIATELY for better UX
arrivedShimmer.value = true;
try {
@@ -1878,6 +1689,20 @@ class PickupsController extends GetxController
? resp!['message'].toString()
: 'Your office would not accept this arrival.')
: null;
+ // ── A write that never landed must not look like one that did ──
+ //
+ // `lastArrivalRefusal` stays null on a transport failure, deliberately:
+ // the caller lets a rider with no signal carry on rather than stranding
+ // him at a door. But it then advanced the rung *silently*, so the rider
+ // saw ARRIVED and had no way to know the hub had not been told — and
+ // the first person to find out was an operator wondering why he had
+ // been at a kitchen for forty minutes.
+ //
+ // He still carries on. He is simply told.
+ lastArrivalNotice = lastArrivalRefusal == null
+ ? 'Marked arrived on your phone. Your office has not been told — '
+ 'you had no connection. Tell them if it matters.'
+ : null;
debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}');
return false;
}
@@ -1957,18 +1782,25 @@ class PickupsController extends GetxController
final dLat = double.tryParse(dropLat) ?? 0.0;
final dLng = double.tryParse(dropLng) ?? 0.0;
+ // ── Unconditional ──
+ //
+ // This was wrapped in `if (dLat != 0 && dLng != 0)`, so a consignment
+ // whose drop pin was missing skipped the fence entirely — silently, with
+ // no log and no record. That is the one case where the fence matters
+ // most: nobody can say afterwards where the rider was standing. A missing
+ // pin is now [GeofenceOutcome.noTarget] and is refused with a sentence
+ // that sends the rider to his office.
+ //
// Fenced against the DROP, not the pickup. Using the pickup coordinates
- // here would fence the rider to the kitchen he left an hour ago.
- if (dLat != 0 && dLng != 0) {
- final inFence = await _checkGeofence(
- dLat,
- dLng,
- rLat,
- rLng,
- 'Delivery arrived',
- );
- if (!inFence) return false;
- }
+ // here would fence the rider to the counter he left an hour ago.
+ final inFence = await _checkGeofence(
+ dLat,
+ dLng,
+ rLat,
+ rLng,
+ 'Delivery arrived',
+ );
+ if (!inFence) return false;
// The clock the completion record reads, same key the pickup leg uses.
final prefs = await SharedPreferences.getInstance();
@@ -2027,16 +1859,25 @@ class PickupsController extends GetxController
final dLat = double.tryParse(dropLat) ?? 0.0;
final dLng = double.tryParse(dropLng) ?? 0.0;
- if (dLat != 0 && dLng != 0) {
- final inFence = await _checkGeofence(
- dLat,
- dLng,
- rLat,
- rLng,
- 'Delivered',
- );
- if (!inFence) return false;
- }
+ // ── Unconditional ──
+ //
+ // This was wrapped in `if (dLat != 0 && dLng != 0)`, so a consignment
+ // whose drop pin was missing skipped the fence entirely — silently, with
+ // no log and no record. That is the one case where the fence matters
+ // most: nobody can say afterwards where the rider was standing. A missing
+ // pin is now [GeofenceOutcome.noTarget] and is refused with a sentence
+ // that sends the rider to his office.
+ //
+ // Fenced against the DROP, not the pickup. Using the pickup coordinates
+ // here would fence the rider to the counter he left an hour ago.
+ final inFence = await _checkGeofence(
+ dLat,
+ dLng,
+ rLat,
+ rLng,
+ 'Delivered',
+ );
+ if (!inFence) return false;
_pickedTime = _formatDateTimeFull(DateTime.now());
diff --git a/lib/data/accepted_store.dart b/lib/data/accepted_store.dart
index 9371ca7..bff9b7e 100644
--- a/lib/data/accepted_store.dart
+++ b/lib/data/accepted_store.dart
@@ -57,6 +57,84 @@ Future _drainLegacy(String base, WorkScope scope) async {
debugPrint('[SCOPE] drained legacy "$base" into ${scope.key}');
}
+/// Moves records out of the old line-suffixed keys into this scope's key.
+///
+/// ── Why this one MERGES where [_drainLegacy] drops ──
+///
+/// A line-suffixed key is not anonymous the way a global one is. It already
+/// names the rider and the tenant — `completed_bookings::u38.t13.milkMan` —
+/// so everything in it provably belongs to this scope. There is nothing to
+/// attribute and nothing to guess, and dropping it would throw away work the
+/// rider actually did.
+///
+/// Both old drawers are merged, because the whole point of removing the line
+/// is that one rider's day is one day: a meal round and a logistics collection
+/// finished in the same shift belong in the same history. Ids already present
+/// win, so a re-run cannot duplicate a row.
+Future _drainLineScoped(String base, WorkScope scope) async {
+ final prefs = await SharedPreferences.getInstance();
+ final target = scope.scoped(base);
+
+ // Read the target through `get`, not the typed accessors: these base keys
+ // hold a JSON blob for record stores and a String list for id stores, and
+ // `getStringList` throws outright when it meets the blob.
+ final Object? existing = prefs.get(target);
+ final merged =