1559 lines
71 KiB
Dart
1559 lines
71 KiB
Dart
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/order_manifest.dart';
|
||
import 'package:miler/data/stop_area.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;
|
||
|
||
/// One order's sheet, from a row inside an expanded place — see
|
||
/// [RouteTimeline.onOrderTap]. Falls back to [onStopTap] when unset.
|
||
final void Function(Map<String, dynamic> stop)? onOrderTap;
|
||
|
||
/// 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.onOrderTap,
|
||
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.isAtSource));
|
||
|
||
@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,
|
||
// What the four figures describe: the collections still owed, measured
|
||
// from where the rider is standing — the same origin the page used to
|
||
// build the trip in the first place, so the recount and the original are
|
||
// on the same footing. See [Trip.outstanding].
|
||
remaining: trip.outstanding(
|
||
collectedIds: collectedIds,
|
||
// Work he has declined is not work in front of him — see the note on
|
||
// [Trip.outstanding]. Without this the Distance cell measured a route
|
||
// through stops the rider had refused.
|
||
rejectedIds: rejectedIds,
|
||
originLat: riderLat,
|
||
originLng: riderLng,
|
||
),
|
||
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() {
|
||
// Arrived outranks active: he is at that counter now, and a stop he has
|
||
// only been routed to can wait.
|
||
for (final rung in const [
|
||
StopState.arrived,
|
||
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, within: trip.stops);
|
||
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,
|
||
onOrderTap: onOrderTap ?? 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];
|
||
// ── One reader for "which counter is this", not two ──
|
||
//
|
||
// This built its own key — `stopSourceId`, falling back to
|
||
// `stopSourceName` — while [MilkRun.sourceKeyOf] is the function that
|
||
// exists to answer exactly this, and is what the bulk collect, the
|
||
// per-kitchen gate and `sameSourceAs` all key on.
|
||
//
|
||
// They did not agree. `stopSourceId` reads `kitchenid` and `sourceid`;
|
||
// `sourceKeyOf` reads those **and `pickuplocationid`** — so a row
|
||
// identified only by its location id was one place according to the
|
||
// collect and a different one according to the card that offers it. That
|
||
// is the same two-readers-one-question failure `sourceNameOf` documents,
|
||
// repeated on the id, and it splits a kitchen's orders across two groups
|
||
// on the screen while the collect still treats them as one.
|
||
final key = grouped
|
||
? MilkRun.sourceKeyOf(stop, within: trip.stops)
|
||
: '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;
|
||
|
||
// ── An unnamed counter is named by where it is ──
|
||
//
|
||
// A grouped place with no source name fell through to
|
||
// `MilkRun.navigationLabel`, which answers with the **customer** — so
|
||
// the heading over a dropdown read as one more order rather than as the
|
||
// place the orders under it are collected from.
|
||
//
|
||
// The address is what names a place the payload did not name, and it is
|
||
// what the rider wants off that line anyway: he is deciding where to
|
||
// ride, not who to hand a bag to.
|
||
final place = compactAddress(pickupAddressOf(first));
|
||
|
||
// ── When a lone stop is a *place*, and when it is the stop ──
|
||
//
|
||
// `flat` means the header **is** the stop: no counter above it worth
|
||
// naming, so the card is titled with the customer and opens the detail
|
||
// sheet instead of a list. That is always right on a parcel route,
|
||
// where the rider goes to one door for one consignment.
|
||
//
|
||
// On a kitchen line it was reached by a different route and gave the
|
||
// wrong answer. A lone stop was flat whenever the payload carried **no
|
||
// source name** — and a missing name is a gap in the data, not evidence
|
||
// that there is no counter. So an order whose row lost its kitchen name
|
||
// was drawn as a bare header with the customer's name on it, sitting
|
||
// above the dropdown its seven siblings were in: seven bags in a list
|
||
// and one stranded over it, off the same shelf.
|
||
//
|
||
// [MilkRun.sourceKeyOf] now groups such a row back in by its address,
|
||
// which fixes that case at the root. This is the same correction one
|
||
// level down, for a place that genuinely does hold a single order: on a
|
||
// kitchen line a lone stop is only flat when there is **nothing at all**
|
||
// to name its place with — no counter name and no address. With an
|
||
// address it is a place holding one order, headed by where it is, with
|
||
// the order in the list under it.
|
||
final flat =
|
||
all.length == 1 &&
|
||
(!grouped || (stopSourceName(first).isEmpty && place.isEmpty));
|
||
|
||
// A grouped place is named by its counter, or failing that by where it
|
||
// is. 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
|
||
? OrderManifest.customerOf(first)
|
||
: (grouped
|
||
? (stopSourceName(first).trim().isNotEmpty
|
||
? stopSourceName(first)
|
||
: place)
|
||
: 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(),
|
||
// ── The counter's street, under its name ──
|
||
//
|
||
// Grouped places only. A flat node *is* the stop and is already named
|
||
// by its customer or its own address, so an address line under it
|
||
// would print the same place twice; a kitchen is a name the rider has
|
||
// to convert into a location, and until now the only thing that could
|
||
// do that conversion was the preview sheet.
|
||
//
|
||
// Trimmed through [compactAddress] — the geocoder appends
|
||
// `Tamil Nadu, 641002, India` to every row, which is three
|
||
// components that are identical on every stop of every route and
|
||
// would push the part that names the street off the end of the line.
|
||
// Not repeated when it is already the heading — see [name] above.
|
||
location: flat || name.trim() == place ? '' : place,
|
||
stops: stops,
|
||
allStops: all,
|
||
firstIndex: indices.first,
|
||
state: stops.isEmpty ? StopState.collected : _groupRung(stops),
|
||
stopStates: [for (final s in stops) _stateOf(s)],
|
||
// The whole bucket's states, so the card can tell an order that is
|
||
// still this counter's business from one already delivered — see
|
||
// [RouteGroup.groupSize].
|
||
allStopStates: [for (final s in all) _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 · BASE' : '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;
|
||
// [MilkRun.sourceKeyOf], the same reader [routeGroups] uses and the same
|
||
// one the bulk collect keys on. This had its own id-then-name expression,
|
||
// which misses `pickuplocationid` and therefore split a kitchen the
|
||
// collect still treats as one — see the note in [routeGroups].
|
||
final stop = trip.stops[i];
|
||
final key = MilkRun.sourceKeyOf(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
|
||
// [OrderManifest].
|
||
Text(
|
||
pending > 0
|
||
? '${OrderManifest.countLabel(indices.length)} · $pending to accept'
|
||
: OrderManifest.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 · BASE')
|
||
// ── A milk run does not return anything ──
|
||
//
|
||
// He loads at the kitchen and his last address is a
|
||
// customer's door: there is no base 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 · BASE'
|
||
: '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 ||
|
||
// Arrived belongs here for the same reason active does, and it is the
|
||
// reason this reads as a predicate: see [StopStateX.isAtSource].
|
||
state.isAtSource ||
|
||
// 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,
|
||
// Both: this answers *which rung is the stop on*, and it is the same for
|
||
// either — he is at the counter and the next thing he does is collect.
|
||
StopState.arrived || 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.isAtSource
|
||
? (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)),
|
||
label: _labelOf(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.
|
||
|
||
/// The label the counter printed on this order, or `''`.
|
||
///
|
||
/// ── It used to be derived, and that was the problem ──
|
||
///
|
||
/// This computed the order's position in its own pickup group and returned
|
||
/// `Bag 3` — the app numbering a list it had built and presenting the result
|
||
/// as something stencilled on an object. On a route where no counter prints
|
||
/// labels that was every row, and it sat two columns from a rail numbering
|
||
/// the same rows.
|
||
///
|
||
/// Only what the payload actually carries survives. See
|
||
/// [OrderManifest.labelFor].
|
||
String _labelOf(Map<String, dynamic> stop) => OrderManifest.labelFor(stop);
|
||
|
||
// ── 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 base',
|
||
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(),
|
||
);
|
||
}
|
||
}
|