Session expiry, arrival geofence guard, multi-destination stops

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
This commit is contained in:
2026-09-18 11:05:40 +05:30
parent 127fa062ed
commit d612916fe4
53 changed files with 6346 additions and 748 deletions

View File

@@ -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<void> _notifyProfileController() async {
try {
if (Get.isRegistered<ProfileController>()) {
@@ -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<AuthNext> 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<bool> 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<bool> 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;
}
}

View File

@@ -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<bool> _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());