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; const PickupCard({ super.key, required this.item, required this.displayStep, this.distanceMeters, this.riderSpeedMps, this.enabled = true, this.isSkipped = false, }); 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) { final StopKind kind = stopKindOf(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 String phone = _val(['pickupcontactno']); final int deliverQty = deliveryParcelCount(item); final int collectQty = pickupParcelCount(item); final double cash = stopCollectionAmount(item); // ── The card's one accent, driven by STATE not type ── // // This was `kind.accent`, which meant the stop *type* coloured the left // stripe, the badge and — worst — the primary CTA. So "Start Pickup" was // maroon, "Start Delivery" royal blue and "Start Combined Stop" indigo: the // single most important control on the screen changed colour according to a // category, and a rider scanning for "the button" had no constant to look // for. Uber's rule is one accent moment per screen precisely because the // accent is the only thing that says *this* matters. // // So type is now carried in words (see the meta line) and the accent is // spent on state, which is what actually varies and matters: amber when this // is a resume of a skipped stop, the brand accent otherwise. final Color accent = isSkipped ? ColorConstants.warning : ColorConstants.primary; 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 ? Icons.refresh_rounded : Icons.navigation_rounded, label: isSkipped ? 'Resume' : 'Start', ), ], ) : null, child: AnimatedOpacity( duration: const Duration(milliseconds: 200), opacity: enabled ? 1 : 0.7, // ── The card itself opens the stop ── // // Tapping a card did nothing here. Everything it knows — the map, the // full address, the parcel breakdown, the customer's number, the notes — // was reachable only through the ⋯ menu or by starting the stop, which // is a commitment rather than a look. Home has opened a detail sheet on // tap since it was built; a rider moving between the two tabs learned // one gesture and found it dead on the other. // // `HitTestBehavior.opaque` so the whole card responds, including its // padding. The buttons inside keep their own taps — a child's gesture // wins over an ancestor's — so Start, Call and ⋯ are unaffected. // PressScale, not a bare GestureDetector: this card opens a sheet that // takes ~340ms to arrive, and with no press state the rider got no // acknowledgement at all in that gap — the classic "did it register?" // second tap. The dip under the thumb is the receipt. child: PressScale( onTap: () => _openDetail(context), semanticLabel: 'Stop $displayStep, $customer, $address', semanticHint: 'Opens the full stop details', child: Container( margin: EdgeInsets.symmetric(horizontal: 16.w, vertical: 7.h), // ── Fill, not an edge ── // // Border and shadow both gone; the fill separates the card from the // page on its own, matching the Home stop card and the way most large // apps draw a list card. The border, the shadow and the fill were // three devices doing one job. // // See [ColorConstants.cardSurface] for why the fill sits 9.4% off the // page rather than the ~5% a "subtle" grey gives: with no edge, it is // the only thing left doing the separating, and it has to hold up in // sunlight. decoration: BoxDecoration( color: ColorConstants.cardSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), // The card's one piece of chrome: a 4px stripe down the left edge. It // now carries STATE — amber when this is a skipped stop being resumed, // the brand accent otherwise — and costs no vertical space at all, // which the badges and panels it replaced all did. Stop type moved to // the chip on the meta line, so the stripe no longer doubles as a // colour-coded category marker. // // IntrinsicHeight is load-bearing, not decoration. The stripe is a // Container with a width and no height, so it only fills the card // when the Row stretches it — and `CrossAxisAlignment.stretch` sizes // children to the Row's own cross-axis extent. Inside a ListView // that extent is UNBOUNDED, so the stripe was asked to be infinitely // tall and the whole card failed to lay out: every stop rendered as // nothing while the progress rail above (a separate widget reading // the same list) kept showing the right count. IntrinsicHeight gives // the Row a real height first, so stretch has something finite to // stretch to. child: ClipRRect( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( width: 4.w, color: enabled ? accent : accent.withValues(alpha: 0.35), ), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── One card, one box ── // // This stop card used to nest up to ten containers inside its // own border: a filled type badge, a bordered "Stop #N" chip, an // amber full-width ETA panel, a 30px tinted icon tile beside the // address, a bordered "ACTION REQUIRED" panel holding two rows // that each carried their own 24px tinted icon tile, and a // bordered note panel — before reaching the button. Around 390px // of height, so barely two stops fitted on a phone, and the // rider scrolled to answer "where am I going next". // // Uber's Base system states the rule this broke: *the UI // recedes, content leads* — containers stay subordinate to what // they hold — and it prefers whitespace and contrast to layered // elevation. A box does not make a thing important; being the // biggest, darkest text on the card does. // // So every inner box is gone. What they contained is now type // and colour on plain rows, and the accent survives in exactly // two places: the card's left edge and the CTA. Uber calls that // "a single accent moment"; four tinted panels competing was the // opposite of one. // // Result is roughly half the height, so three to four stops fit // where two did. // ── Meta line: what kind of stop, which one, how far ── Padding( padding: EdgeInsets.fromLTRB(14.w, 11.h, 12.w, 0), child: Row( children: [ // Darkened filled chip — a mark, not a surface. The card // stays neutral, so the type colour never becomes a pane. // // Flexible with an inner ellipsis, because the label is the // only elastic thing on this line: "PICKUP & DELIVERY" is // nearly three times the width of "PICKUP", and it shares // the row with a stop number and the distance chip, both of // which have a fixed claim. The label is what gives way — a // truncated type word still reads, an overflowing row does // not. Flexible( child: Container( padding: EdgeInsets.symmetric( horizontal: 8.w, vertical: 4.h, ), decoration: BoxDecoration( color: enabled ? kind.chipColor : kind.chipColor.withValues( alpha: 0.45, ), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( kind.icon, size: 12.sp, color: Colors.white, ), SizedBox(width: 5.w), Flexible( child: Text( kind.chipLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.5.sp, fontWeight: FontWeight.w800, letterSpacing: 0.5, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), if (displayStep > 0) ...[ SizedBox(width: 7.w), Text( 'Stop $displayStep', maxLines: 1, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w800, letterSpacing: 0.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], SizedBox(width: 8.w), const Spacer(), // Distance and ETA lost their amber panel but not their // prominence — this is still the pair he checks at every red // light, so it is the one bordered thing on the line. _DistanceEta( meters: distanceMeters, speedMps: riderSpeedMps, muted: !enabled, ), ], ), ), // ── Identity: the name is the thing he shouts at the gate ── Padding( padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( customer, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 19.sp, fontWeight: FontWeight.w800, height: 1.15, letterSpacing: -0.3, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 3.h), // Two lines, not three. A third line of street // address never changes which gate he walks to, and // it cost every card in the list 18px. // // See [kFullAddressTrial] for the design switch that // lifts this on both stop-card screens at once. Text( address, maxLines: kFullAddressTrial ? kFullAddressMaxLines : 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, height: 1.35, fontWeight: FontWeight.w600, // Slate, not the brown M3 leftover, and dark // enough to stay legible on the card's fill. color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), if (phone.isNotEmpty) ...[ SizedBox(width: 8.w), _CallButton(phone: phone), ], ], ), ), // ── What to do here, as one line of text ── _ActionLine( kind: kind, deliverQty: deliverQty, collectQty: collectQty, cash: cash, skipped: isSkipped, ), // ── Customer note: a line with an icon, not a panel ── if (notes.isNotEmpty) Padding( padding: EdgeInsets.fromLTRB(14.w, 6.h, 12.w, 0), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( Icons.sticky_note_2_rounded, size: 13.sp, color: ColorConstants.warning, ), SizedBox(width: 6.w), Expanded( child: Text( notes, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, height: 1.3, fontWeight: FontWeight.w700, color: ColorConstants.warning, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), // ── 5. Primary CTA — the only thing to press ── Padding( padding: EdgeInsets.fromLTRB( 14.w, 12.h, 14.w, 12.h, ), child: Row( children: [ Expanded( child: SizedBox( height: _tapTarget(48.h), // ── Green, filled, and the loudest thing // on the card ── // // This was the stop-kind accent — brand // maroon on a pickup — which put the // forward action in the same hue the app // uses for Reject and for destructive // confirmations. One rule now holds across // the whole run: green advances the job, // maroon is brand and navigation, red // destroys. See [MilerButton]. // // Hand-rolled `ElevatedButton` replaced by // the shared button so the radius, the // label size and the press state match // every other CTA the rider presses today. child: enabled ? MilerButton( label: isSkipped ? 'Resume stop' : kind.startCtaLabel, icon: isSkipped ? Icons.refresh_rounded : Icons.arrow_forward_rounded, color: ColorConstants.acceptGreen, height: _tapTarget(48.h), onPressed: () => _startStop(context), ) : Container( alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), border: Border.all( color: ColorConstants.borderStrong, ), ), child: Text( 'Finish the current stop first', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 13.5.sp, color: ColorConstants .secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ), ), if (!isSkipped) ...[ SizedBox(width: 10.w), _SquareIconButton( icon: Icons.more_horiz_rounded, size: _tapTarget(48.h), background: ColorConstants.pureSurface, border: ColorConstants.borderStrong, 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; 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. 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 Container( padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 4.h), // White, not the app's neutral grey: the card underneath is now grey, and // a grey chip on a grey card has no edge at all. Inner surfaces go lighter // than their container. decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), border: Border.all( color: muted ? ColorConstants.borderSubtle : ColorConstants.borderStrong, width: 1.2, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.near_me_rounded, size: 13.sp, color: fg), SizedBox(width: 5.w), Text( travel == '—' ? distance : '$distance · $travel', style: TextStyle( fontSize: 12.5.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; const _CallButton({required this.phone}); @override Widget build(BuildContext context) { return _SquareIconButton( icon: Icons.call_rounded, size: math.max(52.0, 52.w), // Neutral, like its twin on the Home stop row: a green-tinted tile put a // second green control beside a green-adjacent CTA, and green already // means "done" elsewhere in the app. Calling is an affordance, not a state. background: ColorConstants.pureSurface, border: ColorConstants.borderStrong, iconColor: ColorConstants.slateText, semanticLabel: 'Call customer', 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), ), ), ), ); } }