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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user