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

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

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

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

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

1538 lines
59 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 bag this order travels in, as recorded at the counter. Empty on a
/// parcel route, where there is no load to match against. See [BagManifest].
final String bag;
/// 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
/// bag 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 bag 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;
const PickupCard({
super.key,
required this.item,
required this.displayStep,
this.distanceMeters,
this.riderSpeedMps,
this.enabled = true,
this.isSkipped = false,
this.bag = '',
this.collectedIds = const {},
this.outForDeliveryIds = 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 bag sitting in
// his box. See [MilkRun.workingKind].
final StopKind kind = MilkRun.workingKind(item, collectedIds: collectedIds);
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 String phone = _val(['pickupcontactno']);
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);
final bool pickupIsSource =
ServiceProfile.active.deliversToCustomer && source.isNotEmpty;
final String pickupName = pickupIsSource ? source : customer;
final String dropAddress = _val(['dropaddress', 'DropAddress']);
final String dropName = pickupIsSource
? customer
: (dropAddress.isEmpty ? '' : 'Collection centre');
/// True once the rider is carrying it: the drop becomes the destination and
/// takes the top of the card.
final bool dropLeads =
pickupIsSource && (dropName.isNotEmpty || dropAddress.isNotEmpty);
// ── 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 bag — 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: [
Text(
(dropLeads ? dropName : pickupName).isEmpty
? 'Not named'
: (dropLeads ? dropName : pickupName),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w800,
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(
() {
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.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
// ── The pairing, as one object ──
//
// `Bag 1 · Joe`, not a bag 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 (bag.isNotEmpty) ...[
SizedBox(height: 14.h),
_BagPair(
bag: bag,
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.w600,
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 bag 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.w800,
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.w700,
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.w600,
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
// bag 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.w800,
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.w600,
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.w700,
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.w700,
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;
const _CallButton({required this.phone, this.size, this.semanticLabel});
@override
Widget build(BuildContext context) {
return _SquareIconButton(
icon: LucideIcons.phone,
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: ColorConstants.acceptGreen.withValues(alpha: 0.12),
border: Colors.transparent,
iconColor: ColorConstants.acceptGreen,
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;
const _SquareIconButton({
required this.icon,
required this.size,
required this.background,
required this.border,
required this.iconColor,
required this.semanticLabel,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: Material(
color: background,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
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: bag, 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 bag this order travels in, as recorded at the counter.
final String bag;
/// 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,
bag = '',
reason = '';
const _QueueRow.stop({
required this.stop,
required this.density,
required this.displayStep,
required this.bag,
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.w800,
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.w700,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
if (order != null) ...[
const Spacer(),
Flexible(child: _OrderProvenance(source: order!)),
],
],
),
);
}
}
/// `Hub route` / `Nearest first` — where the queue's order came from.
///
/// Deliberately quiet. It is a provenance mark, not a warning: nearest-first
/// is a reasonable order when nobody has assigned one, and the rider does not
/// need alarming about it. What he needs is to not be told it is his route.
class _OrderProvenance extends StatelessWidget {
final RouteOrderSource source;
const _OrderProvenance({required this.source});
@override
Widget build(BuildContext context) {
final assigned = source.isAdmin;
final tint = assigned
? ColorConstants.acceptGreen
: ColorConstants.secondaryText;
return Tooltip(
message: source.explanation,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: tint.withValues(alpha: assigned ? 0.10 : 0.07),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
assigned ? LucideIcons.route : LucideIcons.navigation,
size: 11.sp,
color: tint,
),
SizedBox(width: 4.w),
Flexible(
child: Text(
source.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: tint,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
);
}
}
/// `Bag 1 · Joe` — the pairing, as one object.
///
/// ── Why they are one line and not two facts ──
///
/// The rider is holding a bag and looking for a door. Printing the bag 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 _BagPair extends StatelessWidget {
final String bag;
final String customer;
final bool muted;
/// Bigger on the live card, quieter down the queue.
final double size;
const _BagPair({
required this.bag,
required this.customer,
this.muted = false,
this.size = 15,
});
String get _bagText =>
bag.toLowerCase().startsWith('bag ') ? bag : 'Bag $bag';
@override
Widget build(BuildContext context) {
if (bag.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(
_bagText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w800,
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.w700,
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.w800,
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 bag;
final double? distanceMeters;
final double? riderSpeedMps;
final VoidCallback onOpen;
const _NextCard({
required this.item,
required this.bag,
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.w600,
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.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
if (bag.isNotEmpty) ...[
SizedBox(height: 10.h),
_BagPair(bag: bag, 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 bag;
final double? distanceMeters;
final VoidCallback onOpen;
const _LaterRow({
required this.item,
required this.bag,
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 (bag.isNotEmpty) ...[
_BagPair(bag: bag, 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.w600,
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.w600,
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 bag;
final String reason;
final VoidCallback onResume;
final VoidCallback onOpen;
const _SkippedRow({
required this.item,
required this.bag,
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.w700,
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.w600,
height: 1.3,
color: ColorConstants.warning,
fontFamily: FontConstants.fontFamily,
),
),
],
SizedBox(height: 12.h),
Row(
children: [
if (bag.isNotEmpty) ...[
_BagPair(bag: bag, 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: ServiceProfile.active.deliversToCustomer,
);
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 (ServiceProfile.active.deliversToCustomer && drop.isNotEmpty) return drop;
return (item['pickupaddress'] ?? '').toString().trim();
}