Files
doormile_milderapp/lib/views/Dashboard/pickups/card.dart
Thiru-tenext d612916fe4 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
2026-09-18 11:05:40 +05:30

1668 lines
66 KiB
Dart

part of 'pickups.dart';
// ─────────────────────────────────────────────────────────────────────────────
// UNIFIED STOP CARD
//
// One card renders all three operational modes — PICKUP, DELIVERY, and
// PICKUP & DELIVERY (combined) — because a rider scanning a route list must
// not have to learn three different layouts. The mode changes the accent, the
// badge, and the action breakdown; the *shape* never moves.
//
// Reading order, top to bottom, matches the order the rider asks the questions:
// 1. WHAT kind of stop, which number, and how far? → meta line
// 2. WHO and WHERE? → name + address
// 3. WHAT exactly do I do here? → action line
// 4. GO. → primary CTA
//
// Each of those is a ROW OF TEXT, not a panel. See the note inside build().
//
// Thumb zone: the CTA and the call button live in the bottom band of the card.
// Everything above is read-only — nothing the rider needs to *press* sits in
// the top 60%, which is where his hand covers the screen while reading.
// ─────────────────────────────────────────────────────────────────────────────
class PickupCard extends StatelessWidget {
final Map<String, dynamic> item;
final int displayStep;
/// Live straight-line distance from the rider to this stop, in metres.
/// Null when no usable fix exists — the strip then says so instead of
/// inventing a zero.
final double? distanceMeters;
/// The rider's current ground speed in m/s, if known. Drives the ETA;
/// [RouteMetricsHelper] falls back to an urban average when it's unusable.
final double? riderSpeedMps;
final bool enabled;
final bool isSkipped;
/// The label this order travels in, as recorded at the counter. Empty on a
/// parcel route, where there is no load to match against. See [OrderManifest].
final String label;
/// Orders the rider is carrying.
///
/// Needed because "picked up" is not a *status* on this screen — the accepted
/// store normalises these rows to `accepted`, and what actually records the
/// hand-over is the collected set. Without it, [MilkRun.workingKind] reads a
/// label already in the rider's box as still needing collection, and the button
/// says **Start Pickup** for a delivery.
final Set<String> collectedIds;
/// Orders whose delivery leg the rider has actually started.
///
/// Distinct from [collectedIds] on purpose, and the distinction is the whole
/// point of the chip: a label in his box is **PICKED**, a round he has set off
/// on is **ACTIVE**. The backend cannot answer this — `pickup-complete`
/// stamps a hyperlocal consignment `Out_for_Delivery` at the counter — so the
/// rung is resolved through [MilkRun.stageOf] against this set rather than
/// off the row's raw status.
final Set<String> outForDeliveryIds;
/// `next_action` per order, as the pivot returned it.
///
/// Only needed to tell a **hub-routed** parcel from one whose routing has not
/// been reported yet: both sit on `Created`, and only the recorded pivot
/// instruction separates them. Everything else the card asks resolves from
/// the row's own `consignmentstatus`.
///
/// Defaulting to empty is safe by construction — without it a hub parcel
/// reads as [NextLeg.unknown], which withholds the customer destination and
/// the Start-delivery control exactly as the hub leg does. It loses the
/// precise wording, never the guard.
final Map<String, String> pivotActions;
const PickupCard({
super.key,
required this.item,
required this.displayStep,
this.distanceMeters,
this.riderSpeedMps,
this.enabled = true,
this.isSkipped = false,
this.label = '',
this.collectedIds = const {},
this.outForDeliveryIds = const {},
this.pivotActions = const {},
});
String _val(List<String> keys, [String fallback = '']) {
for (final k in keys) {
final v = item[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString();
}
return fallback;
}
/// Minimum 48dp tap targets, enforced against the raw logical pixel value so
/// a small-screen `.h` scale-down can never shrink below the floor.
///
/// Was 52dp with a 56dp CTA. The card's text carries the reading now — the
/// name, the address and the action line all went up a step — and a 56dp
/// button under bigger type made the bottom band the loudest thing on a card
/// whose job is to be scanned, not pressed. 48dp is still Material's floor
/// for a thumb, so nothing is harder to hit; the button just stops competing
/// with the words above it.
static double _tapTarget(double scaled) => math.max(48.0, scaled);
@override
Widget build(BuildContext context) {
// ── The label follows the leg, not the payload ──
//
// The booking adapter stamps `type: pickup` on every row it builds, so a
// milk-run order the rider has already collected still described itself as
// a pickup — and this card offered **Start Pickup** for a label sitting in
// his box. See [MilkRun.workingKind].
final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds);
// ── Where this parcel goes next, per order ──
//
// The card used to answer this from [ServiceProfile.deliversToCustomer] — a
// line-level constant — which is false on logistics. So a hyperlocal parcel
// already released into the rider's hands drew its *sender* as the
// destination, and a Chennai-bound one drew a receiver he is never going to.
final NextLegDecision leg = NextLegResolver.resolve(
item,
pivotAction: pivotActions[MilkRun.idOf(item)] ?? '',
);
final String customer = _val([
'pickupcustomer',
'tenantname',
], kind.hasDelivery ? 'Delivery point' : 'Pickup point');
final String address = _val([
'pickupaddress',
'PickupAddress',
], 'Address not available');
final String orderId = _val(['orderid']);
final String notes = _val(['notes', 'Notes']);
final int deliverQty = deliveryParcelCount(item);
final int collectQty = pickupParcelCount(item);
final double cash = stopCollectionAmount(item);
// ── The two ends of the journey, named ──
//
// Which name belongs at which end depends on the line of work, and getting
// it wrong is a rider riding to a subscriber's flat to collect a lunch:
//
// • **Meal run.** He collects at a kitchen and hands over at a door, so
// the source names the pickup and the customer names the drop.
// • **Parcel run.** A booking is a first-mile collection *from* the
// customer, and where it goes afterwards is the hub's problem — so the
// customer names the pickup, and the drop end appears only if the
// payload actually carries one.
final String source = stopSourceName(item);
// The receiver names the far end whenever the rider is carrying it there —
// whether that is a milk round (line-level) or a hyperlocal parcel the
// backend routed direct (per-order).
// Asked of the ROW first. On a meal tenant this static answered true for
// every card on the screen, so a CX customer pickup sitting next to a
// kitchen load was drawn as though the rider were already carrying it to a
// receiver. [WorkPolicy] reads the row's own `next_action` /
// `pickup_source_type` and only falls back to the line where the row is
// silent — which keeps every existing payload rendering exactly as it does
// today.
final bool carryingToCustomer =
WorkPolicy.deliversToCustomer(item) || leg.isCustomer;
final bool pickupIsSource = carryingToCustomer && source.isNotEmpty;
final String pickupName = pickupIsSource ? source : customer;
final String dropAddress = _val(['dropaddress', 'DropAddress']);
// ── Who is actually at the far end ──
//
// `pickupcustomer` is the booking's customer, which on a parcel booking is
// the **sender** — the person the rider collected FROM. Using it to name the
// drop put the sender's name over the receiver's door, and on a
// multi-destination pickup it put the same name over all three of them.
// The receiver is carried per destination and is the honest answer whenever
// the payload has one.
final String recipient = _val(['recipientname']);
final String dropName = recipient.isNotEmpty
? recipient
: pickupIsSource
? customer
: (dropAddress.isEmpty ? '' : 'Collection centre');
// ── The Call button has to dial the door he is standing at ──
//
// `pickupcontactno` is the booking's customer — the SENDER — and it was
// what this button dialled on every card, delivery legs included. So a
// rider at the receiver's gate pressed Call and rang the man he had
// collected from that morning, in another city; on a three-drop pickup he
// rang him at all three gates. The receiver's own number is carried per
// destination and is the right one to ring whenever the rider is on his way
// to that receiver — the same test the name above uses, so the card cannot
// show one person and phone another.
final String recipientPhone = _val(['recipientphone']);
final String phone = (recipient.isNotEmpty && recipientPhone.isNotEmpty)
? recipientPhone
: _val(['pickupcontactno']);
/// `Stop 2 of 3` on a customer pickup with several doors, `''` otherwise.
/// Sits with the leg eyebrow because it answers the same question — what
/// kind of stop is this — and because three cards that differ only in their
/// address are three chances to deliver the wrong bag.
final String stopLabel = MilkRun.stopLabel(item);
/// True once the rider is carrying it: the drop becomes the destination and
/// takes the top of the card.
// ── The immediate destination leads; never a hub leg's receiver ──
//
// On [NextLeg.hub] the receiver is in another district and the rider is
// going to a hub. Letting that address take the headline is the navigation
// mistake this whole change exists to prevent, so a hub leg never leads
// with a drop — the final destination is demoted to context below.
final bool dropLeads =
!leg.isHub &&
(pickupIsSource || leg.isCustomer) &&
(dropName.isNotEmpty || dropAddress.isNotEmpty);
// ── On a base leg the BASE leads ──
//
// The card was right to refuse the receiver — he is in another district and
// leading with him is the navigation mistake the rule above exists to
// prevent. But refusing the receiver only left `pickupName`, so a handover
// card read:
//
// BASE HANDOVER
// Anitha R
// Gandhipuram
//
// …the customer he collected from an hour ago, under a heading telling him
// to go to a base. The place he is actually going appeared nowhere on the
// card and only on the sheet's slider, after he had already set off. On a
// round with two bases that is a parcel left in the wrong building.
//
// `next_hub` rides on the row (the backend sends all six fields), so the
// destination is known here. A base with no coordinates still leads with
// its name — it is a caption rather than a destination, and the fence
// refuses the handover anyway with a sentence pointing at the office.
final HandoverHub? handoverBase = leg.isHub
? (HandoverHub.from(item['next_hub'] ?? item['nexthub']) ??
PickupLocations.baseFor(
item['next_hub_id'] ?? item['nexthubid'] ?? item['hubid'],
))
: null;
final bool baseLeads = handoverBase != null;
/// What the card is headed with, in one place so the name and the address
/// below it can never describe two different places.
final String headlineName = baseLeads
? handoverBase.name
: (dropLeads ? dropName : pickupName);
final String headlineAddress = baseLeads
? [
handoverBase.address,
handoverBase.pincode,
].where((v) => v.trim().isNotEmpty).join(', ')
: '';
// ── No accent stripe any more ──
//
// The card carried a 4px coloured bar down its left edge, on the argument
// that state deserves an accent. It does — but the status tag at the top of
// the card says the same thing in a word, in a colour, at reading size, and
// it does not clip the corner radius or force an `IntrinsicHeight` around
// the whole card to have a height to stretch to. One statement of state per
// card, and it is the one with words on it.
return Slidable(
key: ValueKey('slidable_$orderId'),
startActionPane: enabled
? ActionPane(
motion: const ScrollMotion(),
extentRatio: 0.35,
children: [
SlidableAction(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
onPressed: (_) => _startStop(context, gated: true),
// Same green as the Start button it is a shortcut for.
backgroundColor: ColorConstants.acceptGreen,
foregroundColor: Colors.white,
icon: isSkipped
? LucideIcons.rotateCw
: LucideIcons.navigation,
label: isSkipped ? 'Resume' : 'Start',
),
],
)
: null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: enabled ? 1 : 0.7,
// ── The card itself opens the stop ──
//
// Everything this card knows — the map, the full address, the parcel
// breakdown, the customer's number, the notes — used to be reachable
// only through the ⋯ menu or by *starting* the stop, which is a
// commitment rather than a look. Tapping the card opens the same detail
// sheet Home opens, so a rider moving between the two tabs has one
// gesture rather than one that dies on arrival.
child: GlassCard(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h),
onTap: () => _openDetail(context),
child: Padding(
padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Name and state, on one line ──
//
// The destination's name is the headline of a delivery card:
// it is what he reads off the doorbell. The status tag rides
// beside it rather than on a line of its own, which is where a
// filled type chip and a "Stop 3" caption used to sit — three
// marks about categories above the one word that matters.
// ── Identity left, state and journey right ──
//
// The distance and ETA had a full-width row of their own under
// the label — a fifth storey on a card the rider reads at a red
// light. Home's group node and this queue's own UP NEXT tile
// already carry the journey as a narrow right-hand column at
// the headline's shoulder; the live card was the one surface
// still spending a row on it. Same geometry now on all three:
// who and where on the left, the state and the ride stacked on
// the right, one fixation for the whole card and one row
// shorter.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// ── What kind of journey this is, above the name ──
//
// HUB HANDOVER and CUSTOMER DELIVERY are different
// physical acts with different endings, and the row
// has to say which before the rider reads the place.
// Drawn only once the parcel is actually his; an
// uncollected stop has no leg yet.
if (leg.leg.eyebrow.isNotEmpty ||
stopLabel.isNotEmpty) ...[
Text(
[
if (leg.leg.eyebrow.isNotEmpty)
leg.leg.eyebrow,
if (stopLabel.isNotEmpty)
stopLabel.toUpperCase(),
].join(' · '),
style: TextStyle(
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: leg.isHub
? ColorConstants.deliveryAccent
: leg.isUnknown
? ColorConstants.warning
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 3.h),
],
Text(
headlineName.isEmpty ? 'Not named' : headlineName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w900,
height: 1.1,
letterSpacing: -0.6,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 6.h),
// ── The area, not the street ──
//
// This printed the raw address at up to four lines:
// `12, SNS Colony, Peelamedu, Coimbatore 641004`,
// wrapping under the customer's name on the one card
// that is supposed to be readable in a second. Three
// of those four components are the same on every stop
// of the run — the city, the state, the pincode — so
// they say nothing about *which* stop this is, and
// the door number is not something a rider navigates
// by from here.
//
// The locality is what he is deciding on. The full
// string is one tap away on the detail sheet and it
// is what the map at the door uses, so nothing is
// lost — see [areaOf].
Text(
() {
// A base is read at a gate, so it gets its street
// and pincode rather than the locality summary the
// other legs use — "Peelamedu" is not enough to
// find a loading bay by.
if (baseLeads) {
// Empty rather than repeating the name: a base
// with no address on the row has nothing more
// to say here, and printing "Salem Base" twice
// reads as a rendering fault.
return headlineAddress;
}
final full = dropLeads
? (dropAddress.isEmpty
? address
: dropAddress)
: address;
final area = areaOf(item, preferDrop: dropLeads);
return area.isEmpty ? full : area;
}(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
height: 1.35,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
// ── The pairing, as one object ──
//
// `Bag 1 · Joe`, not a label chip in one place and a
// name in another. The join between them is the
// entire operational risk on a meal run, and making
// the rider do it himself at a gate is where the
// wrong lunch reaches the wrong door. (The name is
// the headline above, so the pair is spatial here;
// the written form earns its place further down the
// queue — see [_LaterRow] — and on the manifest.)
if (label.isNotEmpty) ...[
SizedBox(height: 14.h),
_LabelPair(
label: label,
customer: '',
muted: !enabled,
size: 15.5,
),
],
],
),
),
SizedBox(width: 12.w),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
// The task, not the record. `OrderStatusTag` says
// "Picked up", a true statement about the past; the
// word this card needs is the one naming the next
// physical act. Read off the ladder, not off
// `orderstatus` — see [MilkRun.stageOf].
_TaskChip(
status: MilkRun.stageOf(
item,
acceptedIds: const {},
collectedIds: collectedIds,
outForDeliveryIds: outForDeliveryIds,
),
),
SizedBox(height: 10.h),
// How far, and how long — the pair he checks at every
// red light, at the same shoulder Home puts it.
_JourneyBlock(
meters: distanceMeters,
speedMps: riderSpeedMps,
muted: !enabled,
),
],
),
],
),
// Money is not a detail — it is the one fact on this card the
// rider is personally accountable for.
if (cash > 0 || deliverQty > 1 || collectQty > 1) ...[
SizedBox(height: 10.h),
_ActionLine(
kind: kind,
deliverQty: deliverQty,
collectQty: collectQty,
cash: cash,
skipped: isSkipped,
),
],
// A note is what the customer said that the address does not:
// "gate code 4412", "call before you come up".
if (notes.isNotEmpty) ...[
SizedBox(height: 10.h),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
LucideIcons.stickyNote,
size: 16.sp,
color: ColorConstants.warning,
),
SizedBox(width: 8.w),
Expanded(
child: Text(
notes,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
height: 1.35,
fontWeight: FontWeight.w700,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
// ── Where it came from, quietly ──
//
// Once the food is in the box the pickup is history — but it is
// still what establishes custody, and on a run with two kitchens
// it is how he knows which shelf this label came off. So it stays,
// one line, under a hairline, at half the weight of the name
// above.
if (dropLeads && pickupName.isNotEmpty) ...[
SizedBox(height: 14.h),
Container(height: 1, color: ColorConstants.borderSubtle),
SizedBox(height: 12.h),
Row(
children: [
Icon(
LucideIcons.store,
size: 16.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 8.w),
Text(
'PICKED UP FROM',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(width: 8.w),
Expanded(
child: Text(
pickupName,
// Two lines before an ellipsis — the same rule the
// route header earned on a device: "DailyGrubs RS
// Puram K…" cuts exactly the words that say which
// kitchen, and a wrong-source assumption at a
// handover is a real dispute later.
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
height: 1.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
],
// ── The only thing to press ──
SizedBox(height: 16.h),
Row(
children: [
Expanded(
child: SizedBox(
height: _tapTarget(ButtonSizes.primary),
// ── Brand red, because this button *navigates* ──
//
// The rule across the run is: green advances the job at
// a door, brand red is navigation and identity, red
// destroys. Start opens the map screen and sets the
// rider moving — it finishes nothing — so it belongs to
// the navigation colour, and it is the loudest thing on
// the card either way.
child: enabled
? MilerButton(
label: isSkipped
? 'Resume stop'
: kind.startCtaLabel,
icon: isSkipped
? LucideIcons.rotateCw
: LucideIcons.navigation,
color: ColorConstants.primary,
height: _tapTarget(ButtonSizes.primary),
onPressed: () => _startStop(context),
)
: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
// Canvas, not neutralLight: on this white
// card the old fill measured 1.02:1 — a
// disabled bar you had to be told about.
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(
DesignConstants.radiusXl,
),
),
child: Text(
'Finish the current stop first',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 14.sp,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
),
),
if (phone.isNotEmpty) ...[
SizedBox(width: 10.w),
_CallButton(phone: phone),
],
if (!isSkipped) ...[
SizedBox(width: 10.w),
_SquareIconButton(
icon: LucideIcons.ellipsis,
size: _tapTarget(ButtonSizes.primary),
background: MilerSurface.canvas,
border: Colors.transparent,
iconColor: ColorConstants.slateText,
semanticLabel: 'More options',
onTap: () {
final parentState = context
.findAncestorStateOfType<_MyPickupsState>();
if (parentState != null) {
_showMyOptionsSheet(context, item, parentState);
}
},
),
],
],
),
],
),
),
),
),
);
}
/// Everything the app knows about this stop, in the same sheet Home uses.
///
/// Deliberately the same widget rather than a Bookings-specific one: the two
/// screens show the same stop, and two sheets would drift.
void _openDetail(BuildContext context) {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
StopDetailSheet.show(
context,
stop: item,
stopNumber: displayStep,
riderLat: parentState?._currentLocation?.latitude,
riderLng: parentState?._currentLocation?.longitude,
);
}
/// Opens the stop's map/navigation screen.
///
/// [gated] applies the on-duty check — the swipe action can fire from a
/// resting thumb, so it verifies duty first; the explicit CTA does not
/// re-ask because the card is only enabled for an on-duty rider anyway.
Future<void> _startStop(BuildContext context, {bool gated = false}) async {
final parentState = context.findAncestorStateOfType<_MyPickupsState>();
if (parentState == null) return;
// ── Never a collection, on a line that collects in bulk ──
//
// This tab is gated on the collected set, so on a kitchen line every card
// here is already a delivery and this never fires. It is asked anyway
// because this method opens the map screen directly rather than through
// `startPickup`, and a stop that reached it in the pickup phase would put
// the rider through a map, an I'VE ARRIVED and a confirmation sheet for a
// label he is supposed to collect at the counter with the rest of them. See
// [MilkRun.worksOnOwnScreen].
if (!MilkRun.worksOnOwnScreen(item, collectedIds: collectedIds)) {
AppFeedback.info(
context,
'Collections are confirmed on Home — select the orders at the '
'kitchen and slide to update.',
);
return;
}
// ── No release gate here any more ──
//
// A collected order is a live delivery the moment it is in the rider's
// hands. Holding it until every kitchen was done meant a rider who had
// collected three lunches at 11:40 could not start dropping them while a
// second kitchen was still cooking — so he waited, and the first three
// customers ate late for no reason. Kitchens are worked one at a time and
// each one's orders go straight onto the round.
if (gated) {
if (!await parentState.ensureOnlineForAction()) return;
if (!context.mounted) return;
}
await openScreen(
context,
_PickupMapScreen(pickup: item, parentState: parentState),
swipeToGoBack: false,
);
}
}
// ── Distance + ETA, as one chip ──────────────────────────────────────────────
//
// Was a full-width amber panel with its own border, on the argument that this
// is the one number that changes while the rider is moving. The argument was
// right; the panel was not the way to honour it. A tinted band 40px tall
// pushed the address and the button down on every card in the list, and in
// sunlight an amber fill on white reads as a grey smudge anyway — the very
// thing the card's own comments warned about for badges.
//
// Bare text on the meta line was the correction, and it went one step too far:
// the two live figures ended up sharing a line with the type badge and the stop
// number in a similar weight, so nothing marked where the static half of the
// line ended and the moving half began. A single pill fixes that at no cost in
// height — it wraps BOTH figures, so distance and ETA read as one fact about
// one journey rather than two numbers that happen to be adjacent, and the
// chip's edge does the separating that four different font weights were trying
// to do. One chip, not two: they are never useful apart.
/// The journey as a right-hand column: distance leading, ride time under it,
/// both right-aligned. The same two facts `_DistanceEta` prints in a line —
/// that form survives for the quieter tiers, where the row is shared with a
/// name — but the live card stacks them at the headline's shoulder, exactly
/// where Home's group node puts them, so the two screens' biggest cards agree
/// about where "how far" lives. Nothing renders when no distance is known:
/// a dash column would be furniture.
class _JourneyBlock extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool muted;
const _JourneyBlock({
required this.meters,
required this.speedMps,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final distance = RouteMetricsHelper.formatDistance(meters);
if (distance == '\u2014') return const SizedBox.shrink();
final travel = RouteMetricsHelper.formatDuration(
RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps),
);
final Color fg = muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.navigation, size: 15.sp, color: fg),
SizedBox(width: 4.w),
Text(
distance,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w900,
letterSpacing: -0.3,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
],
),
if (travel != '\u2014') ...[
SizedBox(height: 2.h),
Text(
'~$travel',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
],
);
}
}
class _DistanceEta extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool muted;
const _DistanceEta({
required this.meters,
required this.speedMps,
this.muted = false,
});
@override
Widget build(BuildContext context) {
final distance = RouteMetricsHelper.formatDistance(meters);
final travel = RouteMetricsHelper.formatDuration(
RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps),
);
if (distance == '—') return const SizedBox.shrink();
final Color fg = muted
? ColorConstants.secondaryText
: ColorConstants.slateText;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.navigation, size: 16.sp, color: fg),
SizedBox(width: 5.w),
Text(
// `~`, matching the live card's journey block: every ETA on this
// queue is an estimate, and two tiers spelling that differently
// reads as two data sources.
travel == '—' ? distance : '$distance · ~$travel',
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: fg,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}
// ── What to do here ──────────────────────────────────────────────────────────
//
// Replaces the bordered "ACTION REQUIRED" panel, its all-caps heading, and the
// two rows inside it that each carried a 24px tinted icon tile — four boxes to
// say "Collect 3 parcels, cash ₹340".
//
// One line of text says the same thing. A combined stop still gets both jobs,
// separated by a bullet and each in its own leg colour, so the "TWO jobs here"
// signal the panel existed for survives: it is now carried by colour and by
// the word "and", which a rider reads faster than he parses a stacked panel.
class _ActionLine extends StatelessWidget {
final StopKind kind;
final int deliverQty;
final int collectQty;
final double cash;
final bool skipped;
const _ActionLine({
required this.kind,
required this.deliverQty,
required this.collectQty,
required this.cash,
required this.skipped,
});
static String _money(double v) {
if (v >= 100) return '₹${v.round()}';
final rounded = (v * 100).round() / 100;
return rounded == rounded.roundToDouble()
? '₹${rounded.round()}'
: '₹${rounded.toStringAsFixed(2)}';
}
@override
Widget build(BuildContext context) {
final spans = <InlineSpan>[];
TextStyle style(Color c) => TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w800,
color: c,
fontFamily: FontConstants.fontFamily,
);
// Parcel legs are neutral; only the cash is coloured. These used to be
// royal blue and pink respectively, which is colour spent on naming a leg
// that the word beside it already names — and it made the line read as three
// unrelated fragments. Matches [_MetaLine] on the Home stop row.
if (kind.hasDelivery) {
final n = deliverQty > 0 ? deliverQty : 1;
spans.add(
TextSpan(text: 'Deliver $n', style: style(ColorConstants.slateText)),
);
}
if (kind.hasPickup) {
if (spans.isNotEmpty) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.borderStrong)),
);
}
final n = collectQty > 0 ? collectQty : 1;
spans.add(
TextSpan(text: 'Collect $n', style: style(ColorConstants.slateText)),
);
}
if (spans.isEmpty) return const SizedBox.shrink();
if (cash > 0) {
spans.add(
TextSpan(text: ' · ', style: style(ColorConstants.secondaryText)),
);
spans.add(
TextSpan(
// `acceptGreen` is a fill colour; as text on the card fill it measures
// 2.28:1. See [ColorConstants.moneyGreen].
text: 'Collect ${_money(cash)}',
style: style(ColorConstants.moneyGreen),
),
);
}
if (skipped) {
spans.add(
TextSpan(
text: ' · Skipped earlier',
style: style(ColorConstants.warning),
),
);
}
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0),
child: Text.rich(
TextSpan(children: spans),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
);
}
}
class _CallButton extends StatelessWidget {
final String phone;
/// Edge length, when it has to line up with something.
///
/// Defaults to the card's own 52. The map sheet passes
/// [ButtonSizes.primary] (56) so the button sits flush in a row beside
/// Navigate and "I've arrived" — four points short reads as misaligned when
/// three controls share one line.
final double? size;
/// Who the rider is ringing, as a spoken action — `Call the customer`.
///
/// Defaults to the neutral form. A caller that knows the leg passes the
/// resolved one, so the label can never say *customer* while the number is
/// the kitchen's. See [StopContact].
final String? semanticLabel;
/// A disc rather than a rounded square. The sheets draw it beside a round
/// avatar, where a square tile reads as a control belonging to something
/// else; the cards keep the square, which matches the tiles around it.
final bool round;
const _CallButton({
required this.phone,
this.size,
this.semanticLabel,
this.round = false,
});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: LucideIcons.phone,
round: round,
size: size ?? math.max(52.0, 52.w),
// ── Green, and the same green everywhere a phone appears ──
//
// It was a neutral grey tile with a maroon handset, on the reasoning that
// green means "done". That reasoning cost more than it saved: the call
// control on the Home route card is green-tinted, the one on the map
// sheet was grey, and a rider reaching for the same affordance on two
// screens met two different objects. Green is the phone colour in every
// app he already uses — it is what the glyph means before the palette
// gets a say — and "done" is carried by a tick and a word, not by a hue
// on a handset.
background: Narrative.emeraldWash,
border: Colors.transparent,
iconColor: Narrative.emerald,
semanticLabel: semanticLabel ?? 'Call this stop',
onTap: () async {
// A dialer that fails to open is silent by default: the rider is at
// the door, taps Call, and nothing happens. Say so, and show him the
// number so he can dial it himself.
final ok = await launchPhoneDialer(phone);
if (!ok && context.mounted) {
AppFeedback.error(context, "Couldn't open the dialer — $phone");
}
},
);
}
}
// ── Square icon button with a guaranteed tap target ──────────────────────────
class _SquareIconButton extends StatelessWidget {
final IconData icon;
final double size;
final Color background;
final Color border;
final Color iconColor;
final String semanticLabel;
final VoidCallback onTap;
final bool round;
const _SquareIconButton({
required this.icon,
required this.size,
required this.background,
required this.border,
required this.iconColor,
required this.semanticLabel,
required this.onTap,
this.round = false,
});
@override
Widget build(BuildContext context) {
final radius = BorderRadius.circular(
round ? DesignConstants.radiusFull : DesignConstants.radiusXl,
);
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: radius,
child: InkWell(
onTap: onTap,
borderRadius: radius,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: radius,
border: Border.all(color: border, width: 1.5),
),
child: Icon(icon, color: iconColor, size: 22.sp),
),
),
),
);
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// THE QUEUE, IN THREE DENSITIES
///
/// ```
/// NOW one task card, full weight, one big action
/// UP NEXT who and where, quietly — no action
/// LATER one line each: label, name, distance
/// SKIPPED the reason, and the way back in
/// ```
///
/// ── Why the list is not one card repeated ──
///
/// It was: every stop drew the same card, and "which one is live" was expressed
/// by dimming the others to 70% and swapping their button for a sentence. That
/// is a *logic* rule wearing a very quiet costume — a rider scanning a screen of
/// eight identical cards still has to find the bright one, which is exactly the
/// decision this screen exists to take off him.
///
/// Density is the honest encoding. The live stop is physically bigger and
/// carries the only button on the screen; the next one is legible but plainly
/// secondary; the rest are one line each. Nobody has to compare opacities.
///
/// It also costs less: eight full cards is eight screens of scrolling for a day
/// a rider can now see most of at once.
enum QueueDensity {
/// The one stop being worked. Exactly one of these exists at a time.
now,
/// The stop after it — enough to prepare for, nothing to act on.
next,
/// Everything after that.
later,
/// Been to, not finished. Carries its reason and a way back in.
skipped,
}
/// One row of the delivery queue: a heading, or a stop with everything the list
/// already decided about it. See `_MyPickupsState._queueRows`.
class _QueueRow {
final String? heading;
/// How many stops the heading covers, when it is worth saying — `LATER 3`.
final int count;
final Map<String, dynamic>? stop;
final QueueDensity density;
final int displayStep;
/// The label this order travels in, as recorded at the counter.
final String label;
/// Why a skipped stop was skipped. Read from the skip cache rather than the
/// stop, because the stop is refetched from the API every three seconds and
/// the reason is the rider's, not the server's.
final String reason;
const _QueueRow.heading(this.heading, {this.count = 0})
: stop = null,
density = QueueDensity.later,
displayStep = 0,
label = '',
reason = '';
const _QueueRow.stop({
required this.stop,
required this.density,
required this.displayStep,
required this.label,
this.reason = '',
}) : heading = null,
count = 0;
bool get isHeading => heading != null;
/// Only the live stop is actionable. This is the rule the whole screen turns
/// on, kept in one place so a card cannot decide otherwise.
bool get enabled =>
density == QueueDensity.now || density == QueueDensity.skipped;
}
/// A section heading in the delivery queue.
///
/// Type, not a container: three words on a page, at the weight that separates
/// sections without becoming a bar. A tinted strip here would be furniture
/// between the rider and the card he is meant to be reading.
class _QueueHeading extends StatelessWidget {
final String label;
final int count;
/// What ordered this queue, shown beside the first heading.
///
/// ── Why a screen says this at all ──
///
/// A list ordered by the hub's solved route and a list the app ordered by
/// what is nearest look exactly the same, and only one of them is a plan the
/// office, the customer and the rider all share. When the app is guessing it
/// has to say so — otherwise "follow your route" means one thing upstairs and
/// another on the bike. See [RouteOrder].
final RouteOrderSource? order;
const _QueueHeading({required this.label, this.count = 0, this.order});
@override
Widget build(BuildContext context) {
// NOW is the live section and the only one that carries the accent — the
// rest are signposts, and a page of red labels would spend the accent on
// wayfinding rather than on the work.
final live = label == 'NOW';
// ── On the cards' own text line ──
//
// 20 put this on nothing: the cards' white edge is at 16 and every name
// inside them starts at 16 + 18 = 34, so the eyebrow hovered four points
// off one line and fourteen off the other — the near-miss distance that
// reads as a mistake. It sits on the text line, so the page has one
// reading edge: NOW, the customer, UP NEXT, the next customer, straight
// down. The right inset mirrors it for the provenance chip.
return Padding(
padding: EdgeInsets.fromLTRB(34.w, live ? 12.h : 22.h, 34.w, 8.h),
child: Row(
children: [
Text(
label,
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w900,
letterSpacing: 1.2,
color: live
? ColorConstants.primary
: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
if (count > 0) ...[
SizedBox(width: 8.w),
Text(
'$count',
style: TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w800,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
// ── The provenance chip is gone ──
//
// `As assigned` / `Nearest first` sat at the head of the queue, in a
// tinted pill with a glyph, telling the rider where the *ordering* of
// his list came from. It is a fact about this app's sorting, not
// about his work: he cannot act on it, he cannot change it, and on
// the common day it said the same three words every time he opened
// the tab. What he needs from that row is which trip and how far
// through it he is, and both are already on it.
],
),
);
}
}
/// `Bag 1 · Joe` — the pairing, as one object.
///
/// ── Why they are one line and not two facts ──
///
/// The rider is holding a label and looking for a door. Printing the label in one
/// place and the customer in another makes him do the join himself, on a phone,
/// at a gate — and that join is the entire operational risk on a meal run: five
/// near-identical lunch bags, one confident rider, the wrong door.
///
/// So they are written the way he thinks them.
class _LabelPair extends StatelessWidget {
final String label;
final String customer;
final bool muted;
/// Bigger on the live card, quieter down the queue.
final double size;
const _LabelPair({
required this.label,
required this.customer,
this.muted = false,
this.size = 15,
});
/// ── The word is gone; the code is not ──
///
/// This prefixed everything with `Bag `, so a counter's own code rendered as
/// `Bag DG-1042` and an app-derived number rendered as `Bag 3` — a reference
/// to an object nobody had labelled. What a counter prints is shown exactly
/// as it was sent, and nothing is shown where nothing was sent.
String get _labelText => label;
@override
Widget build(BuildContext context) {
if (label.isEmpty) return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h),
decoration: BoxDecoration(
color: muted
? MilerSurface.canvas
: ColorConstants.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
child: Text(
_labelText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w900,
letterSpacing: -0.2,
color: muted
? ColorConstants.secondaryText
: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
),
if (customer.isNotEmpty) ...[
SizedBox(width: 8.w),
Flexible(
child: Text(
'· $customer',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.2,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
],
);
}
}
/// What the rider is being asked to do here, in one word.
///
/// Deliberately *task* wording rather than record wording: the status tag says
/// "Picked up", which is a fact about the past. On the card he is working, the
/// useful word is the one that describes the next physical act — READY,
/// ARRIVED, DONE — because that is what he is checking the card to find out.
class _TaskChip extends StatelessWidget {
final StopStatus status;
const _TaskChip({required this.status});
({String label, Color color}) get _look => switch (status) {
StopStatus.deliveryArrived => (
label: 'ARRIVED',
color: ColorConstants.primary,
),
StopStatus.arrived => (label: 'AT PICKUP', color: ColorConstants.primary),
// ── The two rungs the ladder used to collapse into one ──
//
// Collected but not yet riding, and riding. Both fell through to READY,
// which is why the jump from Picked to the delivery-active state was
// invisible on this card: the word never changed, so the only thing the
// rider had to go on was the ladder's own behaviour. They are the same
// green — neither is a warning — and differ in the word, which is the part
// that is read.
StopStatus.picked => (label: 'PICKED', color: ColorConstants.acceptGreen),
StopStatus.outForDelivery => (
label: 'ACTIVE',
color: ColorConstants.primary,
),
StopStatus.delivered => (label: 'DONE', color: ColorConstants.acceptGreen),
StopStatus.skipped => (label: 'SKIPPED', color: ColorConstants.warning),
StopStatus.cancelled => (
label: 'CANCELLED',
color: ColorConstants.secondaryText,
),
_ => (label: 'READY', color: ColorConstants.acceptGreen),
};
@override
Widget build(BuildContext context) {
final look = _look;
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 6.h),
decoration: BoxDecoration(
color: look.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Text(
look.label,
maxLines: 1,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
color: look.color,
fontFamily: FontConstants.fontFamily,
),
),
);
}
}
/// UP NEXT — where he is going after this one.
///
/// Enough to prepare for and nothing to act on: no button, no source line, no
/// notes, no money. Preparing is a glance; acting is the card above.
class _NextCard extends StatelessWidget {
final Map<String, dynamic> item;
final String label;
final double? distanceMeters;
final double? riderSpeedMps;
final VoidCallback onOpen;
const _NextCard({
required this.item,
required this.label,
required this.distanceMeters,
required this.riderSpeedMps,
required this.onOpen,
});
@override
Widget build(BuildContext context) {
final name = _destinationName(item);
final address = _destinationAddress(item);
return GlassCard(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h),
onTap: onOpen,
child: Padding(
padding: EdgeInsets.fromLTRB(18.w, 14.h, 18.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// A step down from NOW's hero, a step up from LATER's
// row. The three tiers now differ in weight as well as
// in position, which is what makes their labels a
// convenience rather than the only thing ranking them.
fontSize: 17.sp,
fontWeight: FontWeight.w700,
height: 1.15,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
_DistanceEta(
meters: distanceMeters,
speedMps: riderSpeedMps,
muted: true,
),
],
),
if (address.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
if (label.isNotEmpty) ...[
SizedBox(height: 10.h),
_LabelPair(label: label, customer: '', muted: true, size: 14),
],
],
),
),
);
}
}
/// LATER — one line each.
///
/// Bag, name, distance. That is the whole row, because until a stop is next
/// there is nothing a rider can usefully do with anything else about it, and
/// eight full cards is eight screens of scrolling for a day he should be able to
/// take in at once.
class _LaterRow extends StatelessWidget {
final Map<String, dynamic> item;
final String label;
final double? distanceMeters;
final VoidCallback onOpen;
const _LaterRow({
required this.item,
required this.label,
required this.distanceMeters,
required this.onOpen,
});
@override
Widget build(BuildContext context) {
final name = _destinationName(item);
return InkWell(
onTap: onOpen,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
child: Row(
children: [
if (label.isNotEmpty) ...[
_LabelPair(label: label, customer: '', muted: true, size: 13),
SizedBox(width: 12.w),
],
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
Text(
RouteMetricsHelper.formatDistance(distanceMeters),
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
);
}
}
/// SKIPPED — what happened, and the way back in.
///
/// Never the word "Failed". Operations needs the reason and the rider needs a
/// route back to a stop he has already ridden to once, so the row carries both
/// and nothing else.
class _SkippedRow extends StatelessWidget {
final Map<String, dynamic> item;
final String label;
final String reason;
final VoidCallback onResume;
final VoidCallback onOpen;
const _SkippedRow({
required this.item,
required this.label,
required this.reason,
required this.onResume,
required this.onOpen,
});
@override
Widget build(BuildContext context) {
final name = _destinationName(item);
final why = reason.trim().isNotEmpty
? reason.trim()
: (item['skipreason'] ?? item['notes'] ?? '').toString().trim();
return GlassCard(
margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 5.h),
onTap: onOpen,
child: Padding(
padding: EdgeInsets.fromLTRB(18.w, 14.h, 18.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
const _TaskChip(status: StopStatus.skipped),
],
),
if (why.isNotEmpty) ...[
SizedBox(height: 6.h),
Text(
why,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
height: 1.3,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(height: 12.h),
Row(
children: [
if (label.isNotEmpty) ...[
_LabelPair(
label: label,
customer: '',
muted: true,
size: 13.5,
),
SizedBox(width: 12.w),
],
Expanded(
child: SizedBox(
height: ButtonSizes.secondary,
child: MilerButton(
label: 'Resume',
icon: LucideIcons.rotateCw,
variant: MilerButtonVariant.outlined,
color: ColorConstants.primary,
height: ButtonSizes.secondary,
onPressed: onResume,
),
),
),
],
),
],
),
),
);
}
}
/// Where this stop is going, whichever line of work it belongs to.
///
/// Shared by the three quiet densities so a name cannot be resolved one way on
/// the live card and another way two rows down.
String _destinationName(Map<String, dynamic> item) {
final customer = (item['pickupcustomer'] ?? item['tenantname'] ?? '')
.toString()
.trim();
if (customer.isNotEmpty) return customer;
final source = stopSourceName(item);
return source.isEmpty ? 'Stop' : source;
}
/// Where a stop is, for the collapsed queue — the **area**, not the street.
///
/// A rider reading twenty rows is choosing between localities; twenty street
/// strings, each cut off mid-word, answer nothing and cost a line each to do
/// it. The full address is on the detail sheet and in the map at the door.
/// See [areaOf].
String _destinationAddress(Map<String, dynamic> item) {
final area = areaOf(
item,
preferDrop: WorkPolicy.deliversToCustomer(item),
);
if (area.isNotEmpty) return area;
// Nothing usable to reduce — fall back to whatever the row carries rather
// than showing a blank where a place should be.
final drop = (item['dropaddress'] ?? item['DropAddress'] ?? '')
.toString()
.trim();
if (WorkPolicy.deliversToCustomer(item) && drop.isNotEmpty) return drop;
return (item['pickupaddress'] ?? '').toString().trim();
}