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