Files
2026-08-11 13:16:33 +05:30

1548 lines
67 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> acceptedIds;
final Set<String> 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<String> selectedIds;
/// Toggles one stop's tick.
final void Function(String orderId)? onToggleSelect;
/// Accepts exactly the ticked stops.
final Future<void> Function(List<String> orderIds)? onAcceptSelected;
/// Replaces the whole selection — used by the select-all row.
final void Function(List<String> 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<void> Function(Map<String, dynamic> stop)? onUnrejectStop;
/// Opens a single stop's detail sheet (map + full order details).
final void Function(Map<String, dynamic> stop)? onStopTap;
/// Rings the customer for this stop.
final void Function(Map<String, dynamic> 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<String> 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<String, dynamic> stop) =>
stopStateOf(stop, acceptedIds: acceptedIds, rejectedIds: rejectedIds);
String _idOf(Map<String, dynamic> 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<String> 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<String> 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 = <InlineSpan>[];
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;