1548 lines
67 KiB
Dart
1548 lines
67 KiB
Dart
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;
|