import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ───────────────────────────────────────────────────────────────────────── /// TRIP CARD — one slot's whole route, start to finish. /// /// The hub manager assigns a whole slot's route to one miler, so Home is /// trip-shaped rather than a loose list of bookings. The card shows the route /// exactly as it will be ridden: /// /// ⌂ START · HUB /// ① PICKUP Priya Sharma ~5 min [Reject] [Accept] /// ② DELIVERY Arun Venkatesh ~5 min ✓ Accepted /// ③ BOTH Meena Krishnan ~10 min ✓ Completed /// ⌂ RETURN · HUB /// /// Two things this card gets right that the old loose-booking list did not: /// /// • **The trip stays after it is accepted.** It does not vanish to Bookings. /// The rider watches the same card fill up — a completion ring in the /// header, stops turning green underneath — so Home stays a live picture of /// his day instead of emptying out the moment he commits. /// /// • **Accept is per stop AND all at once.** A rider can take stops 1, 2 and /// 4 and decline 3 — a hub can route him past an address he knows is shut. /// So every pending stop carries its own Accept/Reject, and the bar at the /// bottom does the remaining ones in one tap for the common case. /// ───────────────────────────────────────────────────────────────────────── class TripCard extends StatelessWidget { final Trip trip; /// Ids the rider has accepted / rejected, so every stop renders its own /// state. Optimistic — see [stopStateOf]. final Set acceptedIds; final Set rejectedIds; /// Order ids the rider has ticked. /// /// Selection is now the *only* way a stop is chosen — the per-row Accept and /// Reject buttons, and the whole-trip accept button under the route, are all /// gone. He ticks what he wants and takes it from the floating bar. final Set selectedIds; /// Toggles one stop's tick. final void Function(String orderId)? onToggleSelect; /// Accepts exactly the ticked stops. final Future Function(List orderIds)? onAcceptSelected; /// Replaces the whole selection — used by the select-all row. final void Function(List orderIds)? onSelectAll; /// Returns a rejected stop to undecided, so it can be ticked again. /// /// Deliberately not "accept it now": accepting is one deliberate press on the /// floating bar, and a per-row control that quietly commits work would be the /// per-stop Accept button coming back through the side door. final Future Function(Map stop)? onUnrejectStop; /// Opens a single stop's detail sheet (map + full order details). final void Function(Map stop)? onStopTap; /// Rings the customer for this stop. final void Function(Map stop)? onCallStop; /// Opens the whole route on a map. Null hides the action. final VoidCallback? onViewRoute; /// Shift window, cash, parcels and pace for the rider's whole day. /// /// Supplied by the page because it spans every trip, not just this one, and /// [buildSummary] needs it: the shift window and the trip's own slot are two /// different time ranges and the brief is where they are finally shown side /// by side. Null renders the summary without the shift lines. final RouteBrief? brief; /// Order ids with an action in flight, so a stop cannot be re-ticked while /// its acceptance is on the wire. final Set busyStopIds; const TripCard({ super.key, required this.trip, this.acceptedIds = const {}, this.rejectedIds = const {}, this.brief, this.selectedIds = const {}, this.onToggleSelect, this.onAcceptSelected, this.onSelectAll, this.onUnrejectStop, this.onStopTap, this.onCallStop, this.onViewRoute, this.busyStopIds = const {}, }); StopState _stateOf(Map stop) => stopStateOf(stop, acceptedIds: acceptedIds, rejectedIds: rejectedIds); String _idOf(Map stop) => (stop['orderid'] ?? '').toString(); @override Widget build(BuildContext context) { // No frame around the trip. // // The card used to be one bordered, shadowed white box holding a second // bordered box per stop — a frame inside a frame inside a slate page. The // outer one was the weaker of the two: it grouped things the tabs above // had already grouped, and it charged 16px of page margin plus 16px of its // own padding plus a border for the privilege, on the narrowest axis the // rider has. So it is gone, and the stops below take the boxes instead — // they are what he actually picks between. Summary and footer now sit // directly on the page, and the seam dividers went with the frame. return Padding( // ── The horizontal scale for this whole screen ── // // Every inset on Home is one of two values, and both are 4pt steps off // this base: // // **28 — the page inset.** The shift card, the three stat cards, the // pinned tab strip and the accept bar. These are full-width page // elements and they all begin and end here. // // **32 — the route inset.** The rail's numbered node and the stop // card's right edge. One step further in, because the rail-plus-card is // an indented list item *under* the summary rather than another card in // the same column, and it should read that way. // // They arrived at 16, 18, 20 and 28 respectively — patched one complaint // at a time — which is what a gutter looks like when nobody writes the // scale down. `card_density_test.dart` now holds both values. // // Home is wider than Bookings' 16 on purpose: it is a much denser screen — // shift card, three stat cards, a pinned tab strip and a rail, all above // the route — and at 16 the column read as pressed against the border. padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [buildSummary(), buildStops(), buildActionBar()], ), ); } // ═══════════════════════════════════════════════════════════════════════ // THE CARD IN THREE PIECES // // Home does not use [build]; it places these three sections as separate // slivers so that the Trip 1 / 2 / 3 bar can be a *pinned* sliver sitting // between the summary and the stops. A pinned header has to be a sliver in // its own right, which is impossible from inside one `SliverToBoxAdapter` // holding the whole card. // // They stay methods on this widget rather than three separate widgets because // the stop list alone needs nine of this class's fields — splitting would mean // maintaining that list twice. All three are pure and take no `BuildContext`, // so calling them from the page is safe. // // [build] still composes them in order, so any caller wanting the whole card // as one widget — and the widget tests — keep working unchanged. // ═══════════════════════════════════════════════════════════════════════ /// The collapsed trip brief: progress, time left, cash — and everything else /// one tap away. See [TripBriefStrip] for what this used to be and why the /// four surfaces it replaced were the wrong price for the information. Widget buildSummary() => TripBriefStrip( trip: trip, brief: brief ?? RouteBrief.empty, percent: trip.completionPercent( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ), onViewRoute: onViewRoute, ); /// HUB → every stop in order → HUB. Widget buildStops() => _sequence(); /// Accept-remaining / selection bar, or the finished confirmation. Widget buildActionBar() => _footerBar( trip.pendingOrderIds(acceptedIds: acceptedIds, rejectedIds: rejectedIds), ); // ── HUB → stops → HUB ────────────────────────────────────────────────── Widget _sequence() { // Computed once for the whole trip, not per stop — it is a comparison // across all of them. final addressTail = trip.sharedAddressTail; return Padding( // ── The stop rows sit inside the summary cards, symmetrically ── // // The whole route block is inset 8 further than the shift and stat cards // above it: the rail's numbered node starts at 28 and the stop card's // right edge lands at 28, against the summary's 20. // // Both sides moved together on purpose. Insetting only the right made the // card's own margins even but left the rail closer to the border than // anything else on the screen, so the row still read as pushed against // the edge — just from the other side. A list indents as a block or it // does not indent at all. // // And the block genuinely is one: a rail plus its card is an indented list // item under the summary, not another card in the same column, so it is // correct that it does not share a page edge with the cards above. padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Select-all leads the list it selects ── // // This sat at the *foot* of the route, under the last hub row. On a // six-stop trip that is a full screen of scrolling away from the // first stop, and it is the control a rider reaches for **before** // reading the list, not after: "take the lot" is the common case, and // he was made to scroll past everything he had just decided to take // in order to say so. // // At the head of the list it is the first thing under the trip tabs, // it reads as a heading for the rows beneath it — which is what a // select-all is — and the running count sits where his eye already is // when he ticks a row. _selectAllRow(), _hubRow(isStart: true), // ── One screen, one question ── // // Home lists the stops that still need something *from him here*: // // • **pending** — undecided, waiting to be ticked and accepted. // • **rejected** — declined, kept visible so he can see he handled // it rather than wondering where it went, and can take it back. // // Everything else has moved to the tab where it is acted on: accepted // and in-progress stops to Bookings, finished ones to Activity. Both // used to sit here as rows the rider could do nothing with, and there // is no bound on how many of those a shift produces — six stops in, // he was scrolling past six dead cards to reach one live one, so the // screen got harder to use in exact proportion to how much work he // had done. // // **Skipped** stops went the same way, to Activity. A skip is a // record of something that happened at a door — like a completed // stop, and unlike a pending one — and Home is the screen for the one // question "what am I taking?". Keeping skips here meant the accept // list grew a permanent tail of stops that were not on offer, and the // rider had to read past his own morning to find the afternoon's // work. Activity carries them with the reason he gave and the same // Resume control. // // They are filtered at the *row*, not removed from [trip]: the // brief's ring and its "2 of 6 stops done" are computed from the // trip, and a trip that shed its accepted and finished stops would // report 0 of 1 and empty its own ring every time he took something. // The indices are the trip's own too, so the numbering does not // renumber itself as the day goes on. for (var i = 0; i < trip.stops.length; i++) if (_showsOnHome(_stateOf(trip.stops[i]))) _stopRow(i, addressTail), // A trip whose every stop has been taken or finished still has hub // rows and a brief, and would otherwise show an unexplained gap // between them. if (trip.stops.isNotEmpty && !trip.stops.any((s) => _showsOnHome(_stateOf(s)))) _allTakenNote(), _hubRow(isStart: false), ], ), ); } /// The band the hub node and its label share. /// /// Tall enough for the tallest thing on the row, which is the MAP action, not /// the node — see [_hubRow]. Both sides are centred in this one height, so /// they line up by construction at any text scale instead of by a nudge that /// only holds at one. double get _hubBand => 36.h; Widget _hubRow({required bool isStart}) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( children: [ // The node, centred in the shared band rather than sitting at the // top of it. SizedBox( height: _hubBand, child: Center( child: Container( width: 22.w, height: 22.w, decoration: BoxDecoration( color: _hubColor.withValues(alpha: 0.14), shape: BoxShape.circle, border: Border.all(color: _hubColor, width: 1.5), ), child: Icon( Icons.warehouse_rounded, size: 12.sp, color: _hubColor, ), ), ), ), if (isStart) Expanded( child: Container( width: 2, margin: EdgeInsets.symmetric(vertical: 3.h), color: ColorConstants.borderSubtle, ), ), ], ), SizedBox(width: 8.w), Expanded( // ── The label sits level with the node, not near it ── // // This was a 1dp top padding, which is a guess at where a 11.5sp // line lands inside a 22dp circle — wrong at every text scale but // the one it was eyeballed at, so the hub icon read as floating // above its own label. // // It was then boxed to the *node's* height, 22, which fixed the // alignment and broke the row: the MAP action beside the label is a // tap target with its own padding and a chevron, comfortably taller // than 22, so it was cropped through the middle. Half a control is // worse than a misaligned icon. // // Both sides are now centred in one band sized for the tallest // thing on the row ([_hubBand]). Nothing is clipped and the two // centres coincide by construction. The bottom padding stays // outside the band so the connector below still gets its gap. child: Padding( padding: EdgeInsets.only(bottom: isStart ? 10.h : 0), child: SizedBox( height: _hubBand, child: Row( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, // `spaceBetween` with a [Flexible] label rather than a // `Spacer`: a Spacer is flex-1, so it would have split the free // space evenly with a flexible label and squeezed the text for // no reason. This way the label takes its natural width, the // action takes its own, and only a genuinely narrow screen // makes the label give ground. mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( isStart ? 'START · HUB' : 'RETURN · HUB', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w800, letterSpacing: 0.7, color: _hubColor, fontFamily: FontConstants.fontFamily, ), ), ), // The long caption ("Leave the hub with the delivery // parcels") was read once and then never again — it is the // same on every trip, every day. The label alone carries it. // ── Why the map lives on this row ── // // It was buried at the bottom of the trip brief, which is the // wrong place twice over: it is an *action* among a list of // read-only figures, and it was two taps deep behind a strip // the rider has no other reason to open. // // The route starts here. "Before you leave the hub, here is // the whole run on a map" is the one moment the map answers a // question the rider is actually asking, and this row is // where his eye already is when he starts. It also had a slot // going spare — the caption that used to fill it is gone. if (isStart && onViewRoute != null) _ViewRouteAction(onTap: onViewRoute!), ], ), ), ), ), ], ), ); } /// "Select all · N of M selected", at the head of the route. /// /// Rendered only when there is more than one undecided stop — with one stop /// its own tick already is the select-all — and only when the page supplies a /// selection handler. /// /// It is a tinted strip rather than a bare row: it sits between the trip tabs /// and the first hub row, both of which are strong horizontal marks, and an /// unbacked tick out here read as a stray control belonging to neither. Widget _selectAllRow() { final pendingIds = trip.pendingOrderIds( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ); if (onToggleSelect == null || pendingIds.length < 2) { return const SizedBox.shrink(); } final picked = pendingIds.where(selectedIds.contains).toList(); final all = picked.length == pendingIds.length; final some = picked.isNotEmpty && !all; return Padding( padding: EdgeInsets.only(bottom: 8.h), child: Material( color: some || all ? ColorConstants.primary.withValues(alpha: 0.06) : ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: InkWell( // The whole strip toggles, not just the 28pt box — this is the one // control on the screen the rider reaches for without looking. onTap: () => onSelectAll?.call(all ? const [] : pendingIds), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Padding( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), child: Row( children: [ _SelectBox( selected: all, partial: some, accent: ColorConstants.primary, label: all ? 'Clear the selection' : 'Select every pending stop', onTap: () => onSelectAll?.call(all ? const [] : pendingIds), ), SizedBox(width: 5.w), Expanded( child: Text( picked.isEmpty ? 'Select all ${pendingIds.length} stops' : '${picked.length} of ${pendingIds.length} selected', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w800, letterSpacing: -0.2, color: picked.isEmpty ? ColorConstants.secondaryText : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), if (picked.isNotEmpty) Padding( padding: EdgeInsets.only(right: 8.w), child: Text( all ? 'CLEAR' : 'ALL', style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), ), ); } /// Which states earn a row on Home. See the note at the call site. static bool _showsOnHome(StopState state) => state == StopState.pending || state == StopState.rejected; /// The single control a rejected row is allowed. /// /// Outlined and compact, right-aligned: it is a way back, not the thing the /// screen is for, and a filled button here would out-rank the accept on the /// floating bar. Widget _rowAction({ required String label, required IconData icon, required Color color, required bool busy, required VoidCallback onPressed, }) { return Padding( padding: EdgeInsets.only(top: 9.h), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ Flexible( child: MilerButton( label: label, icon: icon, variant: MilerButtonVariant.outlined, color: color, height: ButtonSizes.compact, expand: false, loading: busy, onPressed: busy ? null : onPressed, ), ), ], ), ); } /// Shown in place of the stop list when every stop in the trip has been taken /// or finished — so the gap between the two hub rows says what happened /// instead of looking like a failed load. Widget _allTakenNote() { return Padding( padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w), child: Row( children: [ Icon( Icons.check_circle_rounded, size: 18.sp, color: ColorConstants.acceptGreen, ), SizedBox(width: 9.w), Expanded( child: Text( 'Every stop on this trip is taken — work them from Bookings', style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, height: 1.35, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } Widget _stopRow(int index, List addressTail) { final stop = trip.stops[index]; final kind = stopKindOf(stop); final state = _stateOf(stop); final id = _idOf(stop); final stopBusy = busyStopIds.contains(id); final rejected = state == StopState.rejected; final done = state == StopState.done; final pending = state == StopState.pending; final accepted = state == StopState.accepted; final selected = selectedIds.contains(id); // Accepted counts as locked here. See the decision row below for why an // accepted stop no longer offers Reject beside its own confirmation. final locked = done || accepted || state == StopState.active; // ── Colour encodes STATE, not stop type ── // // This was `kind.accent`, so the rail ran maroon / royal-blue / indigo down // the list according to whether each stop was a pickup, a delivery or both. // That spends the strongest signal available on a *category* — a fact that // never changes and that the label already states in words — and leaves // nothing to say the thing that does change: how far through the trip he is. // // Now the rail reads as progress, which is what a rail is for. final Color nodeColor = rejected ? ColorConstants.secondaryText : done || accepted ? ColorConstants.acceptGreen : state == StopState.active ? ColorConstants.primary : ColorConstants.slateText; final name = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') .toString(); final phone = (stop['pickupcontactno'] ?? '').toString().trim(); // City / state / pincode stripped — see [commonAddressTail]. What is left // is the only part that tells this stop from the next one. // Trimmed to what distinguishes this stop from the next, unless the design // trial is on — see [kFullAddressTrial]. final address = kFullAddressTrial ? (stop['pickupaddress'] ?? '').toString() : trip.shortAddress(stop, addressTail); final deliver = deliveryParcelCount(stop); final collect = pickupParcelCount(stop); final cash = stopCollectionAmount(stop); final service = roundTripDuration(Trip.serviceTimeFor(stop)); return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Rail ── // Narrowed again, 26px + 10px gutter → 22px + 8px. The node carries // one digit or one tick; everything past that was width borrowed from // the address, and the address is what tells one stop from the next. Column( children: [ // Nudged down so the circle centres on the badge beside it // instead of sitting flush with the top edge — the flush // alignment made stop 1 look pinned to the corner and squeezed // the gap above it to nothing. SizedBox(height: 10.h), Container( width: 22.w, height: 22.w, alignment: Alignment.center, decoration: BoxDecoration( color: rejected ? ColorConstants.pureSurface : nodeColor, shape: BoxShape.circle, border: Border.all(color: nodeColor, width: 2), ), child: done ? Icon( Icons.check_rounded, size: 13.sp, color: Colors.white, ) : rejected ? Icon(Icons.close_rounded, size: 12.sp, color: nodeColor) : Text( '${index + 1}', style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w800, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ), Expanded( child: Container( width: 2, margin: EdgeInsets.symmetric(vertical: 3.h), color: done ? ColorConstants.acceptGreen : ColorConstants.borderSubtle, ), ), ], ), SizedBox(width: 8.w), // ── Stop body ── // // Now the only card on the screen. With the trip's outer frame gone // there is exactly one border left, and it is drawn around the thing // the rider is actually deciding about: this stop. // // ── One surface, one border ── // // A pending stop used to be filled with its *type* accent at 5% and // bordered with the same colour at 38%, so the list came down the // screen as a lavender card, then a pink card, then a blue one — a // different tinted pane per stop kind. Uber's Base system is explicit // about why that is wrong: surfaces are white or one neutral grey, // borders are a single neutral, and the accent is reserved for "one // moment per screen" because it is "the only thing that says this // matters". Tinting by category spends that on a fact that never // changes and that the label spells out in words anyway. // // It also did not survive the environment. The same 5% fill that // reads as a soft tint indoors is a grey smudge on a phone held at // arm's length in sunlight — the exact failure this file already // documents for badges on the Bookings card. // // Nothing is lost by removing it, because the tint was never what // told the rider a stop was unanswered: a pending stop is the only // one carrying Accept / Reject buttons and a tick box, and the footer // counts them out loud. Those are stronger signals, and they work in // sunlight and for a colour-blind rider. Expanded( child: Opacity( opacity: rejected ? 0.55 : 1.0, child: Container( margin: EdgeInsets.only(bottom: 8.h), // The medium step of the inner-padding scale: 18 on the shift // card, 16 here, 10 on the small stat cards. Was 11, which was // tighter than anything else on the page and read as cramped // directly under a card using 18. padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h), // ── Fill, not an edge ── // // No border and no shadow: the surface itself separates the card // from the page, which is how most large apps draw a list card. // A 1.5px border plus a drop shadow plus a fill was three // devices doing one job, and the border was the one competing // with the content — six of them stacked down the screen read as // a form, not a route. // // The fill has to be strong enough to work alone; see // [ColorConstants.cardSurface] for why it sits 9.4% off the page // and not the ~5% a "subtle" grey would give. decoration: BoxDecoration( color: ColorConstants.cardSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), // ── The whole card opens the stop ── // // The tap used to live on the name-and-address block alone, and // it meant two different things: on a pending stop it toggled // selection, and the detail sheet was hidden behind a // long-press. That is a gesture nobody discovers, on the exact // stops the rider is deciding about — and it made the same tap // do different things depending on state. // // Tap now always opens the sheet, anywhere on the card, matching // the Bookings card. Selection keeps the tick box, which is the // visible, explicit control for it and sits right there on the // row. // // `HitTestBehavior.opaque` so the padding counts too. The tick // box, the call button and the Accept / Reject pair keep their // own taps — a child's gesture wins over an ancestor's. child: PressScale( onTap: onStopTap == null ? null : () => onStopTap!(stop), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Badge · state · time at the door · tick Row( children: [ // ── Type, as a label ── // // Was a solid `kind.accent` pill with white text, so // every card carried a saturated maroon / blue / indigo // block as its loudest element — three brand-strength // ── The leading group owns all the slack ── // // The chip used to be a `Flexible` sitting beside a // `Spacer` — and a Spacer is an `Expanded`, so the two // *shared* the free width equally and the chip was // capped at roughly 23pt however short its label was. // That is what rendered it as "P…", and no amount of // shortening the word could have fixed it. // // One Expanded now takes the whole remainder and the // chip sizes naturally inside it. The state chip beside // it gives way when a stop carries both, because // "Accepted" truncating still reads, while a truncated // type chip says nothing at all. Expanded( child: Row( children: [ // Flexible, but now competing only with the state // chip rather than with a Spacer — so on a pending // stop, which is the common case and the one with // no state chip, it is the sole child and takes // its natural width. It only gives ground when a // stop genuinely carries both marks. Flexible( child: Container( padding: EdgeInsets.symmetric( horizontal: 8.w, vertical: 4.h, ), decoration: BoxDecoration( color: kind.chipColor, 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.sp, fontWeight: FontWeight.w800, letterSpacing: 0.5, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), if (state != StopState.pending) ...[ SizedBox(width: 6.w), Flexible(child: _StateChip(state: state)), ], ], ), ), // Fixed width, right-aligned — the third column. // // "~5 min" is materially narrower than "~10 min", so // left-to-right this text began in a different place on // every card and dragged the whole trailing cluster with // it. Reserving the widest case and right-aligning // inside it puts the duration on a column too, so all // three trailing elements run straight down the route. SizedBox( width: 54.w, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: Text( '~${formatTripDuration(service)}', style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w700, // Darker grey than the page uses: on the card's // light fill, `secondaryText` measures 2.83:1. color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ), // Calling ahead is the one action a rider takes from // the LIST rather than at the door — "are you open?", // "I'm five minutes away" — so it belongs on the row, // not buried in the detail sheet. SizedBox(width: 4.w), // ── Two fixed columns, always ── // // Both controls used to be conditional children of this // row — `if (phone.isNotEmpty)` and `if (pending)` — so // whenever one was absent the other slid across to take // its place. A stop with no phone number pushed its tick // box 48dp right; an already-accepted stop pushed its // call button 44dp right. Down a route of mixed states // the two controls zig-zagged left and right from card // to card, and a rider reaching for "the tick" had to // find it again on every row. // // The slots are reserved whether or not they are filled, // so both live on the same column the whole way down the // list and a thumb can travel straight. _TrailingSlot( child: phone.isEmpty ? null : _RowIconButton( icon: Icons.call_rounded, semanticLabel: 'Call $name', onTap: () => onCallStop?.call(stop), ), ), _TrailingSlot( child: (pending && onToggleSelect != null) ? _SelectBox( selected: selected, // One accent for selection app-wide, not a // different colour per stop kind. accent: ColorConstants.primary, label: 'Select stop ${index + 1}', // Dead while this stop's acceptance is on // the wire: un-ticking it there would leave // the tick and the request disagreeing. onTap: stopBusy ? null : () => onToggleSelect!(id), ) : null, ), ], ), SizedBox(height: 4.h), // Identity. No gesture of its own any more — the card // above handles the tap for the whole surface. Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 16.5.sp, fontWeight: FontWeight.w800, height: 1.15, letterSpacing: -0.3, color: ColorConstants.slateText, decoration: rejected ? TextDecoration.lineThrough : null, fontFamily: FontConstants.fontFamily, ), ), if (address.isNotEmpty) ...[ SizedBox(height: 2.h), // Two lines normally: a third line of street // address never changes which gate the rider walks // to, and a card whose height depends on how verbose // a customer was makes the whole list ragged. The // full text is one tap away in the detail sheet. // // See [kFullAddressTrial] for the switch that lifts // both this cap and the tail-stripping above. Text( address, maxLines: kFullAddressTrial ? kFullAddressMaxLines : 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, height: 1.35, fontWeight: FontWeight.w600, // Was `onSurfaceVariant`, the brown M3 leftover // of the red seed. Slate matches the rest of the // card and reads better on the fill. color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ], ), SizedBox(height: 7.h), // ── What happens here, as one line ── // // Was three tinted chips — blue "Deliver 1", pink // "Collect 2", green "₹340" — which put up to three more // coloured surfaces on a card that already had a tinted // fill and a coloured badge. The words carry it: nobody // needs a blue box to know that "Deliver" is a delivery. // // Cash keeps green, because money is the one item here that // is a *state* the rider is accountable for rather than a // label — and green means money everywhere else in the app. _MetaLine( deliver: kind.hasDelivery ? (deliver > 0 ? deliver : 1) : 0, collect: kind.hasPickup ? (collect > 0 ? collect : 1) : 0, cash: cash, ), // ── Decision ── // Compact and right-aligned. Full-width buttons on every // stop made six identical green bars march down the card // and read as the loudest thing on screen — louder than // the addresses they belong to. Sized to their text, they // sit where the eye finishes the row and stay out of the // way until wanted. // A finished stop needs no action row at all: the chip // beside the badge already says "Completed", and repeating // it in a button-shaped box read as a second, tappable // claim. Only an ACTIVE stop keeps a row, because that one // carries an instruction ("finish it in Bookings") rather // than a status. // // ── No buttons on a stop row ── // // Every row used to carry its own Accept and Reject. Six // stops meant twelve buttons on one screen, the same two // words repeated down the page, and — because a decision // fired the moment it was tapped — six separate wire calls // and six chances to hit the wrong one at arm's length on // a parked scooter. // // Choosing is now one gesture and committing is another: // the rider **ticks** the stops he wants (the box on the // meta row above) and accepts the lot from the floating // bar. That is the standard list-multi-select idiom — // Gmail, Files, Photos — and it means nothing leaves the // phone until he presses one deliberate button. Stops he // does not tick are simply not taken; there is no separate // Reject to mis-tap. // // What is left on the row is a *statement* for stops that // are no longer his to decide — accepted, being worked, or // finished — which is [_LockedNote], never a control. if (locked) ...[ SizedBox(height: 9.h), _LockedNote(state: state), ], // ── The way back ── // // A rejected stop is on Home precisely because the rider // might change his mind about it, so it keeps exactly one // control — and it does not commit work. Undoing a // rejection returns the stop to undecided, where it is // ticked and accepted like any other. Nothing here accepts // anything by itself; that is still the floating bar's // single job. // // A *skipped* stop used to carry a Resume here. It is now // on Activity, with the same control — see // [_showsOnHome]. if (rejected && onUnrejectStop != null) _rowAction( label: 'Undo reject', icon: Icons.undo_rounded, color: ColorConstants.slateText, busy: stopBusy, onPressed: () => onUnrejectStop!(stop), ), ], ), ), ), ), ), ], ), ); } // `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See // [_MetaLine], which says the same three things on one uncoloured line. // ── Footer: accept remaining, or go work the trip ────────────────────── Widget _footerBar(List pendingIds) { final finished = trip.isFinished( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ); if (finished) { return Padding( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), child: Container( height: ButtonSizes.primary, alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.acceptGreen.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(ButtonSizes.radius), border: Border.all( color: ColorConstants.acceptGreen.withValues(alpha: 0.4), width: 1.5, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.verified_rounded, size: 20.sp, color: ColorConstants.acceptGreen, ), SizedBox(width: 8.w), // Flexible for the same reason as [_LockedNote]'s: this sentence // is wider than the bar it sits in once the type scales up, and // an unflexed Text in a centred Row overflows rather than // shrinking. Flexible( child: Text( 'Trip complete · head back to the hub', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w800, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ); } // Nothing left to decide. The way on to the work is the accepted-count pill // floating over the page — one route to Bookings, always in the same place, // rather than a button that appears at the foot of a trip only once that // trip happens to be fully decided. if (pendingIds.isEmpty) return const SizedBox.shrink(); // Only ticks on stops that are still this trip's and still undecided. final picked = pendingIds.where((id) => selectedIds.contains(id)).toList(); // ── One accept, one place ── // // An "Accept all N stops" button used to sit here, under the last hub row. // It was a second bulk action over a *different* set of stops from the // floating bar's — and the difference between the two sets was exactly the // stops the rider had just decided he did not want. Now there is one accept // in the whole screen, on the floating bar, and it always means "the ones I // ticked". Taking a whole trip is the select-all tick at the head of the // list followed by that same button. // // What is left down here is one line telling him where the action went. The // select-all itself moved to the top — see [_selectAllRow]. return Padding( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), child: Text( picked.isNotEmpty ? 'Accept the stops you picked from the bar below' : 'Tick the stops you want, then accept them below', textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ); } } /// Rupee amounts without trailing noise: `1030`, `40`, `12.5`. /// /// Lifted out of [TripCard] so [_MetaLine] shares the one implementation rather /// than the two drifting apart. String _trim(double v) { if (v >= 100) return v.round().toString(); final rounded = (v * 10).round() / 10; return rounded == rounded.roundToDouble() ? rounded.round().toString() : rounded.toStringAsFixed(1); } /// "MAP ›" on the START · HUB row. /// /// The label was "VIEW ROUTE", which spent three syllables and most of the row /// saying what the map pin beside it and the destination screen both already /// say. "MAP" is the noun the rider uses for the thing he is opening. /// /// A text action rather than a button: it sits on a label row inside the route /// list, and a filled control there would out-rank the Accept bar that carries /// the trip's actual decision. Text plus a chevron is the standard way to say /// "there is more of this elsewhere" without claiming to be the main thing on /// the screen. /// /// The visible text is tight against the row's right edge while the tap target /// extends past it in every direction, so it clears the 44dp floor without the /// label appearing to float away from the margin. class _ViewRouteAction extends StatelessWidget { final VoidCallback onTap; const _ViewRouteAction({required this.onTap}); @override Widget build(BuildContext context) { return Semantics( button: true, label: 'View the whole route on a map', child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Padding( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 7.h), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.map_rounded, size: 14.sp, color: ColorConstants.primary, ), SizedBox(width: 5.w), Text( 'MAP', style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), Icon( Icons.chevron_right_rounded, size: 16.sp, color: ColorConstants.primary, ), ], ), ), ), ); } } /// A trailing control slot on the meta row. /// /// Always [ButtonSizes.minTapTarget] wide, occupied or not, so the call button /// and the tick box sit on the same two columns for every stop in the route — /// see the note at the call site. An empty slot renders nothing but still holds /// its width. class _TrailingSlot extends StatelessWidget { final Widget? child; const _TrailingSlot({this.child}); @override Widget build(BuildContext context) => SizedBox(width: ButtonSizes.minTapTarget, child: child); } /// Compact icon action that sits inline on a stop row. class _RowIconButton extends StatelessWidget { final IconData icon; final String semanticLabel; final VoidCallback onTap; const _RowIconButton({ required this.icon, required this.semanticLabel, required this.onTap, }); @override Widget build(BuildContext context) { return Semantics( button: true, label: semanticLabel, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, // 44dp hit area around a 30px control — small on screen, never small // under a thumb. child: SizedBox( width: ButtonSizes.minTapTarget, height: ButtonSizes.minTapTarget, child: Center( child: Container( // Same 28dp square as the tick box beside it. These were 30dp and // 24dp, which read as uneven even once they were aligned. width: 28.w, height: 28.w, alignment: Alignment.center, // Green glyph on a white tile. // // White rather than the app's neutral grey because the card it // sits on is grey, and a grey control on a grey card disappears — // inner surfaces go lighter than their container, never the same // shade. The green is on the icon and its edge, not the fill: a // solid green tile would put a second green block on a card whose // Accept button is already green. decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: ColorConstants.acceptGreen.withValues(alpha: 0.45), width: 1.5, ), ), child: Icon(icon, size: 16.sp, color: ColorConstants.acceptGreen), ), ), ), ), ); } } /// Tick box for selecting a stop. class _SelectBox extends StatelessWidget { final bool selected; final bool partial; final Color accent; final String label; /// Null while the stop's acceptance is in flight. final VoidCallback? onTap; const _SelectBox({ required this.selected, required this.accent, required this.label, required this.onTap, this.partial = false, }); @override Widget build(BuildContext context) { final on = selected || partial; return Semantics( checked: selected, label: label, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, // 44dp hit area around a 22px box: the visible control stays compact // but the target clears the accessibility floor. child: Container( width: ButtonSizes.minTapTarget, height: ButtonSizes.minTapTarget, alignment: Alignment.center, child: AnimatedContainer( duration: const Duration(milliseconds: 160), // Matches the call button next to it — see the note there. width: 28.w, height: 28.w, alignment: Alignment.center, decoration: BoxDecoration( color: on ? accent : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: on ? accent : ColorConstants.borderStrong, width: 2, ), ), child: on ? Icon( partial ? Icons.remove_rounded : Icons.check_rounded, size: 17.sp, color: Colors.white, ) : null, ), ), ), ); } } /// Replaces the Accept/Reject pair once the decision has been made, keeping the /// row in the same place on every card. /// /// It covers three states, and the wording is the difference between them: an /// accepted stop is *taken* and waiting on Bookings, an active one is being /// worked right now, a done one is finished. None of the three offers a control /// — a stop the hub has already been told about is not re-decided from a list /// row. (Accepting is undoable, but from the snackbar that follows the tap; see /// the decision-row note in `_stopRow`.) class _LockedNote extends StatelessWidget { final StopState state; const _LockedNote({required this.state}); @override Widget build(BuildContext context) { final done = state == StopState.done; final accepted = state == StopState.accepted; final color = done || accepted ? ColorConstants.acceptGreen : ColorConstants.primary; return Container( height: ButtonSizes.secondary, alignment: Alignment.center, // White on the card's light fill, with a neutral edge; the state colour // lives on the glyph and the words inside. A tinted panel with a tinted // border was two coloured boxes deep for one sentence. decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(ButtonSizes.radius), border: Border.all(color: ColorConstants.borderStrong, width: 1.5), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( done ? Icons.verified_rounded : accepted ? Icons.check_circle_rounded : Icons.directions_bike_rounded, size: 17.sp, color: color, ), SizedBox(width: 7.w), // Flexible, because this row is as wide as its words and the column // it sits in is narrower than the card: an unflexed Text here // overflowed as soon as a state with a full sentence — accepted, or // in-progress — appeared on a stop row. Flexible( child: Text( done ? 'Completed' : accepted ? 'Accepted — work it from Bookings' : 'In progress — finish it in Bookings', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w800, color: color, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } } /// ── The work at this stop, on one line ── /// /// Replaces three tinted chips (`_tag`) with plain text. Uber's rule for a /// figure that has to survive a glance is weight and contrast, not a container; /// three containers on one row was the opposite of that, and on a combined stop /// they wrapped onto a second line and made the card taller than its neighbours. /// /// Only the cash is coloured. Parcel counts are labels; the money is a state the /// rider is accountable for, and green means money throughout the app. class _MetaLine extends StatelessWidget { final int deliver; final int collect; final double cash; const _MetaLine({ required this.deliver, required this.collect, required this.cash, }); @override Widget build(BuildContext context) { TextStyle style(Color c) => TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w800, letterSpacing: -0.2, color: c, fontFamily: FontConstants.fontFamily, ); final dot = TextSpan( text: ' · ', style: style(ColorConstants.borderStrong), ); final spans = []; if (deliver > 0) { spans.add( TextSpan( text: 'Deliver $deliver', style: style(ColorConstants.slateText), ), ); } if (collect > 0) { if (spans.isNotEmpty) spans.add(dot); spans.add( TextSpan( text: 'Collect $collect', style: style(ColorConstants.slateText), ), ); } if (cash > 0) { if (spans.isNotEmpty) spans.add(dot); spans.add( TextSpan( text: '₹${_trim(cash)} cash', // `acceptGreen` is a fill colour — 2.28:1 as text on the card fill. style: style(ColorConstants.moneyGreen), ), ); } if (spans.isEmpty) return const SizedBox.shrink(); return Text.rich( TextSpan(children: spans), maxLines: 2, overflow: TextOverflow.ellipsis, ); } } /// Small label naming a stop's state once the rider has decided. /// /// The tinted pill behind it is gone. State is the one thing on this card that /// legitimately earns colour, so the colour stays — on the icon and the word, /// where it costs no surface. A pill around two words, inside a bordered card, /// was a third box for a fact that reads fine as text. class _StateChip extends StatelessWidget { final StopState state; const _StateChip({required this.state}); ({Color color, IconData icon}) get _look => switch (state) { StopState.accepted => ( color: ColorConstants.acceptGreen, icon: Icons.check_circle_outline_rounded, ), StopState.active => ( color: ColorConstants.primary, icon: Icons.directions_bike_rounded, ), StopState.done => ( color: ColorConstants.acceptGreen, icon: Icons.verified_rounded, ), StopState.skipped => ( color: ColorConstants.warning, icon: Icons.replay_rounded, ), StopState.rejected => ( color: ColorConstants.secondaryText, icon: Icons.block_rounded, ), StopState.pending => ( color: ColorConstants.secondaryText, icon: Icons.schedule_rounded, ), }; @override Widget build(BuildContext context) { final look = _look; return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(look.icon, size: 12.sp, color: look.color), SizedBox(width: 4.w), Flexible( child: Text( state.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.sp, fontWeight: FontWeight.w800, letterSpacing: 0.6, color: look.color, fontFamily: FontConstants.fontFamily, ), ), ), ], ); } } /// Hub / collection-centre accent, matching the Bookings route rail. final Color _hubColor = ColorConstants.tertiary;