Files
doormile_milderapp/lib/views/Dashboard/home/trip_card.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

1466 lines
65 KiB
Dart
Raw 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:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/Dashboard/home/stop_card.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/views/Dashboard/home/route_timeline.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/miler_type.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/bag_manifest.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.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;
/// Takes the rider back into the stop he is already working.
///
/// This is the way in that used to be a red banner floating over the foot of
/// the page — see the note on the live row in [_stopRow]. Null renders the
/// active row as a plain statement instead.
final void Function(Map<String, dynamic> stop)? onContinueStop;
/// 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;
/// Orders the rider is carrying. Service routes only — see [StopState.collected].
final Set<String> collectedIds;
/// Opens turn-by-turn navigation for one stop.
///
/// On the row itself, not only in the detail sheet: setting off is the single
/// most common thing a rider does from this list, and making him open a sheet
/// first is a tap per stop on a screen he uses ten times a morning. Where it
/// points follows the stop's stage — see [MilkRun.navigationTarget].
final void Function(Map<String, dynamic> stop)? onNavigateStop;
/// Walks one stop up its next rung — accepted → arrived → picked — through a
/// slide-to-confirm sheet.
///
/// The selection bar does this for a ticked set; this is the single-stop path
/// for a rider standing at one counter with one card in front of him.
final void Function(Map<String, dynamic> stop)? onAdvanceStop;
/// Opens the pickup preview for a whole place — the leg to it and the load
/// waiting on it. See [PickupPreviewSheet].
final void Function(RouteGroup group)? onPreviewGroup;
/// Where the rider is, for "how far is the next pickup".
///
/// Optional, and the timeline degrades cleanly without it: no fix means no
/// distance line rather than a fabricated one. See [RouteGroup.meters].
final double? riderLat;
final double? riderLng;
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.onContinueStop,
this.busyStopIds = const {},
this.collectedIds = const {},
this.onNavigateStop,
this.onAdvanceStop,
this.onPreviewGroup,
this.riderLat,
this.riderLng,
});
StopState _stateOf(Map<String, dynamic> stop) => stopStateOf(
stop,
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
collectedIds: collectedIds,
);
String _idOf(Map<String, dynamic> stop) => (stop['orderid'] ?? '').toString();
/// ── The tick outlives the accept ──
///
/// On a parcel route the box is only for choosing what to take on, so it goes
/// once the stop is taken. A service rider keeps ticking the same stops
/// afterwards: he selects every order from one kitchen and walks them through
/// *arrived* and *picked* together, which is what the bar's ladder acts on.
static bool _isChoosable(StopState state) =>
state == StopState.pending ||
(ServiceProfile.active.handsOffAtCollection &&
(state == StopState.accepted || state == StopState.active));
@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.
// Horizontal inset is zero here now: each section stands on its own
// [MilerPanel], which carries `MilerSurface.panelGutter`. Keeping 16 as
// well would inset the panels by 28 in this whole-card path while Home —
// which places the three sections directly — drew them at 12, so the
// reference shots would not be pictures of the screen.
padding: EdgeInsets.symmetric(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() => _timeline();
// ── The "NEXT ACTION" head is gone, and its job moved down one line ───────
//
// It was a glass card above the route carrying the place name at 25sp, the
// load, the travel time and a full-width red **Navigate to pickup** — and the
// group for that same place, forty points below, carried the identical button
// over the identical name. Two primary actions, one destination, and the
// rider had to read both to find out they were the same.
//
// Everything it said is now said once, by the group node that owns it: the
// name, the state chip, `5 orders · 5 bags`, the distance and ETA, and one
// journey button. That is the whole point of a timeline — the head *is* the
// first node — and it gave back ~190pt of the viewport.
//
// What is not lost: [_nextGroup] still resolves the current unit of work for
// the callers below.
/// The next place the rider owes work to, and every order he has there.
///
/// Ordered by *rung*, not by route position: a counter he has already reached
/// outranks one he has only accepted, because a rider standing at a shelf is
/// mid-handover and everything else can wait. Within a rung it is the first
/// stop in route order, which is the order the hub built.
({
List<Map<String, dynamic>> stops,
String place,
StopState state,
int index,
})?
_nextGroup() {
for (final rung in const [StopState.active, StopState.accepted]) {
for (var i = 0; i < trip.stops.length; i++) {
if (_stateOf(trip.stops[i]) != rung) continue;
final stop = trip.stops[i];
final key = MilkRun.sourceKeyOf(stop);
final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty;
final stops = grouped
? [
for (final s in trip.stops)
if (MilkRun.sourceKeyOf(s) == key && _stateOf(s) == rung) s,
]
: [stop];
final place = grouped
? stopSourceName(stop)
: MilkRun.navigationLabel(stop);
if (place.trim().isEmpty) continue;
return (stops: stops, place: place, state: rung, index: i);
}
}
return null;
}
/// Accept-remaining / selection bar, or the finished confirmation.
Widget buildActionBar() => _footerBar(
trip.pendingOrderIds(acceptedIds: acceptedIds, rejectedIds: rejectedIds),
);
// ── The run, as a timeline ───────────────────────────────────────────────
//
// This replaced `_sequence()`: a select-all strip, a hub row, a heading per
// kitchen, one [StopCard] per order and a closing hub row. The cards were the
// cost — 138pt each, so five orders off one counter came to ~690pt against a
// ~600pt viewport, and a rider working one kitchen could not see one kitchen.
//
// The stops are the same stops and the state is the same state; what changed
// is that the *place* became the node and the orders became milestones under
// it. See [RouteTimeline].
Widget _timeline() {
final groups = routeGroups();
if (groups.isEmpty) {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h),
child: trip.stops.isEmpty ? const SizedBox.shrink() : _allTakenNote(),
);
}
// ── The route is the screen's working region ──
//
// Same move as the brief above it: Home's ground is the canvas, and a
// spine of stops drawn straight onto it is grey on grey. [MilerPanel]
// gives the route a white layer-1 surface to stand on — one panel for the
// whole route, not one per kitchen, which is the distinction the earlier
// pass was after when it removed the card-per-kitchen.
return MilerPanel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RouteTimeline(
groups: groups,
selectedIds: selectedIds,
busyIds: busyStopIds,
onSelectAll: onSelectAll,
onToggleSelect: onToggleSelect,
onStopTap: onStopTap,
// One journey action per place. Five Navigate buttons pointing at
// one counter was the largest single source of clutter on the old
// screen, and the rider had to work out whether they differed.
onNavigateGroup: onNavigateStop,
// Ringing the kitchen is an action on the *place*, so it sits on
// the pickup card beside Navigate — not on five order rows that all
// dial the same counter.
onCallGroup: onCallStop,
// The card's own tap, and what Navigate goes through. Supplied by
// the page rather than built here: these three methods are pure and
// take no `BuildContext` — Home calls them directly as slivers —
// and a sheet needs one.
onPreviewGroup: onPreviewGroup,
// The way back into the stop he is already inside. This is what
// replaced the red banner that used to float over the foot of the
// page — see [PickupTimelineGroup._action].
onContinue: onContinueStop,
onUnreject: onUnrejectStop == null
? null
: (stop) => onUnrejectStop!(stop),
onViewRoute: onViewRoute,
),
// A meal run does not come back: the rider finishes at the last
// subscriber's door and goes home. A parcel day is a loop, so it
// still ends where it started.
if (!ServiceProfile.active.sourceIsKitchen) ...[
SizedBox(height: 6.h),
_returnNote(),
],
if (trip.stops.isNotEmpty &&
!trip.stops.any((s) => _showsOnHome(_stateOf(s))))
_allTakenNote(),
],
),
);
}
/// The run's pickup places, in the order the route reaches them.
///
/// On a line with sources this is one group per kitchen. On a parcel route
/// there are none, so every stop becomes its own single-order group — the
/// same timeline one level shallower, which is honest: a parcel rider really
/// does ride to each address for one consignment.
///
/// A group whose orders have all left Home — collected, and now on the work
/// tab — emits nothing at all. Its heading would be a title over a gap.
@visibleForTesting
List<RouteGroup> routeGroups() {
final order = <String>[];
final buckets = <String, List<int>>{};
final grouped = ServiceProfile.active.sourceIsKitchen;
// Every stop is bucketed, not only the ones Home still draws: a group's bag
// numbers are counted against the whole group the rider was handed, so the
// collected ones have to stay in the arithmetic even once they have left
// the screen. See [RouteGroup.allStops].
for (var i = 0; i < trip.stops.length; i++) {
final stop = trip.stops[i];
// Key on the id where there is one, falling back to the name: the id is
// what the hub controls, but a payload that only carries a name still has
// to group rather than collapsing every stop into one nameless pile.
final key = grouped
? (stopSourceId(stop).isNotEmpty
? stopSourceId(stop)
: stopSourceName(stop))
: 'stop:${_idOf(stop)}';
if (!buckets.containsKey(key)) {
buckets[key] = <int>[];
order.add(key);
}
buckets[key]!.add(i);
}
final groups = <RouteGroup>[];
for (final key in order) {
final indices = buckets[key]!;
final all = [for (final i in indices) trip.stops[i]];
final visible = [
for (final s in all)
if (_showsOnHome(_stateOf(s))) s,
];
// ── A finished place settles; it does not vanish ──
//
// A group with nothing left on Home is one the rider has collected in
// full. It stays as one settled line — a green node and a "Collected"
// chip — so the run still reads as a sequence and he can see 01 is behind
// him. The work itself has moved to the work tab; what is left here is
// the fact that it did. A group that is neither visible nor collected
// (all rejected, all finished on the server) emits nothing.
// **Collected**, not merely finished. A stop the rider is carrying has
// moved to the work tab and the place he took it from is behind him — a
// fact worth one settled line. A stop that is *done* is finished work and
// belongs to Activity; drawing its place here would put yesterday's
// counters on today's run.
final settled =
visible.isEmpty &&
all.every((s) => _stateOf(s) == StopState.collected);
if (visible.isEmpty && !settled) continue;
groups.add(() {
final stops = visible;
final first = all.first;
final flat =
all.length == 1 && (!grouped || stopSourceName(first).isEmpty);
// A grouped place is named by its counter. A flat one *is* the stop, so
// it is named by the customer — `MilkRun.navigationLabel` answers
// "pickup" for a parcel that has not been collected yet, which is a
// description of the leg rather than a name the rider can read off a
// gate.
final name = flat
? BagManifest.customerOf(first)
: (grouped
? stopSourceName(first)
: MilkRun.navigationLabel(first));
return RouteGroup(
key: key,
// A route with no source names still groups — into one unnamed
// group — and a heading reading "· 5 orders" would be worse than
// the customer's own name.
name: name.trim().isEmpty
? MilkRun.navigationLabel(first)
: name.trim(),
stops: stops,
allStops: all,
firstIndex: indices.first,
state: stops.isEmpty ? StopState.collected : _groupRung(stops),
stopStates: [for (final s in stops) _stateOf(s)],
travel: trip.travelTimeToStop(indices.first),
// ── Measured, or absent ──
//
// Rider → this place, from a live fix where there is one and from
// whatever the booking carries otherwise. `metersToStop` returns null
// rather than zero when it has nothing to work with, and the header
// prints nothing rather than "0 m" — a distance is a thing the rider
// plans around, so a wrong one is worse than a missing one.
meters: RouteMetricsHelper.metersToStop(
first,
riderLat: riderLat,
riderLng: riderLng,
),
// One order at one address with no counter above it worth naming:
// the header is the stop. Always true on a parcel route, where the
// rider rides to each customer for one consignment — a header plus a
// single child would print the same name twice.
flat: flat,
);
}());
}
return groups;
}
/// The rung a whole group is on: the state of its first order that the rider
/// can still act on, falling back to the first order's own state.
///
/// A selection spanning two rungs makes the bar fall back to Accept, so the
/// group only ever gathers one — the same rule the old kitchen heading used.
StopState _groupRung(List<Map<String, dynamic>> stops) {
for (final s in stops) {
final state = _stateOf(s);
if (_isChoosable(state)) return state;
}
return _stateOf(stops.first);
}
/// The closing leg of a parcel day, as a line rather than a node-and-band.
Widget _returnNote() => Padding(
padding: EdgeInsets.only(left: 38.w, top: 2.h),
child: Row(
children: [
Icon(
LucideIcons.warehouse,
size: 14.sp,
color: ColorConstants.secondaryText,
),
SizedBox(width: 8.w),
// Flexible: a tracked-out eyebrow at 2.0× text scale on a 320pt phone
// is wider than the row it sits in, and a bare `Text` in a `Row` has no
// give. `trip_brief_layout_test.dart` sweeps for exactly this.
Flexible(
child: Text(
ServiceProfile.active.endsAtHub ? 'RETURN · HUB' : 'END · HOME',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.eyebrow,
),
),
],
),
);
// ── HUB → stops → HUB ──────────────────────────────────────────────────
//
// Retained only for the tests that still pin the old composition. Nothing on
// Home calls it — see [_timeline].
// ignore: unused_element
Widget _sequence() {
// Computed once for the whole trip, not per stop — it is a comparison
// across all of them.
final addressTail = trip.sharedAddressTail;
// ── One journey button per screen ──
//
// The head above the list already offers "Navigate to pickup" for the
// current place. Repeating it on each of that place's five cards is five
// more buttons that do the identical thing, on the screen whose whole
// premise is that there is exactly one next action — and the rider then has
// to work out whether they differ. They do not, so only the head has it.
//
// Cards for *other* places keep theirs: those are journeys the head is not
// offering.
final head = _nextGroup();
final headIds = <String>{
if (head != null)
for (final s in head.stops) _idOf(s),
};
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.
// ── Grouped by source, when there is one ──
//
// A service rider's day is two stops then ten doors, and a flat list
// of ten hides that shape completely: he cannot see that five of them
// come from one kitchen and five from another until he opens each
// card. Grouping states it, and gives the bulk collect somewhere to
// live that is unambiguously about *those five*.
if (ServiceProfile.active.sourceIsKitchen)
..._groupedRows(addressTail, headIds)
else
for (var i = 0; i < trip.stops.length; i++)
if (_showsOnHome(_stateOf(trip.stops[i])))
_stopRow(i, addressTail, headIds),
// 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(),
// ── A meal run does not come back ──
//
// The parcel day is a loop: leave the hub empty, fill up at customer
// doors, return with the consignments — so "RETURN · HUB" is the last
// real thing on the route and the rider is not finished until he is
// there.
//
// A meal rider finishes at the last subscriber's door and goes home.
// Drawing him a return leg to a warehouse he has never been to states
// a journey nobody is making, and puts an un-completable step at the
// end of a route he has in fact completed.
if (!ServiceProfile.active.sourceIsKitchen) _hubRow(isStart: false),
],
),
);
}
/// Stop rows under a heading per source, in route order.
///
/// Groups are emitted in the order their first stop appears, so the sections
/// follow the route the hub built rather than an alphabetical order that
/// would send the rider to the wrong kitchen first. A group whose stops have
/// all left Home — collected, and now on Bookings — emits nothing at all;
/// its heading would be a title over an empty space.
List<Widget> _groupedRows(List<String> addressTail, Set<String> headIds) {
final order = <String>[];
final groups = <String, List<int>>{};
for (var i = 0; i < trip.stops.length; i++) {
if (!_showsOnHome(_stateOf(trip.stops[i]))) continue;
// Key on the id where there is one, falling back to the name: the id is
// what the hub controls, but a payload that only carries a name still has
// to group rather than collapsing every stop into one nameless pile.
final stop = trip.stops[i];
final key = stopSourceId(stop).isNotEmpty
? stopSourceId(stop)
: stopSourceName(stop);
if (!groups.containsKey(key)) {
groups[key] = <int>[];
order.add(key);
}
groups[key]!.add(i);
}
return [
for (final key in order) ...[
_kitchenHeader(key, groups[key]!),
for (final i in groups[key]!) _stopRow(i, addressTail, headIds),
SizedBox(height: 6.h),
],
];
}
/// `🏪 Kitchen 1 · 5 meals` with the one action that group affords.
Widget _kitchenHeader(String key, List<int> indices) {
final first = trip.stops[indices.first];
final name = stopSourceName(first);
// A route with no source names at all still groups — into one unnamed
// group — and a heading reading "· 5 meals" would be worse than none.
if (name.isEmpty) return const SizedBox.shrink();
final states = [for (final i in indices) _stateOf(trip.stops[i])];
final pending = states.where((s) => s == StopState.pending).length;
final accent = ColorConstants.serviceAccent;
// ── Every order off one counter, in one tick ──
//
// This is the selection the ladder is actually made of. He does not arrive
// at an order, he arrives at a kitchen — so the control that gathers "all
// of this kitchen's orders" belongs on the kitchen's own heading, and the
// bar underneath then speaks for exactly that counter.
//
// The set is the group's orders **on one rung**: whichever rung the first
// choosable stop is on. A selection spanning two rungs makes the bar fall
// back to Accept (see `_selectionAction`), which would be a heading that
// ticks five cards and then offers the wrong verb for three of them.
final groupIds = <String>[];
StopState? rung;
for (final i in indices) {
final state = _stateOf(trip.stops[i]);
if (!_isChoosable(state)) continue;
rung ??= state;
if (state != rung) continue;
final id = _idOf(trip.stops[i]);
if (id.isNotEmpty) groupIds.add(id);
}
final picked = groupIds.where(selectedIds.contains).length;
final allPicked = groupIds.isNotEmpty && picked == groupIds.length;
return Padding(
padding: EdgeInsets.only(top: 10.h, bottom: 8.h),
child: Row(
children: [
if (onSelectAll != null && groupIds.isNotEmpty)
SelectBox(
selected: allPicked,
partial: picked > 0 && !allPicked,
accent: ColorConstants.primary,
label: 'Select every order from $name',
circular: true,
onTap: () => onSelectAll!(allPicked ? const [] : groupIds),
)
else
Icon(LucideIcons.store, size: 16.sp, color: accent),
SizedBox(width: 7.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
// ── One order, one bag ──
//
// This counted "meals", which is a word for the thing inside
// the bag rather than the thing the rider carries — and it left
// him comparing "5 meals" on the phone against a shelf of bags.
// The count he can *check* is orders against bags, and under
// this workflow they are the same number by definition. See
// [BagManifest].
Text(
pending > 0
? '${BagManifest.countLabel(indices.length)} · $pending to accept'
: BagManifest.countLabel(indices.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
// ── One bulk flow at a counter, not two ──
//
// A `Collect N` button used to sit here and open a second sheet with
// its own tick-list, alongside the tick-plus-bar ladder below. Two
// ways to do one physical thing is two mental models for a rider
// holding a crate, and the two wrote different state: one posted
// PICKED, the other kept collection local.
//
// The ladder won — it is the same gesture on every rung and every
// line of work — and the load sheet's one irreplaceable feature, the
// missing-bag report, moved into the pickup confirmation where the
// handover is actually confirmed. See [StopActionSheet].
],
),
);
}
/// 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(
// Where this rider's day actually begins. A warehouse
// glyph on a meal run points at a building he will never
// visit; his first address is a kitchen counter.
ServiceProfile.active.sourceIsKitchen
? LucideIcons.store
: LucideIcons.warehouse,
size: 12.sp,
color: _hubColor,
),
),
),
),
// No connector under the node any more. It used to run down into
// a rail of numbered stop nodes drawn *outside* the cards; the
// journey thread now lives inside each card, where it connects
// the two ends of one order, and a stub of line pointing at a
// card edge would be a rail with nothing on it.
],
),
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
? (ServiceProfile.active.sourceIsKitchen
? 'START · KITCHEN'
: 'START · HUB')
// ── A milk run does not return anything ──
//
// He loads at the kitchen and his last address is a
// customer's door: there is no hub at the end of
// his day and nothing to carry back to one. The
// logistics rider genuinely does return collected
// shipments to the depot, so the label follows the
// capability rather than the screen position.
: (ServiceProfile.active.endsAtHub
? 'RETURN · HUB'
: 'END · HOME'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
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.w700,
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.w700,
letterSpacing: 0.6,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
),
);
}
/// Which states earn a row on Home. See the note at the call site.
///
/// ── Accepted is Home's problem too, on a service run ──
///
/// For a parcel, accepting is the whole decision and everything after it
/// happens at a customer's door, so an accepted stop belongs on Bookings.
///
/// Accepting a subscriber's lunch changes nothing physical: the food is still
/// in a kitchen he has not reached. Handing the card over at that moment
/// empties Home the instant he starts his shift and leaves him working a
/// Bookings list of meals he is not carrying. So Home keeps it until he is —
/// see [ServiceProfile.handoffAt].
/// ── The stop he is on never leaves this screen ──
///
/// An active stop stays on Home for every tenant now. It used to be hidden on
/// a parcel route and announced instead by a red banner floating over the
/// bottom of the page — a second, separate object saying "you have a job
/// running" about a stop the list underneath was refusing to show. The row
/// itself is the better place for it: it already has the customer, the
/// address, the parcels and the position in the route, and it is where the
/// rider is looking.
/// ── A declined stop is history, not a row ──
///
/// `rejected` used to belong here, so a stop the rider had turned down stayed
/// on Home struck through with an UNDO beside it — a row he had finished with,
/// occupying the screen he uses to decide what is next, for the rest of the
/// shift. On a run where he declines three, that is three permanent rows of
/// work that is not his.
///
/// Declining is a decision, and decisions are recorded on Activity. It files
/// there the moment he makes it — see `_rejectStop` — and the undo goes with
/// it: an action that reverses itself from the page it was performed on is
/// what kept the row here in the first place.
static bool _showsOnHome(StopState state) =>
state == StopState.pending ||
state == StopState.active ||
// Accepted stays only where the rider works that rung from this screen;
// on a parcel route it moves to Bookings the moment he takes it.
(state == StopState.accepted &&
ServiceProfile.active.handsOffAtCollection);
/// The lifecycle status a Home row's state corresponds to.
///
/// [StopState] is the screen's vocabulary and [StopStatus] is the lifecycle's;
/// this is the one place they are mapped, so the row's button and the sheet's
/// heading cannot end up describing different rungs.
StopStatus _rungOf(StopState state) => switch (state) {
StopState.accepted => StopStatus.accepted,
StopState.active => StopStatus.arrived,
_ => StopStatus.unknown,
};
/// 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.
///
/// An illustration now, per the user's direction (2026-08-20) — the sentence
/// it replaces ("Every stop on this trip is taken — work them from …") lives
/// on as the image's semantic label, so a screen reader still gets the fact
/// and the handoff pill below still names the tab to go to.
Widget _allTakenNote() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 38.h, horizontal: 8.w),
child: Center(
child: Semantics(
label:
'Every stop on this trip is taken — work them from '
'${ServiceProfile.active.workTabLabel}',
image: true,
child: Image.asset(
'assets/images/bookings_taken.png',
width: 280.w,
height: 280.w,
fit: BoxFit.contain,
// A missing asset must degrade to nothing, never to the grey
// missing-image box in the middle of Home.
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
),
),
);
}
/// One stop, as one card.
///
/// Everything about *shape* now lives in [StopCard] — the journey thread, the
/// two legs, the footer. What stays here is the trip's own state: which
/// controls this stop has earned, and what each of them does.
Widget _stopRow(
int index,
List<String> addressTail, [
Set<String> headIds = const {},
]) {
final stop = trip.stops[index];
final state = _stateOf(stop);
final id = _idOf(stop);
final stopBusy = busyStopIds.contains(id);
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. Unless the design
// trial is on, in which case the raw address is used; see
// [kFullAddressTrial].
final address = kFullAddressTrial
? (stop['pickupaddress'] ?? '').toString()
: trip.shortAddress(stop, addressTail);
final choosable = _isChoosable(state);
// ── The next rung, only on the stop he is standing at ──
//
// The route the rider is meant to take is: navigate to the counter, tick
// the orders that come off it, and claim the rung once for the lot from
// the bar. So an *accepted* card offers the journey (see [StopCard]) and
// not the claim — a per-card "Arrived" beside a Navigate button is two
// answers to one question, and the wrong one is a wrong claim to the hub.
//
// An **arrived** stop keeps a card-level rung, because by then the batch
// question is settled: he is at the counter, and the sheet it opens acts
// on every order from that counter anyway ([_advanceStopWithSheet]).
final advance = state == StopState.active
? (MilkRun.nextActionLabel(_rungOf(state)) == null
? null
: 'Mark as picked')
: null;
return StopCard(
stop: stop,
// The route's own numbering, which does not renumber itself as stops
// leave the screen — see the filter note in [_sequence].
index: index + 1,
state: state,
address: address,
service: roundTripDuration(Trip.serviceTimeFor(stop)),
bag: _bagOf(stop),
// The kitchen heading above already names where this came from.
groupedUnderSource: ServiceProfile.active.sourceIsKitchen,
selected: selectedIds.contains(id),
busy: stopBusy,
// Tap opens the sheet, anywhere on the card. Selection keeps the tick,
// which is the visible, explicit control for it.
onTap: onStopTap == null ? null : () => onStopTap!(stop),
onToggleSelect: (choosable && onToggleSelect != null)
? () => onToggleSelect!(id)
: null,
// 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".
onCall: (phone.isEmpty || onCallStop == null)
? null
: () => onCallStop!(stop),
// Hidden when the stop has no coordinates for the leg it is on: a
// Navigate that opens a map on nothing is worse than none, because the
// rider only finds out after committing to follow it.
onNavigate:
(onNavigateStop == null ||
MilkRun.navigationTarget(stop) == null ||
// Already offered, once, by the head above the list.
headIds.contains(id))
? null
: () => onNavigateStop!(stop),
// A rejected stop is on Home precisely because the rider might change his
// mind. Undoing returns it to undecided, where it is ticked and accepted
// like any other — nothing here accepts anything by itself.
onUnreject: onUnrejectStop == null ? null : () => onUnrejectStop!(stop),
// The way back into the stop he is already working. This used to be a red
// banner floating over the foot of the page.
//
// Offered only for a stop that is genuinely worked on its own screen. On
// a kitchen line a collection is not: its rungs are the slide sheet and
// the floating bar on this very page, so a Continue that opened the
// single-stop map/arrive/confirm flow was a second route up the same
// ladder — and the one that ends in a sheet asking him to confirm a
// delivery for a bag still on a counter. See [MilkRun.worksOnOwnScreen].
onContinue:
(onContinueStop == null ||
!MilkRun.worksOnOwnScreen(stop, collectedIds: collectedIds))
? null
: () => onContinueStop!(stop),
onAdvance: (advance == null || onAdvanceStop == null)
? null
: () => onAdvanceStop!(stop),
advanceLabel: advance,
);
}
// `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See
// [_MetaLine], which says the same three things on one uncoloured line.
/// Which bag this order travels in.
///
/// Derived from its position in **its own pickup group**, not from the whole
/// route: the rider counts bags off one counter at a time, so the third order
/// from Kitchen 2 is *its* Bag 3 and not the route's ninth. A label the
/// kitchen actually printed always wins — see [BagManifest].
String _bagOf(Map<String, dynamic> stop) {
if (!ServiceProfile.active.sourceIsKitchen) return stopBagLabel(stop);
final key = MilkRun.sourceKeyOf(stop);
final group = [
for (final s in trip.stops)
if (MilkRun.sourceKeyOf(s) == key) s,
];
return BagManifest.bagFor(stop, group);
}
// ── 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(
LucideIcons.badgeCheck,
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.w700,
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),
// Full width, or `textAlign` has nothing to align within: this sits in a
// `CrossAxisAlignment.start` column, so the Text shrink-wrapped to its
// own words and centring them inside their own box moved nothing. The
// caption has asked to be centred for as long as it has been left.
child: SizedBox(
width: double.infinity,
child: Text(
// Verb-first and half the length. A hint under a list is read at a
// glance or not at all; twelve words set as a sentence was furniture.
picked.isNotEmpty
? 'Accept your picked stops below'
: 'Tick stops to accept them',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
);
}
}
/// "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(LucideIcons.map, size: 14.sp, color: ColorConstants.primary),
SizedBox(width: 5.w),
Text(
'MAP',
style: TextStyle(
fontSize: 11.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0.6,
color: ColorConstants.primary,
fontFamily: FontConstants.fontFamily,
),
),
Icon(
LucideIcons.chevronRight,
size: 16.sp,
color: ColorConstants.primary,
),
],
),
),
),
);
}
}
/// Hub / collection-centre accent, matching the Bookings route rail.
final Color _hubColor = ColorConstants.tertiary;
/// ─────────────────────────────────────────────────────────────────────────
/// THE LOADING ROUTE — the real timeline, in grey
///
/// ── Why this is not the app's generic `SkeletonList` ──
///
/// Home used to load as three shimmering rounded rectangles, which is a
/// skeleton of *a list of cards* — a screen Home stopped being when the route
/// became a timeline. So the placeholder promised one layout and the arrival
/// delivered another, and every redesign of the route silently made the lie
/// bigger, because nothing links the two.
///
/// `skeletonizer` (2.1.3, MIT, 2.3k likes) removes the link problem rather than
/// fixing this instance of it: it takes the **real widget tree** — this real
/// [TripCard], built over placeholder stops — and paints its text and shapes as
/// bones. The loading state is therefore the actual node, spine, load line and
/// journey column, at the actual sizes, and it cannot drift from the screen it
/// is standing in for. Changing the timeline changes the skeleton.
///
/// It earns the dependency for the same reason: the alternative is a
/// hand-drawn second copy of a layout that is still moving.
///
/// The placeholder route is deliberately one kitchen with three orders — the
/// commonest shape — and the strings are never read: `Skeletonizer` replaces
/// every one of them with a bone before paint.
class HomeRouteSkeleton extends StatelessWidget {
const HomeRouteSkeleton({super.key});
static Map<String, dynamic> _stop(int i) => {
'orderid': 'skeleton-$i',
'type': 'pickup',
'pickupcustomer': 'Loading customer',
'pickupaddress': 'Loading address, Coimbatore',
'kitchenid': 'skeleton-kitchen',
'kitchenname': 'Loading kitchen',
'Quantity': '1',
};
@override
Widget build(BuildContext context) {
return Skeletonizer(
// The app's own shimmer feel: one soft sweep, not a strobe. A loading
// screen a rider sees forty times a shift is the last place for motion
// that asks to be watched.
effect: ShimmerEffect(
baseColor: ColorConstants.borderSubtle,
highlightColor: ColorConstants.neutralLight,
duration: const Duration(milliseconds: 1100),
),
child: TripCard(
trip: Trip.fromStops(
id: 'skeleton',
stops: [for (var i = 0; i < 3; i++) _stop(i)],
),
).buildStops(),
);
}
}