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