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());

View File

@@ -57,6 +57,84 @@ Future<void> _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<void> _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 = <Map<String, dynamic>>[];
final mergedIds = <String>{};
final ids = <String>{};
if (existing is String) {
for (final row in _decode(existing)) {
merged.add(row);
final id = _rowKey(row);
if (id.isNotEmpty) mergedIds.add(id);
}
} else if (existing is List) {
ids.addAll(existing.map((e) => e.toString()));
}
var moved = false;
for (final legacy in scope.legacyScopedKeys(base)) {
if (legacy == target || !prefs.containsKey(legacy)) continue;
final Object? value = prefs.get(legacy);
if (value is String) {
for (final row in _decode(value)) {
final id = _rowKey(row);
if (id.isNotEmpty && !mergedIds.add(id)) continue;
merged.add(scope.stamp(row));
}
} else if (value is List) {
ids.addAll(value.map((e) => e.toString()));
}
await prefs.remove(legacy);
moved = true;
}
if (!moved) return;
if (merged.isNotEmpty) {
await prefs.setString(target, jsonEncode(merged));
} else if (ids.isNotEmpty) {
await prefs.setStringList(target, ids.toList());
}
debugPrint('[SCOPE] merged line-scoped "$base" into ${scope.key}');
}
/// The identity a stored row is de-duplicated on while merging.
String _rowKey(Map<String, dynamic> row) {
for (final k in const [
'orderid',
'OrderId',
'bookingid',
'consignmentid',
'id',
]) {
final v = row[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString();
}
return '';
}
/// Runs the one-time drain for every legacy key. Cheap after the first call —
/// `containsKey` on a loaded prefs map.
Future<void> migrateLegacyStores() async {
@@ -75,6 +153,9 @@ Future<void> migrateLegacyStores() async {
_kOrderLabelsKeyBase,
]) {
await _drainLegacy(base, scope);
// Then fold in anything the old line-suffixed keys still hold. Order
// matters: the global drain writes the scoped key, and this merges into it.
await _drainLineScoped(base, scope);
}
}

View File

@@ -295,6 +295,44 @@ class ApiConfig {
'referenceno',
'orderid',
]);
// ── One customer pickup can be several drops ──
//
// A customer-app booking carries N destinations and `GET /miler/bookings`
// returns ONE ROW PER DESTINATION once the pickup has been collected —
// same `bookingid`, same `bookingreference`, different door. The backend
// labels them for us: `destinationseq` is which one this is and
// `destinationcount` how many there are (both absent, or 1, on every
// console and milk-run booking, which is the whole existing world).
//
// Everything the rider's device remembers about a stop is keyed on
// `orderid` — the accepted store dedupes on it, the consignment-id map
// files under it, the collected and out-for-delivery sets hold it, the ETA
// and km keys are built from it. So three drops sharing one `orderid` is
// not a display bug: `addAcceptedBookings` deduped two of the three away
// before any screen saw them, and the two consignment ids that lost the
// race were unrecoverable, which is a bag the rider is holding with no
// door to take it to.
//
// So `orderid` becomes the **stop** key and carries the destination on it.
// The booking's own reference is untouched below in `bookingreference`,
// which is what every screen shows the rider and what he reads out on the
// phone. Single-destination bookings are byte-identical to before — the
// suffix only exists where there is something to tell apart.
final destinationCount =
int.tryParse(
(pick(['destinationcount', 'destinationCount']) ?? '').toString(),
) ??
1;
final destinationSeq =
int.tryParse(
(pick(['destinationseq', 'destinationSeq']) ?? '').toString(),
) ??
0;
final bookingKey = ref ?? id;
final stopKey = destinationCount > 1
? '$bookingKey#$destinationSeq'
: bookingKey;
final status = pick([
'status',
'bookingstatus',
@@ -320,11 +358,37 @@ class ApiConfig {
return <String, dynamic>{
// identity
'pickupid': id,
'orderid': ref ?? id,
// The STOP key — see the note above. `bookingreference` is the booking's
// own name and is what gets shown; this is what gets remembered.
'orderid': stopKey,
'orderheaderid': id,
'bookingid': id, // keep original too
'bookingreference': s(ref),
// ── Which drop of the visit this is ──
//
// Carried so a card can say "Stop 2 of 3" rather than showing three rows
// that are identical down to the reference number. `destinationcount` is
// 1 for every single-destination and console booking, and the UI treats
// 1 as "say nothing".
'destinationseq': destinationSeq,
'destinationcount': destinationCount,
// ── The parcel's own number, and who is waiting for it ──
//
// All three exist per DESTINATION, not per booking: on a three-drop
// pickup each door has its own tracking number and its own receiver, and
// the booking-level customer is the SENDER, who is not at any of them.
// Dropped by this adapter until now, so the rider arrived at a stranger's
// door with the sender's name on his screen and no number to read out.
'trackingno': s(pick(['trackingno', 'trackingNo', 'tracking_no'])),
'recipientname': s(
pick(['recipientname', 'recipientName', 'recipient_name']),
),
'recipientphone': s(
pick(['recipientphone', 'recipientPhone', 'recipient_phone']),
),
// status
'orderstatus': fromConsignment.isNotEmpty
? fromConsignment

578
lib/data/geofence.dart Normal file
View File

@@ -0,0 +1,578 @@
/// ─────────────────────────────────────────────────────────────────────────
/// 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);
}
}
}

View File

@@ -336,11 +336,14 @@ class MilerApi {
}
// ═══════════════════════════════════════════════════════════════════════
// AUTH (3 routes — reset-pin is intentionally not one of them)
// AUTH (4 routes — reset-pin is intentionally not one of them)
// ═══════════════════════════════════════════════════════════════════════
/// Step 1. 404 when there is no account, 403 when the row is not role 5 or
/// not Active — both surface as `ok: false` with the server's message.
///
/// Answers `{success, message, phone, pin_set}` — **top level, not under
/// `data`**. Read the branch with [pinSetOf]; never off the message string.
static Future<ApiResult> login(String phone) => _send(
'POST',
'/miler/login',
@@ -352,6 +355,79 @@ class MilerApi {
},
);
/// Does this account already have a PIN on file?
///
/// ── The one thing the login response is asked for ──
///
/// Riders set their own PIN on first sign-in now; the console no longer
/// issues one. `pin_set` is how the server says which of the two screens the
/// rider is owed, and it is a **boolean** — the message beside it is prose
/// and prose gets reworded. Branching on "PIN verification required" would
/// break the day somebody improves that sentence.
///
/// Null when the response did not carry the field at all: an older server, or
/// a failure. A caller that cannot tell should send the rider to Enter-PIN,
/// which is the safe direction — a rider who does have a PIN can use it, and
/// one who does not gets a refusal he can report, rather than being walked
/// into a Set-PIN screen that will 409.
static bool? pinSetOf(ApiResult res) {
final raw = res.raw;
final v = raw is Map ? (raw['pin_set'] ?? raw['pinSet']) : null;
if (v is bool) return v;
if (v is String) {
final t = v.toLowerCase();
if (t == 'true') return true;
if (t == 'false') return false;
}
return null;
}
/// First-time PIN creation, self-service. `POST /miler/set-pin`.
///
/// ── This route did not exist, and its absence shaped the whole screen ──
///
/// The only PIN-write the backend had was `POST /miler/reset-pin`, which
/// needs an ADMIN token — it was once open, and reset-pin followed by
/// verify-pin took over any rider account given nothing but a phone number.
/// So the app could not let a rider set a PIN at all, and `AuthProvider`
/// answered its own Create-MPIN screen with a manufactured 403 telling him
/// his office issues it.
///
/// That is no longer true. This route is rider-authenticated by phone,
/// **cannot overwrite an existing PIN** (409 if one is set), and returns a
/// full session — the same `{token, user}` shape as [verifyPin] — so the
/// rider lands signed in without a second round trip.
///
/// Refusals: `404` no such phone, `403` inactive or not a miler, `409` a PIN
/// already exists — send that rider to Enter-PIN instead.
static Future<ApiResult> setPin({
required String phone,
required String pin,
String? deviceToken,
}) async {
final res = await _send(
'POST',
'/miler/set-pin',
auth: false,
body: {
'phone': phone,
'new_pin': pin,
'configid': configId,
if (hasTenantId) 'tenantid': tenantId,
if (deviceToken != null && deviceToken.isNotEmpty)
'device_token': deviceToken,
},
);
// Same token handling as verify-pin, deliberately: this IS a sign-in, and
// a second implementation of "where does the session come from" is how the
// two paths drift.
if (res.ok && res.raw is Map) {
final token = _str((res.raw as Map)['token']);
if (token.isNotEmpty) await ApiConfig.setToken(token);
}
return res;
}
/// Step 2. On success the token is stored and the caller gets `user`.
///
/// The login is under `user` / `user.profile` — there is no `data` key on
@@ -1001,10 +1077,24 @@ class MilerApi {
///
/// The signature expires in ten minutes, so a failed upload is re-*signed*
/// rather than retried against the old URL.
/// ── A pickup proof has no consignment, and must not pretend to ──
///
/// The server builds the storage key as `{folder}/{tag}-{id}-…`, choosing the
/// id from `consignmentid`, then `bookingid`, then the rider. A pickup photo
/// is taken *before* `pickup-complete` mints anything, so there is no
/// consignment to key it on — and this method only offered `consignmentid`.
/// The one caller that needed it passed a **booking** id in that slot, so
/// every pickup proof this app has ever uploaded was filed under a
/// consignment number that does not exist, colliding with whatever real
/// consignment later took it.
///
/// [bookingId] is the field the server already has for this case. Send
/// whichever one the stop actually has.
static Future<ApiResult> signUpload({
required String purpose,
String contentType = 'image/jpeg',
Object? consignmentId,
Object? bookingId,
}) => _send(
'POST',
'/miler/uploads/sign',
@@ -1012,6 +1102,7 @@ class MilerApi {
'purpose': purpose,
'contentType': contentType,
if (consignmentId != null) 'consignmentid': consignmentId,
if (bookingId != null) 'bookingid': bookingId,
},
);
@@ -1034,6 +1125,35 @@ class MilerApi {
File file, {
required String purpose,
Object? consignmentId,
Object? bookingId,
}) async =>
(await uploadProofRef(
file,
purpose: purpose,
consignmentId: consignmentId,
bookingId: bookingId,
))?.url;
/// The same upload, returning **both** references the platform uses.
///
/// ── Why the key matters, and why it was being thrown away ──
///
/// `POST /miler/uploads/sign` answers with `uploadurl`, `url` **and `key`**.
/// This method used to keep only `url` and discard the key, which was fine
/// while the only consumer was `deliver` — that route takes a `photourl`.
///
/// It is not fine for `POST /miler/bookings/:id/parcel`, whose `photos` field
/// wants the **storage key**, not a URL. The server's own note says why: the
/// customer is served a short-lived signed link *derived from* the key, never
/// a permanent one. Sending a URL there would store a link that either
/// expires or, worse, never does.
///
/// So both come back and each caller takes the one its route is specified in.
static Future<UploadRef?> uploadProofRef(
File file, {
required String purpose,
Object? consignmentId,
Object? bookingId,
}) async {
if (!file.existsSync()) return null;
@@ -1045,6 +1165,7 @@ class MilerApi {
purpose: purpose,
contentType: contentType,
consignmentId: consignmentId,
bookingId: bookingId,
);
if (!signed.ok) {
debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}');
@@ -1057,6 +1178,7 @@ class MilerApi {
.toString()
.trim();
final publicUrl = (map?['url'] ?? '').toString().trim();
final objectKey = (map?['key'] ?? '').toString().trim();
if (uploadUrl.isEmpty || publicUrl.isEmpty) {
debugPrint('[UPLOAD] sign returned no url pair');
return null;
@@ -1078,7 +1200,9 @@ class MilerApi {
body: await file.readAsBytes(),
)
.timeout(const Duration(seconds: 30));
if (res.statusCode >= 200 && res.statusCode < 300) return publicUrl;
if (res.statusCode >= 200 && res.statusCode < 300) {
return UploadRef(url: publicUrl, key: objectKey);
}
debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}');
} catch (e) {
debugPrint('[UPLOAD] PUT failed: $e');
@@ -1169,7 +1293,10 @@ class MilerApi {
if (heading != null) 'heading': _str(heading),
if (accuracy != null) 'accuracy': _str(accuracy),
if (status != null && status.isNotEmpty) 'status': status,
if (orderId != null) 'orderid': _str(orderId),
// The device's own stop key can carry a `#seq` destination suffix (see
// ApiConfig's adapter); the server has never seen one and this is a
// breadcrumb tag, not a join key. Send the booking's half.
if (orderId != null) 'orderid': _str(orderId).split('#').first,
if (battery != null) 'battery': _str(battery),
'is_charging': isCharging,
if (connection != null) 'connection': connection,
@@ -1298,6 +1425,26 @@ class MilerApi {
/// Dimensions are centimetres and weight is kilograms. They are what
/// `pickup-complete` recomputes chargeable weight from, so a parcel submitted
/// without them bills on the booked figure rather than the real one.
/// Where an uploaded image lives, in the two forms the platform uses.
///
/// `deliver` takes [url] as `photourl`; `parcel` takes [key] as one entry of
/// `photos`. Neither is a substitute for the other — see [MilerApi.uploadProofRef].
@immutable
class UploadRef {
const UploadRef({required this.url, required this.key});
/// The object's public URL.
final String url;
/// The object's storage key — `{folder}/{tag}-{id}-{date}-{time}-{rand}.{ext}`.
///
/// May be empty if an older server build does not return one; a caller that
/// needs it must treat empty as "no photo" rather than sending a blank entry.
final String key;
bool get hasKey => key.isNotEmpty;
}
class ParcelEntry {
final Object? parcelId;
final double weight;
@@ -1305,12 +1452,33 @@ class ParcelEntry {
final double width;
final double height;
/// Storage keys of the photographs taken of this parcel at the door.
///
/// ── The field the app never sent ──
///
/// `POST /miler/bookings/:id/parcel` has accepted `photos` for as long as the
/// route has existed, and the server's own note says why it matters: *"Weight
/// without a photograph is a number the customer has no way to check, and
/// this is the only point in the flow where anyone is standing next to the
/// parcel."*
///
/// Meanwhile the rider app **compels** the photograph — `StopVerificationPage`
/// will not let him confirm without one — uploaded it to object storage, and
/// then dropped the reference on the floor. The evidence existed in a bucket
/// nobody could search, for every CX pickup this app has ever done.
///
/// **Keys, not URLs.** The customer is served a short-lived signed link
/// derived from the key; a URL stored here either expires or never does.
/// See [MilerApi.uploadProofRef].
final List<String> photos;
const ParcelEntry({
this.parcelId,
required this.weight,
this.length = 0,
this.width = 0,
this.height = 0,
this.photos = const <String>[],
});
Map<String, dynamic> toJson() => {
@@ -1319,6 +1487,10 @@ class ParcelEntry {
'length': length,
'width': width,
'height': height,
// Omitted entirely when there is none, rather than sent as `[]`: an empty
// array is a claim that no photograph was taken, and a failed upload is
// not that claim.
if (photos.isNotEmpty) 'photos': photos,
};
}

View File

@@ -78,9 +78,68 @@ class MilkRun {
static const bool deliveryArrivalIsLocalOnly = true;
/// Order id of a stop, however the payload spells it.
///
/// On a customer-app pickup with several destinations this is the **stop**
/// key, not the booking's — `DM-482913#1` — because everything the device
/// remembers is filed under it and three doors sharing one key means three
/// doors sharing one memory. The suffix is added by `ApiConfig` where the row
/// is adapted; see the note there. [bookingKeyOf] is the plain booking key,
/// and `bookingreference` on the row is what the rider is shown.
static String idOf(Map<String, dynamic> stop) =>
(stop['orderid'] ?? stop['orderId'] ?? '').toString();
/// The booking this stop belongs to, with any destination suffix removed.
///
/// Used where the fact being recorded is about the **visit** rather than the
/// drop. Collection is the case that matters: the rider collects a whole
/// pickup once, so the collected set is written with the booking key while
/// the delivery stops that come out of it each carry their own. See
/// [wasCollected].
static String bookingKeyOf(Map<String, dynamic> stop) {
final raw = idOf(stop);
final cut = raw.indexOf('#');
if (cut > 0) return raw.substring(0, cut);
final ref = (stop['bookingreference'] ?? '').toString().trim();
return ref.isNotEmpty ? ref : raw;
}
/// How many doors this one pickup visit is for. 1 for everything but a
/// multi-destination customer-app booking.
static int destinationCountOf(Map<String, dynamic> stop) =>
int.tryParse((stop['destinationcount'] ?? '').toString()) ?? 1;
/// Which door of the visit this stop is, counting from 0.
static int destinationSeqOf(Map<String, dynamic> stop) =>
int.tryParse((stop['destinationseq'] ?? '').toString()) ?? 0;
/// `Stop 2 of 3`, or `''` when the pickup has only one door.
///
/// Empty rather than `Stop 1 of 1` deliberately: a label that appears on
/// every card in the app stops being read, and the fact it carries — this
/// bag is one of several from the same counter — is only ever news when
/// there are several.
static String stopLabel(Map<String, dynamic> stop) {
final count = destinationCountOf(stop);
if (count <= 1) return '';
return 'Stop ${destinationSeqOf(stop) + 1} of $count';
}
/// True when the visit this stop came from has been collected.
///
/// Asks for the stop's own key first and the booking's second, and the second
/// question is the one that matters. A multi-destination pickup is collected
/// **once**, under the booking key, while it is still a single pre-pickup
/// row; the poll after `pickup-complete` then replaces that row with one per
/// door, each carrying a key the collected set has never seen. Asking only
/// the stop key would drop every one of those bags back to "not collected"
/// at the moment the rider actually had them in his hands.
static bool wasCollected(
Map<String, dynamic> stop,
Set<String> collectedIds,
) =>
collectedIds.contains(idOf(stop)) ||
collectedIds.contains(bookingKeyOf(stop));
/// The consignment this stop became once it was collected, or '' before that.
///
/// The delivery route keys on this and not on the booking id — they come from
@@ -326,7 +385,7 @@ class MilkRun {
// this function did before and is still right for a milk run, whose rows
// often carry no consignment state at all.
if (!ServiceProfile.active.deliversToCustomer) return false;
if (collectedIds.contains(idOf(stop))) return true;
if (wasCollected(stop, collectedIds)) return true;
final status = stopStatusOf(stop);
return status.isPicked || status.isDeliveryLeg;
}

View File

@@ -68,6 +68,19 @@ import 'package:flutter/foundation.dart';
/// flutter run --dart-define=MOCK_BACKEND=true # the canned day
const bool kMockBackend = bool.fromEnvironment('MOCK_BACKEND');
/// One customer pickup with three doors, for looking at.
///
/// **Off unless asked for**, and separate from [kMockBackend] on purpose. The
/// bookings route above is deliberately empty — "inventing one here would put
/// fictional consignments in front of a parcel rider" — and that stays exactly
/// true by default. This is the one shape that cannot be reached any other way:
/// `GET /miler/bookings` only returns a row per destination once a
/// multi-destination customer-app booking has been collected, so seeing the
/// three-door card on a bench needs either a seeded backend or this.
///
/// flutter run --dart-define=MOCK_BACKEND=true --dart-define=MOCK_MULTIDROP=true
const bool kMockMultiDrop = bool.fromEnvironment('MOCK_MULTIDROP');
/// Canned answers for the routes the rider's day passes through.
///
/// Everything else gets a generic success, on purpose: the alternative is a
@@ -108,6 +121,103 @@ class MockBackend {
/// of it and no log line makes it look like a real session.
static const String token = 'MOCK-TOKEN-not-a-real-session';
/// One collected customer pickup, as three rows — the shape
/// `milerStopsForBooking` returns once `pickup-complete` has fanned a
/// multi-destination booking out into a consignment per door.
///
/// Written in the **raw wire shape**, not the app's stop shape, because that
/// is what the mock intercepts: these go through `ApiConfig.pickupFromBooking`
/// exactly as a real response would, so the adapter is being exercised rather
/// than bypassed. Same `bookingid` and `bookingreference` on all three, which
/// is the whole point — they are told apart only by `destinationseq`.
///
/// Ids are `MOCK-` per rule 3 so `purgeDemoRecords()` can find anything that
/// leaks into a store.
static const List<Map<String, dynamic>> multiDropVisit = [
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900500,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841020',
'destinationseq': 0,
'destinationcount': 3,
'next_action': 'deliver',
'step': 1,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '22, Anna Nagar West, Chennai, Tamil Nadu',
'deliverylatitude': 11.0210,
'deliverylongitude': 76.9810,
'recipientname': 'Anitha Raghavan',
'recipientphone': '9840012201',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900001, 'itemcategory': 'General'},
{'bookingparcelid': 900002, 'itemcategory': 'General'},
],
},
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900501,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841021',
'destinationseq': 1,
'destinationcount': 3,
'next_action': 'deliver',
'step': 2,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '7, Panampilly Nagar, Ernakulam, Kerala',
'deliverylatitude': 11.0301,
'deliverylongitude': 76.9902,
'recipientname': 'Suresh Menon',
'recipientphone': '9847712345',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900003, 'itemcategory': 'General'},
],
},
{
'bookingid': 900482913,
'bookingreference': 'MOCK-482913',
'status': 'Converted_To_Consignment',
'consignmentid': 900502,
'consignmentstatus': 'Out_for_Delivery',
'trackingno': 'MOCK-TRK-8841022',
'destinationseq': 2,
'destinationcount': 3,
'next_action': 'deliver',
'step': 3,
'pickup_source_type': 'customer',
'pickup_source_name': 'Ravi Kumar',
'pickupaddress': '14, Cross Cut Road, Gandhipuram, Coimbatore 641012',
'pickuplatitude': 11.0183,
'pickuplongitude': 76.9725,
'deliveryaddress': '90, Indiranagar 2nd Stage, Bengaluru, Karnataka',
'deliverylatitude': 11.0405,
'deliverylongitude': 77.0011,
'recipientname': 'Meena Prakash',
'recipientphone': '9880045512',
'customername': 'Ravi Kumar',
'customerphone': '9843311220',
'parcels': [
{'bookingparcelid': 900004, 'itemcategory': 'General'},
],
},
];
/// Answers [method] [path], or null when the caller should fall through to
/// the network. Null is never returned while [enabled] — see the class note.
static Map<String, dynamic>? respond(
@@ -160,7 +270,7 @@ class MockBackend {
// at all. A parcel booking list has no fixture and inventing one here would
// put fictional consignments in front of a parcel rider.
if (route.endsWith('/bookings') || route.endsWith('/assignments')) {
return _ok(const <dynamic>[]);
return _ok(kMockMultiDrop ? multiDropVisit : const <dynamic>[]);
}
// ── Everything the rider writes ──

77
lib/data/session.dart Normal file
View File

@@ -0,0 +1,77 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/geofence.dart';
import 'package:miler/data/mutation_guard.dart';
import 'package:miler/data/proof_store.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// ENDING A SESSION, IN ONE PLACE
///
/// A rider's session ends two ways, and until now only one of them was
/// implemented:
///
/// * **He presses Log out.** The Account screen tore everything down —
/// scoped stores, doorstep photos, bearer token, in-flight guard, cached
/// fix — raised `logged_out` and sent him to sign-in.
/// * **The server stops accepting his token.** `MilerApi` drops the token on
/// any 401 and calls `onUnauthorized`, which **nothing ever assigned**. So
/// the credential vanished and nothing else did.
///
/// The second case left a state that looks signed in and cannot work: the
/// profile is still in SharedPreferences, `logged_out` is still `false`, so
/// the app draws the rider's own name over a dashboard whose every call comes
/// back `401 authorization header is required`. Observed on a real handset —
/// Karthikeyan CBE Rider, rider 23, name and tenant on screen, `authtoken`
/// absent from disk, Home / Deliveries / Activity and the background heartbeat
/// all failing in a loop.
///
/// What that costs is not a blank screen. A rider opens the app, sees himself
/// signed in and sees no jobs, and concludes there is no work today — there is
/// no prompt anywhere telling him to sign in again. He waits; the stops go
/// undelivered; support cannot tell over the phone.
///
/// So the teardown lives here, both callers use it, and neither can drift from
/// the other. The rule it encodes: **the credential and the appearance of
/// being signed in end together, always.**
/// ─────────────────────────────────────────────────────────────────────────
/// Tears down everything this device holds about the signed-in rider.
///
/// Safe to call twice — every step is idempotent — which matters because a 401
/// rarely arrives alone: the home poll, the deliveries queue and the heartbeat
/// can all get one within the same second.
///
/// Deliberately knows nothing about navigation. Where the rider goes next is a
/// UI decision and belongs to the caller; this is the part that must happen
/// identically whether he chose to leave or was shown the door.
Future<void> endSession() async {
// Finished work, skipped stops, carried bags and bag labels are scoped per
// rider/tenant/line — this drops *this* scope so nothing survives into the
// next rider's session on a shared handset.
await clearScopedStores();
// Doorstep photos are exactly the kind of record that must not outlive the
// session that took them.
await ProofStore.clearScope();
// The credential goes with the session, not at the next sign-in. Anything
// that reads the token without checking a flag — a background isolate, the
// notification handler, a heartbeat that outlives a route change — must not
// be able to keep calling as the rider who has left.
await ApiConfig.clearToken();
// A stale in-flight mutation must not find a key still held from the session
// being closed.
MutationGuard.reset();
// A cached position is a fact about a rider and must not outlive him: the
// next person to sign in would have his first proximity check measured from
// wherever the last rider was standing.
Geofence.resetCache();
// The flag the launch path reads to decide between Home and sign-in.
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('logged_out', true);
}

446
lib/data/task_profile.dart Normal file
View File

@@ -0,0 +1,446 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/next_leg.dart';
import 'package:miler/data/service_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHAT **THIS ONE JOB** REQUIRES — asked of the job, never of the rider
///
/// There is one kind of Miler. The same rider can be carrying ten meal parcels
/// out of a kitchen, five customer pickups booked in the Doormile app, and a
/// hub handover, all inside one shift. So nothing about a rider can decide how
/// a stop behaves: a rider is not "a milk man" or "a logistics rider", he is a
/// person with a queue, and every item in that queue brings its own workflow.
///
/// This is the replacement for reading [ServiceProfile.active]. That static
/// answers "who is this rider", which is the wrong question and gives the same
/// answer for every row on the screen — so a customer pickup sitting next to a
/// kitchen collection got the kitchen's rules, or vanished.
///
/// ── Read, never inferred ──
///
/// Every field below is derived from what the **server said about this row**:
/// `pickup_source_type` and `next_action`, both already on `GET /miler/bookings`
/// and both already parsed ([PickupSource], [NextLeg]). Nothing here looks at a
/// tenant, a business name, an address, a pincode or a missing id. Where the
/// server has not said, the answer is [WorkKind.unknown] and the screens draw
/// the plain, safe shape — the same doctrine [PickupSource] already states:
/// the server's word, or an honest "not known", never a guess.
/// ─────────────────────────────────────────────────────────────────────────
enum WorkKind {
/// Collect in bulk from a place of business — a kitchen, a base, a shop.
/// The meal round's morning load and an ordinary hub collection are the same
/// shape of work: one address, a manifest, many bags.
sourcePickup,
/// Collect from a person's front door, against a booking that customer made
/// in the Doormile app. Needs the verify-at-the-door flow: count what is
/// actually there, weigh it, capture the destination(s), take payment if the
/// booking asks for it.
customerPickup,
/// Already in the rider's hands, going to a receiver.
customerDelivery,
/// Already in the rider's hands, going to a base. The network carries it from
/// there — this rider does not ride it to another city.
hubHandover,
/// Nothing left for this rider on this item.
done,
/// In custody, and the server has not said where it goes. Deliberately not
/// folded into [customerDelivery] or [done]: see [NextLeg.unknown], which
/// makes the same call for the same reason.
unknown,
}
/// The capabilities one work item needs, resolved from that item alone.
@immutable
class TaskProfile {
const TaskProfile._({
required this.kind,
required this.groupsBySource,
required this.leavesHomeOnAccept,
required this.deliversToCustomer,
required this.endsAtHub,
required this.capturesShipmentDetails,
required this.mayCollectCash,
});
/// What kind of work this row is.
final WorkKind kind;
/// This stop is collected in bulk from a named place of business, so Home can
/// head it with that place — a kitchen load is one visit, not fourteen rows.
///
/// ── NOT a drop-in for `ServiceProfile.active.sourceIsKitchen` ──
///
/// Read this before migrating a grouping call site. `sourceIsKitchen` looks
/// like "does this group?" and is not: it **selects which key to group by**.
///
/// • On the milk round, stops group under the **source they were collected
/// from** — the kitchen.
/// • On logistics, stops group under the **base that assigned the work**,
/// drops and collections alike, *including a collection made at a
/// customer's own door*. `hub_pickup_group_test.dart` states the rule:
/// "one building assigned them, so one node holds them".
///
/// Substituting this flag for that one splits a single hub run into two
/// groups — a customer collection stops keying to the assigning base and
/// wanders off under its own name. That was tried, and those tests caught it.
///
/// ── And DO NOT migrate a grouping site to this flag ──
///
/// This was attempted twice and reverted twice. The second attempt is the
/// instructive one: with the safe default below, `hub_pickup_group_test.dart`
/// went green and the **milk round lost every kitchen heading** —
/// `service_flow_test.dart` reports `Found 0 widgets with text "Kitchen 2"`.
///
/// The reason is the thing to understand before trying again: **the rows do
/// not carry the discriminator**. Neither the meal fixtures nor the logistics
/// fixtures set `pickup_source_type`, so per-stop classification cannot tell
/// a kitchen load from a customer door — both land on [WorkKind.unknown].
/// `ServiceProfile.active.sourceIsKitchen` was not merely *deciding* the
/// grouping; it was *supplying information the payload lacks*. Removing it
/// removes the fact, and no default can be right for both lines at once:
/// grouping by default breaks logistics, not grouping breaks the milk round.
///
/// What a grouping site actually needs is the stop's own **source identity**,
/// which the rows DO carry — `sourcename`, `pickuplocationid`, `sourceid`.
/// `MilkRun.sourceKeyOf` already reads exactly those and returns `id:…` or
/// `name:…` for a stop with a real named counter, falling back to `at:…` or
/// `none` for one without. That distinction is the grouping key, and it works
/// on today's payloads. Build on it, not on this flag.
///
/// This flag stays honest about its own narrow claim: "the server said this
/// is collected from a business". It is correct for rows that carry
/// `pickup_source_type`, and it is silent — not wrong — for rows that do not.
final bool groupsBySource;
/// Whether accepting moves it off Home immediately.
///
/// A customer pickup does: it becomes an appointment the rider owes someone,
/// and it belongs on Bookings until he has collected it. A source pickup does
/// not: accepting a kitchen load does not put anything in his hands, so it
/// stays on Home until he is actually carrying it.
///
/// Replaces `ServiceProfile.active.handoffAt`.
final bool leavesHomeOnAccept;
/// Finishes at a receiver's door.
final bool deliversToCustomer;
/// Finishes by handing over at a base.
final bool endsAtHub;
/// The door flow has to capture what is being shipped and where it is going —
/// package count, weights, destinations, recipients. Only a customer pickup
/// does this; a kitchen manifest is already known before the rider arrives.
final bool capturesShipmentDetails;
/// Money may change hands on this item. Whether it actually does is the
/// row's own COD figure — this only says the flow must be able to.
final bool mayCollectCash;
/// The safe shape: a plain stop, no grouping, no door capture, no promises
/// about where it ends.
static const TaskProfile unknownTask = TaskProfile._(
kind: WorkKind.unknown,
groupsBySource: false,
leavesHomeOnAccept: false,
deliversToCustomer: false,
endsAtHub: false,
capturesShipmentDetails: false,
mayCollectCash: false,
);
static const TaskProfile sourcePickup = TaskProfile._(
kind: WorkKind.sourcePickup,
groupsBySource: true,
leavesHomeOnAccept: false,
deliversToCustomer: false,
endsAtHub: false,
capturesShipmentDetails: false,
mayCollectCash: false,
);
static const TaskProfile customerPickup = TaskProfile._(
kind: WorkKind.customerPickup,
groupsBySource: false,
leavesHomeOnAccept: true,
deliversToCustomer: false,
endsAtHub: false,
capturesShipmentDetails: true,
mayCollectCash: true,
);
static const TaskProfile customerDelivery = TaskProfile._(
kind: WorkKind.customerDelivery,
groupsBySource: false,
leavesHomeOnAccept: true,
deliversToCustomer: true,
endsAtHub: false,
capturesShipmentDetails: false,
mayCollectCash: true,
);
static const TaskProfile hubHandover = TaskProfile._(
kind: WorkKind.hubHandover,
groupsBySource: false,
leavesHomeOnAccept: true,
deliversToCustomer: false,
endsAtHub: true,
capturesShipmentDetails: false,
mayCollectCash: false,
);
static const TaskProfile done = TaskProfile._(
kind: WorkKind.done,
groupsBySource: false,
leavesHomeOnAccept: true,
deliversToCustomer: false,
endsAtHub: false,
capturesShipmentDetails: false,
mayCollectCash: false,
);
/// Which surface this item belongs on. The whole point of the type.
///
/// • **Home** — assigned, not yet in the rider's hands, and not yet an
/// appointment he has taken on.
/// • **Bookings** — a customer visit he has accepted and still owes.
/// • **Deliveries** — anything already in his custody.
/// • **Activity** — finished.
WorkSurface surfaceWhen({required bool accepted}) => switch (kind) {
WorkKind.done => WorkSurface.activity,
WorkKind.customerPickup =>
accepted ? WorkSurface.bookings : WorkSurface.home,
WorkKind.sourcePickup => WorkSurface.home,
WorkKind.customerDelivery ||
WorkKind.hubHandover ||
WorkKind.unknown => WorkSurface.deliveries,
};
/// Resolve a queue row.
///
/// Order matters: `next_action` is asked first because it is the server's
/// statement about **where this parcel is in its life**, and that outranks
/// where it was collected from. A parcel picked up at a customer's door is a
/// delivery once it is in the bag; it stops being a pickup the moment it is
/// collected, and only the leg knows that.
static TaskProfile of(Map<String, dynamic> row) {
final leg = NextLegResolver.rowNextAction(row);
switch (leg) {
case NextActionWord.deliver:
case NextActionWord.startDelivery:
return customerDelivery;
case NextActionWord.inwardAtHub:
return hubHandover;
case NextActionWord.handedToHub:
case NextActionWord.none:
return done;
}
// Still to be collected — `next_action: pickup`, or a row too old to carry
// one. `stoptype` is the second witness: the server derives it from the
// booking status, so it answers even when next_action is absent.
final stopType = (row['stoptype'] ?? '').toString().trim().toLowerCase();
if (leg.isEmpty && stopType == 'delivery') {
// Collected, and nothing said where it goes. Custody without a
// destination — NextLeg.unknown's case exactly.
return unknownTask;
}
return switch (PickupSource.of(row)) {
PickupSource.customer => customerPickup,
PickupSource.hub ||
PickupSource.merchant ||
PickupSource.store => sourcePickup,
// ── No word from the server: take the safe shape, not a kind ──
//
// This said [sourcePickup] once, and that was the bug. `sourcePickup`
// carries `groupsBySource: true`, so every row too old to carry a
// `pickup_source_type` — which is every fixture in the existing suite,
// and every booking written before these fields shipped — started
// grouping under whatever name it could scrape together. A logistics day
// that is meant to bucket under one assigning base split into a group per
// customer. `hub_pickup_group_test.dart` caught it.
//
// An absent field is not evidence of a kitchen. It is evidence of
// nothing, and [unknownTask] is what nothing looks like: no grouping, no
// door capture, no promise about where it ends. Same doctrine
// [PickupSource] already states — the server's word, or an honest "not
// known", never a guess.
PickupSource.unknown => unknownTask,
};
}
}
/// The four places work can live in the rider app.
enum WorkSurface { home, bookings, deliveries, activity }
/// What a whole trip looks like, derived from the stops actually in it.
///
/// ── Why a trip needs its own answer ──
///
/// A few things on Home describe the **day**, not one stop: whether it starts
/// at a kitchen, whether it ends by returning to a base. Those used to read the
/// rider's line, which made them constant for the whole app — true enough when
/// a rider only ever did one kind of work.
///
/// Under mixed work they are no longer constant, and they are no longer even
/// exclusive: one trip can hold a kitchen load *and* five customer pickups, and
/// can finish by handing some parcels to a base while the last meal goes to
/// somebody's door. So the question changes from "which line is this rider on"
/// to "what is actually in this trip", and the honest answer is a fold over the
/// stops.
///
/// [endsAtHub] is `any`, not `every`, deliberately: if even one parcel has to
/// be handed in at a base, the rider's day ends at that base. Telling him
/// "END · HOME" while he is still carrying something the network needs is the
/// failure worth avoiding.
abstract final class TripShape {
/// The trip collects in bulk somewhere — so Home can head that group with the
/// place it came from.
static bool groupsBySource(Iterable<Map<String, dynamic>> stops) =>
stops.any((s) => TaskProfile.of(s).groupsBySource);
/// Something in the trip has to be handed over at a base before the rider is
/// finished.
static bool endsAtHub(Iterable<Map<String, dynamic>> stops) =>
stops.any((s) => TaskProfile.of(s).endsAtHub);
/// The trip contains at least one customer visit the rider owes — the work
/// that belongs on Bookings rather than Home once accepted.
static bool hasCustomerPickup(Iterable<Map<String, dynamic>> stops) =>
stops.any((s) => TaskProfile.of(s).kind == WorkKind.customerPickup);
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE BRIDGE — per item where the row knows, per line where it does not
///
/// [TaskProfile] is the right answer and it could not simply replace
/// [ServiceProfile.active] in one pass, for a reason worth stating plainly
/// rather than discovering twice: **most rows do not carry the discriminator
/// yet.** `pickup_source_type` and `next_action` shipped recently; a booking
/// written before them, and every fixture in the existing suite, carries
/// neither. [TaskProfile.of] correctly answers [WorkKind.unknown] for those,
/// and a screen that took `unknown` at face value would lose the milk round its
/// kitchen headings and the logistics day its door capture — which is what
/// happened both times a grouping site was migrated wholesale.
///
/// So this is the migration path, not a second source of truth:
///
/// the row said something → the row decides, per item
/// the row said nothing → the rider's line decides, exactly as before
///
/// Every method below is that shape. The consequence is the one that matters
/// for mixed work: a CX customer pickup sitting next to a kitchen collection
/// now gets **its own** rules on the strength of its own `pickup_source_type`,
/// instead of inheriting whatever the rider's tenant happens to say. The
/// fallback keeps every existing row behaving precisely as it does today, which
/// is what makes this safe to ship in the same release as the fence.
///
/// As the backend fills these fields in on every row (see handoff BE-6), the
/// fallbacks stop being reachable and can be deleted a call site at a time.
/// ─────────────────────────────────────────────────────────────────────────
abstract final class WorkPolicy {
/// Does the rider have to establish what is being shipped at this stop —
/// count the parcels, weigh them, capture destination and recipient, take
/// payment?
///
/// This is the question that most needed taking off the rider. It had exactly
/// one call site, `ServiceProfile.active.needsVerification`, and that static
/// gives the *same answer for every row on the screen*: on a meal tenant a CX
/// customer pickup was walked straight past the door flow — no parcel count,
/// no destination, no weight — and the booking pivoted on whatever the
/// customer had typed into the app days earlier. On a parcel tenant the
/// reverse: a kitchen collection of fourteen known bags asked the rider to
/// itemise a manifest that was already on his screen.
///
/// A customer pickup needs it. A collection from a counter does not — the
/// manifest is known before he arrives.
static bool capturesShipmentDetails(Map<String, dynamic> row) {
final profile = TaskProfile.of(row);
if (profile.kind == WorkKind.unknown) {
return ServiceProfile.active.needsVerification;
}
return profile.capturesShipmentDetails;
}
/// Does this stop establish the shipment's addresses and price — the
/// logistics desk, as against a proof-of-collection photo?
///
/// Same shape and the same reason. Falls back to
/// `ServiceProfile.active.capturesShipmentAddresses`.
static bool capturesShipmentAddresses(Map<String, dynamic> row) {
final profile = TaskProfile.of(row);
if (profile.kind == WorkKind.unknown) {
return ServiceProfile.active.capturesShipmentAddresses;
}
return profile.capturesShipmentDetails;
}
/// Does collecting here bring a shipment into existence?
static bool initiatesShipment(Map<String, dynamic> row) {
final profile = TaskProfile.of(row);
if (profile.kind == WorkKind.unknown) {
return ServiceProfile.active.initiatesShipment;
}
return profile.kind == WorkKind.customerPickup;
}
/// Does an accepted stop stay on Home until the rider is actually carrying
/// it, rather than moving to Bookings the moment he accepts?
///
/// The per-item replacement for `ServiceProfile.active.handsOffAtCollection`
/// (`handoffAt == HandoffPoint.collected`). A **source pickup** stays: taking
/// on a kitchen load puts nothing in his hands and he still has to go and
/// collect it. A **customer pickup** does not: accepting it is an appointment
/// he now owes somebody, and it belongs on Bookings until he has been.
///
/// Asking the rider's line for this gave one answer for every row on the
/// screen — so on a meal tenant an accepted CX pickup sat on Home beside ten
/// kitchen bags with nothing to distinguish it, and on a parcel tenant a
/// kitchen load vanished off Home the instant it was accepted.
static bool staysOnHomeUntilCollected(Map<String, dynamic> row) {
final profile = TaskProfile.of(row);
if (profile.kind == WorkKind.unknown) {
return ServiceProfile.active.handsOffAtCollection;
}
return !profile.leavesHomeOnAccept;
}
/// Which surface this item belongs on.
///
/// The whole point of [TaskProfile], and the answer the four-tab shell cannot
/// yet act on in full — see the release report. Correct per item today, so
/// the queues can be split without re-deriving the rule.
static WorkSurface surfaceOf(
Map<String, dynamic> row, {
required bool accepted,
}) => TaskProfile.of(row).surfaceWhen(accepted: accepted);
/// True when this one item ends by handing over at a base.
///
/// Read from the row's `next_action`. Unlike the flags above there is no
/// fallback and none is wanted: [TaskProfile.hubHandover] is reached only
/// when the server has said `inward_at_hub`, and guessing a base leg from the
/// rider's line is how a hyperlocal parcel gets routed to a counter.
static bool endsAtHub(Map<String, dynamic> row) =>
TaskProfile.of(row).kind == WorkKind.hubHandover;
/// True when this one item ends at a receiver's door.
///
/// Falls back to the line, because a row with no `next_action` genuinely does
/// not say — and on a meal run every stop does end at a door.
static bool deliversToCustomer(Map<String, dynamic> row) {
final profile = TaskProfile.of(row);
if (profile.kind == WorkKind.unknown) {
return ServiceProfile.active.deliversToCustomer;
}
return profile.deliversToCustomer;
}
}

View File

@@ -87,7 +87,9 @@ abstract final class WorkBoundary {
}) {
final status = stopStatusOf(stop);
if (status.isPicked || status.isDeliveryLeg) return true;
return collectedIds.contains(MilkRun.idOf(stop));
// Booking key as well as stop key: a multi-destination pickup is collected
// once, before it splits into one row per door. See [MilkRun.wasCollected].
return MilkRun.wasCollected(stop, collectedIds);
}
/// True when this order is finished or withdrawn, whatever screen it was on.

View File

@@ -42,13 +42,8 @@ import 'package:miler/data/service_profile.dart';
class WorkScope {
final int userId;
final int tenantId;
final ServiceLine line;
const WorkScope({
required this.userId,
required this.tenantId,
required this.line,
});
const WorkScope({required this.userId, required this.tenantId});
/// The scope of the session running right now.
///
@@ -65,26 +60,17 @@ class WorkScope {
? raw
: int.tryParse(raw?.toString() ?? '') ?? 0;
final tenantId = await ApiConfig.storedTenantId();
return WorkScope(
userId: userId,
tenantId: tenantId,
line: ServiceProfile.active.line,
);
return WorkScope(userId: userId, tenantId: tenantId);
} catch (e) {
debugPrint('[SCOPE] could not resolve the session scope: $e');
return WorkScope(
userId: 0,
tenantId: 0,
line: ServiceProfile.active.line,
);
return const WorkScope(userId: 0, tenantId: 0);
}
}
/// A signed-out or unreadable session. Deliberately still a real scope
/// rather than null: code paths that run before login write to their own
/// drawer instead of into the last rider's.
static WorkScope get anonymous =>
WorkScope(userId: 0, tenantId: 0, line: ServiceProfile.active.line);
static WorkScope get anonymous => const WorkScope(userId: 0, tenantId: 0);
/// The suffix that turns a store key into this scope's key.
///
@@ -93,11 +79,36 @@ class WorkScope {
/// All three parts are in it because all three can change independently: a
/// rider can move tenant, a tenant can run either line, and one device can
/// see several riders.
String get key => 'u$userId.t$tenantId.${line.name}';
String get key => 'u$userId.t$tenantId';
/// Scopes a legacy global key.
String scoped(String baseKey) => '$baseKey::$key';
/// The keys this scope's records used to live under, newest convention first.
///
/// ── Why the line came out of the key ──
///
/// The key used to carry a third part, the rider's service line:
/// `completed_bookings::u38.t13.milkMan`. That was right while a rider was
/// one thing for the life of his account. He is not: one Miler carries meal
/// parcels, logistics collections and customer pickups in the same shift, and
/// keying his records by a line splits one day's work across two drawers —
/// with whichever drawer the app resolved into today being the only one he
/// can see. Work he finished an hour ago disappears because a label changed.
///
/// Rider and tenant still scope it. Those are facts about *who* stored the
/// record, which is what ownership means. The line was a fact about *what he
/// was doing*, which belongs on the work item, not on the drawer.
///
/// Every historical spelling is listed so a rider upgrading mid-shift keeps
/// what he has already done.
Iterable<String> legacyScopedKeys(String baseKey) sync* {
for (final line in ServiceLine.values) {
yield '$baseKey::u$userId.t$tenantId.${line.name}';
}
}
/// Does [record] provably belong to this scope?
///
/// Used on rows that were written before scoping existed, and as a
@@ -127,12 +138,9 @@ class WorkScope {
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser == 0 && rowTenant == 0 && rowLine.isEmpty) return false;
if (rowUser == 0 && rowTenant == 0) return false;
if (rowUser != 0 && rowUser != userId) return false;
if (rowTenant != 0 && rowTenant != tenantId) return false;
if (rowLine.isNotEmpty && rowLine != line.name) return false;
return true;
}
@@ -168,11 +176,8 @@ class WorkScope {
'scopeuserid',
]);
final rowTenant = intOf(const ['tenantid', 'TenantId', 'scopetenantid']);
final rowLine = (record['scopeline'] ?? '').toString();
if (rowUser != 0 && userId != 0 && rowUser != userId) return true;
if (rowTenant != 0 && tenantId != 0 && rowTenant != tenantId) return true;
if (rowLine.isNotEmpty && rowLine != line.name) return true;
return false;
}
@@ -184,18 +189,16 @@ class WorkScope {
...record,
'scopeuserid': userId,
'scopetenantid': tenantId,
'scopeline': line.name,
};
@override
bool operator ==(Object other) =>
other is WorkScope &&
other.userId == userId &&
other.tenantId == tenantId &&
other.line == line;
other.tenantId == tenantId;
@override
int get hashCode => Object.hash(userId, tenantId, line);
int get hashCode => Object.hash(userId, tenantId);
@override
String toString() => 'WorkScope($key)';

View File

@@ -1,4 +1,3 @@
import 'dart:async' show unawaited;
import 'dart:io';
import 'package:miler/helpers/http_overrides.dart';
import 'package:flutter/material.dart';
@@ -22,7 +21,10 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/background/backgroundservice.dart';
import 'package:miler/helpers/shift_end_alarm.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/session.dart';
import 'package:miler/views/onboardscreens/Sign_in.dart';
import 'package:miler/xpress/delivery_bootstrap.dart';
import 'package:miler/views/helpers/constants/app_theme.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
@@ -77,9 +79,29 @@ Future<bool> recheckVersion() async {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// One-time drain of the pre-scoping global stores. See [migrateLegacyStores]
// for why unattributable rows are dropped rather than adopted.
unawaited(migrateLegacyStores());
// ── Awaited, because the screens read what it writes ──
//
// This was `unawaited(...)`, which meant the one-time drain of the
// pre-scoping stores raced the first frame. On the launch after an upgrade —
// the only launch where it does anything — Home, Deliveries and Activity
// could all read the scoped key *before* the migration had written it, and a
// rider mid-shift opened the app to an empty day. The rows were not lost; he
// simply could not see them until he killed and reopened the app, which is
// not a thing he knows to do.
//
// It is cheap on every other launch: `containsKey` against an already-loaded
// preferences map, for eight base keys, and it returns before the first
// `await` when there is nothing to move.
//
// Guarded, and deliberately not fatal. A migration that throws — a corrupt
// blob, a prefs backend that will not open — must not stop the rider signing
// in and working. He loses local history he can no longer see anyway; the
// server's record is untouched by any of this.
try {
await migrateLegacyStores();
} catch (e, st) {
debugPrint('[SCOPE] legacy store migration failed, continuing: $e\n$st');
}
HttpOverrides.global = MyHttpOverrides();
try {
@@ -102,6 +124,25 @@ Future<void> main() async {
// way to receive it at all. Refreshed from `GET /miler/profile` first so the
// resolution below reads the current answer. Cheap and non-blocking for a
// rider we already have a name for; see [refreshTenantFromProfile].
// ── What happens when the server stops accepting this rider ──
//
// `MilerApi` drops the bearer token on any 401 and calls `onUnauthorized`.
// Nothing ever assigned it, so the second half never happened: the credential
// was deleted and the app went on believing it was signed in, because the
// profile and `logged_out = false` were both still on disk. Every call after
// that came back `401 authorization header is required`, forever, behind a
// dashboard showing the rider's own name.
//
// Observed on a handset: rider 23, name and tenant drawn on Account, no
// `authtoken` in SharedPreferences, Home / Deliveries / Activity and the
// heartbeat all failing in a loop. What it costs is not a blank screen — it
// is a rider who sees no jobs, concludes there is no work today, and is never
// told to sign in again.
//
// Registered here, before the line branch below, because both lines make
// authenticated calls and either can be the one to receive the 401.
MilerApi.onUnauthorized = _handleSessionExpired;
await refreshTenantFromProfile();
final profile = await TenantController.to.load();
@@ -140,6 +181,53 @@ Future<void> main() async {
runApp(const _RootApp());
}
/// True while a session-expiry teardown is already under way.
///
/// A 401 rarely arrives alone — the home poll, the deliveries queue and the
/// background heartbeat can each get one within the same second, and every one
/// of them calls this. Without the latch the rider would be torn down three
/// times and pushed at the sign-in screen three times, which on GetX stacks
/// three routes he then has to dismiss.
bool _signingOut = false;
/// Ends the session the server has stopped accepting, and says so.
///
/// Deliberately not silent. The rider did not choose this, so he is told what
/// happened in the words of the thing that happened — his session ended — and
/// landed somewhere he can act on it. Dropping him on sign-in with no message
/// reads as the app having crashed and lost his work.
Future<void> _handleSessionExpired() async {
if (_signingOut) return;
_signingOut = true;
try {
await endSession();
// A 401 can reach a background isolate, where there is no navigator and
// `Get.offAll` would throw. The teardown above has still happened, so the
// next launch reads `logged_out` and opens sign-in — the rider is not left
// holding a dead session either way.
if (Get.context == null) {
debugPrint('[AUTH] session expired with no UI attached — '
'cleared, sign-in will open on next launch');
return;
}
Get.offAll(() => const SignIn());
Get.snackbar(
'Signed out',
'Your session ended. Please sign in again to see your jobs.',
snackPosition: SnackPosition.BOTTOM,
);
} catch (e, st) {
// Never let the teardown itself throw into an API response handler: the
// call that received the 401 has its own error path and this must not
// replace it with a crash.
debugPrint('[AUTH] session teardown failed: $e\n$st');
} finally {
_signingOut = false;
}
}
/// Paints the status and navigation bars to match the page behind them.
void _applySystemChrome() {
SystemChrome.setSystemUIOverlayStyle(

View File

@@ -93,6 +93,11 @@ class AuthProvider {
required String fcmToken,
int? pin,
String? pinRaw,
/// True for a rider setting his PIN for the first time — routes to
/// `POST /miler/set-pin` and sends the digits as `new_pin`. The response,
/// the token handling and the persisted identity are identical either way.
bool firstTime = false,
}) async {
// The legacy `rider/login` path on the retired backend that used to sit
// behind a flag here is gone with the rest of the old backend.
@@ -101,6 +106,8 @@ class AuthProvider {
pin: pin,
pinRaw: pinRaw,
fcmToken: fcmToken,
path: firstTime ? '/miler/set-pin' : '/miler/verify-pin',
pinField: firstTime ? 'new_pin' : 'pin',
);
}
@@ -117,13 +124,27 @@ class AuthProvider {
/// AssignMilerToBooking pushes "New Pickup Assigned" to exactly that token.
/// Omitting it leaves the profile's token empty and silently makes the app
/// poll-only.
/// ── One normaliser, two routes ──
///
/// `POST /miler/set-pin` answers with the **same envelope** as verify-pin —
/// `{success, token, tenantid, tenantname, user:{authname, contactno,
/// profile:{…}}}` — because it is also a sign-in: a rider who sets his PIN is
/// logged in by that one call and does not verify afterwards.
///
/// So the path and the PIN field name are arguments rather than a second copy
/// of this function. Everything below — finding the token in any of the nine
/// places this contract has worn it, splitting the display name, building the
/// legacy `{status, details:{…}}` envelope that [loginParsed] persists from —
/// is the part that must not drift between the two paths, and now cannot.
Future<http.Response> _loginNew({
required String contactNo,
int? pin,
String? pinRaw,
String? fcmToken,
String path = '/miler/verify-pin',
String pinField = 'pin',
}) async {
final uri = Uri.parse(ApiConfig.url('/miler/verify-pin'));
final uri = Uri.parse(ApiConfig.url(path));
// The backend bcrypt-compares the PIN as a STRING, so a leading zero is
// significant. Prefer the raw text the rider typed — round-tripping through
// int drops it ("0512" -> 512 -> "512") and fails a valid PIN.
@@ -132,7 +153,7 @@ class AuthProvider {
: pin?.toString();
final body = {
'phone': contactNo,
if (pinValue != null) 'pin': pinValue,
if (pinValue != null) pinField: pinValue,
// Riders live in partition 1001. It defaults server-side, so omitting it
// appeared to work — but a miler row created without it can never log in,
// and sending it explicitly is the only way the app and the console agree
@@ -168,7 +189,7 @@ class AuthProvider {
// Signing in with nothing to sign in to. This is the one call that cannot
// be intercepted in [MilerApi] — auth posts its own request, because it
// runs before there is a token for the shared transport to attach.
final mocked = MockBackend.respond('POST', '/miler/verify-pin', body: body);
final mocked = MockBackend.respond('POST', path, body: body);
final http.Response res = mocked != null
? http.Response(
@@ -451,6 +472,7 @@ class AuthProvider {
required String fcmToken,
int? pin,
String? pinRaw,
bool firstTime = false,
}) async {
final res = await login(
contactNo: contactNo,
@@ -460,6 +482,7 @@ class AuthProvider {
fcmToken: fcmToken,
pin: pin,
pinRaw: pinRaw,
firstTime: firstTime,
);
final Map<String, dynamic> jsonMap = res.body.isNotEmpty
? json.decode(res.body) as Map<String, dynamic>
@@ -608,51 +631,39 @@ class AuthProvider {
return Login.fromJson(jsonMap);
}
/// Deprecated. `POST /miler/reset-pin` is admin-only and this app must never
/// call it — it was once open, and reset-pin followed by verify-pin took over
/// any rider account given nothing but a phone number.
///
/// ── The 403 this used to manufacture is gone ──
///
/// There was no rider-facing PIN write, so this returned a synthetic
/// `403 "Your MPIN is issued by your office and cannot be changed from the
/// app."` — true when written, and a dead end for the Create-MPIN screen.
///
/// `POST /miler/set-pin` is that route, shipped 2026-09-16. It is
/// self-service, cannot overwrite an existing PIN (409), and returns a full
/// session. Riders set their own PIN on first sign-in and the console no
/// longer issues one, so nothing needs this method any more.
@Deprecated('Use AuthController.setPin, which calls POST /miler/set-pin.')
Future<http.Response> updatePin({
required int userId,
required int pin,
}) async {
// ── There is no rider-facing set-PIN endpoint, and that is deliberate ──
//
// The only PIN-write route on the backend is `POST /miler/reset-pin`, and
// it requires an ADMIN token. It was once open, and reset-pin followed by
// verify-pin took over any rider account given nothing but a phone number.
// The app must not call it; rider PIN resets go through ops.
//
// So this stays a no-op success: the Create-MPIN screen's flow completes
// and the PIN the account was issued with remains the one that works.
// Making it fail instead would strand a rider on a screen with no way
// forward, which is worse and no more honest.
// ── It used to answer 200 ──
//
// "Making it fail instead would strand a rider on a screen with no way
// forward, which is worse and no more honest." Half of that was right and
// the conclusion was wrong. What the manufactured 200 actually did:
//
// 1. Create-MPIN told the rider his new MPIN was saved.
// 2. The app wrote it to `dbPin` locally.
// 3. The server never heard about it.
// 4. Every login from then on returned `incorrect PIN`, forever, with no
// way for the rider to tell that the PIN he was typing had never
// existed anywhere but his own handset.
//
// Being stranded on a screen that tells you who can help is not worse than
// that. It is the only version of this that a rider can act on.
ApiConfig.logGap(
'updatePin',
'No rider-facing set-PIN route; reset-pin is admin-only by design. '
'Refusing rather than reporting a write that did not happen.',
'reset-pin is admin-only; first-time PIN creation goes through '
'POST /miler/set-pin. This method should have no callers.',
);
return http.Response(
json.encode(<String, dynamic>{
'status': false,
'code': 403,
'code': 410,
'message':
'Your MPIN is issued by your office and cannot be changed from the '
'app. Ask your supervisor to reset it, then sign in with the MPIN '
'they give you.',
'This app no longer changes PINs through reset-pin. Set your PIN '
'on the sign-in screen.',
}),
403,
410,
headers: {'content-type': 'application/json'},
);
}

View File

@@ -240,6 +240,48 @@ class UpdatePickupProvider {
// consignment id, and pressing **Delivered** at the door was refused by
// the app itself. Recorded here, at the only moment it is guaranteed to
// be in hand. See [rememberConsignmentId].
// ── What the rider captured, and what the contract can carry ──
//
// `POST /miler/bookings/:id/pickup-complete` takes **latitude and
// longitude and nothing else**. The door flow, meanwhile, *demands* a
// photograph of the parcels and — on a short count or a broken seal —
// a written explanation before it will let the rider confirm
// (`StopVerificationPage`, `_needsPickupNote`).
//
// So the app was compelling a rider to produce exactly the evidence
// that matters in a dispute, uploading the photo to object storage, and
// then dropping the URL and the note on the floor: this method reads
// `dropimage` for the delivery leg and has never read `pickupimage`,
// `proofimage` or `notes` on this one. Nobody at the hub could see any
// of it, and nobody knew it was missing.
//
// Two things change here, and neither of them invents a field:
//
// * the photo and the note are kept **on the device**, against the
// order, so the record exists and the office can ask for it — see
// `ProofStore` and the completed-stop record;
// * the gap is *logged*, every time, with what was dropped. It is the
// same channel every other missing route reports through, so it
// shows up where the rest of the contract gaps do instead of being
// a thing one person remembers.
//
// BACKEND DEPENDENCY: see handoff BE-2. When `pickup-complete` accepts
// `pickupimageurl` / `notes` / `condition`, send them here and delete
// this block.
final droppedProof = (data['pickupimage'] ?? data['proofimage'] ?? '')
.toString()
.trim();
if (droppedProof.isNotEmpty || notes.trim().isNotEmpty) {
ApiConfig.logGap(
'pickup-complete',
'The rider captured evidence this route cannot carry — '
'photo=${droppedProof.isNotEmpty ? 'yes' : 'no'}, '
'note=${notes.trim().isNotEmpty ? '"${notes.trim()}"' : 'none'}. '
'pickup-complete accepts latitude/longitude only. Held on the '
'device against booking $id. See BE-2.',
);
}
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
// ── Which lifecycle the server is running, read from the server ──
//
@@ -566,7 +608,37 @@ class UpdatePickupProvider {
// and it keeps the chargeable total correct even though the per-parcel
// figures are an even split rather than a measurement.
final double each = totalWeight / count;
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
// ── The photographs, at last ──
//
// `photos` has been on this contract since the route shipped and the app
// has never sent it. The keys arrive on the verification map (see
// `_withParcelPhotos`); an empty list means the upload failed, and the
// field is then omitted rather than sent empty — an empty array is a claim
// that nobody photographed anything.
//
// Attached to the FIRST parcel only. The rider takes one photograph of the
// load, not one per box — repeating the same key on every parcel would
// report N photographs where one was taken.
final photoKeys = <String>[
for (final k in (pickup['photos'] as List?) ?? const [])
if (k.toString().trim().isNotEmpty) k.toString().trim(),
];
final parcels = [
for (var i = 0; i < count; i++)
ParcelEntry(
weight: each,
photos: i == 0 ? photoKeys : const <String>[],
),
];
if (photoKeys.isEmpty) {
ApiConfig.logGap(
'submitParcels',
'booking $bookingId: no photo key available — the parcels are being '
'recorded without the door photograph.',
);
}
final res = await MilerApi.submitParcels(bookingId, parcels);
debugPrint(

View File

@@ -67,6 +67,7 @@ import 'package:miler/data/order_manifest.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/order_events.dart';
import 'package:miler/data/work_domain.dart';
import 'package:miler/data/geofence.dart';
import 'package:miler/data/work_repository.dart';
import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/service_day.dart';
@@ -600,55 +601,67 @@ class _HomepageState extends State<Homepage>
}
// ==================== PROXIMITY METHODS ====================
Future<bool> _isNearPickupLocation(Map<String, dynamic> Booking) async {
// ── The second proximity gate ──
//
// `PickupsController._checkGeofence` guards a single stop. This one guards
// the *bulk* "mark selected as arrived" action on Home, and it runs before
// the controller is ever called.
//
// It honours the same switch and, since this change, the same **radius**.
// It was hardcoded to 500 m while the controller read a configured 100 —
// so the app had two fences of different sizes on two routes into the same
// rung, and which one a rider met depended on whether he had ticked boxes
// or slid a sheet. One number now: [kGeofenceRadiusMeters].
if (kBypassGeofenceForTesting) {
debugPrint(
'[GEOFENCE] OFF for bulk arrival — enforcement is disabled in this '
'build. Stop completion is NOT proximity-verified. '
'See kGeofenceEnforced.',
);
return true;
}
try {
final pickupLat = _parseDouble(
Booking['pickuplat'] ?? Booking['PickupLat'],
);
final pickupLng = _parseDouble(
Booking['pickuplon'] ?? Booking['PickupLon'],
);
if (pickupLat == 0 || pickupLng == 0) return true;
final riderLoc = await _getValidCoordinates();
if (riderLoc == null) return true;
final double riderLat = double.tryParse(riderLoc.$1) ?? 0;
final double riderLng = double.tryParse(riderLoc.$2) ?? 0;
if (riderLat == 0 || riderLng == 0) return true;
final double distanceInMeters = Geolocator.distanceBetween(
riderLat,
riderLng,
pickupLat,
pickupLng,
);
// The phone's own error is credited to the rider here too — see
// [kGeofenceRadiusMeters] for why a 10 m fence measured with a ±20 m fix
// has to, and `_getValidCoordinates` for the fix it is measured with.
final double slack = _lastFixAccuracy;
return (distanceInMeters - slack) <= kGeofenceRadiusMeters;
} catch (e) {
debugPrint('[PROXIMITY] Error checking proximity: $e');
return true;
}
/// The fence's answer for the last stop [_isNearPickupLocation] measured.
///
/// Kept so the bulk-arrival sheet can name a distance instead of repeating a
/// generic "you are too far" for every ticked box.
GeofenceDecision? _lastBulkGeofence;
/// The bulk gate on Home: the *selected stops* route into the arrived rung.
///
/// ── One fence, reached two ways ──
///
/// `PickupsController._checkGeofence` guards a single stop; this guards the
/// "mark selected as arrived" action, and it runs before the controller is
/// ever called. The two used to be separate implementations with separate
/// radii — 500 m hardcoded here against the controller's configured 100 — so
/// which fence a rider met depended on whether he ticked boxes or slid a
/// sheet. Both now call [Geofence] and there is no arithmetic left in this
/// file to drift.
///
/// ── It used to fail open in four places ──
///
/// A booking with no pin, a rider fix that could not be read, a zero
/// coordinate pair and any thrown exception each ended in `return true`. All
/// four are refusals now; [Geofence] decides which sentence the rider sees.
Future<bool> _isNearPickupLocation(Map<String, dynamic> booking) async {
final decision = await Geofence.check(
targetLat: _parseDouble(booking['pickuplat'] ?? booking['PickupLat']),
targetLng: _parseDouble(booking['pickuplon'] ?? booking['PickupLon']),
action: 'Arrived',
);
_lastBulkGeofence = decision;
return decision.allowed;
}
/// How many bulk status writes may be in flight at once.
///
/// Not unbounded. Twenty simultaneous requests from one handset queue at the
/// radio on a rider's 3G connection and finish *slower* than a handful, and a
/// thundering herd from one rider is a poor shape to hand the backend. Six is
/// enough to hide the per-request latency — the thing that made the old
/// sequential loop take a minute — without any of that.
static const int _bulkLanes = 6;
/// Straight-line kilometres from the rider to a stop, measured on the device.
///
/// Handed to `updatePickedStatus` as `actualKms` purely so its priority-3
/// branch — an OSRM road-distance request per order, six-second timeout —
/// never runs during a bulk action. It is the same straight line that branch
/// already falls back to when the routing service fails.
double _bulkLegKm(
String riderLat,
String riderLng,
String stopLat,
String stopLng,
) {
final rLat = double.tryParse(riderLat) ?? 0;
final rLng = double.tryParse(riderLng) ?? 0;
final sLat = double.tryParse(stopLat) ?? 0;
final sLng = double.tryParse(stopLng) ?? 0;
if (rLat == 0 || rLng == 0 || sLat == 0 || sLng == 0) return 0;
return Geolocator.distanceBetween(rLat, rLng, sLat, sLng) / 1000.0;
}
Future<Map<String, bool>> _checkProximityForOrders(
@@ -695,7 +708,7 @@ class _HomepageState extends State<Homepage>
content: Text(
specificMessage ??
'You must be within '
'${kGeofenceRadiusMeters.toStringAsFixed(0)} metres of the '
'${kGeofenceRadiusMetres.toStringAsFixed(0)} metres of the '
'pickup location to mark this booking as arrived.',
style: TextStyle(
fontSize: FontConstants.regular(context),
@@ -2054,6 +2067,34 @@ class _HomepageState extends State<Homepage>
// is slide again at a stop that will keep refusing. See
// [PickupsController.lastPickupRefusal].
if (nextStatus == 'PICKED') return dc.lastPickupRefusal ?? 'refused';
// ── Optimistic covers a write that did not LAND, never a rider who is
// not THERE ──
//
// The paragraph above is right about the case it describes: a rider at
// a counter with no signal must not have his own report of where he is
// snapped back by a late poll.
//
// But `updateArrivedStatus` answers false for three different things,
// and only one of them is that case. It is also false when the geofence
// refused — the rider is 1.4km from the door and no request was ever
// made — and when the server itself rejected the arrival on a business
// rule. Advancing on those wrote ARRIVED onto a stop the rider had not
// reached, and the admin console, which only ever hears what the server
// was told, went on showing the stop as pending. That is the divergence
// reported from the field: arrived on the phone, not arrived in the
// office, and no way for either side to tell who was wrong.
//
// So the two "he is not there / the office said no" cases stop here and
// are handed back to be shown. A dead network still falls through and
// still advances — with [PickupsController.lastArrivalNotice] telling
// him the office has not been told.
//
// `lastBlockedReason` is read first and is always fresh: `_checkGeofence`
// clears it on entry, so a non-null value can only have come from the
// check that just ran.
final notThere = dc.lastBlockedReason ?? dc.lastArrivalRefusal;
if (notThere != null) return notThere;
}
// ── The rung the SERVER produced, not the one the button is named after ──
@@ -2930,13 +2971,22 @@ class _HomepageState extends State<Homepage>
}
if (targetStatus == 'ARRIVED') {
final proximityResults = await _checkProximityForOrders(selectedOrderIds)
.timeout(
const Duration(seconds: 3),
onTimeout: () => Map<String, bool>.fromEntries(
selectedOrderIds.map((id) => MapEntry(id, true)),
),
);
// ── The 3-second fail-OPEN is gone ──
//
// This was wrapped in `.timeout(3s, onTimeout: () => everything inside
// the fence)`. Each check took its own 8-second GPS fix, so twenty of
// them could never finish in three seconds — the timeout fired on
// essentially every bulk arrival and waved every order through whatever
// the distance was. A hole straight through the 100 m rule, and one that
// looked like a safety net.
//
// It is not needed now: [Geofence] reuses one live fix for
// [kGeofenceFixReuse], so twenty checks cost one GPS settle and complete
// in milliseconds. If the fix itself cannot be had, each check refuses on
// its own terms and says which of location-off / permission / no-signal
// it was — which is the answer the rider can act on, and the one a
// blanket "allow everything" was hiding.
final proximityResults = await _checkProximityForOrders(selectedOrderIds);
final farOrders = proximityResults.entries
.where((e) => !e.value)
@@ -2948,7 +2998,8 @@ class _HomepageState extends State<Homepage>
await _showProximityWarning(
context,
specificMessage: farOrders.length == selectedOrderIds.length
? 'You must be within 500 meters of the pickup location(s) to mark bookings as arrived.'
? 'You must be within ${kGeofenceRadiusMetres.round()} m of the pickup '
'location to mark it arrived.'
: 'Some selected bookings are too far from their pickup locations.',
);
}
@@ -3041,11 +3092,41 @@ class _HomepageState extends State<Homepage>
/// than at the next poll.
final Set<String> bulkCollected = <String>{};
for (final orderId in selectedOrderIds) {
// ── Phase 1: the network, overlapped ──
//
// This was a plain sequential `for`, so twenty orders meant twenty full
// round trips end to end — status call, GPS fix, routing call and four
// preferences writes each, nothing overlapping anything. Measured at
// 3–5 s per order, which is the 1–2 minutes riders were reporting for a
// twenty-bag counter pick.
//
// The writes are independent — different bookings, different
// [MutationGuard] keys (`picked:1042`), and the backend carries an
// `Idempotency-Key` per action — so they can be in flight together. The
// *local* bookkeeping is NOT done here: it is collected and applied once,
// below, so the order of local state changes stays deterministic and a
// growing JSON blob is not rewritten twenty times.
//
// Bounded to [_bulkLanes] rather than `Future.wait` over everything:
// twenty simultaneous requests on a rider's 3G connection queue at the
// radio and finish slower than six, and it is a kinder shape for the
// backend than a thundering herd from one handset.
final List<String> bulkIds = [];
final Map<String, Map<String, dynamic>> bulkStops = {};
for (final id in selectedOrderIds) {
// Not `_ordersMap` — a stop past ACCEPT is no longer in it, and every
// rung after the first was silently skipped here. See [_bulkStopFor].
final Booking = _bulkStopFor(orderId);
if (Booking == null) continue;
final stop = _bulkStopFor(id);
if (stop == null) continue;
bulkIds.add(id);
bulkStops[id] = stop;
}
/// Which orders the backend confirmed.
final Set<String> bulkOk = <String>{};
Future<void> runOne(String orderId) async {
final Booking = bulkStops[orderId]!;
final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0;
final orderHeaderId =
@@ -3085,10 +3166,7 @@ class _HomepageState extends State<Homepage>
// Home, still selectable, and pressing again costs a tap. So it
// records what the backend confirmed and says what it did not —
// the same rule `_advanceStop` already applies to PICKED.
if (success) {
_hiddenOrderIds.add(orderId);
acceptedBookings.add(Booking);
} else {
if (!success) {
debugPrint(
'[BULK] accept REFUSED for $orderId — leaving it on Home',
);
@@ -3110,10 +3188,7 @@ class _HomepageState extends State<Homepage>
// successful bulk arrival was a network call, a success count and
// a row that came straight back on ACCEPTED — which is what "mark
// as arrived does nothing" looked like from the outside.
if (success) {
Booking['orderstatus'] = 'arrived';
await addArrivedOrderIds([orderId]);
}
if (success) Booking['orderstatus'] = 'arrived';
} else if (targetStatus == 'PICKED') {
debugPrint('[BULK] Picking Booking $orderId');
success = await dc.updatePickedStatus(
@@ -3125,11 +3200,31 @@ class _HomepageState extends State<Homepage>
ridersLng: bulkRidersLng,
pickupLat: pickupLat,
pickupLng: pickupLng,
// ── Pre-computed, so the routing call never fires ──
//
// Left at 0 this falls through to priority 3, which is an OSRM
// road-distance request per order with a 6-second timeout. On a
// bulk pick the rider never "started" each stop individually, so
// the cumulative-tracking key is absent for most of them and that
// request ran for nearly all twenty — to compute a number that
// `UpdatePickupProvider` never reads and `pickup-complete` never
// sends. The backend derives earnings from the coordinates by its
// own haversine; this figure only feeds Activity's local
// "km ridden".
//
// So it is measured here, on the device, for free — the same
// straight line the priority-3 branch already falls back to when
// OSRM fails.
actualKms: _bulkLegKm(
bulkRidersLat,
bulkRidersLng,
pickupLat,
pickupLng,
),
proofImage: bulkProofImageUrl,
);
if (success) {
_startPickupPosting(Booking);
_hiddenOrderIds.add(orderId);
// ── The write that says the load is in his hands ──
//
@@ -3146,11 +3241,8 @@ class _HomepageState extends State<Homepage>
// time are wrong" half of it.
//
// Recorded only after the backend confirmed, same rule as accept.
await addCollectedOrderIds([orderId]);
// Applied in phase 2 — see [bulkCollected].
bulkCollected.add(orderId);
// One copy of the day, and it is now stale — Deliveries must not
// render this order from a response that predates the hand-over.
unawaited(WorkRepository.instance.invalidate());
// ── Picked is a hand-over on one line and a finish on the other ──
//
@@ -3163,14 +3255,8 @@ class _HomepageState extends State<Homepage>
// drops he makes himself — so filing it as completed sent it to
// Activity instead of to Deliveries, and the delivery leg had
// nothing to work. See [ServiceProfile.handsOffAtCollection].
if (ServiceProfile.active.handsOffAtCollection) {
await addAcceptedBookings([Booking]);
} else {
await removeAcceptedBookings([orderId]);
await addCompletedBookings([Booking]);
}
// Off the arrived rung — it has been collected.
await removeArrivedOrderIds([orderId]);
// The handsOffAtCollection split is applied in phase 2, once,
// against the whole batch.
}
}
} catch (e) {
@@ -3179,12 +3265,78 @@ class _HomepageState extends State<Homepage>
}
if (success) {
successCount++;
bulkOk.add(orderId);
} else {
debugPrint('[BULK] Failed to update Booking $orderId');
}
}
// Bounded-concurrency drain. `next++` is safe: there is no `await`
// between reading and incrementing it, and Dart does not preempt inside a
// synchronous run of statements.
var next = 0;
final int lanes = _bulkLanes < bulkIds.length
? _bulkLanes
: (bulkIds.isEmpty ? 1 : bulkIds.length);
await Future.wait(
List.generate(lanes, (_) async {
while (true) {
final i = next++;
if (i >= bulkIds.length) break;
await runOne(bulkIds[i]);
}
}),
);
successCount += bulkOk.length;
// ── Phase 2: every local store written once ──
//
// Each of these helpers decodes a JSON blob, mutates it and writes it
// back. Called per order inside the loop that was O(n²) writes against a
// growing list, and it left a window where a rider backgrounding the app
// mid-batch had half a batch filed.
final List<String> okIds = bulkIds.where(bulkOk.contains).toList();
final List<Map<String, dynamic>> okStops = [
for (final id in okIds) bulkStops[id]!,
];
if (okIds.isNotEmpty) {
// ── ARRIVED is deliberately NOT hidden ──
//
// Accept and Picked take the row off Home; arriving does not. An
// arrived stop is still work in front of the rider and stays on the
// list — see `arrived_stays_on_home_test.dart`. Hiding all three here
// would have made a bulk arrival look like the stops had vanished.
if (targetStatus == 'ACCEPTED') {
_hiddenOrderIds.addAll(okIds);
acceptedBookings.addAll(okStops);
} else if (targetStatus == 'ARRIVED') {
await addArrivedOrderIds(okIds);
} else if (targetStatus == 'PICKED') {
_hiddenOrderIds.addAll(okIds);
await addCollectedOrderIds(okIds);
// ── Picked is a hand-over on one line and a finish on the other ──
//
// Right for **logistics**, where what he collects goes to the base
// and the booking's own story ends at the counter. On a **milk run**
// collection is the *middle* of the day — every bag is followed by a
// round of drops he makes himself — so filing it as completed would
// send it to Activity instead of Deliveries and the delivery leg
// would have nothing to work. See [ServiceProfile.handsOffAtCollection].
if (ServiceProfile.active.handsOffAtCollection) {
await addAcceptedBookings(okStops);
} else {
await removeAcceptedBookings(okIds);
await addCompletedBookings(okStops);
}
// Off the arrived rung — it has been collected.
await removeArrivedOrderIds(okIds);
// One copy of the day, and it is now stale — Deliveries must not
// render these orders from a response that predates the hand-over.
unawaited(WorkRepository.instance.invalidate());
}
}
// The leg every card reads is derived from this set, so it has to land
// before the next build — not at the next poll three seconds later.
if (bulkCollected.isNotEmpty && mounted) {

View File

@@ -3,6 +3,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/task_profile.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
@@ -164,7 +165,8 @@ class StopCard extends StatelessWidget {
// he is riding to. After it, the drop leads — and that card lives on the
// work tab, where [PickupCard] does exactly the same thing in the other
// direction.
final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty;
// Per row: a CX pickup on a meal tenant is not a two-leg meal stop.
final twoLeg = WorkPolicy.deliversToCustomer(stop) && drop.isNotEmpty;
final grouped = groupedUnderSource && source.isNotEmpty;
final headline = (twoLeg && !grouped && source.isNotEmpty)
? source
@@ -278,7 +280,7 @@ class StopCard extends StatelessWidget {
final chip = _live
? LiveMark(label: state == StopState.arrived ? 'Arrived' : 'Active')
: (state == StopState.pending &&
!ServiceProfile.active.handsOffAtCollection)
!WorkPolicy.staysOnHomeUntilCollected(stop))
? null
: StopStateChip(state: state, filled: true);
@@ -410,7 +412,7 @@ class StopCard extends StatelessWidget {
// On a meal run the parcel counts are the one-label rule restated
// as an item count, which is exactly the second number this app
// does not allow. Only the money survives.
countsHidden: ServiceProfile.active.deliversToCustomer,
countsHidden: WorkPolicy.deliversToCustomer(stop),
);
final showLabel = !labelShownAbove && printed.isNotEmpty;
if (!showLabel && meta.isEmpty) return const SizedBox.shrink();

View File

@@ -21,6 +21,7 @@ import 'package:miler/data/stop_area.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/task_profile.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// TRIP CARD — one slot's whole route, start to finish.
@@ -694,7 +695,28 @@ class TripCard extends StatelessWidget {
// give. `trip_brief_layout_test.dart` sweeps for exactly this.
Flexible(
child: Text(
ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME',
// ── Line OR row, and the OR is the point ──
//
// This was migrated to `TripShape.endsAtHub(trip.stops)` alone and
// reverted within the hour, for the reason `task_profile.dart`
// already documents about `sourceIsKitchen`: **the rows cannot
// answer this one.** A base leg is only knowable from
// `next_action: inward_at_hub`, which a parcel does not carry until
// it has been collected — so a logistics trip of five *pre-pickup*
// stops folds to `false` and a rider who has always ended his day
// at a base was told "END · HOME". `two_lines_test` and
// `card_density_test` both caught it.
//
// The line knows where the day ends; it is a fact about the rider's
// round, not about any one parcel. The fold is kept beside it
// because it can only ever *add* — a meal rider carrying one
// hub-routed parcel now correctly reads RETURN · BASE, which
// neither source could say on its own. `any`, not `every`: one
// parcel the network needs is enough to send him to a base.
(ServiceProfile.active.endsAtHub ||
TripShape.endsAtHub(trip.stops))
? 'RETURN · BASE'
: 'END · HOME',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.eyebrow,
@@ -1073,7 +1095,8 @@ class TripCard extends StatelessWidget {
// logistics rider genuinely does return collected
// shipments to the depot, so the label follows the
// capability rather than the screen position.
: (ServiceProfile.active.endsAtHub
: ((ServiceProfile.active.endsAtHub ||
TripShape.endsAtHub(trip.stops))
? 'RETURN · BASE'
: 'END · HOME'),
maxLines: 1,

View File

@@ -136,7 +136,6 @@ class PickupCard extends StatelessWidget {
], 'Address not available');
final String orderId = _val(['orderid']);
final String notes = _val(['notes', 'Notes']);
final String phone = _val(['pickupcontactno']);
final int deliverQty = deliveryParcelCount(item);
final int collectQty = pickupParcelCount(item);
@@ -157,15 +156,54 @@ class PickupCard extends StatelessWidget {
// The receiver names the far end whenever the rider is carrying it there —
// whether that is a milk round (line-level) or a hyperlocal parcel the
// backend routed direct (per-order).
// Asked of the ROW first. On a meal tenant this static answered true for
// every card on the screen, so a CX customer pickup sitting next to a
// kitchen load was drawn as though the rider were already carrying it to a
// receiver. [WorkPolicy] reads the row's own `next_action` /
// `pickup_source_type` and only falls back to the line where the row is
// silent — which keeps every existing payload rendering exactly as it does
// today.
final bool carryingToCustomer =
ServiceProfile.active.deliversToCustomer || leg.isCustomer;
WorkPolicy.deliversToCustomer(item) || leg.isCustomer;
final bool pickupIsSource = carryingToCustomer && source.isNotEmpty;
final String pickupName = pickupIsSource ? source : customer;
final String dropAddress = _val(['dropaddress', 'DropAddress']);
final String dropName = pickupIsSource
// ── Who is actually at the far end ──
//
// `pickupcustomer` is the booking's customer, which on a parcel booking is
// the **sender** — the person the rider collected FROM. Using it to name the
// drop put the sender's name over the receiver's door, and on a
// multi-destination pickup it put the same name over all three of them.
// The receiver is carried per destination and is the honest answer whenever
// the payload has one.
final String recipient = _val(['recipientname']);
final String dropName = recipient.isNotEmpty
? recipient
: pickupIsSource
? customer
: (dropAddress.isEmpty ? '' : 'Collection centre');
// ── The Call button has to dial the door he is standing at ──
//
// `pickupcontactno` is the booking's customer — the SENDER — and it was
// what this button dialled on every card, delivery legs included. So a
// rider at the receiver's gate pressed Call and rang the man he had
// collected from that morning, in another city; on a three-drop pickup he
// rang him at all three gates. The receiver's own number is carried per
// destination and is the right one to ring whenever the rider is on his way
// to that receiver — the same test the name above uses, so the card cannot
// show one person and phone another.
final String recipientPhone = _val(['recipientphone']);
final String phone = (recipient.isNotEmpty && recipientPhone.isNotEmpty)
? recipientPhone
: _val(['pickupcontactno']);
/// `Stop 2 of 3` on a customer pickup with several doors, `''` otherwise.
/// Sits with the leg eyebrow because it answers the same question — what
/// kind of stop is this — and because three cards that differ only in their
/// address are three chances to deliver the wrong bag.
final String stopLabel = MilkRun.stopLabel(item);
/// True once the rider is carrying it: the drop becomes the destination and
/// takes the top of the card.
// ── The immediate destination leads; never a hub leg's receiver ──
@@ -179,6 +217,46 @@ class PickupCard extends StatelessWidget {
(pickupIsSource || leg.isCustomer) &&
(dropName.isNotEmpty || dropAddress.isNotEmpty);
// ── On a base leg the BASE leads ──
//
// The card was right to refuse the receiver — he is in another district and
// leading with him is the navigation mistake the rule above exists to
// prevent. But refusing the receiver only left `pickupName`, so a handover
// card read:
//
// BASE HANDOVER
// Anitha R
// Gandhipuram
//
// …the customer he collected from an hour ago, under a heading telling him
// to go to a base. The place he is actually going appeared nowhere on the
// card and only on the sheet's slider, after he had already set off. On a
// round with two bases that is a parcel left in the wrong building.
//
// `next_hub` rides on the row (the backend sends all six fields), so the
// destination is known here. A base with no coordinates still leads with
// its name — it is a caption rather than a destination, and the fence
// refuses the handover anyway with a sentence pointing at the office.
final HandoverHub? handoverBase = leg.isHub
? (HandoverHub.from(item['next_hub'] ?? item['nexthub']) ??
PickupLocations.baseFor(
item['next_hub_id'] ?? item['nexthubid'] ?? item['hubid'],
))
: null;
final bool baseLeads = handoverBase != null;
/// What the card is headed with, in one place so the name and the address
/// below it can never describe two different places.
final String headlineName = baseLeads
? handoverBase.name
: (dropLeads ? dropName : pickupName);
final String headlineAddress = baseLeads
? [
handoverBase.address,
handoverBase.pincode,
].where((v) => v.trim().isNotEmpty).join(', ')
: '';
// ── No accent stripe any more ──
//
// The card carried a 4px coloured bar down its left edge, on the argument
@@ -261,9 +339,15 @@ class PickupCard extends StatelessWidget {
// has to say which before the rider reads the place.
// Drawn only once the parcel is actually his; an
// uncollected stop has no leg yet.
if (leg.leg.eyebrow.isNotEmpty) ...[
if (leg.leg.eyebrow.isNotEmpty ||
stopLabel.isNotEmpty) ...[
Text(
leg.leg.eyebrow,
[
if (leg.leg.eyebrow.isNotEmpty)
leg.leg.eyebrow,
if (stopLabel.isNotEmpty)
stopLabel.toUpperCase(),
].join(' · '),
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
@@ -279,9 +363,7 @@ class PickupCard extends StatelessWidget {
SizedBox(height: 3.h),
],
Text(
(dropLeads ? dropName : pickupName).isEmpty
? 'Not named'
: (dropLeads ? dropName : pickupName),
headlineName.isEmpty ? 'Not named' : headlineName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -312,6 +394,17 @@ class PickupCard extends StatelessWidget {
// lost — see [areaOf].
Text(
() {
// A base is read at a gate, so it gets its street
// and pincode rather than the locality summary the
// other legs use — "Peelamedu" is not enough to
// find a loading bay by.
if (baseLeads) {
// Empty rather than repeating the name: a base
// with no address on the row has nothing more
// to say here, and printing "Salem Base" twice
// reads as a rendering fault.
return headlineAddress;
}
final full = dropLeads
? (dropAddress.isEmpty
? address
@@ -1560,7 +1653,7 @@ String _destinationName(Map<String, dynamic> item) {
String _destinationAddress(Map<String, dynamic> item) {
final area = areaOf(
item,
preferDrop: ServiceProfile.active.deliversToCustomer,
preferDrop: WorkPolicy.deliversToCustomer(item),
);
if (area.isNotEmpty) return area;
@@ -1569,6 +1662,6 @@ String _destinationAddress(Map<String, dynamic> item) {
final drop = (item['dropaddress'] ?? item['DropAddress'] ?? '')
.toString()
.trim();
if (ServiceProfile.active.deliversToCustomer && drop.isNotEmpty) return drop;
if (WorkPolicy.deliversToCustomer(item) && drop.isNotEmpty) return drop;
return (item['pickupaddress'] ?? '').toString().trim();
}

View File

@@ -36,6 +36,15 @@ enum DeliveryOutcome {
/// It is not going to happen. `POST /miler/bookings/:id/cancel`.
cancelled,
/// Handed in at a base, ending this rider's custody.
/// `POST /miler/consignments/:id/inward-at-hub` — see [handOverAtHub].
///
/// Not a kind of [delivered]. A base handover has no receiver, no proof
/// photo, no OTP and no COD, and it must never reach `deliver` — that route
/// moves the consignment to `Out_for_Delivery` against a receiver in another
/// district, which is a state the handset cannot undo.
handedOver,
}
extension DeliveryOutcomeX on DeliveryOutcome {
@@ -43,6 +52,7 @@ extension DeliveryOutcomeX on DeliveryOutcome {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skip',
DeliveryOutcome.cancelled => 'Cancelled',
DeliveryOutcome.handedOver => 'Handed over',
};
IconData get icon => switch (this) {
@@ -54,12 +64,15 @@ extension DeliveryOutcomeX on DeliveryOutcome {
// skip-reason sheet's "Delivery paused" row already wears.
DeliveryOutcome.skipped => LucideIcons.clock,
DeliveryOutcome.cancelled => LucideIcons.circleX,
// A building, not a tick: the parcel is not finished, it has changed hands.
DeliveryOutcome.handedOver => LucideIcons.building2,
};
Color get colour => switch (this) {
DeliveryOutcome.delivered => ColorConstants.acceptGreen,
DeliveryOutcome.skipped => ColorConstants.warning,
DeliveryOutcome.cancelled => ColorConstants.errorRed,
DeliveryOutcome.handedOver => ColorConstants.acceptGreen,
};
/// What Activity will show once the round is over.
@@ -67,6 +80,7 @@ extension DeliveryOutcomeX on DeliveryOutcome {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skipped',
DeliveryOutcome.cancelled => 'Cancelled',
DeliveryOutcome.handedOver => 'Handed over',
};
}
@@ -583,6 +597,14 @@ Future<Map<String, dynamic>?> _closeDelivery(
// reportable from `Collected_By_Miler` as well as `Out_for_Delivery` — a
// customer who is not home is not home whether or not the rider remembered
// to press Start round. See [ConsignmentStateX.canSkip].
// ── A handover is deliberately NOT gated here ──
//
// [DeliverGate] answers the question "may this be *delivered*", and a
// base-routed parcel is `Created` — which it reads as `awaitingInward` and
// refuses with "this parcel hasn't been released for delivery yet". Correct
// for a delivery, exactly wrong for the rung that performs the inward.
// `inward-at-hub` is the server's own judge of its preconditions and answers
// `INVALID_STATE` when they are not met.
final gated =
outcome == DeliveryOutcome.delivered ||
outcome == DeliveryOutcome.skipped;
@@ -820,6 +842,15 @@ Future<Map<String, dynamic>?> _closeDelivery(
notes: notes,
);
case DeliveryOutcome.handedOver:
// The fence, the consignment lookup and the `inward-at-hub` write all
// live in [handOverAtHub] — this is the one close path, so the record
// that gets filed is the same shape whichever rung produced it.
ok = await handOverAtHub(stop);
if (!ok && lastHandoverFailure != null && context.mounted) {
AppFeedback.error(context, lastHandoverFailure!);
}
case DeliveryOutcome.skipped:
// ── Straight to the consignment route ──
//
@@ -1050,3 +1081,142 @@ Future<Map<String, dynamic>?> _closeDelivery(
'notes': notes,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// THE BASE HANDOVER — the leg the rider could not finish
// ═══════════════════════════════════════════════════════════════════════════
/// Why the last [handOverAtHub] refused, in the rider's words. Null on success.
String? lastHandoverFailure;
/// The base this stop must be handed in at, or null when it is not a base leg.
///
/// Two sources, in order. `next_hub` rides on the row itself (request 25) and
/// is the authoritative one. [PickupLocations.baseFor] is the fallback for a
/// row that names a hub id without expanding it — a shape older payloads use.
HandoverHub? handoverBaseFor(Map<String, dynamic> stop) {
final fromRow = HandoverHub.from(stop['next_hub'] ?? stop['nexthub']);
if (fromRow != null) return fromRow;
return PickupLocations.baseFor(
stop['next_hub_id'] ?? stop['nexthubid'] ?? stop['hubid'],
);
}
/// Hands a base-routed consignment in, ending this rider's custody of it.
///
/// ── The leg that existed everywhere except the app ──
///
/// `MilerApi.inwardAtHub` and `MilerLifecycle.inwardAtHub` were both written,
/// documented and covered by `hub_handover_contract_test.dart`. Neither had a
/// single call site. So with `MILER_HUB_HANDOVER_ENABLED` on server-side, a
/// Gandhipuram → Chennai parcel arrived on Deliveries as [NextLeg.hub],
/// [releaseForDelivery] correctly refused to start a customer round for it —
/// telling the rider "hand it over at base" — and there was no control in the
/// app that could record him doing so. The parcel sat in his queue until hub
/// staff inwarded it from the console, and the assignment closed against
/// nobody, so the job reported zero distance and zero value on his earnings.
///
/// ── Correct in both positions of the server flag ──
///
/// This never asks which way the flag is set, because the app must not mirror
/// it. With the flag **off**, `pickup-complete` inwards the parcel itself and
/// the row comes back `Inwarded_at_Hub` / `handed_to_hub` — [NextLeg.closed] —
/// so this function is never reached and no control is drawn. With it **on**,
/// the row is `Created` / `inward_at_hub` — [NextLeg.hub] — and this is the
/// rung that finishes it. The leg is read from the row, every time.
///
/// ── Fenced at the base, like every other presence claim ──
///
/// 100 m, against the base's own coordinates. A base with no coordinates is
/// refused rather than waved through, for the same reason a customer stop with
/// no pin is: nobody can say afterwards where the rider was standing.
///
/// Idempotent twice over — the shared `Idempotency-Key` covers a retry after a
/// dropped response, and a parcel already inwarded answers 200 with
/// `already_inwarded: true`, which [MilerLifecycle.inwardAtHub] reads as the
/// success it is. A rider pressing again on bad signal at a loading bay is
/// confirmed, not refused.
Future<bool> handOverAtHub(Map<String, dynamic> stop) async {
lastHandoverFailure = null;
final orderId = MilkRun.idOf(stop);
final leg = NextLegResolver.resolve(
stop,
pivotAction: (await getPivotNextActions())[orderId] ?? '',
);
if (!leg.isHub) {
// Not a refusal the rider caused — the row says this parcel is not going to
// a base. Saying so beats posting a handover the server will reject.
lastHandoverFailure =
'This parcel is not going to a base. Check the stop and try again.';
debugPrint('[HANDOVER] $orderId is not a base leg — $leg');
return false;
}
final base = handoverBaseFor(stop);
// ── The fence, before anything else is spent ──
//
// A base with no coordinates lands on [GeofenceOutcome.noTarget] and is
// refused with a sentence pointing at the office, which is the only party who
// can add the missing pin.
final decision = await Geofence.check(
targetLat: base?.latitude,
targetLng: base?.longitude,
action: 'Handed over',
);
if (!decision.allowed) {
lastHandoverFailure = decision.reason;
debugPrint('[HANDOVER] $orderId refused by the fence — $decision');
return false;
}
final consignmentId = await resolveConsignmentId(stop);
if (consignmentId.isEmpty) {
lastHandoverFailure =
'This stop has no shipment reference yet, so it cannot be handed over. '
'Ask your office to check it — pressing again will not help.';
debugPrint('[HANDOVER] no consignment id for $orderId');
return false;
}
debugPrint(
'[TRACE][HANDOVER] consignment=$consignmentId base=${base?.id} '
'POST /miler/consignments/$consignmentId/inward-at-hub — calling',
);
final res = await MilerApi.inwardAtHub(
consignmentId,
hubId: (base?.id.isNotEmpty ?? false) ? base!.id : null,
// The position the fence just judged, not a fresh one: two fixes seconds
// apart are two different answers and the hub's history row should carry
// the one the app actually allowed the handover on.
lat: decision.riderLat,
lon: decision.riderLng,
);
final t = MilerLifecycle.inwardAtHub(res);
MilerLifecycle.report('inward-at-hub', t);
debugPrint(
'[TRACE][HANDOVER] consignment=$consignmentId -> ${res.status} '
'${res.code} confirmed=${t.isConfirmed} raw=${res.raw}',
);
if (t.isConfirmed) return true;
// ── A 200 that names no state is not proof ──
//
// The rule request 15 exists for, and the one this app has been bitten by on
// `reached`: a bare success is not a transition. The rider is not shown a
// handover the hub may not have recorded.
if (t.isUnconfirmed) {
lastHandoverFailure =
'Your office did not confirm the handover. Check with the base before '
'you leave the parcel.';
return false;
}
final serverMsg = (res.message).trim();
lastHandoverFailure = serverMsg.isNotEmpty && serverMsg.length < 140
? 'The base would not accept this parcel: $serverMsg'
: 'The base would not accept this parcel. Ask your office to check it.';
return false;
}

View File

@@ -193,6 +193,11 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
@override
void initState() {
super.initState();
// Which leg this stop is on. Async because it reads the pivot store, so the
// sheet opens in its delivery shape and corrects itself a frame later —
// the base CTA is behind a slide, so there is no window in which the rider
// can commit the wrong rung.
unawaited(_resolveHubLeg());
_camera = MilerMapCamera(controller: _mapController, vsync: this);
_routeAnim =
AnimationController(
@@ -1360,6 +1365,73 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
collectedIds: widget.parentState?._collectedIds ?? const <String>{},
).isDelivery;
/// True when this stop ends at a base rather than at a receiver's door.
///
/// Read from the row's own `next_action`, never from the rider's line — the
/// same resolver [handOverAtHub] and the Deliveries queue use, so the card,
/// the sheet and the write cannot disagree about where a parcel is going.
///
/// Held in state rather than computed in `build` because resolving it reads
/// the pivot store, which is async.
bool _isHubLeg = false;
/// The base this stop is handed in at, once [_resolveHubLeg] has looked.
HandoverHub? _handoverBase;
/// Resolves [_isHubLeg] once, on open.
///
/// Silent on failure: a stop whose leg cannot be read falls back to the
/// delivery shape it had before this existed, which is the behaviour every
/// other screen already has.
Future<void> _resolveHubLeg() async {
try {
final leg = NextLegResolver.resolve(
widget.pickup,
pivotAction:
(await getPivotNextActions())[MilkRun.idOf(widget.pickup)] ?? '',
);
if (!mounted) return;
final base = leg.isHub ? handoverBaseFor(widget.pickup) : null;
setState(() {
_isHubLeg = leg.isHub;
_handoverBase = base;
// ── The destination is the base, not the door he collected at ──
//
// `MilkRun.navigatesToCustomer` is false for a base leg — correctly, a
// base-routed parcel must never point at its receiver 500 km away — so
// `_pickupLocation` fell back to the **pickup** coordinates and the map
// sent the rider back to the customer he had just collected from.
//
// A base with no coordinates is left alone: navigating to `0, 0` lands
// in the Gulf of Guinea. The handover is still refused by the fence in
// that case, with a sentence pointing at the office.
if (base != null && base.isNavigable) {
_pickupLocation = LatLng(base.latitude, base.longitude);
}
});
} catch (e) {
debugPrint('[HANDOVER] could not resolve the leg: $e');
}
}
/// Hands the parcel in at the base and closes the stop.
///
/// The base-routed twin of [_markDelivered]: same shape, different rung. It
/// does not open [DeliveryProofPage] — there is no receiver to photograph and
/// no OTP to take; the evidence the contract asks for is the position, which
/// [handOverAtHub] stamps from the fix the fence judged.
Future<void> _handOverAtBase() async {
if (_isNavigating || !mounted) return;
HapticFeedback.mediumImpact();
// Straight through the one close path. [closeDelivery] routes
// `handedOver` to [handOverAtHub] — which takes the fence, resolves the
// consignment and posts `inward-at-hub` — and then files the same finished
// record every other outcome files, so Activity, the carried set and the
// queue all see one kind of closed stop. Calling [handOverAtHub] here as
// well would post the handover twice.
await _closeDelivery(DeliveryOutcome.handedOver);
}
/// True once the rider has set off from **this screen** — slid Start
/// delivery, or opened navigation on the collection leg.
///
@@ -1447,6 +1519,30 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
// [MilkRun.deliveryArrivalIsLocalOnly]), so a button for it would write
// nothing. Once he sets off the stop is simply **active**, and the only
// question left is whether it was handed over.
// ── A base handover is its own rung ──
//
// It is not a delivery: there is no receiver, no proof photo, no OTP and no
// "customer was out" outcome. It is also not nothing, which is what the app
// offered before — [releaseForDelivery] refuses a base leg, correctly, and
// the rider was left holding a parcel with a message telling him to hand it
// over and no control that would record it.
if (_isHubLeg) {
final onTheRoad = _setOff;
// Named where it is known. "Handed over at Coimbatore Base" is a rider
// confirming a place; "Handed over at base" is him confirming a category,
// and on a round with two bases that is the difference between a parcel
// arriving and a parcel being looked for.
final base = _handoverBase?.name ?? '';
return MilerSlideAction(
label: onTheRoad
? (base.isEmpty ? 'Handed over at base' : 'Handed over at $base')
: 'Slide to start ride',
icon: onTheRoad ? LucideIcons.check : LucideIcons.chevronRight,
color: ColorConstants.acceptGreen,
onCommit: onTheRoad ? _handOverAtBase : _startRideToBase,
);
}
if (_isDeliveryLeg) {
final onTheRoad = _setOff;
final slide = MilerSlideAction(
@@ -1572,6 +1668,16 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
return false;
}
// ── Not refused, but not recorded either ──
//
// The rung advances locally below, which is right — a dead network must
// not strand a rider at a door. What was wrong is that it advanced
// *silently*: the rider saw ARRIVED and could not tell the difference
// between a stop the hub knows about and one it does not.
if (!ok && dc.lastArrivalNotice != null && mounted) {
AppFeedback.warn(context, dc.lastArrivalNotice!);
}
// The rung the rider sees, on the row he is looking at and in the store
// that survives the refresh this flow triggers. `reached` does not
// persist on every deployment yet — see `getArrivedOrderIds` — and the
@@ -1607,8 +1713,100 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
/// here — no photo, a dead signature, a refused PUT — resolves to an empty
/// string, which is what this call site was sending unconditionally until
/// now. The local copy stays on the device either way.
/// The parcel photograph's storage references, once uploaded. Null until it
/// has been, and after a failed attempt — a failure is not a photograph.
UploadRef? _parcelProof;
/// Uploads the door photograph once per stop.
///
/// Best-effort by design: a dead object store must not strand a rider at a
/// customer's door holding parcels he cannot record. When it fails the parcel
/// update goes without `photos` — which is honest, and better than an empty
/// array claiming no photograph was taken.
Future<void> _ensureParcelProofUploaded(int bookingId) async {
if (_parcelProof != null || bookingId <= 0) return;
final path = (_verificationProofPath ?? '').trim();
if (path.isEmpty) return;
final file = File(path);
if (!file.existsSync()) return;
// Kept on the device first, whatever the network does — see ProofStore.
try {
final orderId = (widget.pickup['orderid'] ?? '').toString();
if (orderId.isNotEmpty) await ProofStore.save(orderId, path);
} catch (e) {
debugPrint('[PICKUP] could not keep a local copy of the proof: $e');
}
try {
_parcelProof = await MilerApi.uploadProofRef(
file,
purpose: MilerApi.proofPickup,
// The BOOKING id, in the booking field. There is no consignment yet —
// `pickup-complete` has not run — and this used to pass the booking id
// in the `consignmentid` slot, filing every pickup proof under a
// consignment number that did not exist.
bookingId: bookingId,
);
} catch (e) {
debugPrint('[PICKUP] parcel proof upload failed: $e');
}
if (_parcelProof == null || !_parcelProof!.hasKey) {
ApiConfig.logGap(
'uploads/sign',
'the parcel photo for booking $bookingId could not be uploaded (or the '
'server returned no key); the parcels are being recorded without '
'one and the copy stays on the device.',
);
}
}
/// The local path of the photograph the verification screen took, if any.
String? _verificationProofPath;
/// Copies the uploaded key onto the verification map the provider reads.
Map<String, dynamic> _withParcelPhotos(Map<String, dynamic> verification) {
final key = _parcelProof?.key ?? '';
if (key.isEmpty) return verification;
final pickup = verification['pickup'];
if (pickup is! Map) return verification;
return {
...verification,
'pickup': {...pickup, 'photos': <String>[key]},
};
}
Future<String> _uploadParcelProof(String path, int bookingId) async {
if (path.isEmpty || bookingId <= 0) return '';
// Already sent, on the way to `POST /parcel`. Pushing the same bytes twice
// costs a rider at a doorstep several seconds on a mobile connection and
// leaves two copies of one photograph in the bucket.
await _ensureParcelProofUploaded(bookingId);
if (_parcelProof != null) return _parcelProof!.url;
// ── Kept on the device first, whatever the network does ──
//
// The uploaded URL goes nowhere: `pickup-complete` takes latitude and
// longitude and nothing else, so even a successful upload leaves the hub
// with no reference to the photograph (see BE-2, and the gap logged in
// `UpdatePickupProvider`). The rider was being made to photograph the
// parcels — the flow will not let him confirm without it — for a record
// that existed only in a bucket nobody could search.
//
// A local copy against the order id is the one form of this evidence that
// is actually retrievable today: the office rings the rider and he has it.
// Best-effort, and deliberately before the upload, because the case where
// it matters most is the one where the network is the problem.
try {
final orderId = (widget.pickup['orderid'] ?? '').toString();
if (orderId.isNotEmpty) await ProofStore.save(orderId, path);
} catch (e) {
debugPrint('[PICKUP] could not keep a local copy of the proof: $e');
}
try {
final url = await MilerApi.uploadProof(
File(path),
@@ -1686,6 +1884,22 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
await _openGoogleMapsNavigation();
}
/// Sets off for the base.
///
/// Deliberately **not** [_startDelivery]. `start-delivery` moves the
/// consignment to `Out_for_Delivery` against a receiver in another district;
/// the hub then sees a rider actively delivering a parcel that is going onto
/// a line-haul truck, and there is no way back from that state on the
/// handset. A base leg has no release rung at all — the rider simply rides
/// there, and the next thing the server hears is the handover.
Future<void> _startRideToBase() async {
if (_isNavigating || !mounted) return;
setState(() => _setOff = true);
HapticFeedback.mediumImpact();
_hasOpenedNavigation = false;
await _openGoogleMapsNavigation();
}
/// Records a stop that could not be handed over.
///
/// The same chooser the proof page shows, reached without having to slide
@@ -1845,7 +2059,15 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
Map<String, dynamic> verifyResult = <String, dynamic>{'verified': true};
if (ServiceProfile.active.needsVerification) {
// ── Asked of the stop, not of the rider ──
//
// `ServiceProfile.active.needsVerification` gives the same answer for
// every row on the screen. On a meal tenant that walked a CX customer
// pickup straight past the door flow — no parcel count, no destination,
// no weight — and the booking pivoted on whatever the customer typed into
// the app days earlier. [WorkPolicy] asks the row, and falls back to the
// line only where the row says nothing.
if (WorkPolicy.capturesShipmentDetails(widget.pickup)) {
final verified = await openScreen<Map<String, dynamic>>(
context,
StopVerificationPage(pickup: widget.pickup),
@@ -1870,7 +2092,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
// customer. Its answers ride along in the same map, and
// `_startPickupNavigation` sends them — addresses first, because
// `pickup-complete` routes the consignment from them.
if (ServiceProfile.active.capturesShipmentAddresses) {
if (WorkPolicy.capturesShipmentAddresses(widget.pickup)) {
final seeded = Map<String, dynamic>.from(widget.pickup);
// Don't ask for the weight twice: the verification page has just taken
// it, so the desk opens with it filled in.
@@ -2038,6 +2260,12 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
if (_isNavigating || !mounted) return;
setState(() => _isNavigating = true);
// The photograph the verification screen took, remembered so the parcel
// update and the pickup-complete proof both use the one upload.
_verificationProofPath = (verificationData['parcelImage'] ?? '')
.toString()
.trim();
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = widget.pickup;
@@ -2184,9 +2412,23 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
}
if (pickupId > 0) {
// ── The photograph is uploaded HERE, before the parcels are sent ──
//
// It used to be uploaded much later, inside `_payAndCreateOrder`, and
// only its URL was kept — so `POST /parcel` went out with no `photos`
// and the evidence ended up in a bucket nobody could search. `photos`
// wants the storage **key**, so the upload has to happen before this
// call, not after it.
//
// Uploaded once and remembered: `_payAndCreateOrder` reuses the same
// ref for `proofImage` rather than pushing the same bytes twice.
await _ensureParcelProofUploaded(pickupId);
final parcelRes = await UpdatePickupProvider().submitParcels(
pickupId,
verificationData,
// The key rides in on the verification map the provider already
// reads, so no third source of truth about this door.
_withParcelPhotos(verificationData),
);
if (parcelRes?['status'] != true) {
debugPrint('[PARCEL] booking $pickupId: not recorded — $parcelRes');
@@ -2220,7 +2462,7 @@ class _PickupMapScreenState extends State<_PickupMapScreen>
// the compliance stamp, the completed record, the next-stop list and the
// hand-off screen — runs once and unchanged for either.
final dynamic result =
(ServiceProfile.active.initiatesShipment && shipmentCapture != null)
(WorkPolicy.initiatesShipment(widget.pickup) && shipmentCapture != null)
? await openScreen<Map<String, dynamic>>(
context,
ShipmentReviewPage(

View File

@@ -15,7 +15,7 @@ library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart' show kDebugMode, mapEquals, setEquals;
import 'package:flutter/foundation.dart' show mapEquals, setEquals;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
@@ -82,6 +82,10 @@ import 'package:miler/data/service_day.dart';
import 'package:miler/data/work_domain.dart';
import 'package:miler/data/work_repository.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/lifecycle.dart';
import 'package:miler/data/geofence.dart';
import 'package:miler/data/task_profile.dart';
import 'package:miler/data/pickup_locations.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/utils/external_navigation.dart';
@@ -943,33 +947,10 @@ class _MyPickupsState extends State<MyPickups>
fontFamily: FontConstants.fontFamily,
),
),
_fetchDiagLine(),
],
);
}
/// The last fetch's stage counts, shown under an empty state in debug only.
/// Never compiled into release — [kDebugMode] is a const, so the whole widget
/// folds away.
Widget _fetchDiagLine() {
if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink();
return Padding(
padding: EdgeInsets.only(top: 18.h),
child: Text(
'debug · $_fetchDiag\n'
'${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10.sp,
height: 1.5,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
);
}
Widget _buildTripRail() {
// The whole run, finished stops included — see [_railStops]. Drawn as long
// as *anything* happened today, so a rider who has just closed his last
@@ -2182,8 +2163,10 @@ class _MyPickupsState extends State<MyPickups>
// separate causes have made this tab look empty — a gate on the raw API
// count, a missing empty state on an unfilled trip tab, and stops
// filtered out as still-pending — and every one of them looked identical
// on screen. The counts are now shown on the empty state itself, so the
// failing stage is visible instead of guessed at.
// on screen. The counts go to the log so the failing stage can be read
// off a `flutter logs` rather than guessed at. They are deliberately NOT
// drawn on the empty state any more: that put developer diagnostics in
// front of the rider on every debug and demo build.
_fetchDiag =
// Which line the app resolved the rider onto. Three of the reports
// that landed here as "the tab is wrong" were really "the app thinks
@@ -3118,9 +3101,16 @@ class _MyPickupsState extends State<MyPickups>
// is the picture now, at a size worth looking at, and
// it keeps its own copy.
//
// [_fetchDiagLine] stays: it is `kDebugMode`-gated and
// folds away entirely in release, so nothing ships
// underneath the art.
// Nothing is drawn under the art. A stage-count
// readout used to sit here — `debug · line=milkMan
// api=0 accepted=0 …` — on the argument that it was
// `kDebugMode`-gated and folded away in release.
// True, and it still meant every internal build,
// every demo and every screenshot showed a rider a
// line of diagnostics under a picture telling him he
// was all caught up. The counts are still gathered
// and still logged — see `[MYPICKUPS][DIAG]` — where
// the person who needs them is looking.
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -3140,7 +3130,6 @@ class _MyPickupsState extends State<MyPickups>
),
),
),
_fetchDiagLine(),
],
),
),

View File

@@ -193,7 +193,7 @@ class _PickupBottomSheetState extends State<_PickupBottomSheet>
// stays "Confirm pickup".
final String confirmLabel = isDelivery
? 'Confirm delivery'
: (ServiceProfile.active.initiatesShipment
: (WorkPolicy.initiatesShipment(widget.pickup)
? 'Initiate order'
: 'Confirm pickup');

View File

@@ -35,6 +35,7 @@ import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/data/service_profile.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/session.dart';
import 'package:miler/data/proof_store.dart';
import 'package:miler/controllers/rewards_controller.dart';
import 'package:miler/controllers/summary_controller.dart';
@@ -866,8 +867,32 @@ void _showLogoutDialog(BuildContext context) {
// Doorstep photos are exactly the kind of record that must
// not outlive the session that took them.
await ProofStore.clearScope();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('logged_out', true);
// ── The session ends here, not at the next sign-in ──
//
// This did not clear the bearer token. The rider was sent
// to the sign-in screen and `logged_out` kept him there, so
// it *looked* finished — but the token stayed in
// SharedPreferences under `authtoken`, still valid, until
// the next `verifyPinWithServer` happened to overwrite it.
//
// Two things followed. Anything that reads the token
// without checking the flag — a background isolate, the
// notification handler, a heartbeat that outlives the
// route change — could go on making authenticated calls as
// the rider who just left. And a handset handed to the next
// rider carried the previous one's credential on disk.
//
// Logging out is the one moment the app is certain the
// session is over. The credential goes then.
// Everything above plus the token, the in-flight guard, the
// cached fix and the `logged_out` flag now live in one
// place — see [endSession]. The other caller is the 401
// handler in `main.dart`: a session the server has stopped
// accepting has to end exactly as thoroughly as one the
// rider chose to end, and when the two were written out
// separately only this one existed.
await endSession();
Get.offAll(() => const SignIn());
},
height: ButtonSizes.secondary,

View File

@@ -1856,6 +1856,23 @@ class AppFeedback {
duration: const Duration(seconds: 2),
);
/// It went through on the phone but not to the office.
///
/// The band between [error] and [success], and the app needed one: a status
/// the rider set with no signal is neither. Refusing it would strand him at a
/// door; a green tick would tell him the hub knows, which it does not. Amber
/// says "done here, not there", which is the only true thing available.
///
/// Longer than [info] on purpose — it carries a consequence the rider may
/// need to act on later.
static void warn(BuildContext context, String message) => _show(
context,
message,
icon: LucideIcons.triangleAlert,
fill: ColorConstants.warning,
duration: const Duration(seconds: 5),
);
/// Neutral news: something changed that the rider did not ask for, or a
/// background action finished. Never used for failures.
static void info(BuildContext context, String message) => _show(

View File

@@ -25,10 +25,23 @@ import 'package:miler/views/helpers/constants/miler_type.dart';
/// ── Why a strip is the right shape ──
///
/// Everything the rider needs at the door was already fetched. Connectivity is
/// needed to *report* the stop, not to work it. So the honest message is not
/// "you are offline" but "keep going, this will sync" — and that is a line of
/// needed to *report* the stop, not to work it. So the right shape is a line of
/// text, not a screen.
///
/// ── What it must not say ──
///
/// It said "Changes will sync when you're back online". **There is no mutation
/// queue in this app** — no outbox, no replay, no retry loop. A status the
/// rider sets with no signal is attempted once and fails, and nothing ever
/// sends it again. The banner was promising a mechanism that does not exist,
/// to the one person who would be blamed when the hub had no record of the
/// stop.
///
/// The copy now says what is actually true: he can keep working the stop, and
/// the *status* needs signal. Building the queue is a real piece of work and a
/// separate one (see the release report); until it exists this line must not
/// imply it.
///
/// The full-screen page survives for the one case where it is truthful: a cold
/// start with no cached session, where there genuinely is nothing to show.
///
@@ -46,7 +59,7 @@ class OfflineBanner extends StatelessWidget {
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
label: 'Offline. Your changes will sync when you are back online.',
label: 'Offline. Status updates need a connection. Stop details are still available.',
excludeSemantics: true,
child: Material(
color: ColorConstants.warning,
@@ -64,9 +77,10 @@ class OfflineBanner extends StatelessWidget {
SizedBox(width: 9.w),
Expanded(
child: Text(
// States what is true and what happens next. "You're
// offline" alone tells a rider mid-stop nothing he can use.
'Offline · Changes will sync when you’re back online',
// States what is true and what he can still do. It must
// not promise a sync — nothing in this app replays a failed
// mutation. See the class note.
'Offline · Status updates need a connection',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.on(ColorConstants.onAccent).semibold,

View File

@@ -6,6 +6,8 @@ import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/controllers/auth.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/widget/Bottom_page.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
@@ -178,11 +180,35 @@ class _CreateMpinBodyState extends State<_CreateMpinBody> {
final ok = await _auth.setPin(_pin(_newMpinControllers));
if (!mounted) return;
setState(() => isLoading = false);
// ── Setting the PIN IS the sign-in ──
//
// `POST /miler/set-pin` returns a full session — token and user — so there
// is no verify-pin afterwards and no second PIN to type. This used to push
// the rider to the unlock screen to enter the PIN he had just chosen, which
// was the only thing it could do while the write had no route behind it.
if (ok) {
openScreen(context, Mpin());
} else {
setState(() => _error = 'Could not save your PIN. Please try again.');
Get.offAll(() => const BottomPage());
return;
}
// A 409 means he is not a first-time rider after all — he already has a
// PIN, here or on another handset. Sending him to Enter-PIN is the useful
// answer; "could not save your PIN" would leave him retyping one the
// server will never accept.
if (_auth.lastSetPinWasAlreadySet) {
if (_auth.lastSetPinFailure != null) {
AppFeedback.infoGlobal(_auth.lastSetPinFailure!);
}
openScreen(context, Mpin());
return;
}
setState(
() => _error =
_auth.lastSetPinFailure ??
'Could not save your PIN. Please try again.',
);
}
@override

View File

@@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/views/helpers/widgets/page_transitions.dart';
import 'package:miler/views/onboardscreens/otp_page.dart';
import 'package:miler/views/onboardscreens/Creat_mpin.dart';
import 'package:miler/views/onboardscreens/Mpin.dart';
import 'package:miler/views/onboardscreens/auth_scaffold.dart';
import 'package:miler/controllers/auth.dart';
@@ -138,21 +138,43 @@ class _SignInState extends State<SignIn> {
if (!mounted) return;
setState(() => isLoading = false);
if (decision == AuthNext.otp) {
await controller.auth.sendOtp(phoneController.text);
if (!mounted) return;
openScreen(context, OtpPage());
} else if (decision == AuthNext.verifyPin) {
openScreen(context, Mpin());
} else {
// notRegistered / error. Reported on the screen rather than in a snackbar
// under the keyboard, where the old version put it.
setState(() {
_showError = true;
_errorText =
'We could not sign you in with that number. Check it and try '
'again, or contact your manager.';
});
// ── The server decides which screen, on one boolean ──
//
// `POST /miler/login` answers `pin_set`. False means a rider who has never
// signed in — the console no longer issues PINs, so he sets his own. True
// is the path every existing rider has always taken.
//
// This used to branch to an OTP screen when the directory said "no such
// account": the code was never verified (`verifyOtp` returned true without
// checking) and it dead-ended at a Create-MPIN screen with no route to
// call. A rider who took it could not get back.
switch (decision) {
case AuthNext.setPin:
openScreen(context, CreateMpin());
case AuthNext.verifyPin:
openScreen(context, Mpin());
case AuthNext.notRegistered:
setState(() {
_showError = true;
_errorText =
'That number is not registered as a Miler. Check it, or ask '
'your manager to add you.';
});
case AuthNext.inactive:
setState(() {
_showError = true;
_errorText =
'This account is not active. Contact your manager.';
});
case AuthNext.error:
// The directory could not be reached. Not a fact about the rider, and
// deliberately worded as the temporary thing it is.
setState(() {
_showError = true;
_errorText =
'We could not reach the server. Check your connection and try '
'again.';
});
}
}