1053 lines
47 KiB
Dart
1053 lines
47 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.
|
|
/// Why the last [releaseForDelivery] said no, in the rider's words.
|
|
///
|
|
/// ── One message was covering four different problems ──
|
|
///
|
|
/// Every failure below returned a bare `false`, and the caller printed *"This
|
|
/// parcel isn't ready to go out yet. Check your connection and try again."* for
|
|
/// all of them. Three of the four have nothing to do with a connection, and
|
|
/// two of them cannot be fixed by trying again at all:
|
|
///
|
|
/// • **No consignment.** The pickup never converted, or its id was lost. The
|
|
/// rider can retry this until his battery dies.
|
|
/// • **At a hub.** A cross-city parcel is `Inwarded_at_Hub` and will be
|
|
/// delivered by somebody else. There is nothing here for him to start.
|
|
/// • **Refused.** The server gave a reason and it was thrown away. It is
|
|
/// read now — and *translated*, because the reason arrives as a state
|
|
/// machine talking to itself (`consignment is Inwarded_At_Hub, not
|
|
/// Collected_By_Miler`) and that is not a sentence for a rider. See
|
|
/// [_refusalSentence].
|
|
/// • **Unreachable.** The only one where "check your connection" is true.
|
|
///
|
|
/// Telling a rider to check a working connection, about a parcel that is not
|
|
/// his to deliver, is how a screen teaches him to ignore what it says. Null
|
|
/// after a success.
|
|
String? lastReleaseFailure;
|
|
|
|
/// Whether a "somebody else has this" hold is even possible on this rider's
|
|
/// line.
|
|
///
|
|
/// ── The state that cannot happen, refusing the rider anyway ──
|
|
///
|
|
/// `Inwarded_at_Hub`, `Tripsheet_Loaded` and `In_Transit` are the logistics
|
|
/// network's own rungs: a cross-city parcel is dropped at a collection point
|
|
/// and delivered by a different rider on a different day. That is a real hold
|
|
/// and it must keep blocking on the logistics line.
|
|
///
|
|
/// On a meal run **it cannot happen at all.** Every DailyGrubs order is
|
|
/// hyperlocal — collected at the kitchen and carried straight to the door, one
|
|
/// rider, one leg, no network in between. So one of those states coming back
|
|
/// for a meal stop is not news about the food; it is a bad read, and the two
|
|
/// ways it happens are both known: a consignment reference that resolved to
|
|
/// the wrong record, and a row whose state belongs to a different booking.
|
|
///
|
|
/// The rider was shown the hold anyway, mid-round, on a slider whose only job
|
|
/// is to set him off — and there was nothing he could do about it, because the
|
|
/// thing it described was not true of his stop.
|
|
///
|
|
/// So a hold is only believed on a line that has somewhere to hold things. On
|
|
/// every other line the state is treated as unreadable, which sends the
|
|
/// question to the server: it is the judge of its own consignment, and one
|
|
/// refusal carrying its own reason beats a client-side refusal inventing one.
|
|
bool get _handoffHoldIsPossible => ServiceProfile.active.endsAtHub;
|
|
|
|
Future<bool> releaseForDelivery(Map<String, dynamic> stop) async {
|
|
lastReleaseFailure = null;
|
|
|
|
// ── A hub-routed parcel has no customer round to start ──
|
|
//
|
|
// This is the gate that keeps a Gandhipuram → Chennai consignment out of the
|
|
// delivery lifecycle. `start-delivery` moves it to `Out_for_Delivery` against
|
|
// a receiver 500km away, the hub then sees a rider "actively delivering" a
|
|
// parcel that is going onto a line-haul truck, and there is no way back from
|
|
// that state on the handset.
|
|
//
|
|
// Asked of the order, never of the line — see [NextLegResolver]. The pivot's
|
|
// own instruction is read from the store because that is the only place the
|
|
// routing answer survives a rebuild; `GET /miler/bookings` does not carry it.
|
|
final leg = NextLegResolver.resolve(
|
|
stop,
|
|
pivotAction: (await getPivotNextActions())[MilkRun.idOf(stop)] ?? '',
|
|
);
|
|
if (leg.isHub) {
|
|
debugPrint('[NEXTLEG] release refused for ${MilkRun.idOf(stop)} — $leg');
|
|
lastReleaseFailure =
|
|
'This parcel goes back to base, not to the customer. Hand it over at '
|
|
'base — there is no delivery to start here.';
|
|
return false;
|
|
}
|
|
|
|
// ── `unknown` is NOT refused here, deliberately ──
|
|
//
|
|
// It would be the cautious-looking thing to do and it is the wrong thing.
|
|
// A row that says nothing is exactly the case the consignment re-read below
|
|
// exists for: `Created` and a blank status both arrive on a poll that is
|
|
// behind the rider, and the server is the judge of its own consignment.
|
|
// Refusing here short-circuits that read and strands a rider on a stop the
|
|
// server would have released — the failure `_handoffHoldIsPossible` already
|
|
// documents, arriving by a new route.
|
|
//
|
|
// Only [NextLeg.hub] is refused, because that is the one answer the server
|
|
// has actually given.
|
|
|
|
final consignmentId = await resolveConsignmentId(stop);
|
|
if (consignmentId.isEmpty) {
|
|
debugPrint('[MILKRUN] no consignment id for ${MilkRun.idOf(stop)}');
|
|
lastReleaseFailure =
|
|
"This stop has no delivery reference yet, so it can't be started. "
|
|
'Ask your office to check it — sliding again will not help.';
|
|
return false;
|
|
}
|
|
|
|
// What the list row already told us, before spending a request on it.
|
|
var state = consignmentStateFromRaw(stop['consignmentstatus']);
|
|
// ── A row saying `Created` is the one row worth re-reading ──
|
|
//
|
|
// `Created` is the state a consignment holds for as long as it takes the
|
|
// pivot to route it, and `GET /miler/bookings` is a poll behind the rider by
|
|
// design. So a stop collected seconds ago can arrive on this tab carrying
|
|
// the state it had *before* `pickup-complete` finished with it, and every
|
|
// other state on the row is durable enough not to have that problem.
|
|
//
|
|
// Acting on it cost the rider the round: the guard below read it as hub
|
|
// custody and refused, and no amount of sliding cleared a row that only the
|
|
// next poll was going to correct. It is asked directly instead — one request,
|
|
// on the one state where the row is not evidence.
|
|
if (state == ConsignmentState.unknown || state == ConsignmentState.created) {
|
|
state = await ConsignmentGate.stateOf(consignmentId);
|
|
}
|
|
if (state.isDeliverable || state.isDelivered) {
|
|
// ── Released without a request, and the trace has to say so ──
|
|
//
|
|
// In compatibility mode the pivot has already released the consignment, so
|
|
// Start ride spends nothing here and the console was *already* Active
|
|
// before the rider slid. A silent `true` made that indistinguishable from
|
|
// a release this press actually made, which is the exact ambiguity the
|
|
// production trace has to resolve.
|
|
debugPrint(
|
|
'[TRACE][START-RIDE] consignment=$consignmentId '
|
|
'state="${state.name}" startDeliveryCalled=false '
|
|
'reason=already-released-by-pickup-complete (compatibility mode)',
|
|
);
|
|
return true;
|
|
}
|
|
// ── What may be *attempted*, as against what may be *claimed* ──
|
|
//
|
|
// This refused everything that was not `Collected_By_Miler` or unreadable,
|
|
// and `Created` fell into it wearing the hub's wording. Both halves were
|
|
// wrong for a parcel on the rider's own back.
|
|
//
|
|
// `Created` now goes to the server like `unknown` does, for the reason
|
|
// `unknown` does: the app is not the judge of a state it cannot fully
|
|
// account for, and one 400 carrying the backend's own sentence is worth more
|
|
// than a client-side refusal that invents one. A hyperlocal consignment the
|
|
// pivot has not finished releasing is let through; a hub-routed one is
|
|
// refused by the server, and the rider is told what the server *meant* —
|
|
// translated out of the state machine's vocabulary, see [_refusalSentence].
|
|
// A hold that cannot exist on this line is a bad read, not a fact about the
|
|
// stop. See [_handoffHoldIsPossible].
|
|
if (state.awaitsHub && !_handoffHoldIsPossible) {
|
|
debugPrint(
|
|
'[MILKRUN] $consignmentId read as ${state.name} on a line with no '
|
|
'handoff — treating as unreadable and asking the server',
|
|
);
|
|
}
|
|
if (!state.needsRelease &&
|
|
!state.awaitsHubInward &&
|
|
!(state.awaitsHub && !_handoffHoldIsPossible) &&
|
|
state != ConsignmentState.unknown) {
|
|
debugPrint('[MILKRUN] $consignmentId is ${state.name} — not releasable');
|
|
// One sentence per state, written once — see
|
|
// [ConsignmentStateX.startRefusalSentence]. This branch and the refusal
|
|
// below are the same answer arrived at from two directions (a state the
|
|
// app read, and a state the server named), and they used to word it
|
|
// differently: a cross-city parcel blocked here said "it's not yours to
|
|
// deliver" while the identical parcel blocked one request later read out
|
|
// a database enum.
|
|
lastReleaseFailure =
|
|
state.startRefusalSentence ??
|
|
'This parcel cannot be started yet. Ask your office to check it.';
|
|
return false;
|
|
}
|
|
|
|
debugPrint(
|
|
'[TRACE][START-RIDE] consignment=$consignmentId state="${state.name}" '
|
|
'POST /miler/consignments/$consignmentId/start-delivery — calling',
|
|
);
|
|
final res = await MilerApi.startDelivery(consignmentId);
|
|
debugPrint(
|
|
'[TRACE][START-RIDE] consignment=$consignmentId startDeliveryCalled=true '
|
|
'-> ${res.status} ${res.code} ok=${res.ok} raw=${res.raw}',
|
|
);
|
|
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);
|
|
if (after.isDeliverable || after.isDelivered) return true;
|
|
|
|
// `status: 0` is the request never leaving, and it is the one failure that
|
|
// is really about the connection.
|
|
lastReleaseFailure = res.status == 0
|
|
? 'Could not reach your office. Check your connection and slide again.'
|
|
: _refusalSentence(res, after);
|
|
return false;
|
|
}
|
|
|
|
/// The rider's version of a refused **Start ride**.
|
|
///
|
|
/// ── What the rider was actually reading ──
|
|
///
|
|
/// The server's own words were passed straight to the screen, on the argument
|
|
/// that one refusal carrying the backend's reason beats a client-side guess
|
|
/// that invents one. That argument holds for *whose* answer it is. It does not
|
|
/// hold for what the answer turned out to be: `start-delivery` refuses with an
|
|
/// assertion about its own model — **"consignment is Inwarded_At_Hub, not
|
|
/// Collected_By_Miler"** — and that is what a rider saw on the slider, in
|
|
/// database vocabulary, with no way to tell it from a crash.
|
|
///
|
|
/// The reason is worth keeping; the wording was never the server's to write.
|
|
/// So the state is what travels, and [ConsignmentStateX.startRefusalSentence]
|
|
/// says it once, in one place, in words that answer the only two questions he
|
|
/// has: is this mine, and will sliding again help.
|
|
///
|
|
/// ── Three sources for that state, best first ──
|
|
///
|
|
/// 1. **A fresh read** — [after], asked immediately after the refusal, which
|
|
/// is the newest thing anyone knows about the consignment.
|
|
/// 2. **The refusal itself.** The read can fail — the row need not carry a
|
|
/// status and the detail route 404s on older deployments — and when it
|
|
/// does the refusal is the only thing left that knows. See
|
|
/// [consignmentStateNamedInRefusal] for why reading it is safe.
|
|
/// 3. **The failure code.** `INVALID_STATE` alone still says the one useful
|
|
/// thing: the consignment has moved on from the rung this press acts at,
|
|
/// so sliding again is not the answer.
|
|
///
|
|
/// Prose from the server is still preferred over a generic sentence — some
|
|
/// routes really do answer in words meant for a person. What is filtered out
|
|
/// is the wire vocabulary. See [namesWireState].
|
|
String _refusalSentence(ApiResult res, ConsignmentState after) {
|
|
final state = after != ConsignmentState.unknown
|
|
? after
|
|
: consignmentStateNamedInRefusal(res.message);
|
|
|
|
final fromState = state.startRefusalSentence;
|
|
if (fromState != null) return fromState;
|
|
|
|
final said = res.message.trim();
|
|
if (said.isNotEmpty && !namesWireState(said)) return said;
|
|
|
|
return res.isInvalidState
|
|
? 'Your office has already moved this parcel on, so it cannot be '
|
|
'started from here. Ask them to check it.'
|
|
: 'Your office would not start this delivery.';
|
|
}
|
|
|
|
/// 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.awaitingInward:
|
|
// Converted but never released and never inwarded. It is in his box,
|
|
// so the hub-hold sentence below would be false — but `deliver` will
|
|
// refuse it, so saying nothing and posting anyway would put a 400 on
|
|
// screen with no explanation attached.
|
|
debugPrint('[DELIVERY] $orderId is Created — not on the road yet');
|
|
if (context.mounted) {
|
|
AppFeedback.error(
|
|
context,
|
|
"This parcel hasn't been released for delivery yet. Slide to "
|
|
'start the ride first, or ask your office to check it.',
|
|
);
|
|
}
|
|
return null;
|
|
|
|
case DeliverGate.awaitingHub:
|
|
// The guard that must survive: a logistics consignment inside the
|
|
// network genuinely is not this rider's to hand over.
|
|
//
|
|
// But only on a line that HAS a network. The same bad read that hit
|
|
// the Start-ride slider reaches this branch too, and refusing here
|
|
// is worse — the rider is at the door with the food in his hand.
|
|
// See [_handoffHoldIsPossible].
|
|
if (!_handoffHoldIsPossible) {
|
|
debugPrint(
|
|
'[DELIVERY] $orderId read as held on a line with no handoff — '
|
|
'ignoring the read and letting the server judge',
|
|
);
|
|
break;
|
|
}
|
|
debugPrint('[DELIVERY] $orderId is held elsewhere — refusing');
|
|
if (context.mounted) {
|
|
AppFeedback.error(
|
|
context,
|
|
"This one has already been handed on, so it can't be delivered "
|
|
'from here.',
|
|
);
|
|
}
|
|
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.delivered && consignmentId.isEmpty) {
|
|
// ── Only `deliver` needs a consignment ──
|
|
//
|
|
// This guard used to catch **skip** as well, and skip is the one thing a
|
|
// rider does when a delivery goes wrong — so the outcome he reaches for
|
|
// precisely when something is already wrong was the one refused with "this
|
|
// order's delivery reference is missing". Nothing he could do cleared it.
|
|
//
|
|
// Cancel was already exempt: it is a *booking* route. Skip now has one too
|
|
// (`POST /miler/bookings/:id/skip`, shipped 24 Aug), so both work without a
|
|
// consignment and only the hand-over — which genuinely keys on one — is
|
|
// held here.
|
|
//
|
|
// 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 office 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.
|
|
}
|
|
|
|
// ── The photograph leaves the phone ──
|
|
//
|
|
// It never could: `deliver` takes `photourl`, which wants a URL, and nothing
|
|
// on the contract accepted an upload — so proof of delivery lived in the
|
|
// app's own directory and died with the next reinstall, on the one record
|
|
// somebody asks about weeks later.
|
|
//
|
|
// `POST /miler/uploads/sign` (24 Aug) closes it: sign, PUT the bytes, send
|
|
// the public URL back on the delivery.
|
|
//
|
|
// **A failed upload never blocks a hand-over.** The parcel is in the
|
|
// customer's hands whatever the network did, and `deliver` accepts an empty
|
|
// `photourl`. The local copy is kept either way, so a failure costs the hub
|
|
// its copy and costs the rider nothing.
|
|
String proofUrl = '';
|
|
if (outcome == DeliveryOutcome.delivered && proofPath.isNotEmpty) {
|
|
final uploaded = await MilerApi.uploadProof(
|
|
File(proofPath),
|
|
purpose: MilerApi.proofDelivery,
|
|
consignmentId: consignmentId.isEmpty ? null : consignmentId,
|
|
);
|
|
if (uploaded != null) {
|
|
proofUrl = uploaded;
|
|
} else {
|
|
ApiConfig.logGap(
|
|
'deliver',
|
|
'the proof photo for $orderId could not be uploaded; the delivery is '
|
|
'being recorded without one and the copy stays on the device.',
|
|
);
|
|
}
|
|
}
|
|
|
|
bool ok = false;
|
|
try {
|
|
switch (outcome) {
|
|
case DeliveryOutcome.delivered:
|
|
ok = await dc.updateDeliveredStatus(
|
|
pickupId: pickupIdInt,
|
|
consignmentId: consignmentId,
|
|
proofImage: proofUrl,
|
|
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.
|
|
//
|
|
// ── Two routes, chosen by what the stop actually has ──
|
|
//
|
|
// With a consignment the skip belongs to it — that is the attempt
|
|
// counter the hub reads. Without one, the booking route is not a
|
|
// fallback but the *correct* call: a stop with no consignment has not
|
|
// been converted, so there is nothing on the delivery side to bump.
|
|
// Posting an empty id to `/consignments//skip` — which is what this
|
|
// did whenever the id was unreachable — 404s and tells the rider his
|
|
// own stop is broken.
|
|
final res = consignmentId.isEmpty
|
|
? await MilerApi.skipBooking(
|
|
stop['bookingid'] ?? stop['orderheaderid'] ?? orderId,
|
|
reason: notes.isEmpty ? 'Skipped by rider' : notes,
|
|
lat: riderFix?.latitude,
|
|
lon: riderFix?.longitude,
|
|
)
|
|
: 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}');
|
|
|
|
// ── The attempt counter is the hub's, and it has a ceiling ──
|
|
//
|
|
// `skip` returns `attemptcount` and it is the source of truth: the
|
|
// consignment stays `Out_for_Delivery` and the parcel stays in the
|
|
// rider's hands, so nothing here may close the stop. At **3** the
|
|
// backend raises an Undeliverable exception for the hub, and there is
|
|
// no automated return or reassignment behind it — a human picks it up
|
|
// from there. Saying so is the difference between a rider trying a
|
|
// fourth time and a rider ringing the hub.
|
|
if (ok) {
|
|
final body = res.data;
|
|
final attempts = body is Map
|
|
? int.tryParse('${body['attemptcount'] ?? ''}') ?? 0
|
|
: 0;
|
|
if (attempts >= 3 && context.mounted) {
|
|
AppFeedback.info(
|
|
context,
|
|
'Third attempt on this stop — your office has been told and will '
|
|
'take it from here.',
|
|
);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// ── Cancel is a booking route, and a collected parcel is past it ──
|
|
//
|
|
// `POST /miler/bookings/:id/cancel` is refused once the stop is picked
|
|
// up — by design: cancelling releases the booking for reassignment, and
|
|
// a parcel already in a rider's box cannot be handed to somebody else.
|
|
// The generic "could not record — try again" invited exactly the retry
|
|
// that can never work, so the one outcome with a *permanent* reason says
|
|
// it.
|
|
final collectedAlready =
|
|
outcome == DeliveryOutcome.cancelled &&
|
|
(stopStatusOf(stop).isPicked || stopStatusOf(stop).isDeliveryLeg);
|
|
|
|
AppFeedback.error(
|
|
context,
|
|
collectedAlready
|
|
? 'You are already carrying this parcel, so it cannot be '
|
|
'cancelled from the app. Skip it to try again later, or ask '
|
|
'your office to cancel it.'
|
|
: 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,
|
|
};
|
|
}
|