Files
doormile_milderapp/lib/views/Dashboard/pickups/delivery_actions.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

711 lines
30 KiB
Dart

part of 'pickups.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// CLOSING A DELIVERY
///
/// The three ways a drop can end, and the one place that writes them.
///
/// ── Why this is not the confirmation sheet ──
///
/// The delivery leg used to run through the pickup flow's screens: the map
/// asked the rider to press **I've arrived**, which opened a sheet offering
/// *Picked up · Skipped · Not picked*. Both halves were wrong on a round he is
/// already carrying. Arrival at a door is not a state anything records — there
/// is no endpoint for it (see [MilkRun.deliveryArrivalIsLocalOnly]) — so
/// pressing it wrote nothing and existed only to reveal the next screen. And
/// the screen it revealed asked him to confirm a *pickup* for a bag in his box.
///
/// So the delivery leg has no arrival step and no sheet. The stop is simply
/// **active** once he sets off, and the only question left is how it ended.
///
/// ── Why the writes live here and not in the control that calls them ──
///
/// The confirmation sheet already knew how to close a stop, and putting a
/// second copy behind a new button would be two implementations of one
/// business action — the thing that lets a delivery recorded from one screen
/// differ from the same delivery recorded from another. The control is a
/// control; this is what it does.
/// ─────────────────────────────────────────────────────────────────────────
enum DeliveryOutcome {
/// Handed over. `POST /miler/consignments/:id/deliver`.
delivered,
/// Not this time — a return visit. `POST /miler/consignments/:id/skip`
/// increments `attemptcount` rather than failing the parcel.
skipped,
/// It is not going to happen. `POST /miler/bookings/:id/cancel`.
cancelled,
}
extension DeliveryOutcomeX on DeliveryOutcome {
String get label => switch (this) {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skip',
DeliveryOutcome.cancelled => 'Cancelled',
};
IconData get icon => switch (this) {
DeliveryOutcome.delivered => LucideIcons.circleCheck,
// A clock, not ⏭. `skip_next` is a media-player transport glyph — "jump
// to the next track" — and on a stop it implied the order is passed
// over. A skip here is a RETURN VISIT ("keep it on board and try again
// later"), and the clock is the app's own later-glyph, the same one the
// skip-reason sheet's "Delivery paused" row already wears.
DeliveryOutcome.skipped => LucideIcons.clock,
DeliveryOutcome.cancelled => LucideIcons.circleX,
};
Color get colour => switch (this) {
DeliveryOutcome.delivered => ColorConstants.acceptGreen,
DeliveryOutcome.skipped => ColorConstants.warning,
DeliveryOutcome.cancelled => ColorConstants.errorRed,
};
/// What Activity will show once the round is over.
String get pastTense => switch (this) {
DeliveryOutcome.delivered => 'Delivered',
DeliveryOutcome.skipped => 'Skipped',
DeliveryOutcome.cancelled => 'Cancelled',
};
}
/// Last resort: ask the backend what this booking became.
///
/// Returns '' when it has become nothing, which is a real answer — the booking
/// was never converted — and not a failure to look.
Future<String> _consignmentIdFromBackend(Map<String, dynamic> stop) async {
final bookingId = (stop['bookingid'] ?? stop['orderheaderid'] ?? '')
.toString()
.trim();
if (bookingId.isEmpty) return '';
try {
final assignmentId = await AssignmentLookup.idForBooking(bookingId);
if (assignmentId == null) return '';
final res = await MilerApi.assignment(assignmentId);
if (!res.ok) return '';
String found = '';
void walk(dynamic node, int depth) {
if (found.isNotEmpty || depth > 4 || node == null) return;
if (node is List) {
for (final item in node) {
walk(item, depth + 1);
}
return;
}
if (node is! Map) return;
for (final key in const [
'consignmentid',
'consignmentId',
'consignment_id',
]) {
final v = node[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0' && str != 'null') {
found = str;
return;
}
}
}
for (final v in node.values) {
walk(v, depth + 1);
}
}
walk(res.data, 0);
if (found.isEmpty) walk(res.raw, 0);
if (found.isNotEmpty) {
debugPrint(
'[DELIVERY] booking $bookingId → consignment $found (assignment)',
);
}
return found;
} catch (e) {
debugPrint('[DELIVERY] consignment lookup failed for $bookingId: $e');
return '';
}
}
/// The consignment this stop delivers against, from four sources in order of
/// freshness — or '' when the booking genuinely was never converted.
///
/// ── Why this is a function and not a lookup ──
///
/// `deliver` and `skip` key on the CONSIGNMENT, which only exists after
/// `pickup-complete` — and **`GET /miler/bookings` does not return it at
/// all** (verified against the live API on 2026-08-20: 22 rows, zero
/// `consignment*` keys). So the row a rider works from *never* carries the id
/// by itself, and any path that reads `stop['consignmentid']` raw refuses
/// every delivery on a fresh session. That was exactly the Update Status
/// sheet's bug: `closeDelivery` had this chain inline while the sheet read
/// the raw key, so the same door could be closed from one screen and
/// "Could not record delivered" from the other. One resolver, every caller.
///
/// 1. the row itself, once a previous resolution has written it back;
/// 2. the id recorded at the pivot on this device, under any of the three
/// keys the pivot has historically filed it under — order id first, that
/// is the key the current write uses;
/// 3. the assignment row (`GET /miler/assignments/:id`), the one record
/// that spans both halves of the job — it knows the booking and, once
/// converted, what the booking became. One extra call at the door beats
/// refusing the rider;
/// 4. nothing, which is a real state — the pickup never converted — and gets
/// a real message rather than a network error.
///
/// A found id is written back onto the row and into the local record, so the
/// rest of the session — and the skip path — get it for free.
Future<String> resolveConsignmentId(Map<String, dynamic> stop) async {
final String orderId = (stop['orderid'] ?? '').toString().trim();
String consignmentId = (stop['consignmentid'] ?? '').toString().trim();
if (consignmentId.isEmpty) {
final ids = await getConsignmentIds();
for (final key in <String>[
orderId,
(stop['pickupid'] ?? '').toString().trim(),
(stop['bookingid'] ?? '').toString().trim(),
]) {
if (key.isEmpty) continue;
final found = (ids[key] ?? '').trim();
if (found.isNotEmpty) {
consignmentId = found;
break;
}
}
}
if (consignmentId.isEmpty) {
consignmentId = await _consignmentIdFromBackend(stop);
}
if (consignmentId.isNotEmpty) {
stop['consignmentid'] = consignmentId;
if (orderId.isNotEmpty) {
await rememberConsignmentId(orderId, consignmentId);
}
}
return consignmentId;
}
/// Asks the server to release one consignment. True when it is on the road.
///
/// ── Two servers, one button ──
///
/// `Collected_By_Miler` and `POST /consignments/:id/start-delivery` are built
/// but **gated off** on the backend (`MILER_COLLECTED_STATE_ENABLED`, default
/// off) until this app ships the press. So both worlds are live at once and
/// this has to be right in each:
///
/// flag OFF `pickup-complete` releases hyperlocal work itself, exactly as
/// before. The consignment is already `Out_for_Delivery` when it
/// reaches this tab, and there is nothing to call.
/// flag ON the pivot stops at `Collected_By_Miler` and this press is what
/// puts the load on the road.
///
/// The row itself now says which — `consignmentstatus` ships on every
/// `GET /miler/bookings` row since 21 Aug 2026. So the common path costs no
/// round trip at all, and with the flag off the app never fires a
/// `start-delivery` that can only be refused: a stream of 4xx per rider press
/// is not a harmless no-op, it is the backend's error log.
///
/// Falls back to asking the consignment when the row does not say, which is
/// also what answers the two refusals a real round produces — `INVALID_STATE`
/// (it left `Collected_By_Miler` some other way) and a lingering
/// `IDEMPOTENCY_IN_PROGRESS` after [MilerApi] has waited the write out.
/// Neither is answerable from the error itself.
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
final consignmentId = await resolveConsignmentId(stop);
if (consignmentId.isEmpty) {
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
return false;
}
// What the list row already told us, before spending a request on it.
var state = consignmentStateFromRaw(stop['consignmentstatus']);
if (state == ConsignmentState.unknown) {
state = await ConsignmentGate.stateOf(consignmentId);
}
if (state.isDeliverable || state.isDelivered) return true;
if (!state.needsRelease && state != ConsignmentState.unknown) {
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
return false;
}
final res = await MilerApi.startDelivery(consignmentId);
if (res.ok) return true;
debugPrint(
'[MILKRUN] start-delivery $consignmentId -> '
'${res.status} ${res.code} ${res.message}',
);
// Already on the road, or already handed over — either way this press is
// not what stands between the rider and the door.
final after = await ConsignmentGate.stateOf(consignmentId);
return after.isDeliverable || after.isDelivered;
}
/// Brings this device into line with a delivery the server already has.
///
/// The same local bookkeeping a successful `deliver` performs — the order
/// leaves the rider's hands, its consignment mapping is forgotten, and it is
/// filed as finished — **without** posting anything. Nothing here invents a
/// state: the state was read from the consignment's own history first.
Future<void> _reconcileAlreadyDone(
Map<String, dynamic> stop,
_MyPickupsState? parentState,
) async {
final orderId = (stop['orderid'] ?? '').toString();
try {
if (orderId.isNotEmpty) {
await removeCollectedOrderIds([orderId]);
await forgetConsignmentIds([orderId]);
}
// The same path a real completion takes — one recorder, so a reconciled
// stop and a delivered one produce the same Activity record.
parentState?.markPickupFinished(stop);
await addCompletedBookings([stop], terminalStatus: 'delivered');
} catch (e) {
debugPrint('[DELIVERY] reconcile failed for $orderId: $e');
}
}
/// Writes [outcome] against [stop], then clears the local bookkeeping so the
/// order leaves Deliveries and lands in Activity.
///
/// Returns the outcome payload the caller should pop with, or null when the
/// write failed — in which case the rider keeps the stop and the screen, which
/// is the only honest thing to do with work the hub has not been told about.
/// One press, one write.
///
/// The delivery path had no shared guard — only a widget's `_isNavigating`
/// flag, which is per-widget and dies with a rebuild, and the outcome is
/// reachable from a sheet that can be re-opened. Two Delivered taps meant two
/// `deliver` posts against a backend that is not idempotent about them. Keyed
/// on the resource and the verb, so every route to the same stop shares it.
Future<Map<String, dynamic>?> closeDelivery(
BuildContext context, {
required Map<String, dynamic> stop,
required _MyPickupsState? parentState,
required DeliveryOutcome outcome,
String notes = '',
String proofPath = '',
}) {
final key = '${outcome.name}:${(stop['orderid'] ?? '').toString()}';
return MutationGuard.run<Map<String, dynamic>?>(
key,
() => _closeDelivery(
context,
stop: stop,
parentState: parentState,
outcome: outcome,
notes: notes,
proofPath: proofPath,
),
);
}
Future<Map<String, dynamic>?> _closeDelivery(
BuildContext context, {
required Map<String, dynamic> stop,
required _MyPickupsState? parentState,
required DeliveryOutcome outcome,
String notes = '',
/// Where the doorstep photo was saved, `''` when the rider completed
/// without one. Carried onto the finished record so the Activity detail
/// page can show it — see [ProofStore] for why it is not sent as
/// `photourl`.
String proofPath = '',
}) async {
final dc = Get.put(PickupsController(), permanent: true);
final String orderId = (stop['orderid'] ?? '').toString();
final int pickupIdInt = int.tryParse('${stop['pickupid'] ?? 0}') ?? 0;
final int orderHeaderId = int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0;
double parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
// The customer's door. On a milk run the stop *is* the customer's address, so
// the pickup coordinates stand in when there is no separate drop point —
// same fallback the sheet uses, for the same reason.
final double dropLat = parseD(stop['droplat'] ?? stop['DropLat'] ?? 0);
final double dropLng = parseD(stop['droplon'] ?? stop['DropLon'] ?? 0);
final double stopLat = parseD(stop['pickuplat'] ?? stop['PickupLat'] ?? 0);
final double stopLng = parseD(stop['pickuplon'] ?? stop['PickupLon'] ?? 0);
final String consignmentId = await resolveConsignmentId(stop);
// ── Ask the consignment what it is, before writing to it ──
//
// ROOT CAUSE, verified against the live API 2026-08-21. `deliver` answers
// `400 consignment is not out for delivery` for TWO opposite situations —
// "not released yet" and "already delivered" — and the app was treating
// both as an error to show the rider. Consignment 34's own history
// (`GET /miler/consignments/logs/34`) reads:
//
// Out_for_Delivery "Package collected by miler and converted…"
// Delivered "Delivered to SEQTEST Ukkadam at (…)"
//
// …so `pickup-complete` DOES release hyperlocal work (no
// `/miler/deliveries/start` is involved, and none exists), and that stop had
// been delivered an hour earlier. It stayed on the Deliveries tab because
// the only thing the app reads is the BOOKING status, which is terminal at
// `Converted_To_Consignment` and never learns about the delivery half — and
// the local "I delivered this" memory had gone with a reinstall.
//
// So the state is read from the one route that reports it, and the answer
// decides. See [ConsignmentGate].
//
// Skip asks the same question and gets a different answer to one of the
// rungs: the backend widened `skip` on 21 Aug 2026 so a failed attempt is
// 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].
final gated =
outcome == DeliveryOutcome.delivered ||
outcome == DeliveryOutcome.skipped;
if (gated && consignmentId.isNotEmpty) {
final state = await ConsignmentGate.stateOf(consignmentId);
if (outcome == DeliveryOutcome.skipped && state.canSkip) {
// Nothing to correct and nothing to release — post it.
} else {
switch (ConsignmentGate.gateFor(state)) {
case DeliverGate.alreadyDelivered:
// The work is done server-side; this device is what is behind. File
// it and drop it from the tab — showing an error for a delivery the
// rider genuinely completed is the bug, not the fix.
debugPrint('[DELIVERY] $orderId already delivered — reconciling');
await _reconcileAlreadyDone(stop, parentState);
if (context.mounted) {
AppFeedback.success(context, 'Already delivered — record updated');
}
return {'outcome': 'completed', 'reconciled': true};
case DeliverGate.awaitingHub:
// The guard that must survive: a logistics consignment sitting at a
// hub genuinely is not this rider's to hand over.
debugPrint('[DELIVERY] $orderId is with the hub — refusing');
if (context.mounted) {
AppFeedback.error(
context,
'This parcel is still with the hub. It can be delivered once the '
'hub releases it.',
);
}
return null;
case DeliverGate.needsRelease:
// Collected, but nobody pressed **Start round** — a rider who left
// straight from the kitchen, or a press that failed on a dead signal.
//
// He is standing at the door. The release is his own to make and the
// route exists, so it is made here rather than sending him back two
// screens to press a button whose only purpose is to make this one
// work. Nothing is assumed: the server performs the transition and
// the state is read again before the delivery is posted.
debugPrint('[DELIVERY] $orderId not started — releasing now');
if (!await releaseForDelivery(stop) ||
!(await ConsignmentGate.stateOf(consignmentId)).isDeliverable) {
if (context.mounted) {
AppFeedback.error(
context,
'This delivery has not been started yet. Open Deliveries and '
'press Start round, then try again.',
);
}
return null;
}
break;
case DeliverGate.closed:
debugPrint('[DELIVERY] $orderId is closed — refusing');
if (context.mounted) {
AppFeedback.error(
context,
'This order was closed by the office and cannot be delivered.',
);
}
return null;
case DeliverGate.deliverable:
case DeliverGate.unknown:
// `unknown` falls through on purpose: a failed read is not evidence,
// and the server is still the judge one call later.
break;
}
}
}
if (outcome != DeliveryOutcome.cancelled && consignmentId.isEmpty) {
// Cancel is a *booking* route, so it is the one outcome that still works
// without a consignment.
//
// Reaching here means four sources came back empty, which is no longer a
// client-side gap: it means the pickup never converted this booking. The
// message says the actionable thing rather than naming an internal id the
// rider has no way to obtain.
// ── Two different failures wore one sentence ──
//
// This always said "never picked up on the system — mark it picked up
// from Home first", which is true for exactly one of the two ways to get
// here, and actively misleading for the other:
//
// • The booking really has NOT been collected. The advice is right.
// • The booking IS `Converted_To_Consignment` — collected, converted,
// sitting on the Deliveries tab — and the app simply cannot obtain the
// consignment id. Telling that rider to "pick it up from Home" sends
// him to look for a stop that is not on Home, to redo work he has
// already done, and it cannot possibly clear the error.
//
// Verified against production 2026-08-21, booking 59
// (`DM-BK-501CB551-45130`, status `Converted_To_Consignment`): every route
// to its consignment id is a dead end — `GET /miler/bookings` returns no
// consignment key on any of its 29 rows, and `GET /miler/assignments`
// returns only 12 rows covering 12 of those bookings, so
// `AssignmentLookup` has nothing to resolve. `?status=`/`?bookingid=`
// filters are ignored by that endpoint. There is no fifth route.
//
// So the rider is told what is actually true and who can fix it, and the
// gap is logged with the evidence rather than blamed on him. The real fix
// is backend — see MILER_API_REQUIREMENTS.md request 3.
final collected =
stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg;
debugPrint('[DELIVERY] $orderId has no consignment id — refusing');
ApiConfig.logGap(
'deliver',
collected
? 'booking $orderId is collected but its consignment id is '
'unreachable: the row carries none, the local pivot record is '
'absent, and GET /miler/assignments does not list this '
'booking. Backend must return consignmentid on /miler/bookings.'
: 'booking $orderId reached the delivery leg with no consignment: '
'its pickup-complete either never ran or did not convert it.',
);
if (context.mounted) {
AppFeedback.error(
context,
collected
? "This order's delivery reference is missing, so it cannot be "
'completed from the app. Ask your hub to check it — '
'collecting it again will not help.'
: 'This order was never picked up on the system — mark it picked '
'up from Home first, then deliver.',
);
}
return null;
}
// One fix for whichever branch runs, taken before the write so a slow GPS
// cannot delay the call the rider is waiting on. Null is fine: every route
// here treats coordinates as optional telemetry, not as a condition.
Position? riderFix;
try {
riderFix = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 5),
),
);
} catch (_) {
// No fix, and the outcome still has to be recordable. See above.
}
bool ok = false;
try {
switch (outcome) {
case DeliveryOutcome.delivered:
ok = await dc.updateDeliveredStatus(
pickupId: pickupIdInt,
consignmentId: consignmentId,
deliveredToName:
(stop['dropcustomer'] ??
stop['pickupcustomer'] ??
stop['PickupCustomer'] ??
'Customer')
.toString(),
dropLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6),
dropLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6),
notes: notes,
);
case DeliveryOutcome.skipped:
// ── Straight to the consignment route ──
//
// Not through `PickupsController.updateSkippedStatus`, which takes an
// `int pickupId` and posts the *pickup* skip. A consignment id is not
// guaranteed to be numeric — it can be a tracking reference — so
// squeezing it through an int parameter would either lose it or, worse,
// fall back to the booking id and skip the wrong thing.
//
// The pickup-leg skip keeps that method; this is the delivery leg's.
final res = await MilerApi.skipConsignment(
consignmentId,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: riderFix?.latitude,
lon: riderFix?.longitude,
);
ok = res.ok;
if (!ok) debugPrint('[DELIVERY][skip] ${res.status} ${res.message}');
case DeliveryOutcome.cancelled:
ok = await dc.updateCancelledStatus(
pickupId: pickupIdInt,
orderHeaderId: orderHeaderId,
pickupLat: (dropLat != 0 ? dropLat : stopLat).toStringAsFixed(6),
pickupLng: (dropLng != 0 ? dropLng : stopLng).toStringAsFixed(6),
notes: notes.isEmpty ? 'Cancelled by rider' : notes,
);
}
} catch (e) {
debugPrint('[DELIVERY][${outcome.name}] $orderId failed: $e');
ok = false;
}
if (!ok) {
// ── Refused is not failed, and neither one may close a stop ──
//
// Completing locally after a write that never landed is what made the app
// show a finished screen while the office still had the order live. The
// rider keeps the stop and gets told why.
if (context.mounted) {
AppFeedback.error(
context,
dc.lastBlockedReason ??
'Could not record ${outcome.pastTense.toLowerCase()} — try again',
);
}
return null;
}
// ── Did the skip actually close anything? Ask, do not assume ──
//
// A delivery closes its consignment: the server says `Delivered` and the row
// stops being delivery work on its own. **A skip is a failed attempt**, and
// what the server does with one is the server's business — it may move the
// consignment to a failure state, or it may quite correctly leave it
// `Out_for_Delivery` for a second attempt or an RTO decision made elsewhere.
//
// Those two need opposite handling, and the app cannot tell them apart from
// the skip's own 200. So it reads the consignment afterwards:
//
// server closed it → a terminal record, filed in Activity, off this tab.
// server kept it open → **parked**, not closed. The stop stays visible
// under SKIPPED with its reason, the parcel stays in
// the rider's hands, and its consignment mapping is
// kept because it is still actionable.
//
// Writing a terminal local record over a consignment the hub still calls
// open is the failure this avoids: two systems disagreeing about whether a
// parcel is somebody's problem, with the rider's screen the only one saying
// it is not. See MILER_API_REQUIREMENTS.md request 14 — there is no route
// that reports a *failed attempt* as an outcome, which is why this has to be
// inferred at all.
var stillOpen = false;
if (outcome == DeliveryOutcome.skipped) {
final after = consignmentId.isEmpty
? ConsignmentState.unknown
: await ConsignmentGate.stateOf(consignmentId);
// The rule, and why unknown counts as open, is on the state itself.
stillOpen = after.isOpenAfterSkip;
debugPrint(
'[DELIVERY][skip] $orderId -> consignment is ${after.name}, '
'${stillOpen ? 'still open — parking' : 'closed — filing'}',
);
if (stillOpen) {
ApiConfig.logGap(
'skip',
'consignment $consignmentId is still ${after.name} after a successful '
'skip: the backend exposes no failed-attempt outcome, so the app '
'parks the stop locally rather than inventing a terminal state.',
);
}
}
// Local bookkeeping, so the order leaves this tab immediately rather than at
// the next poll: the write has landed, and a row that lingers reads as the
// button having done nothing.
try {
if (stillOpen) {
// Parked, not finished. The parcel is still in his hands and the
// consignment is still writable, so neither record is cleared — and the
// completed store, which is what holds a stop off this tab for good, is
// deliberately not written.
await addSkippedBooking({
...stop,
if (proofPath.isNotEmpty) 'proofphotopath': proofPath,
}, reason: notes);
parentState?.markOrderAsSkipped(stop, notes);
unawaited(WorkRepository.instance.invalidate());
return {
'outcome': outcome.name,
'isDelivery': true,
'deliveryOutcome': outcome.name,
'parked': true,
'notes': notes,
};
}
if (orderId.isNotEmpty) {
// It is no longer in his hands, whichever way it ended.
await removeCollectedOrderIds([orderId]);
// And nothing else will be posted against its consignment.
await forgetConsignmentIds([orderId]);
}
// Records it for Activity and drops it from this tab in one call — the same
// path every other finished stop takes. `cancelled` covers both non-delivery
// outcomes, which is what Activity renders as "not completed".
parentState?.markPickupFinished(
{
...stop,
'orderstatus': outcome == DeliveryOutcome.delivered
? 'delivered'
: outcome.name,
// Why he walked away, carried onto the record — Activity has nowhere
// else to get it, and "Skipped" with no reason is half a record.
if (outcome == DeliveryOutcome.skipped && notes.isNotEmpty)
'skipreason': notes,
// The doorstep photo rides onto the record, which is the only place it
// is ever read from — Activity's detail page draws it from here. Absent
// when there is none, so a record without proof has no key rather than
// an empty one that reads as a broken image.
if (proofPath.isNotEmpty) 'proofphotopath': proofPath,
},
cancelled: outcome != DeliveryOutcome.delivered,
// ── A skip is filed as a skip, and it does not come back ──
//
// It used to be flattened into `cancelled`, which put a stop the rider
// walked away from into the same slice as one the office called off.
// Filed under its own word it lands in Activity's *Active* slice, which
// is exactly what it is: work that still owes a return visit.
//
// Reached only when the server agrees the consignment is closed. The
// completed store is what [_MyPickupsState._restoreClosedToday] reads,
// so this stop leaves the tab and stays off it — which is only honest
// because the hub says the same thing.
terminalStatus: outcome == DeliveryOutcome.skipped ? 'skipped' : null,
);
unawaited(ProofStore.prune());
} catch (e) {
debugPrint('[DELIVERY][${outcome.name}] cleanup: $e');
}
// One copy of the day, and it is now stale — Home, Deliveries and Activity
// must all re-read rather than each deciding for itself. See [WorkRepository].
unawaited(WorkRepository.instance.invalidate());
return {
'outcome': outcome == DeliveryOutcome.delivered
? 'completed'
: outcome.name,
'isDelivery': true,
'deliveryOutcome': outcome.name,
'notes': notes,
};
}