1509 lines
58 KiB
Dart
1509 lines
58 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;
|
|
|
|
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 {},
|
|
});
|
|
|
|
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);
|
|
|
|
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 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: [
|
|
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 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.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 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.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
|
|
// 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.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;
|
|
|
|
/// 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.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,
|
|
),
|
|
),
|
|
],
|
|
// ── 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.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 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.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 (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.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 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.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 (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: 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();
|
|
}
|