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

@@ -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)';