Files
doormile_milderapp/lib/views/Dashboard/home/route_timeline.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

3164 lines
143 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:miler/data/bag_manifest.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/stop_area.dart';
import 'package:miler/views/Dashboard/home/stop_card.dart' show LiveMark;
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.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';
/// ─────────────────────────────────────────────────────────────────────────
/// THE ROUTE IS THE INTERFACE
///
/// Home used to draw a stop as a card: a pane of glass carrying a type mark, a
/// state chip, a 21sp headline, three lines of address, a facts row, a bag chip
/// and a button. **138pt per order.** A kitchen with five orders came to ~690pt
/// of card, and the viewport under the app bar and the trip tabs is about 600 —
/// so a rider working one counter could not see one counter. Two kitchens was a
/// screen and a half of scrolling to answer "which one am I on?".
///
/// The cards were not too *styled*; they were the wrong shape. A rider does not
/// choose between five orders from Vidhya Kitchen — he rides to Vidhya Kitchen
/// once and collects five bags. The operational unit is the **place**, and the
/// orders are what he counts when he gets there. So the place is a node and the
/// orders are milestones under it:
///
/// ```
/// ① Vidhya Kitchen Accepted 5.2 km
/// │ 5 orders · 5 bags ~15 min
/// │ [ ➤ Navigate to pickup ]
/// ├── ○ Joe Mathew Bag 1
/// ├── ○ Arun Prakash Bag 2
/// ├── ○ Ravi Kumar Bag 3
/// └── ○ Kumar S Bag 5
///
/// ② Joe Kitchen 1.8 km
/// 3 orders · 3 bags ~5 min
/// ```
///
/// ── The header is two columns, not four lines ──
///
/// It was four stacked left-aligned rows — name, load, distance/ETA, action —
/// which is why the distance read as metadata however it was styled: to find
/// "how far", the eye had to walk down the block and parse each line. Splitting
/// it puts **identity on the left and journey on the right**, on one baseline,
/// so both are answered in a single fixation; it costs two rows rather than
/// four; and down a multi-kitchen run the distances stack into a scan column,
/// which is how "which one is nearest" gets answered without reading.
///
/// A collapsed group is **~58pt** — the height of a section heading. Expanded
/// with five orders it is ~300. The same information, a third of the page.
///
/// ── Why this is not a package ──
///
/// Re-checked against the current releases rather than inherited: `timeline_tile`
/// is the most-liked option on pub.dev (1.9k) and was **last published five
/// years ago**. `timelines_plus` is the maintained fork — 2.0.1, eight days old,
/// 160 pub points, 277 likes — and it is a good package. It still loses here,
/// for a reason that is about this screen rather than about the package: its
/// node is styled through `indicatorStyleBuilder` / `connectorStyleBuilder`,
/// which are **style-per-index and carry no per-node interaction or selection
/// state**. Every one of Miler's nodes is a state machine — pending → accepted
/// → arrived → collected, crossed with a batch tick that drives [SelectionBar] —
/// so the builders would have received a state they cannot express and the row
/// content, the tap targets and the selection would still have been written
/// here. What the package would actually have supplied is [_Spine]: a line, a
/// dot, and the arithmetic that keeps them aligned, which is the sixty lines
/// below.
///
/// `glass_kit` was evaluated for the group surface and rejected for a stronger
/// reason: the app has already decided against `BackdropFilter` in scrolling
/// lists — a real blur re-samples everything behind it every frame, and twenty
/// of them is the most reliable way to drop a mid-range Android to 30fps (see
/// [ColorConstants.glassCard]). The blur is reserved for the two surfaces that
/// genuinely sit over live content. A glassmorphism package would have brought
/// back the thing that decision removed.
///
/// **No dependency was added.** The timeline is Flutter primitives on the
/// existing tokens.
/// ─────────────────────────────────────────────────────────────────────────
/// One pickup place and every order the rider collects there.
///
/// Built by the page from the trip's own stops — this holds no state of its
/// own and derives no counts. The bag lines come from [BagManifest.forGroup],
/// which is the single source for "how many bags" and "which bag": the count is
/// `orders.length` because a bag is what an order arrives in, and the identity
/// is the printed label or the order's position **in this group**. The timeline
/// must never number bags across the route.
@immutable
class RouteGroup {
/// Stable key — the source id where the hub sends one, the name otherwise.
final String key;
/// What the rider reads on a shop sign. Empty on a route with no sources, in
/// which case the group is drawn flat (see [RouteTimeline]).
final String name;
/// The orders still shown on Home, in route order.
final List<Map<String, dynamic>> stops;
/// **Every** order the hub assigned from this place, collected ones
/// included — which is what the bag numbers are counted against.
///
/// Load-bearing, and the reason this is a second list rather than a getter.
/// Bag identity is the order's position *in its own pickup group*, so once
/// the rider has collected Bag 1 and it leaves Home, numbering the four that
/// remain from the visible list would renumber Bag 2 as Bag 1 — and he would
/// be reading a different number off the phone from the one printed on the
/// shelf. See [BagManifest].
final List<Map<String, dynamic>> allStops;
/// The trip index of the first stop, for travel time and for numbering.
final int firstIndex;
/// The rung every choosable order in this group is on.
final StopState state;
/// Each of [stops]' own state, in the same order.
///
/// A group's rung is the rung it is *worked* on, and a group can hold orders
/// that are not on it — one accepted and one still pending off the same
/// counter. The selection control has to gather one rung only: a set spanning
/// two makes the bar fall back to Accept, which would be a control that ticks
/// two orders and then offers a verb that is wrong for one of them.
final List<StopState> stopStates;
/// Travel time to the group's first stop. Zero when unknown — never invented.
final Duration travel;
/// How far the rider is from this place, in metres.
///
/// Null when nothing usable exists — no live fix, no coordinates on the
/// booking, no `kms` from the hub. **Never a zero standing in for unknown**:
/// `0.0` would render as "0 m", which is a confident statement that the rider
/// is standing at the counter. See [RouteMetricsHelper.metersToStop].
final double? meters;
/// How many of this place's orders are on [state], and how many there are in
/// all — the two halves of `Accepted (1 of 4)`.
///
/// The denominator is [allStops], not [stops]: an order that has been
/// collected leaves the visible list but did not leave the counter, and a
/// rider reading "1 of 3" off a place he knows holds four would not trust the
/// number again.
int get onRungCount => stopStates.where((s) => s == state).length;
int get groupSize => allStops.isNotEmpty ? allStops.length : stops.length;
/// True when the header **is** the stop: one order, at one address, with no
/// place above it worth naming.
///
/// A parcel route is always this — the rider rides to each customer for one
/// consignment, so there is no counter to group under and a header followed
/// by a single child would print the same name twice. A meal order whose
/// payload names no kitchen lands here too, for the same reason.
final bool flat;
const RouteGroup({
required this.key,
required this.name,
required this.stops,
required this.allStops,
required this.firstIndex,
required this.state,
required this.stopStates,
this.travel = Duration.zero,
this.meters,
this.flat = false,
});
// ── The journey, as two operational facts ────────────────────────────────
//
// "How far is it and how long will it take" is the question a rider asks
// between finishing one counter and setting off for the next, and until now
// Home could only answer half of it — a leg duration chained from the stop
// before, with no distance anywhere on the route.
//
// Both halves come from one measurement so they cannot contradict each other:
// the metres to the place, and the time that distance takes at the rider's
// usable speed. Where there are no metres the leg estimate still answers the
// time half alone. Where there is neither, **nothing is printed** — an
// invented "~5 min" on a screen a rider plans around is worse than a gap.
/// Ride time to this place. Derived from [meters] when there is a measured
/// distance, and from the route's own leg estimate otherwise.
Duration get rideTime => (meters != null && meters! > 0)
? RouteMetricsHelper.travelTime(meters)
: travel;
/// `2.4 km · ~6 min`, or whichever half is known, or empty.
///
/// The `~` is not decoration: this is an estimate from a straight-line
/// distance and an average speed, and the tilde is what stops the rider
/// reading it as a promise the app cannot keep.
String get journeyLabel {
final ride = rideTime;
return <String>[
if (meters != null && meters! > 0)
RouteMetricsHelper.formatDistance(meters),
if (ride > Duration.zero) '~${RouteMetricsHelper.formatDuration(ride)}',
].join(' · ');
}
/// One line per order still on Home — numbered against the whole group.
///
/// The manifest is built from [allStops] and then filtered, never built from
/// [stops]: the count and the identity both come from the pickup group the
/// rider was handed, not from whatever is left of it.
List<BagLine> get manifest {
final shown = {for (final s in stops) (s['orderid'] ?? '').toString()};
return [
for (final line in BagManifest.forGroup(allStops))
if (shown.contains(line.orderId)) line,
];
}
/// `5 orders · 5 bags`. The two halves of the rule, side by side, so the
/// rider can check the shelf against the phone without doing arithmetic.
String get countLabel => BagManifest.countLabel(stops.length);
/// Orders off this counter the rider has not taken on yet.
int get pendingCount =>
stopStates.where((s) => s == StopState.pending).length;
/// One order's own state. Falls back to the group's rung for an id the group
/// does not hold, which cannot happen but must not throw if it does.
StopState stateOf(String orderId) {
for (final (i, s) in stops.indexed) {
if ((s['orderid'] ?? '').toString() == orderId) return stopStates[i];
}
return state;
}
/// True when every order from this place is in the rider's hands. The group
/// stays on the run as a settled line so the sequence still reads — the work
/// itself has moved to the work tab.
bool get isSettled => stops.isEmpty;
bool get isDone => state == StopState.collected || state == StopState.done;
/// The rider is on this stop right now.
bool get isLive => state == StopState.active;
}
/// The whole run: a head, then one expandable group per pickup place.
///
/// Stateful for exactly one thing — which groups are open. That is a view
/// preference, not operational state, so it deliberately does **not** go into
/// `_HomepageState`: the page owns accept / reject / select / collect and this
/// owns nothing the hub or the stores need to know about. There is one state
/// machine on Home and this is not a second one.
class RouteTimeline extends StatefulWidget {
final List<RouteGroup> groups;
/// Ticked order ids. Owned by the page; the timeline only reads them.
final Set<String> selectedIds;
/// Order ids with an action in flight.
final Set<String> busyIds;
/// Replaces the whole selection — the group's one selection control.
final ValueChanged<List<String>>? onSelectAll;
/// Toggles one order's tick.
final ValueChanged<String>? onToggleSelect;
/// Opens one order's detail sheet. Progressive disclosure for the address,
/// the phone and everything else a step deliberately does not carry.
final ValueChanged<Map<String, dynamic>>? onStopTap;
/// Opens navigation for a whole group. **One per group, never per order** —
/// five Navigate buttons that go to the same counter was the single largest
/// source of clutter on the old screen.
final ValueChanged<Map<String, dynamic>>? onNavigateGroup;
/// Rings the pickup place. Same rule as navigation: it belongs to the
/// *location*, so it is one control on the card and never one per order.
final ValueChanged<Map<String, dynamic>>? onCallGroup;
/// Opens the pickup preview — the leg to this place and the load waiting on
/// it. See [PickupPreviewSheet] for why navigation goes through a preview
/// rather than straight out to the phone's map app.
final ValueChanged<RouteGroup>? onPreviewGroup;
/// Takes the rider back into the stop he is already working — the way in
/// that used to be a red banner floating over the foot of the page.
final ValueChanged<Map<String, dynamic>>? onContinue;
/// Returns a declined order to undecided, so it can be ticked again.
///
/// A rejected stop is on Home precisely because the rider might change his
/// mind, which is the whole reason it stays visible rather than vanishing.
/// Deliberately not "accept it now": accepting is one deliberate press on the
/// floating bar.
final ValueChanged<Map<String, dynamic>>? onUnreject;
/// The whole route on a map, from the head.
final VoidCallback? onViewRoute;
const RouteTimeline({
super.key,
required this.groups,
this.selectedIds = const {},
this.busyIds = const {},
this.onSelectAll,
this.onToggleSelect,
this.onStopTap,
this.onNavigateGroup,
this.onCallGroup,
this.onPreviewGroup,
this.onContinue,
this.onUnreject,
this.onViewRoute,
});
@override
State<RouteTimeline> createState() => _RouteTimelineState();
}
class _RouteTimelineState extends State<RouteTimeline> {
/// ── One place open at a time ──
///
/// This was a `Map<String, bool>` of per-group overrides, so every card the
/// rider opened stayed open: three kitchens expanded is ~600pt of orders, the
/// route stops being a route, and the question the screen exists to answer —
/// *which counter am I on* — is buried under two he is not.
///
/// An accordion is the honest model, because the underlying fact is an
/// accordion: he is at one counter. Opening a place closes the last one, and
/// the run stays readable however many kitchens the hub assigns.
///
/// Null means "nothing chosen by hand yet" and defers to [_defaultOpenKey],
/// so the group he is working is open when the screen loads without the rider
/// having touched anything. Once he does touch one, his choice holds — an
/// accordion that keeps reasserting its own default is a control that fights
/// back.
String? _openKey;
bool _touched = false;
/// The group the rider is working, opened for him.
///
/// 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. Opening every group by default
/// would put him back on a screen he has to scroll.
String? get _defaultOpenKey {
for (final rung in const [
StopState.active,
StopState.accepted,
StopState.pending,
]) {
for (final g in widget.groups) {
if (g.state == rung) return g.key;
}
}
return widget.groups.isEmpty ? null : widget.groups.first.key;
}
bool _isOpen(RouteGroup g) =>
g.key == (_touched ? _openKey : _defaultOpenKey);
/// Opens [g] and closes whatever was open; tapping the open one shuts it.
///
/// **The read happens before the write.** `_touched = true` first, and then
/// `_isOpen(g)` answers against the *new* mode — where `_openKey` is still
/// null — so an open card reported itself shut and the toggle re-opened it.
/// The chevron looked completely dead, and the cause was one statement of
/// ordering inside a two-line setState.
void _toggle(RouteGroup g) {
final wasOpen = _isOpen(g);
setState(() {
_touched = true;
_openKey = wasOpen ? null : g.key;
});
}
@override
Widget build(BuildContext context) {
final open = _defaultOpenKey;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_head(),
for (final (i, g) in widget.groups.indexed)
PickupTimelineGroup(
group: g,
ordinal: i + 1,
expanded: _isOpen(g),
isCurrent: g.key == open,
last: i == widget.groups.length - 1,
selectionMode: widget.selectedIds.isNotEmpty,
selectedIds: widget.selectedIds,
busyIds: widget.busyIds,
onToggle: () => _toggle(g),
onSelectAll: widget.onSelectAll,
onToggleSelect: widget.onToggleSelect,
onStopTap: widget.onStopTap,
onNavigate: widget.onNavigateGroup,
onCall: widget.onCallGroup,
onPreview: widget.onPreviewGroup,
onContinue: widget.onContinue,
onUnreject: widget.onUnreject,
),
],
);
}
/// Every order on the run the rider may tick right now, across the flat
/// groups — a parcel route, where each stop is its own place.
///
/// One rung only, and only when there are at least two: with one stop its own
/// row already is the select-all.
List<String> get _flatSelectIds {
if (!widget.groups.every((g) => g.flat)) return const [];
final ids = <String>[];
for (final g in widget.groups) {
if (g.state != StopState.pending) continue;
for (final (i, s) in g.stops.indexed) {
if (g.stopStates[i] != StopState.pending) continue;
final id = stopIdOf(s);
if (id.isNotEmpty) ids.add(id);
}
}
return ids.length < 2 ? const [] : ids;
}
/// `TODAY'S RUN ─────────────── MAP ›`
///
/// What is left of the old `START · KITCHEN` hub row. The row itself was a
/// node, a label and a 36pt band for a fact the rider knows — his day starts
/// where his day starts — and its one piece of content worth keeping was the
/// map action.
Widget _head() {
final selectIds = _flatSelectIds;
final picked = selectIds.where(widget.selectedIds.contains).toList();
final all = selectIds.isNotEmpty && picked.length == selectIds.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Title left, action right, and nothing between them ──
//
// A rule ran across the gap, and it was the thing making the row look
// off: it is a 1pt line whose *optical* centre sits above the cap-height
// of the eyebrow beside it and below the middle of the pill on the
// other side, so three elements on one row settled on three different
// centre lines. It was also doing no work — the section is already
// separated from the strip above it by whitespace and from the route
// below it by a card edge.
//
// Title and action, pushed apart, both vertically centred on the row.
// The action's right edge lands on the same margin as the cards below,
// because the head and the cards share one padded column.
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
// `spaceBetween`, not a `Spacer`. A Spacer is an `Expanded` with
// flex 1, and the eyebrow beside it is a `Flexible` with flex 1 —
// so the two split the free width evenly, the eyebrow used almost
// none of its half, and the leftover fell *after* the pill. The Map
// action sat ~37pt short of the card edges below it, which is
// exactly the misalignment that reads as sloppiness without anyone
// being able to name it.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Flexible, not a bare `Text`: at 320pt and 2.0× text scale the
// eyebrow and the pill together are wider than the phone, and a
// row of two natural-width children has no give at all. Caught by
// `trip_brief_layout_test.dart`, which sweeps four widths against
// four text scales for exactly this shape of bug.
Flexible(
child: Text(
'TODAY’S RUN',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.eyebrow,
),
),
if (widget.onViewRoute != null) ...[
SizedBox(width: 10.w),
// Sentence case against the eyebrow's caps. The two sit on one
// line and they are not the same kind of thing — one names the
// section, one does something — so they are set differently
// rather than both shouting.
_TextAction(
label: 'Map',
icon: LucideIcons.map,
onTap: widget.onViewRoute!,
),
],
],
),
),
// ── "Take the lot", at the head of what it takes ──
//
// A run of single-order stops — a parcel route — has no counter to
// gather, so the select-all belongs to the run rather than to any one
// node. It sat at the *foot* of the route once: on a six-stop trip that
// is a screen of scrolling from the first stop, and it is the control a
// rider reaches for **before** reading the list rather than after.
// Selection mode only — see the note in [PickupTimelineGroup._body].
if (selectIds.isNotEmpty &&
widget.onSelectAll != null &&
widget.selectedIds.isNotEmpty)
Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: SelectAllAction(
total: selectIds.length,
picked: picked.length,
noun: 'stop',
onTap: () => widget.onSelectAll!(all ? const [] : selectIds),
),
),
],
);
}
}
/// `Select all · 5 orders` / `3 of 5 selected · Clear`
///
/// ── Why this is not a button any more ──
///
/// It has been two things, and both were the same mistake at different sizes: a
/// tinted full-width strip with a checkbox on the run, and a tonal
/// `Select all 5 orders` button in the kitchen group. Either way it was **a
/// filled control roughly the size of the journey action**, sitting one line
/// above it, for a helper that only ever leads to that action. A rider scanning
/// the group saw two slabs and had to read both to find out which one was the
/// thing to do.
///
/// So it loses its surface. What is left is a compact 40pt text action — a
/// small state glyph, the words, and nothing behind them — which is the weight
/// a *means to an action* should carry next to the action itself. It costs
/// ~14pt where the button cost 60, and the tap target is the whole row.
///
/// The wording carries the state rather than a second control doing it: at rest
/// it offers, part-way through it counts, and full it clears. A separate
/// `ALL`/`CLEAR` affordance on the right was one more thing to aim at for a
/// gesture the row already performs.
class SelectAllAction extends StatelessWidget {
final int total;
final int picked;
/// `order` on a kitchen group, `stop` on a parcel run — the same control
/// naming whatever it is gathering.
final String noun;
/// True inside a place's own panel, where the card above already states the
/// count and the scope. False at the run head, which has neither.
final bool scoped;
final VoidCallback onTap;
const SelectAllAction({
super.key,
required this.total,
required this.picked,
required this.onTap,
this.noun = 'order',
this.scoped = false,
});
/// The state glyph. It changes **shape**, not only colour — an empty box, a
/// half-filled one, a tick — so sunlight and a grayscale screen both survive
/// it where a red-vs-grey tick survives neither.
Widget _glyph(bool all, bool some, Color accent) => AnimatedSwitcher(
duration: DesignConstants.motionState,
child: Icon(
all
? LucideIcons.circleCheck
: some
? LucideIcons.circleMinus
: LucideIcons.circleCheck,
key: ValueKey(all ? 'all' : (some ? 'some' : 'none')),
size: 18.sp,
color: accent,
),
);
@override
Widget build(BuildContext context) {
final all = picked == total;
final some = picked > 0 && !all;
final live = some || all;
final plural = total == 1 ? noun : '${noun}s';
// ── The count is dropped from the offer, kept in the answer ──
//
// `Select all · 5 orders` shares a row with the journey action inside a
// kitchen's panel, and the pair does not fit — it ellipsised to
// `Select all · 5 …`, which is a control that has lost the number it was
// there to state.
//
// The number was already redundant *there*: the card says `5 orders · 5
// bags` twenty points above, and the control sits inside the counter it
// applies to, so its scope cannot be misread. The run-level version, which
// has no card above it to state scope, keeps the count.
//
// Once he has ticked something the counts come back, because at that point
// they are the answer rather than the offer: how many, out of how many.
// ── The three states of the offer, and each states its number ──
//
// Resting said a bare `Select all` inside a kitchen, on the argument that
// the card two lines above already said `5 orders`. The card is gone, and
// the argument was thin even then: the count is what makes the offer an
// offer — *five* is the thing the rider is agreeing to, and a control that
// makes him look elsewhere for its own object is a control he reads twice.
final label = picked == 0
? 'Select all $total'
: all
? 'All $total selected · Clear'
: '$picked of $total selected';
final Color accent = live
? ColorConstants.primary
: ColorConstants.secondaryText;
return Semantics(
button: true,
selected: all,
label: all
? 'Clear the selection'
: 'Select all $total $plural at this stop',
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Container(
// Above the 44 floor without a fill to make it look like a button.
constraints: BoxConstraints(minHeight: 44.h),
// 10, not 12. The order rows inset their content 12 from *their*
// rail, but the rail this row sits in is 2 wider — the `+2` on
// [PickupTimelineGroup._railWidth] — so matching the number instead
// of the axis left a 2pt drift. The axis is the thing being matched;
// `home_grid_test.dart` measures it so the two cannot part again.
padding: EdgeInsets.only(left: scoped ? 10.w : 0),
child: Row(
// ── Inside a panel it closes the row; at the run head it opens it ──
//
// Scoped, this control heads a list of orders that are themselves
// right-aligned to the panel edge, and it sat alone on the left
// with the whole width of the panel of empty space after it. A
// control with nothing beside it should sit on an edge, and the
// right one is where a list's own controls live.
//
// Unscoped — the run head — it is the first thing on the screen
// above the route and keeps the reading edge.
// ── Both versions read from the left ──
//
// The scoped one was pushed to the right edge, on the argument
// that a control with nothing beside it should sit on an edge and
// that a list's own controls live on the right. That was an
// argument about a *panel* — it had a right edge to sit on, and
// the orders were inside it.
//
// On the open ground there is no panel, and the right edge now
// belongs to the bag column. A select-all floating out there reads
// as a fourth thing in the Bag axis. It heads the list it selects,
// so it starts where the list starts: the same left axis the
// customer names below it keep.
mainAxisAlignment: MainAxisAlignment.start,
children: [
// ── One rail, not two ──
//
// Measured, not guessed: the scoped row sits inside `_railed`,
// which already spends the rail width on it — so reserving a
// second rail *here* put the label 36pt right of the customer
// names below it. Two corrections for one indent, and the row
// that heads the list ended up further in than the list.
//
// The mark is inline and the caller owns the indent, which is
// the same arrangement every other railed row on this screen
// uses. `home_grid_test.dart` measures the axis so the pair
// cannot drift apart again.
// The mark is drawn by the rail on a scoped row — see
// [PickupTimelineGroup._selectAllMark] — so only the run-head
// version, which has no rail, carries it inline.
if (!scoped) ...[_glyph(all, some, accent), SizedBox(width: 8.w)],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: MilerType.label.copyWith(
fontWeight: FontWeight.w700,
color: accent,
),
),
),
],
),
),
),
);
}
}
/// One pickup place: a header that is always a header, and the orders under it.
class PickupTimelineGroup extends StatelessWidget {
final RouteGroup group;
/// 1-based position in the run — the `01` on the header.
final int ordinal;
final bool expanded;
/// The group the rider is working. Only this one carries an action.
final bool isCurrent;
/// Suppresses the connector running out of the bottom of the last group.
final bool last;
/// True while *any* order on the run is ticked.
///
/// Deliberately the run's state and not this group's: a rider who has ticked
/// three orders at one counter and scrolls to the next expects the same
/// controls there, and a select-all that appeared per-group would leave him
/// looking at a group that offers nothing while he is plainly mid-selection.
final bool selectionMode;
final Set<String> selectedIds;
final Set<String> busyIds;
final VoidCallback onToggle;
final ValueChanged<List<String>>? onSelectAll;
final ValueChanged<String>? onToggleSelect;
final ValueChanged<Map<String, dynamic>>? onStopTap;
final ValueChanged<Map<String, dynamic>>? onNavigate;
final ValueChanged<Map<String, dynamic>>? onCall;
final ValueChanged<RouteGroup>? onPreview;
final ValueChanged<Map<String, dynamic>>? onContinue;
final ValueChanged<Map<String, dynamic>>? onUnreject;
const PickupTimelineGroup({
super.key,
required this.group,
required this.ordinal,
required this.expanded,
required this.isCurrent,
required this.last,
this.selectionMode = false,
this.selectedIds = const {},
this.busyIds = const {},
required this.onToggle,
this.onSelectAll,
this.onToggleSelect,
this.onStopTap,
this.onNavigate,
this.onCall,
this.onPreview,
this.onContinue,
this.onUnreject,
});
/// Orders in this group the rider may tick right now.
///
/// A selection that spans two rungs makes the bar fall back to Accept, so the
/// control only ever gathers the rung the group is on.
List<String> get _choosableIds => [
for (final (i, s) in group.stops.indexed)
// The stop's **own** rung, not the group's. Gating on the group's would
// sweep a pending order into an accepted counter's selection.
if (stopIdOf(s).isNotEmpty &&
group.stopStates[i] == group.state &&
_isChoosable(group.stopStates[i]))
stopIdOf(s),
];
static bool _isChoosable(StopState state) =>
state == StopState.pending ||
(ServiceProfile.active.handsOffAtCollection &&
(state == StopState.accepted || state == StopState.active));
/// The one group the rider is working, given a ground of its own.
///
/// ── Why a wash and not a card ──
///
/// "Which of these is my current mission" was being carried by a chevron
/// rotation and two points of type size, and neither survives a glance at
/// arm's length. The obvious fixes are both wrong for this screen: a card
/// around the active group brings back the border, the shadow and the two
/// gutters this page spent three redesigns removing, and a red left-edge bar
/// spends the brand on a *label* rather than on an action.
///
/// The expanded orders get a ground of their own.
///
/// This is what makes open and shut read as two different things rather than
/// as the same card at two heights. It is a tonal field *inside* the card,
/// under the steps only — so the header stays on the card's own white and the
/// orders read as contents rather than as more header.
///
/// **No horizontal padding.** Insetting it would push the spine right on the
/// open group alone, so numbered nodes would step in and out as the rider
/// expanded one — and one vertical axis for every node is the contract
/// `stop_row_alignment_test.dart` exists to hold. (It bled 8pt each side for
/// one build, via a negative margin. `Container.margin` goes through
/// `Padding`, which asserts non-negative: the whole group rendered as a
/// `RenderErrorBox`.)
bool get _grounded => expanded && !group.flat && !group.isSettled;
@override
Widget build(BuildContext context) {
final manifest = group.manifest;
final chosen = _choosableIds.where(selectedIds.contains).length;
// ── The pickup place is a card again, and this time it earns it ──
//
// The route spent three redesigns getting *out* of cards, and the reasoning
// held: a card per **order** is 138pt of frame around one line of content,
// and five of them off one counter did not fit a viewport.
//
// ── And now the place is not a card either ──
//
// A card per *place* was the right correction to a card per *order*, and
// it is still the wrong object. The thing being grouped here is a stop on
// a route, and a route already has a way of showing that two things belong
// together: the spine, the node, the indent under it. Drawing a white
// rounded rectangle around all of that says the same thing a second time
// in a louder voice — and then the orders needed a *tinted* rectangle
// inside it to separate themselves from their own container, which is the
// point at which the furniture is arguing with itself.
//
// So: no surface, no border, no shadow, no radius. The group is the spine
// plus whitespace plus type, and the orders sit directly on Home's own
// daylight ground. What used to be conveyed by an edge is conveyed by the
// node the header hangs off and by the indent the orders keep to.
return Padding(
padding: EdgeInsets.only(bottom: 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: group.flat
? (onStopTap == null
? null
: () => onStopTap!(group.allStops.first))
: onToggle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [_header(context, chosen), _locationActions(context)],
),
),
if (!group.flat)
// Height and opacity together, on the app's own state duration.
// A group opening is a state change in place, so it uses the
// token that names that — not a spring, and not a curve
// invented here.
AnimatedCrossFade(
firstChild: const SizedBox(width: double.infinity, height: 0),
secondChild: _body(context, manifest, chosen),
crossFadeState: expanded
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: DesignConstants.motionState,
sizeCurve: Curves.easeOutCubic,
),
],
),
);
}
/// ── Actions on the *place*, on the card, whether or not it is open ──
///
/// Navigate and Call are both answers to "I am going to this counter", and
/// neither has anything to do with any one order. They used to live inside the
/// expanded body, which meant a rider who wanted to ring a kitchen had to
/// first open a list of five customers he was not looking for — a location
/// action behind an order-level disclosure.
///
/// So they sit on the card, under the identity, and stay there collapsed. One
/// of each per place, never one per row: five Navigate buttons pointing at the
/// same counter was the single largest source of clutter on the old screen,
/// and five Call buttons dialling the same number would be the same mistake
/// with a different glyph.
///
/// The pair is weighted: Navigate is the filled brand action and hugs its
/// label; Call is a bordered icon button at the far right, 48pt square, which
/// is discoverable without competing with the destination above it.
Widget _locationActions(BuildContext context) {
final phone = stopPhoneOf(group.stops.isEmpty ? {} : group.stops.first);
final showCall = onCall != null && phone.isNotEmpty && !group.isSettled;
// A flat stop's card tap already opens its detail sheet, so an info button
// beside it would be the same gesture twice.
final showInfo = !group.flat && onPreview != null && !group.isSettled;
// Under way, with nothing on this screen able to move it on: say where it
// *is* worked. A sentence, not a button — a control that cannot be pressed
// is not a status, and this is the one case with nothing to offer but
// wayfinding.
if (group.isLive && !_isChoosable(group.state) && onContinue == null) {
return Padding(
padding: EdgeInsets.fromLTRB(_railWidth, 0, 14.w, 14.h),
child: Row(
children: [
Icon(LucideIcons.bike, size: 16.sp, color: ColorConstants.primary),
SizedBox(width: 8.w),
Flexible(
child: Text(
'In progress — finish it in '
'${ServiceProfile.active.workTabLabel}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.label.copyWith(
fontWeight: FontWeight.w700,
color: ColorConstants.primary,
),
),
),
],
),
);
}
// ── A flat stop keeps its journey action on the card ──
//
// The action moved into the dropdown so that only the *open* place shows
// one. A flat stop has no dropdown — the card is the stop — so moving it
// there took `Continue pickup` off a parcel route's live row entirely,
// which is the only way back into a stop the rider is standing in.
// `active_stop_row_test.dart` caught it.
// The journey action belongs to the *place*, so it belongs on the place's
// own action row — for a kitchen as much as for a flat stop. It used to be
// flat-only, with kitchens carrying theirs inside the orders panel; see the
// note on [_railRow] for why that was the wrong container.
final journey = _journeyAction();
final state = _chip;
if (journey == null && state == null && !showCall && !showInfo) {
return const SizedBox.shrink();
}
// ── Both utilities in the right corner, in a fixed order ──
//
// Call, then details. They are the two things a rider does *about* a place
// without going to it, they are the same size, and they sit in the same
// corner on every card in the run — so reaching either is muscle memory
// rather than a search.
//
// Right-aligned by `MainAxisAlignment.end` rather than by a `Spacer`: with
// nothing flexible beside them the two are equivalent, and `end` cannot be
// broken later by adding a `Flexible` sibling that would silently start
// splitting the free width with it.
// ── The utilities sit on the header's own right margin ──
//
// They were inset 14 while the header content above them uses 6, so the two
// circles hung ~8pt further from the card edge than everything they sit
// under — and a circle reads as further in again, because its silhouette
// curves away from the margin where a straight edge would meet it. The
// result was a ragged right side on the one column of the card that is
// supposed to be a fixed landmark.
// ── One row at reading scale, two at accessibility scale ──
//
// The state word, a labelled button and two 48pt circles cost ~300pt of a
// ~314pt row. That is a good trade at 1.0× and impossible at 2.0×, where it
// overflowed a 320pt phone. Rather than truncate the primary action, the
// state lifts onto its own line above the controls — the same threshold the
// header's journey column uses, and height is the cheaper thing to spend
// once type is already large.
final wide = MediaQuery.textScalerOf(context).scale(14) <= 14 * 1.35;
// ── Exactly one flexible thing per row ──
//
// The row held three flex children — the state word, a `Spacer` and the
// button — each with flex 1, so they split the free width in thirds. The
// button was handed ~67pt of the ~130 it needs and collapsed to a bare
// icon, while "Accepted" clipped to "Accepte". Three controls fought over
// space the row actually had.
//
// Wide: the button takes its natural width and the **state** absorbs what is
// left, because a state word is the one thing here that can ellipsise
// without losing its job. Stacked: the button is the flexible one, since it
// is alone on its line with nothing to take space from.
final controls = <Widget>[
if (journey != null) ...[
_CircleAction(
icon: group.isLive ? LucideIcons.arrowRight : LucideIcons.navigation,
color: ColorConstants.primary,
filled: true,
label: group.isLive
? 'Continue this stop'
: 'Navigate to this pickup',
onTap: journey,
),
SizedBox(width: 8.w),
],
if (showCall)
_CircleAction(
icon: LucideIcons.phone,
color: ColorConstants.acceptGreen,
label: 'Call this pickup location',
onTap: () => onCall!(group.stops.first),
),
if (showCall && showInfo) SizedBox(width: 8.w),
if (showInfo)
// ── "i", not "⋯" ──
//
// A three-dot menu promises a *list of choices* and there is one thing
// behind this: where the place is and what is waiting there. An overflow
// glyph that opens a sheet rather than a menu is a small lie the rider
// pays for every time he taps it expecting options.
_CircleAction(
// The storefront, not an "i". This circle answers "about this
// place" — the map to it and the manifest at it — and the
// storefront is already the app's glyph for a pickup place on the
// Activity row and the record's route. An info glyph is the one
// icon that names no subject; the place's own mark does.
icon: LucideIcons.store,
color: ColorConstants.onSurfaceVariant,
label: 'Pickup details and map',
onTap: () => onPreview!(group),
),
];
return Padding(
// Zero on the right: the utilities finish flush with the card's own edge.
// This is the limit — the next point out is outside the card.
padding: EdgeInsets.fromLTRB(_railWidth, 0, 0, 12.h),
child: wide
? Row(
children: [
// ── State left, actions right ──
//
// One row, read left to right as a sentence about the place:
// what it is, then what he can do about it, ordered by weight —
// go there, ring it, read it. Nothing in this row is about which
// bags he has ticked.
Expanded(child: state ?? const SizedBox.shrink()),
...controls,
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (state != null) ...[state, SizedBox(height: 10.h)],
// `end`, not a `Spacer`: a Spacer is an `Expanded` and would
// split the free width with the button's own `Flexible`,
// squeezing the primary action until it overflowed — 3pt, on a
// 320pt phone at 2.0×.
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: controls,
),
],
),
);
}
/// What the card's journey control does — ride there, or get back into the
/// stop he is already inside. Null when the group has neither to offer.
///
/// ── Why this stopped returning a button ──
///
/// It was a labelled `MilerButton` sitting beside two 48pt circles, and the
/// four things on that row — state, button, call, details — could not share
/// 314pt: the state clipped to "Accepte" and the button collapsed to a bare
/// icon. Widening one starved another.
///
/// The fix is not more space, it is fewer languages. The three controls are
/// the three things a rider does *about a place*, so they are one cluster of
/// equal circles — the journey filled in the brand because it is the primary,
/// the other two tonal. That is what the row was reaching for and could not
/// say while one of the three was a different shape.
///
/// The arrow carries it without a word: it is the navigation glyph, it is the
/// only filled control on the card, and the label survives in full on the
/// preview sheet the "i" opens.
VoidCallback? _journeyAction() {
final bool choosable = _isChoosable(group.state);
// ── The primary action on an accepted place is the journey ──
//
// This opened the preview sheet instead, on the argument that handing off
// means the rider *leaves Miler* — route, counts and manifest all behind
// another app — so he should see the leg and the load before committing.
// The reasoning is sound and the placement was not: it put the one thing
// the card exists to offer, once the work is his, behind a bottom sheet. He
// has accepted; the decision is made; what is left is to ride there.
//
// The preview is not lost — it is what the "i" beside this opens, which is
// the control that promises a look at a place rather than a move to it.
if (group.state == StopState.accepted && onNavigate != null) {
return () => onNavigate!(group.stops.first);
}
// The stop under way on a parcel route: back into the flow he is inside.
if (group.isLive && !choosable && onContinue != null) {
return () => onContinue!(group.allStops.first);
}
return null;
}
/// The rail column's width, so everything hanging under the header lines up
/// with the content beside the node rather than with the node itself.
static double get _railWidth => _Spine.width + 2;
// ── The header ───────────────────────────────────────────────────────────
//
// Everything needed to understand the stop and nothing else. The customer,
// the address, the phone and the parcel counts that used to be on five cards
// are one tap away in the steps and the sheet; what belongs here is where,
// how many, how far, what state, and — on the one group he is working — what
// to do next.
/// The load: what is here, and how much of it is not his yet.
///
/// A settled group has nothing left to hand over, so `0 orders · 0 bags`
/// would be arithmetic about an empty set. It states what happened instead.
String _loadLine(int chosen) {
final rejected = group.flat && group.state == StopState.rejected;
if (rejected) {
// A declined stop says so where its counts would be: it is not travelling
// in a bag. It stays on the run, struck through, so the rider can see he
// handled it rather than wondering where it went.
return 'Rejected';
}
if (group.isSettled) {
final n = group.allStops.length;
return '$n ${n == 1 ? 'bag' : 'bags'} collected';
}
return <String>[
// ── A flat node is a stop, and a stop has a position ──
//
// `1 order · 1 bag` says nothing about a single-order node, and a parcel
// pickup does not travel in a bag at all. What names it is where it falls
// in the run — the trip's own index, which does **not** renumber as stops
// finish, so a stop is never renamed mid-shift.
if (group.flat) 'Stop ${group.firstIndex + 1}' else group.countLabel,
// How much of this counter is not his yet. A group can hold one accepted
// order and one still pending, and the count above cannot say so alone.
if (!group.flat &&
group.pendingCount > 0 &&
group.pendingCount < group.stops.length)
'${group.pendingCount} to accept',
// ── The selection count is not stated here ──
//
// It used to append `· 5 selected`, which made this the **third** place
// the same number appeared: the select-all row directly beneath says
// "All 5 selected · Clear" and the floating bar says "Accept · 5". With a
// state chip now leading the line it also no longer fits — the tail
// ellipsised to `5 orders · 5 bags · 5…`, so the one copy that truncated
// was the redundant one.
//
// Involvement is still shown, in the register that costs no width: the
// whole line turns brand red while anything here is ticked.
].join(' · ');
}
Widget _header(BuildContext context, int chosen) {
final rejected = group.flat && group.state == StopState.rejected;
// ── Two columns at reading scale, one at accessibility scale ──
//
// The right-hand journey column is worth a third of the row's width, and at
// 1.0× that is a bargain: it buys a fixation-free read and a scan column
// down the run. At 2.0× on a 320pt phone the same column is most of the
// screen, and the composition inverts — the kitchen name, which is the one
// thing that must never truncate, ellipsised to "Vid…" while a distance sat
// beside it in full.
//
// So past ~1.35× the layout *changes shape* rather than being squeezed into
// the same shape: the journey folds into the load line as a tail, where it
// can ellipsise last instead of the destination ellipsising first. This is
// the responsive break, and it is set on text scale rather than on width
// because the pressure comes from type, not from the device.
final scaled = MediaQuery.textScalerOf(context).scale(14);
final twoColumn = scaled <= 14 * 1.35;
final journey = rejected || group.isSettled ? '' : group.journeyLabel;
final showJourney = journey.isNotEmpty && twoColumn;
final load = [
_loadLine(chosen),
if (journey.isNotEmpty && !twoColumn) journey,
].join(' · ');
final bool tickable =
group.flat && _isChoosable(group.state) && onToggleSelect != null;
final bool ticked =
tickable && selectedIds.contains(stopIdOf(group.stops.first));
// ── One gesture, one meaning ──
//
// The header's tap used to *select* on a flat group and *expand* on a
// grouped one, so the same object did two different things depending on
// data the rider cannot see. Worse, on a kitchen the only way to reach the
// orders was a tap that, one route later, would tick something.
//
// Now the header is unambiguously **disclosure**: it opens and closes the
// place. Selecting is a separate control on the order's own node, and
// opening details is the order row itself. Three responsibilities, three
// targets, none of them overlapping. A flat group has nothing to disclose,
// so its header opens the detail sheet — which is the same promise the
// chevron on it makes.
// ── The header's label wraps the *content*, not the rail ──
//
// It wrapped the whole row with `excludeSemantics: true`, which drops every
// descendant's semantics — and the rail is now a control in its own right.
// A flat stop's tick therefore announced nothing at all: the one node on the
// screen a reader most needs to find had been absorbed into the sentence
// describing the row it sits beside.
// ── The card opens the place; the chevron opens the list ──
//
// Tapping the header used to expand. That made the *whole* card a
// disclosure control and left no way to ask the obvious question — "where
// is this and what am I picking up?" — without first reading a list of
// customers.
//
// So the two are separated by where you touch. The card body opens the
// **pickup preview**: the leg drawn from where he is standing to the
// counter, and the manifest waiting on it. The chevron, which is the one
// element that has always looked like a disclosure, opens the orders. Each
// affordance now does the thing it looks like it does.
// ── And the chevron is a *sibling* of that body, not a child of it ──
//
// It began nested inside the header's own `InkWell`, and the tap went
// nowhere: two tap recognizers over the same pixels, and the arena resolved
// to neither — so the one control on the card that looks like a disclosure
// did nothing at all. Laid out side by side, every pixel belongs to exactly
// one gesture and there is no arena to resolve.
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// A flat group's node doubles as its selection control — it is a
// stop, so ticking it is ticking the order.
_Spine(
node: _GroupNode(
state: group.state,
ordinal: ordinal,
current: isCurrent,
selected: ticked,
),
onNodeTap: tickable
? () => onToggleSelect!(stopIdOf(group.stops.first))
: null,
nodeSelected: ticked,
nodeSize: _GroupNode.size,
// The card's own lead-in (14) plus half the name's line box —
// `cardTitle` at 19sp for the group being worked, `body` at 16 for
// the rest — so the disc sits on the name's centre line rather than
// four points above it.
centre: 14.h + (isCurrent ? 11.5.h : 10.h),
lineAbove: false,
// ── The thread starts at the orders, not at the header ──
//
// Running it down from the place node meant it crossed the
// action row, where it appeared beside a button as a stray red
// stub with nothing at either end. The card is what groups the
// place with its orders now; the spine's job is narrower — it
// threads the *steps* — so it begins where they do.
lineBelow: false,
color: _lineColor,
),
Expanded(
child: InkWell(
// The body opens the **pickup preview** — where this place is and
// what is waiting on it — which is the question the card is for.
// Disclosure is the chevron's job beside it.
// ── The card is the disclosure ──
//
// It briefly opened the pickup preview and left disclosure to a
// chevron beside it. That put the thing the rider does most —
// look at what is at a counter — behind a 30pt disc, and the
// card's whole surface behind a sheet. Tapping a place shows its
// orders; the preview has its own "i" in the corner, where it
// does not compete for the same pixels.
onTap: group.flat
? (onStopTap == null
? null
: () => onStopTap!(group.allStops.first))
: onToggle,
child: Semantics(
button: true,
expanded: group.flat ? null : expanded,
// The two columns are read as one sentence, in the order a
// rider needs them: where, how much, how far. Unaffected by
// the two-column break — a reader hears the same three facts
// either way.
label: [group.name, load, if (showJourney) journey].join(', '),
// The non-flat hint said "Opens the pickup preview" — a
// sentence about a tap this header stopped owning when the
// preview moved to the utility circle. A wrong hint is worse
// than none: a reader acts on it.
hint: group.flat
? 'Opens stop details'
: (expanded ? 'Hides its orders' : 'Shows its orders'),
excludeSemantics: true,
child: Padding(
// ── The card has a right margin too ──
//
// This was 0 on the right, so the chevron sat flush against
// the card's edge — the one element on the header with
// nothing between it and the outside. A card whose content
// touches its own boundary reads as clipped rather than as
// laid out, and it broke the symmetry with the 14 the action
// row below already used.
// 6 → 2 on the right. The journey column and the utilities
// below it are the card's right-hand landmark, and at 6 they
// sat further in than anything else on the card — a margin
// that reads as a gap rather than as alignment. They move out
// together so the column stays one line.
// The journey column keeps its own 6. It is *type*, and type
// right-aligns on its glyphs — pushed to the card's edge it
// would read as falling off. The utility circles below it go
// flush instead: a disc's silhouette curves away from the
// margin, so it needs to overshoot a text edge by a few points
// to look level with it. Optical alignment, not metric.
padding: EdgeInsets.fromLTRB(2.w, 14.h, 6.w, 12.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// ── Name, then its state ──
//
// "Vidhya Kitchen" and "Accepted" are one statement,
// so they are set as one and ellipsise together.
//
// This pairing cost the name ~70pt of a ~260pt row
// once, and rendered it as "Vid…" — but that was
// while the journey column was a `Flexible` taking
// half the row. Against a fixed 84 block the line is
// name + chip + 84, so the name holds ~170pt at the
// design width and the load below gets the full
// column instead of ellipsising to `5 b…`.
Row(
children: [
Flexible(
child: Text(
group.name,
// Two lines before it gives anything up.
// The header's own doctrine is that the
// destination is the one thing that must
// never truncate, and one line broke it on
// the first real route: "Gandhipuram Main
// Rd" rendered as "Gandhipuram Main…" —
// the words that name *which* road cut off
// by a rule meant to protect them. Real
// Indian place names are long; the row can
// afford a wrap, it cannot afford a wrong
// guess at a junction.
maxLines: 2,
overflow: TextOverflow.ellipsis,
// ── The active place is bigger than the
// ones behind it ──
//
// Every group used to be set at one size, so
// a five-kitchen route was five equal
// headings and the rider had to work out
// which one he was riding to. The group he
// is on takes the app's `cardTitle`; the
// rest take `body`. Hierarchy that follows
// *state* rather than position is the whole
// reason a route can be read without
// reading every line.
style:
(isCurrent
? MilerType.cardTitle
: MilerType.body)
.copyWith(
fontWeight: FontWeight.w700,
color: group.isDone || rejected
? ColorConstants.secondaryText
: ColorConstants.slateText,
decoration: rejected
? TextDecoration.lineThrough
: null,
),
),
),
],
),
// ── The state is not here ──
//
// It had its own line between the name and the load,
// and that line only existed when there *was* a
// state — so an accepted card stood ~18pt taller
// than a pending one, and a run of three kitchens
// stepped up and down as the rider worked it. A card
// that changes height on a state change is a card
// whose controls move under the thumb.
//
// It sits on the action row below instead, in space
// that row was already spending on a `Spacer`. Same
// reading order — identity, load, state — and the
// geometry is now identical in every state.
SizedBox(height: 4.h),
Row(
children: [
Flexible(
child: Text(
load,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// Level 3, and readable: the load is what
// he counts against a shelf, so it is set
// in the page's mid-tone rather than the
// pale grey reserved for things nobody has
// to act on.
style: MilerType.label.copyWith(
fontWeight: FontWeight.w600,
color: chosen > 0
? ColorConstants.primary
: ColorConstants.onSurfaceVariant,
),
),
),
],
),
],
),
),
// ── The journey is a column, not a fourth line ──
//
// Distance and ETA were stacked under the load as a third
// and fourth row of small grey text, which is how they
// came to read as metadata: everything in that block was
// left-aligned, so the eye had to walk down four lines and
// parse each one to find the two numbers that answer "how
// far".
//
// They are their own right-hand column now, at the same
// baseline as the name they belong to. Three things follow
// from that and all three are the point:
//
// • **It reads in one fixation.** Identity on the left,
// journey on the right, at a size that carries — the
// same split every mature transit and dispatch product
// lands on, because it is the split the rider's
// question already has.
// • **It removes two rows.** The node is two lines
// instead of four, so a second kitchen fits in the same
// viewport.
// • **It forms a scan column.** Down a multi-kitchen run
// the distances line up on one right edge, so "which is
// nearest" is answered by looking down a column rather
// than reading five blocks.
if (showJourney) ...[
SizedBox(width: 6.w),
_JourneyColumn(
distance: group.meters != null && group.meters! > 0
? RouteMetricsHelper.formatDistance(group.meters)
: null,
ride: group.rideTime > Duration.zero
? '~${RouteMetricsHelper.formatDuration(group.rideTime)}'
: null,
prominent: isCurrent,
),
],
// ── The disclosure says it is one ──
//
// The chevron was removed when the header became the
// toggle, and its *signal* went with it: a collapsed
// place showed nothing that said "there is a list under
// me", so the orders were discoverable only by accident
// — on the control the rider uses more than any other
// on this screen.
//
// It returns as an INDICATOR, not a target. The old bug
// was two tap arenas over one pixel; this glyph is
// passive inside the header's own InkWell, so there is
// exactly one gesture and it owns the whole card. It
// rotates with the state (the app's state duration, no
// bounce), and a flat stop — whose tap pushes the stop
// detail instead — wears the pointing variant, the same
// vocabulary every row of Account uses for "this goes
// somewhere".
SizedBox(width: 4.w),
group.flat
? Icon(
LucideIcons.chevronRight,
size: 20.sp,
color: ColorConstants.borderStrong,
)
: AnimatedRotation(
turns: expanded ? 0.5 : 0,
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
child: Icon(
LucideIcons.chevronDown,
size: 20.sp,
color: expanded
? ColorConstants.secondaryText
: ColorConstants.borderStrong,
),
),
],
),
),
),
),
),
// The UNDO that used to sit here is gone with the row it sat on: a
// declined stop files straight to Activity now rather than staying on
// Home waiting to be un-declined. See `TripCard._showsOnHome`.
],
),
);
}
/// The state, as a word, only once it is worth saying.
///
/// A pending group has no chip: "Pending" on every group on a fresh morning
/// is a mark repeated down the page that separates nothing. The chip appears
/// when the group starts moving.
///
/// ── A word, not a pill, and on the load line ──
///
/// It has been in three places, and the first two both failed by *taking
/// width from something that mattered more*:
///
/// • **Line one, after the name.** A pill is its text plus 18pt of padding —
/// ~106pt for "Accepted" — and it is unflexed, so the destination gave way
/// to it. "Vidhya Kitchen" rendered as "Vid…".
/// • **Line two, before the load.** Same 106pt, taken from
/// `5 orders · 5 bags`, which ellipsised to `5 orders · 5 b…` — a state
/// pill truncating the one fact that tells the rider how many bags to
/// count off a shelf.
///
/// It was also briefly *removed* from the current group, on the grounds that
/// the tonal ground, the lit node and the Navigate button already say it. Two
/// of those are colour and the third is a button whose presence depends on a
/// callback, so a route rendered without navigation stated the rung nowhere a
/// screen reader or a grayscale screen could reach. `service_flow_test.dart`
/// caught that, correctly.
///
/// So: the same word, at half the width, leading the load line — `Accepted ·
/// 5 orders · 5 bags` reads as one statement about this counter, which is
/// what it is. Line one is the destination and its journey, and nothing else
/// competes for it.
///
/// [LiveMark] keeps its filled form. It is the one state the rider must not
/// miss, and it differs in *shape* from every other mark on the page.
Widget? get _chip => switch (group.state) {
// ── Pending is a state, and it is said ──
//
// It used to render nothing, on the grounds that "Pending" on every group
// on a fresh morning is a mark repeated down the page. That was true while
// the word sat on a line of its own — it cost a row per card to say
// "nothing has happened here". On the action row it costs nothing, and the
// silence was worse than the repetition: a rider scanning a run had to
// infer "not mine yet" from the *absence* of a word, which is the one thing
// a glance cannot do.
// ── Pending is neutral, because nothing has gone wrong ──
//
// It was amber, on the argument that undecided work "wants something" and
// should therefore be visible. The argument is right about visibility and
// wrong about the colour: amber is this app's exception hue — a skipped
// stop, a late run, a bag that never arrived — and on a fresh morning every
// kitchen is pending, so the whole screen opened in warning.
//
// A state that is true of *all* the work is not an exception; it is the
// starting condition. Spending the attention colour on it leaves nothing
// to say with when a stop genuinely does go wrong, and a rider who sees
// amber on every group learns to stop reading it. Neutral, and the reason
// it is still readable is that it sits on its own metadata line rather
// than competing with the kitchen name.
StopState.pending => const _StateWord(
label: 'Pending',
color: ColorConstants.secondaryText,
),
// ── "Accepted" alone does not say how much of the counter he took ──
//
// A place with four orders and one accepted rendered the same word as a
// place with four accepted: the rider had taken a quarter of the counter
// and the card told him only that he had taken *something*. The three he
// still has to decide are in the list under it, but that is a scroll and a
// count, and this word exists precisely so he does not have to do either.
//
// Only where there is more than one order to be a fraction of — a parcel
// route is one order per place, and `Accepted (1 of 1)` is noise.
StopState.accepted => _StateWord(
// Brand only on the group he is riding to; slate on the ones he has taken
// but is not working. Red marks the mission, not the commitment — and it
// is now the only thing on the card that can be confused with amber, so
// the two states read apart at a glance.
// `Accepted (2 of 4)` truncated to `Accepted (2 o…` the moment the row
// also carried Navigate, Call and the "i" — which is every accepted
// group. A state that ellipsises has lost the number it exists to state.
label: group.groupSize > 1
? 'Accepted ${group.onRungCount}/${group.groupSize}'
: 'Accepted',
color: isCurrent
? ColorConstants.primary
: ColorConstants.onSurfaceVariant,
),
// ── The one the rider must not miss ──
//
// [LiveMark] rather than another word in a pill: it is the app's own mark
// for "this is running", it differs in *form* as well as hue — a filled,
// breathing chip among outlined ones — so it survives sunlight and a
// grayscale screen. The stop under way used to be hidden from Home
// entirely and announced by a red banner over the foot of the page.
StopState.active => const LiveMark(),
StopState.collected || StopState.done => const _StateWord(
label: 'Collected',
color: ColorConstants.acceptGreen,
),
// ── The two ends of "not going to happen here" ──
//
// Both used to render nothing, so a skipped stop and a declined one were
// indistinguishable from a pending one on the only line that says what a
// stop is doing. They are different facts and they get different marks:
// amber for work still owed, muted grey for work the rider has closed out.
StopState.skipped => const _StateWord(
label: 'Skipped',
color: ColorConstants.warning,
),
StopState.rejected => const _StateWord(
label: 'Declined',
color: ColorConstants.secondaryText,
),
};
/// The spine, coloured by what the group is doing.
///
/// Three states, and no more: behind him, under way, ahead. A route that
/// tinted every rung differently would be a legend the rider has to learn;
/// what he needs from the line is "where does the live part start".
/// The spine, on the same rule as the node it hangs from.
///
/// This tested `isLive || (isCurrent && accepted)` while [_GroupNode] tests
/// `current && !done`, so a current group the rider had not ticked yet drew
/// a brand node on a hairline-grey line — the mark and its own spine
/// disagreeing about whether this is the group he is on. One rule, read the
/// same way in both places: the current group is lit until it is finished,
/// and then it is green.
Color get _lineColor => group.isDone
? ColorConstants.acceptGreen.withValues(alpha: 0.35)
: isCurrent
? ColorConstants.primary.withValues(alpha: 0.30)
: ColorConstants.borderSubtle;
// ── The body: the journey, the selection helper, then the milestones ─────
Widget _body(BuildContext context, List<BagLine> manifest, int chosen) {
final ids = _choosableIds;
final selectable = _isChoosable(group.state) && onToggleSelect != null;
// ── The orders are a panel *inside* the card ──
//
// The ground ran the full width of the card and squared off against its
// edges, so it read as a band stuck to the bottom of the header rather than
// as the card's contents — the orders looked like they had spilled out of
// the thing that owns them.
//
// Inset on all four sides with the app's inner radius, it is unambiguously
// *in* the card: a panel with the place's name above it. That is one nested
// surface, not "a collection of cards inside a card" — the orders themselves
// stay rows on it, with no frame of their own.
//
// The inset moves the step rail right by [_panelInset], so the order nodes
// no longer share an axis with the place node above them. That is correct
// now: the spine stopped running between them when the card became the thing
// that groups them (see the note at `lineBelow`), so there is no line to
// bend — and a nested panel that ignored its own container's inset would
// read as broken. `stop_row_alignment_test.dart` pins the new relationship.
return Padding(
padding: EdgeInsets.fromLTRB(
_panelInset,
0,
_panelInset,
_grounded ? _panelInset : 0,
),
// ── The well is back, because the ground changed under it ──
//
// The tinted panel behind the orders was removed when Home's rows sat
// directly on a white page — a second rectangle then had nothing to
// separate itself from. The route now stands on a white [MilerPanel],
// which put the open list back to white-on-white: an accordion whose
// open state looked exactly like its closed neighbours, which is the
// flatness being fixed on this screen.
//
// So the open orders sit in a canvas-toned well — the one grey that
// measurably stands on this white (1.29:1) and the same tone the page
// ground already is, so the well reads as the card opening *down to the
// page* rather than as a new object. One nested surface, the ladder's
// permitted inset; the rows on it still have no frames of their own.
child: Container(
decoration: _grounded
? BoxDecoration(
color: MilerSurface.canvas,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
)
: null,
// 8 horizontal, not 10: at 320pt and 2.0× text the two extra points
// per side were the difference between fitting and a 31px overflow —
// the accessibility sweep in `home_structure_test` is the referee.
padding: _grounded
? EdgeInsets.fromLTRB(8.w, 8.h, 8.w, 10.h)
: EdgeInsets.only(top: 4.h, bottom: 8.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── The journey action lives with the orders ──
//
// It sat on the collapsed card, which read well but put the
// screen's loudest control on every place in the run at once —
// three kitchens, three red buttons, none of them the one he is
// working. Inside the dropdown there is exactly one on screen,
// because exactly one place is open: opening a counter *is* the
// rider saying "this is the one", and the ride to it is what he
// does next.
// ── One control row, not two ──
//
// The journey button sat on its own line *above* the select-all, so
// opening a kitchen produced two stacked rows of chrome before the
// first order — and they are the two things a rider does with an
// open counter, which makes them one row. Select-all leads: it is
// what he does first and the quieter of the two. Navigate closes the
// row on the right, where the primary action belongs.
// No spine on this row. It sits above the first order, so a line
// through it is a length of route drawn above node 1 — the thing
// the rail is supposed to start at.
if (_railRow(context, ids, chosen) != null)
_railed(
_railRow(context, ids, chosen)!,
line: false,
// The tick sits where the order nodes sit, so the label lands
// on the same axis as the customer names it gathers.
node: _selectAllMark(ids, chosen),
nodeSize: _StepNode.size,
),
for (final (i, line) in manifest.indexed)
PickupTimelineStep(
line: line,
ordinal: i + 1,
state: group.stateOf(line.orderId),
selected: selectedIds.contains(line.orderId),
busy: busyIds.contains(line.orderId),
selectable: selectable,
// The spine closes inside the card now: the run's continuity is
// carried by the cards themselves, so the last order's connector
// has nothing below it to reach.
first: i == 0,
last: i == manifest.length - 1,
lineColor: _lineColor,
onTap: () => onToggleSelect?.call(line.orderId),
onOpen: onStopTap == null ? null : () => onStopTap!(line.stop),
onUnreject: onUnreject == null
? null
: () => onUnreject!(line.stop),
),
],
),
),
);
}
/// `Select all`, at the head of the orders it selects.
///
/// ── Why the journey action left this row ──
///
/// The two shared a line: a bare text link and a filled maroon button, side by
/// side, inside the panel that holds the orders. Two problems, and the second
/// is the one that made it feel wrong without being nameable.
///
/// • **Two control languages.** A link and a solid button read as belonging
/// to different systems when they sit at the same level.
/// • **They are about different things.** `Select all` is about the *orders*
/// — it is the header of the list under it. `Navigate` is about the
/// *place*: it has nothing to do with which bags he ticks, and it was
/// sitting inside the container for them, as the loudest object in it.
///
/// So they split by subject. The journey joins the call and the details in the
/// card's own action row — the three things a rider does *about a place* —
/// where it is the primary among them. This row keeps only what belongs to the
/// list it heads.
///
/// Null when there is nothing to select, so an empty row never reserves height
/// above the first order.
/// The select-all's tick, drawn in the rail's node column.
Widget _selectAllMark(List<String> ids, int chosen) {
final all = chosen == ids.length && ids.isNotEmpty;
final some = chosen > 0 && !all;
final accent = (all || some)
? ColorConstants.primary
: ColorConstants.secondaryText;
return Icon(
all
? LucideIcons.circleCheck
: some
? LucideIcons.circleMinus
: LucideIcons.circleCheck,
size: 18.sp,
color: accent,
);
}
Widget? _railRow(BuildContext context, List<String> ids, int chosen) {
final selectAll = !group.flat && ids.isNotEmpty && onSelectAll != null
? SelectAllAction(
total: ids.length,
picked: chosen,
scoped: true,
onTap: () => onSelectAll!(chosen == ids.length ? const [] : ids),
)
: null;
if (selectAll == null) return null;
return Padding(
// 2, matching the order tiles below it, so the panel has one right edge.
padding: EdgeInsets.only(bottom: 8.h),
child: selectAll,
);
}
/// The inset that turns the expanded ground into a panel *inside* the card.
static double get _panelInset => 10.w;
/// Anything that hangs under the header on the group's own spine, so the line
/// runs unbroken past it rather than restarting at the next node.
/// [line] false reserves the rail column but draws nothing in it, so a row
/// that sits ABOVE the first order keeps the panel's left edge without
/// putting a length of spine above node 1 — see the note on
/// [PickupTimelineStep.first].
/// [node] draws in the rail's own column, so a row that heads the list can
/// put its mark on the node axis instead of inline — where it would push its
/// own label off the axis the list keeps. See [_railRow].
Widget _railed(
Widget child, {
double? bottom,
bool line = true,
Widget? node,
double nodeSize = 0,
}) => IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Spine(
node: node ?? const SizedBox.shrink(),
nodeSize: nodeSize,
lineAbove: line,
lineBelow: line,
color: _lineColor,
),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(2.w, 0, 0, bottom ?? 0),
child: child,
),
),
],
),
);
}
/// One order, as a milestone.
///
/// ── What is deliberately not on it ──
///
/// The address, the phone, the parcel counts, the ETA and the type. All of it
/// was on the card, and putting it back here would rebuild the card with a line
/// down its left-hand side. What a rider reads off this row is *who* and *which
/// bag* — the two facts he matches against a shelf — and everything else is one
/// tap away in the sheet that already exists.
class PickupTimelineStep extends StatelessWidget {
final BagLine line;
/// 1-based position **within its own pickup group**, which is also its bag
/// position when the kitchen printed no label. Never route-global.
final int ordinal;
/// First order in the group.
///
/// ── The line starts at 1, and ends at the last step ──
///
/// `lineAbove` was hard-coded true, so the first order drew a connector
/// *upward* out of its own node — into the select-all row above it, which was
/// drawing its own segment to meet it. The result was a length of spine
/// hanging above node 1, connecting it to a control rather than to a stop.
///
/// A connector means "there is another stop that way". Above the first one
/// there is not. [last] already closed the bottom end for the same reason;
/// this closes the top.
final bool first;
final StopState state;
final bool selected;
final bool busy;
/// Whether ticking is meaningful on this rung.
final bool selectable;
final bool last;
final Color lineColor;
/// Ticks this order. Reached through the **rail**, not through the row.
final VoidCallback onTap;
/// Opens the order's detail sheet. Reached through the **row**.
final VoidCallback? onOpen;
/// Puts a declined order back on offer. Only ever drawn on a rejected step.
final VoidCallback? onUnreject;
const PickupTimelineStep({
super.key,
required this.line,
required this.ordinal,
required this.state,
required this.selected,
required this.busy,
required this.selectable,
this.first = false,
required this.last,
required this.lineColor,
required this.onTap,
this.onOpen,
this.onUnreject,
});
@override
Widget build(BuildContext context) {
final customer = line.customer.isEmpty ? 'Stop $ordinal' : line.customer;
// The area the bag goes to, not the whole postal address: a rider grouping
// four bags out of one kitchen is deciding on neighbourhoods, and a full
// address would wrap and bury the name above it. First component of the
// drop, which is how these are written — `Gandhipuram, Coimbatore`.
final dropArea = _areaOf(line.stop);
final rejected = state == StopState.rejected;
final canOpen = onOpen != null && !busy;
/// Taken, and not yet worked. The row's whole treatment turns on this: a
/// counter of four with two accepted has to answer "which two" before the
/// rider has finished looking at it.
final accepted = state == StopState.accepted;
// ── Three gestures, laid out side by side ──
//
// The row's whole surface used to open the detail sheet. That gave the
// *least* frequent action on a step — reading an address — the largest
// target on it, while ticking, which a rider does five times per counter,
// was a 44pt rail he had to aim at.
//
// So the row body ticks, and the sheet gets an affordance that looks like
// one: the bag-and-id block with a chevron on it, in the right corner. Each
// is a **sibling**, never nested — two tap recognizers over the same pixels
// resolve to neither, which is how the card's chevron came to be completely
// dead for a build.
//
// ┌────┬───────────────────────┬───────────────┐
// │rail│ Joe Mathew │ Bag 1 › │
// │tick│ (tick) │ DG-1001 │ ← opens the sheet
// └────┴───────────────────────┴───────────────┘
final Widget node = _StepNode(
ordinal: ordinal,
state: state,
selected: selected && selectable,
busy: busy,
selectable: selectable,
);
// ── Each order is its own tile ──
//
// The steps were transparent rows on the group's grey panel, separated by
// nothing but air, which read as one continuous list rather than as five
// things the rider counts one at a time. A white tile per order on that grey
// gives each one an edge without a border, a shadow or any of the weight the
// route spent three redesigns shedding — the panel is the container, and
// these are the items in it.
//
// The rail stays *outside* the tile, on the panel's own ground, so the spine
// still threads them.
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Spine(
node: node,
// The whole rail cell is a second tick target — see [_Spine].
// Silent, because the tile beside it announces the same tick with
// the order's own name on it, and a reader should not meet the same
// control twice on one row.
onNodeTap: selectable && !busy ? onTap : null,
nodeSelected: selected,
announceNode: false,
nodeSize: _StepNode.size,
// See [first]: a connector means "another stop that way", and above
// the first order there is not one.
lineAbove: !first,
lineBelow: !last,
color: lineColor,
),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: 6.h, right: 2.w),
// ── No tile behind an order ──
//
// Three grounds used to run here — brand for selected, slate for
// accepted, white for pending — each on its own rounded tile.
// Every one of them was a rectangle inside the panel inside the
// card, and with both of those gone the tiles are five loose
// shapes on open ground: a list that reads as five objects
// rather than as five steps of one visit.
//
// Both facts are still carried, and by the thing that is already
// per-order: the **node**. Selected is a filled brand disc with
// a tick; accepted is a filled slate disc; pending is an
// outlined ring with its sequence number. Shape and fill, which
// survive sunlight — and the name's weight follows, so the row
// reads decided or undecided without a wash across it.
child: SizedBox(
width: double.infinity,
child: Row(
children: [
// ── The body ticks ──
//
// The commonest action on a step, so it gets the largest
// target: everything left of the bag block.
Expanded(
child: Semantics(
button: true,
checked: selectable ? selected : null,
label: selectable
? 'Select $customer, ${line.bag}'
: '$customer, ${line.bag}',
excludeSemantics: true,
// ── The body selects; the corner opens ──
//
// Selecting is what a rider does to an order row five
// times per counter; reading its address is what he
// does to one of them occasionally. The frequent action
// gets the large target — the whole row — and the
// occasional one gets the block that already looks like
// a way in: the bag, the order id and a chevron in the
// right corner ([_OrderOpenBlock]).
//
// The two are **siblings**, never nested: two tap
// recognisers over the same pixels resolve to neither,
// which is how the card's chevron came to be dead for a
// build.
//
// It falls back to opening only where there is nothing
// to select, so a settled row is never inert.
child: InkWell(
onTap: selectable && !busy
? onTap
: (canOpen ? onOpen : null),
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
child: Padding(
padding: EdgeInsets.fromLTRB(12.w, 9.h, 8.w, 9.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
customer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// ── A child of the kitchen, not a sixth kitchen ──
//
// 15sp w500 in the page's mid-tone keeps a name
// comfortably readable outdoors while letting the
// header lead. Ticking promotes it to full slate,
// because a selected row *is* the thing he is
// acting on.
// Weight is the third channel, after the node's
// shape and the tile's ground: a taken order is
// set at w700 in full slate, an undecided one at
// w500 in the mid-tone. Three channels means the
// distinction survives losing any one of them.
style: MilerType.body.copyWith(
fontSize: 15.sp,
fontWeight: selected || accepted
? FontWeight.w600
: FontWeight.w500,
color: rejected
? ColorConstants.secondaryText
: selected || accepted
? ColorConstants.slateText
: ColorConstants.onSurfaceVariant,
decoration: rejected
? TextDecoration.lineThrough
: null,
),
),
// ── Where the bag is going ──
//
// The row carried the customer and the booking
// reference. The reference is what the office reads
// down a phone once, when something has gone wrong;
// the *drop area* is what the rider is deciding on
// every time he looks at this list — it is how he
// knows whether Joe and Priya are the same trip out
// of the kitchen or opposite ends of the city.
//
// So the id moves to the detail sheet, where the
// rare question lives, and the area takes the line
// under the name. Absent rather than blank when the
// payload carries no drop.
if (dropArea.isNotEmpty) ...[
SizedBox(height: 2.h),
Text(
dropArea,
// ── The area wraps; it does not clip ──
//
// At 2× `Peelamedu` became `SNS…`, which
// is not a shorter answer to "where is
// this going" — it is no answer. Height is
// the cheapest thing on this row to spend,
// and the row is already allowed to grow.
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
color: rejected
? ColorConstants.borderStrong
: ColorConstants.secondaryText,
),
),
],
],
),
),
),
),
),
// The UNDO branch is gone: a declined order leaves Home for
// Activity the moment it is declined, so there is no row
// here to un-decline from. See `TripCard._showsOnHome`.
//
// ── The bag, the id and the chevron are one target ──
//
// "Which bag" and "which order" are the two things a rider
// reads off a step, and "tell me more about this order" is
// the action they lead to — so they are one block and one
// gesture, marked by the chevron that has always meant
// *opens*.
_OrderOpenBlock(
bag: rejected ? 'Rejected' : line.bag,
orderId: line.orderId,
selected: selected,
onTap: canOpen ? onOpen : null,
),
],
),
),
),
),
],
),
);
}
}
/// `Bag 1 ›` over the order's own id — the step's way into its detail sheet.
///
/// One block rather than three loose elements, because they answer one
/// question between them: *which order is this, and show me it*. The chevron is
/// the affordance; the bag and the id are what it is about.
/// The neighbourhood a stop is in. One implementation, shared with the
/// Deliveries queue — see [areaOf].
String _areaOf(Map<String, dynamic> stop) => areaOf(stop);
class _OrderOpenBlock extends StatelessWidget {
final String bag;
final String orderId;
final bool selected;
final VoidCallback? onTap;
const _OrderOpenBlock({
required this.bag,
required this.orderId,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final id = displayOrderId(orderId);
return Semantics(
button: onTap != null,
label: id.isEmpty ? bag : '$bag, order $id',
hint: onTap == null ? null : 'Opens order details',
excludeSemantics: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Padding(
padding: EdgeInsets.fromLTRB(4.w, 8.h, 8.w, 8.h),
child: MediaQuery.withClampedTextScaling(
maxScaleFactor: 1.25,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
// Capped so a kitchen's own long printed label (`DG-100042-A`)
// cannot take the customer's name with it.
//
// ── And the column stops growing before the name does ──
//
// `Bag 1` is a shelf reference: it is *compared*, not read as
// prose, so it does not need to keep pace with a rider's text
// size. Left to scale it took ~150pt of a 320pt row at 2×, and
// the thing that yielded was the customer's name — which is
// the one item on the row the brief says must yield last.
//
// Clamped at 1.25×, which keeps it comfortably legible against
// a physical shelf while handing the rest of the row back to
// the name and the area.
constraints: BoxConstraints(maxWidth: 104.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
// ── One order, one bag ──
//
// Read straight off [BagManifest]: never a quantity, never a
// route-global number. Right-aligned on every row so a
// column of them lines up against a shelf being counted.
// ── It ranks under the name, not over it ──
//
// Drawn at w700 in full slate this sat *above* the
// customer, who is w500 in the mid-tone until he is
// ticked. So on a fresh route — every row undecided,
// which is how the screen opens — the boldest thing on
// every line was a shelf number, and the eye read a
// column of bags before it read a single person.
//
// The reference still needs to be scannable against a
// physical shelf, so it keeps a weight of its own: it
// steps to w600 in the mid-tone, one rung under the
// undecided name, and follows the row up to slate the
// moment the row is taken. The bag never leads; it
// travels with whatever the row currently is.
// ── And it is a tag, because it is a label ──
//
// A bag number is a physical reference — the sticker on
// the shelf — not a sentence, and every delivery app the
// rider carries sets that kind of fact as a small tag.
// The fill is the page's canvas tone, the only grey that
// measurably stands on this white panel (1.29:1), so the
// tag reads as an object without borrowing a border. It
// still ranks under the name: quiet ink at rest, brand
// tint the moment the row is ticked.
Container(
padding: EdgeInsets.symmetric(
horizontal: 7.w,
vertical: 3.h,
),
decoration: BoxDecoration(
// White, because these rows live inside the canvas
// well: layer-1-on-canvas, the same 1.29 step the
// rest of the system stands on. (Canvas-on-canvas
// was invisible the moment the well returned.)
color: selected
? ColorConstants.primary.withValues(alpha: 0.10)
: MilerSurface.working,
borderRadius: BorderRadius.circular(
DesignConstants.radiusLg,
),
),
child: Text(
bag,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.label.copyWith(
fontSize: 12.sp,
fontWeight: selected
? FontWeight.w700
: FontWeight.w600,
color: selected
? ColorConstants.primary
: ColorConstants.secondaryText,
),
),
),
// ── The reference is not on the row any more ──
//
// It is what the office reads down a phone once, when
// something has gone wrong. The line it occupied now
// carries the drop area, which is what the rider is
// deciding on every time he looks at the list. The id is on
// the detail sheet this block opens, and on the Activity
// record — both places the rare question is actually asked.
],
),
),
if (onTap != null)
Icon(
LucideIcons.chevronRight,
size: 18.sp,
color: ColorConstants.borderStrong,
),
],
),
),
),
),
);
}
}
/// The order id, as every screen reads it.
String stopIdOf(Map<String, dynamic> stop) =>
(stop['orderid'] ?? '').toString();
/// The order id as a rider should *see* it, or empty when there is nothing
/// worth showing him.
///
/// ── Why a placeholder is worse than a blank ──
///
/// The meal-run fixture mints ids as `MOCK-M-2001` (see `meal_run_mock.dart`),
/// and those were rendering on the step beside real ones like `AM-2001`. A
/// rider reading an order number off his phone to a hub on the telephone has no
/// way to know which of the two is real — and the one time it matters is the one
/// time something has already gone wrong.
///
/// So a development placeholder shows nothing at all. The row simply carries its
/// bag, which is what it carried before ids existed.
String displayOrderId(String raw) {
final id = raw.trim();
return id.toUpperCase().startsWith('MOCK') ? '' : id;
}
/// The number the rider dials for a pickup place.
///
/// One reader, so the Call control cannot end up offered on a stop that carries
/// no number — a dead button on a card is worse than no button, because the
/// rider only finds out it is dead while standing at a locked shutter.
String stopPhoneOf(Map<String, dynamic> stop) =>
(stop['pickupcontactno'] ?? stop['PickupContactNo'] ?? '')
.toString()
.trim();
// ─────────────────────────────────────────────────────────────────────────
// The spine
// ─────────────────────────────────────────────────────────────────────────
/// The rail cell: a 2pt line and a node held at a fixed distance from the top
/// of its row, so nodes stay aligned whatever height the content beside them
/// turns out to be.
///
/// `IntrinsicHeight` above it is what lets the line know how far to run. It is
/// the one measured layout on the screen and it is cheap here — a row is a line
/// of text and a dot — but it is the reason a step must not grow into a card.
class _Spine extends StatelessWidget {
final Widget node;
final double nodeSize;
final bool lineAbove;
final bool lineBelow;
final Color color;
/// Makes the whole rail cell the selection control.
///
/// ── Why the target lives here and not on the node ──
///
/// The mark is 13pt. Wrapping *it* in an `InkResponse` gives a 13×13 hit area,
/// which is a quarter of the app's own floor and unhittable with a glove on.
/// The rail, though, is 44 wide by the full height of the row — so the cell
/// takes the gesture and the mark is simply what it draws. "Tap the dot" then
/// means "tap anywhere on the rail beside this order", which is what a rider
/// aiming at it actually does.
///
/// Null leaves the cell inert, so a node that is not a control cannot swallow
/// taps meant for the row.
final VoidCallback? onNodeTap;
/// Announced state for [onNodeTap]. Ignored when the cell is inert.
final bool nodeSelected;
/// Whether the rail announces the selection, or leaves it to a larger target
/// beside it. See [_SelectableNode.announce].
final bool announceNode;
const _Spine({
required this.node,
required this.nodeSize,
required this.lineAbove,
required this.lineBelow,
required this.color,
this.onNodeTap,
this.nodeSelected = false,
this.announceNode = true,
this.centre,
});
/// Width of the rail column. Every node is centred in it, so the group node
/// and the step nodes share one vertical axis.
///
/// **Exactly one tap target wide.** It was 30, which was fine while the rail
/// was decoration — but the rail now *is* the selection control for an order,
/// and a 30pt-wide target is one a gloved thumb misses. At 44 it is the app's
/// own floor in the narrow axis and a whole row tall in the other, so "tap the
/// dot" is a gesture the rider can make without looking.
///
/// It also buys the indentation the steps wanted anyway: order rows now start
/// a clear 44 in from the card edge, which is what makes them read as contents
/// of the place above rather than as five more places.
static double get width => ButtonSizes.minTapTarget.w;
/// Distance from the top of a row to the centre of its node.
///
/// One constant no longer works, because the two rows it serves no longer
/// have the same lead-in: a step's text starts 11 into a tile, a place's
/// starts 14 into the card and is set six points larger. Sharing 21 put the
/// numbered disc visibly *above* the kitchen name it belongs to — the kind of
/// four-point drift that reads as carelessness without anyone being able to
/// name it.
///
/// So each row passes the centre of its own first line. `nodeCentre` is the
/// step's, and stays the default.
static double get nodeCentre => 21.h;
/// Where the caller wants the node centred, if not [nodeCentre].
final double? centre;
double get _centre => centre ?? nodeCentre;
/// 1.5, not 2. The line is a *thread* the nodes hang on, and at 2pt in a
/// hairline-bordered page it was the heaviest rule on the screen — it read as
/// a divider that happened to have circles on it rather than as a route.
static const double lineWidth = 1.5;
@override
Widget build(BuildContext context) {
final axis = width / 2;
final half = nodeSize / 2;
return SizedBox(
width: width,
child: Stack(
children: [
if (lineAbove)
Positioned(
left: axis - lineWidth / 2,
top: 0,
height: (_centre - half).clamp(0.0, double.infinity),
width: lineWidth,
child: ColoredBox(color: color),
),
if (lineBelow)
Positioned(
left: axis - lineWidth / 2,
top: _centre + half,
bottom: 0,
width: lineWidth,
child: ColoredBox(color: color),
),
if (nodeSize > 0)
if (onNodeTap != null)
// The control owns the cell; the mark is drawn where it would
// have been anyway, so nothing moves — only the hit area grows.
Positioned.fill(
child: _SelectableNode(
selected: nodeSelected,
onTap: onNodeTap!,
announce: announceNode,
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: _centre - half),
child: SizedBox(
width: nodeSize,
height: nodeSize,
child: node,
),
),
),
),
)
else
Positioned(
left: axis - half,
top: _centre - half,
width: nodeSize,
height: nodeSize,
child: node,
),
],
),
);
}
}
/// The place node: a numbered disc that fills in as the group progresses.
///
/// ── One red node on the screen, at most ──
///
/// Every accepted group used to take the brand ring. On a morning where the
/// rider has accepted his whole route that is five red nodes down the page, all
/// claiming to be the important one — and the answer to "which am I riding to"
/// went back to reading the list. Red stopped meaning anything because
/// everything had it.
///
/// The rule now is that the brand marks **the mission, not the commitment**:
///
/// ```
/// current + live ● filled brand — he is standing there
/// current + accepted ◉ brand ring, core — this is the one he rides to
/// accepted, not current ○ slate ring — his, but not yet
/// pending ○ hairline ring — not decided
/// collected / done ✓ filled green — behind him
/// ```
///
/// So at most one node on the screen is red, and it is the answer to the
/// question the screen exists for. The rest differ in fill and weight, which is
/// what keeps them legible in sun and in grayscale.
class _GroupNode extends StatelessWidget {
final StopState state;
final int ordinal;
/// The group the run is currently on — see [_RouteTimelineState].
final bool current;
/// A flat group's node doubles as its selection control, so it has to be able
/// to show a tick like a step's does.
final bool selected;
const _GroupNode({
required this.state,
required this.ordinal,
required this.current,
this.selected = false,
});
static double get size => 28.w;
@override
Widget build(BuildContext context) {
final done = state == StopState.collected || state == StopState.done;
final live = state == StopState.active;
final taken = state == StopState.accepted;
// ── Exactly one red node on the route, and it is the next one ──
//
// This was `current && (live || taken)`, so the brand only arrived once
// the rider had already committed to the stop. On a fresh route every
// stop is pending — which is how the screen opens, and what a rider looks
// at most of the day — so nothing on it was red and stop 1 was drawn
// exactly like stop 7. The screen answered "what is on today" and not
// "where am I going", which is the question it is opened with.
//
// The mission is the group the run is *on*, decided or not. That gives
// Home one brand anchor, always present and always in one place, which is
// what the colour is for. The other half of the old rule is untouched: a
// stop taken on but not being ridden to stays slate, because committed is
// a statement and not a destination.
final bool mission = current && !done;
final Color accent = selected
? ColorConstants.primary
: done
? ColorConstants.acceptGreen
: mission
? ColorConstants.primary
: taken
? ColorConstants.onSurfaceVariant
: ColorConstants.borderStrong;
final filled = done || live || selected;
// The ring may be a hairline grey, but the number on it is a label the
// rider reads — it takes a readable tone rather than the ring's.
final Color numeral = filled
? ColorConstants.onAccent
: mission
? ColorConstants.primary
: ColorConstants.secondaryText;
return AnimatedContainer(
duration: DesignConstants.motionState,
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled ? accent : ColorConstants.pureSurface,
shape: BoxShape.circle,
border: Border.all(color: accent, width: mission ? 2.5 : 2),
// The one node the rider is riding to lifts off the page. A halo rather
// than a shadow: it is the brand at 12%, so it reads as the node being
// *lit* rather than as another elevated object on a flat screen.
boxShadow: mission && !live
? [
BoxShadow(
color: ColorConstants.primary.withValues(alpha: 0.12),
blurRadius: 6,
spreadRadius: 2,
),
]
: null,
),
child: done || selected
? Icon(LucideIcons.check, size: 14.sp, color: ColorConstants.onAccent)
: Text('$ordinal', style: MilerType.figure(12, color: numeral)),
);
}
}
/// The order node: small, and the selection affordance when one is meaningful.
///
/// There is no permanent checkbox on a step. At rest it is a dot; ticked it is
/// a filled check. The row is the target, so the mark can be small.
///
/// ── Six states, told apart by shape ──
///
/// ```
/// pending ○ hairline ring, hollow
/// selected ✓ filled brand, white tick
/// active ◉ brand ring with a brand core — this one, now
/// collected ✓ filled green
/// skipped ! amber ring, amber bang
/// rejected ✕ grey ring, and the row is struck through
/// ```
///
/// Every one of them differs in **fill or glyph** and not only in hue, which is
/// the difference between a route a rider can read in direct sun and one he has
/// to take his glove off and squint at. Colour agrees with the shape; it never
/// carries a state alone.
class _StepNode extends StatelessWidget {
final int ordinal;
final StopState state;
final bool selected;
final bool busy;
/// Whether ticking this step is meaningful right now. Drives the ring tone —
/// a step that can be chosen looks like an empty control, one that cannot
/// looks like a bullet.
final bool selectable;
const _StepNode({
required this.ordinal,
required this.state,
required this.selected,
required this.busy,
this.selectable = false,
});
/// 16 → 13 → 18 → 22. It shrank to 13 while it was a bare dot, which was
/// right for a mark that said nothing. It carries the step's *number* now, and
/// at 18 the figure inside it was 9sp — below the app's own legibility floor,
/// on the one glyph a rider matches against a printed bag. 22 carries an 11sp
/// numeral and still reads as clearly subordinate to the place node's 28.
static double get size => 22.w;
@override
Widget build(BuildContext context) {
if (busy) {
return Padding(
padding: EdgeInsets.all(1.w),
child: CircularProgressIndicator(
strokeWidth: 2,
color: ColorConstants.primary,
),
);
}
final done = state == StopState.collected || state == StopState.done;
final rejected = state == StopState.rejected;
final skipped = state == StopState.skipped;
final live = state == StopState.active && !selected;
final accepted = state == StopState.accepted;
final Color accent = selected
? ColorConstants.primary
: done
? ColorConstants.acceptGreen
// Settled, not finished. Slate rather than green — green is the app's
// "done" signal and this order has not been collected yet — and it is
// the same ink the name beside it is promoted to.
: accepted
? ColorConstants.onSurfaceVariant
: skipped
? ColorConstants.warning
: live
? ColorConstants.primary
: rejected
? ColorConstants.secondaryText
// A step the rider *can* tick reads as an empty control rather than as
// a bullet: the darker ring is what says "there is a decision here".
: selectable
? ColorConstants.onSurfaceVariant
: ColorConstants.borderStrong;
// ── Accepted and pending were the same mark ──
//
// A counter with four orders where the rider had taken two drew four
// identical hollow rings. The one question a rider asks of an open group —
// *which of these are already mine* — had no answer on the screen at all,
// and he had to remember.
//
// `accepted` joins the filled set. Filled-versus-hollow is a **shape**
// difference, so it survives sunlight, grayscale and colour-blindness in a
// way a second hue never would, and it says the right thing: a filled mark
// down the route means "this one is accounted for".
//
// It keeps its **number**. The numeral is the order's bag identity, matched
// against a printed label on a shelf, and it must not change or disappear
// because the rider accepted it — see [BagManifest]. So accepted is a
// filled disc with a white numeral, not a tick: a tick would claim work
// that has not happened.
final filled = selected || done || accepted;
return AnimatedContainer(
duration: DesignConstants.motionState,
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled ? accent : ColorConstants.pureSurface,
shape: BoxShape.circle,
border: Border.all(color: accent, width: 2),
),
child: filled
? (accepted && !selected
// The bag number survives being accepted. It is what the rider
// matches against the shelf, and a tick here would claim a
// collection that has not happened.
? Text(
'$ordinal',
style: MilerType.figure(11, color: ColorConstants.onAccent),
)
: Icon(
rejected ? LucideIcons.x : LucideIcons.check,
size: 10.sp,
color: ColorConstants.onAccent,
))
// ── The steps are numbered ──
//
// They were hollow rings, which said "there is a stop here" and
// nothing else — so a rider counting five bags off a shelf had to
// count the *rows* to know which one he was on. The manifest is an
// ordered list and the timeline should read like one: 1, 2, 3, matching
// the order the counter hands them over in.
//
// Only while there is something to number. A settled or declined step
// keeps its glyph, because at that point the position is history and
// the outcome is the fact.
: (!rejected && !skipped)
? Text('$ordinal', style: MilerType.figure(11, color: accent))
: skipped
? Icon(
LucideIcons.circleAlert,
size: 9.sp,
color: ColorConstants.warning,
)
: live
// A core rather than a glyph: the node the rider is *on* is the one
// place a solid centre reads instantly at a glance, and a tick here
// would claim work that is not finished.
? Container(
width: size / 2.6,
height: size / 2.6,
decoration: BoxDecoration(color: accent, shape: BoxShape.circle),
)
: null,
);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Small parts
// ─────────────────────────────────────────────────────────────────────────
/// The journey cluster: how far, and how long.
///
/// ```
/// ➤ 5.2 km
/// ◷ ~15 min
/// ```
///
/// ── Why it is a block and not two right-aligned lines ──
///
/// Right-aligning the two figures lines up their *right* edges, which is the
/// one edge nobody scans: `5.2 km` and `~15 min` end in different words, so the
/// numbers themselves — the part being compared down a multi-kitchen route —
/// landed at a different x on every row.
///
/// This is a fixed-width block, right-aligned as a whole, with its contents
/// aligned **left inside it**. So the glyphs form one column, the digits form
/// another, and "which of these three kitchens is nearest" is answered by
/// reading straight down rather than by comparing three ragged strings.
///
/// ── The icons ──
///
/// Two, and they are the only icons on the node. A distance and a duration are
/// both bare numbers with a unit; at a glance, in sun, `5.2` and `~15` are the
/// same shape. The glyph is what says *which question this number answers*
/// before the eye has read the unit — which is the whole job, since the rider
/// is deciding whether to set off now.
///
/// They sit in the caption tone, never the brand: an icon that anchors is not
/// an icon that should be looked at.
///
/// ── Two facts, and only what is measured ──
///
/// Either half may be absent and the cluster degrades to the other one rather
/// than printing a dash or a guess: no fix means no distance, and no distance
/// with no leg estimate means no ETA. Both absent and it is not built at all,
/// so the name takes the full width. A rider planning around a fabricated
/// `~5 min` is worse off than one who can see the app does not know.
class _JourneyColumn extends StatelessWidget {
final String? distance;
final String? ride;
/// The group the rider is riding to next takes the larger figures. The rest
/// state the same facts one step quieter — see the note on the name.
final bool prominent;
const _JourneyColumn({
required this.distance,
required this.ride,
required this.prominent,
});
/// Fixed, so the cluster is the same width on every node and the columns line
/// up down the run. A block that resized per row would defeat the alignment it
/// exists for.
///
/// **76, not 84.** It spans both lines of the header, so every point it takes
/// is taken twice — once from the destination and once from
/// `5 orders · 5 bags`, which was ellipsising to `5 orders · 5 ba…`. 76 fits
/// the common pair (`5.2 km` / `~15 min`) with air to spare; the rare
/// `~1h 05m` ellipsises, which is the correct thing to sacrifice, because an
/// hour-long leg is one the rider reads once and not one he compares.
/// Back to 76 after a shave to 68, which was ~3pt short of `3.1 km` at 17sp
/// and rendered the headline metric as `3.1 …`. The block cannot be squeezed
/// to buy width for the left column; the left column has to stop asking.
static double get width => 76.w;
@override
Widget build(BuildContext context) {
if (distance == null && ride == null) return const SizedBox.shrink();
return SizedBox(
width: width,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (distance != null)
_line(
icon: LucideIcons.navigation,
value: distance!,
style: MilerType.figure(
prominent ? 17 : 15,
color: ColorConstants.slateText,
),
),
if (distance != null && ride != null) SizedBox(height: 4.h),
if (ride != null)
_line(
icon: LucideIcons.clock,
value: ride!,
// The ride time qualifies the distance rather than competing with
// it: same column, one step down in size and weight.
style: MilerType.label.copyWith(
fontSize: prominent ? 13.sp : 12.5.sp,
fontWeight: FontWeight.w600,
color: ColorConstants.secondaryText,
),
),
],
),
);
}
Widget _line({
required IconData icon,
required String value,
required TextStyle style,
}) {
return Row(
children: [
Icon(icon, size: 13.sp, color: ColorConstants.secondaryText),
SizedBox(width: 6.w),
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
],
);
}
}
/// The state, as a word on the load line.
///
/// A pill costs its text **plus 18pt of horizontal padding**, and on a two-line
/// node every one of those points comes out of either the destination or the
/// bag count. Colour and weight carry a five-letter state perfectly well without
/// a capsule around it — the capsule was buying separation from a line that is
/// already separated by a `·`.
///
/// Set at the load line's own size so the two read as one statement rather than
/// as a label stuck to a fact.
class _StateWord extends StatelessWidget {
final String label;
final Color color;
const _StateWord({required this.label, required this.color});
@override
Widget build(BuildContext context) {
// ── A state is a box now, not a loose word ──
//
// Bare coloured text sat on the action row beside a red Navigate disc, a
// green Call disc and a grey "i" — four coloured things, one of which was
// just... words. It read as a label that had lost its control rather than
// as a state, and at a glance the only thing separating "Pending" from
// "Accepted" was a hue on 12sp type.
//
// A tinted capsule at 10% of its own colour gives the state an edge of its
// own, so it is a *thing* on the card rather than a stray phrase. The ink
// stays at full strength — every colour used here (amber, brand, green,
// grey) clears AA against its own 10% wash — and the tint means the four
// states now differ in area as well as in hue, which is what survives
// sunlight.
// Hugs its text. The wide row hands the state an `Expanded` — deliberately,
// so it is the thing that ellipsises when the row is tight — and a bare
// `Container` in one stretches to the full slack, which turned a two-word
// state into a wash of amber halfway across the card.
// ── A word, not a capsule ──
//
// The tint was a 10% wash behind every state on every group, which on a
// three-kitchen run is three coloured pills before the rider has read a
// single word. This is operational metadata — where the work has got to —
// and metadata is set, not stamped. What carries it now is position (its
// own line, under the identity) and weight, which is enough because there
// is nothing else on that line competing with it.
//
// The one exception keeps its form: [LiveMark] is filled and breathing,
// because "this stop is running" is the single state a rider must not
// miss, and it differs in shape from everything else here rather than
// merely in hue.
return Align(
alignment: AlignmentDirectional.centerStart,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: MilerType.label.copyWith(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
/// The dedicated selection target, wrapped around a node.
///
/// ── Why selection got its own control ──
///
/// Ticking used to be *the row's tap*, which meant the row could not also open
/// anything — so an order's address, phone and notes were reachable only through
/// a chevron, and a rider who wanted to look at a stop had to hit a 34pt glyph
/// instead of the 300pt row in front of him. The two gestures were fighting over
/// one target and details lost.
///
/// Splitting them gives each its own: the **row** opens the sheet, the **node**
/// ticks. The node is the right home for it — it is already the thing that
/// changes shape when selected, so the control and its own state indicator are
/// the same object rather than a checkbox bolted beside one.
///
/// The visible mark stays small (13pt for a step); the *target* is the full rail
/// width by the full row height, which clears 44pt comfortably. A rider aiming
/// at "the dot" hits it.
class _SelectableNode extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
final Widget child;
/// Whether this rail is the thing that *announces* the selection.
///
/// On a step it is not: the tile beside it is a second, larger target for the
/// same tick, and it carries the order's own name — so two controls announced
/// "Select …" for one order and a screen reader read the route twice. The rail
/// stays tappable and goes silent; the tile speaks.
///
/// On a flat stop the rail is the *only* selection control, because the header
/// body opens the pickup preview. There it speaks.
final bool announce;
const _SelectableNode({
required this.selected,
required this.onTap,
required this.child,
this.announce = true,
});
@override
Widget build(BuildContext context) {
final control = InkResponse(
onTap: onTap,
radius: ButtonSizes.minTapTarget / 2,
containedInkWell: false,
child: child,
);
if (!announce) return ExcludeSemantics(child: control);
return Semantics(
button: true,
checked: selected,
label: selected ? 'Deselect this order' : 'Select this order',
excludeSemantics: true,
child: control,
);
}
}
/// A 48pt circular utility on the card: call the place, or open its details.
///
/// Tonal, never filled. The filled slot on this card belongs to the journey
/// button inside the dropdown, and a screen with two filled controls has no
/// primary action. The tint carries the meaning instead — green for a call,
/// because that is what every phone on earth puts on one and it is the single
/// piece of iconography a rider never has to learn; slate for details, because
/// reading about a place is not an action with a colour.
class _CircleAction extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final VoidCallback onTap;
/// Solid rather than tonal — the one primary among the three.
final bool filled;
const _CircleAction({
required this.icon,
required this.color,
required this.label,
required this.onTap,
this.filled = false,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: label,
excludeSemantics: true,
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
// ── 48 of target, 44 of disc ──
//
// The painted circle shrank inside a target that did not. A 48pt disc
// puts 14pt of tint either side of a 20pt glyph, so even flush against
// the card the *icon* sat ~24pt off the edge and the corner read as
// empty. 44 is the app's tap floor and still comfortably hittable —
// the surrounding `InkWell` keeps the full 48 of touch area.
child: SizedBox(
width: ButtonSizes.secondary,
height: ButtonSizes.secondary,
child: Center(
child: Container(
width: ButtonSizes.minTapTarget,
height: ButtonSizes.minTapTarget,
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled ? color : color.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 20.sp,
color: filled ? ColorConstants.onAccent : color,
),
),
),
),
),
),
);
}
}
/// `🗺 Map` — the run head's one action, at the far right of its rule.
///
/// ── It was too small to find ──
///
/// A 15pt glyph and an 11sp eyebrow inside 6pt of padding is a ~44×27 control
/// carrying letters a rider cannot read in sun without stopping. It is the only
/// way to the whole route on a map, and it looked like a caption.
///
/// It is a proper secondary action now: a tonal pill, 13sp at w800, a 20pt map
/// glyph, and a target that clears the app's floor in both axes. Still tonal
/// rather than filled — the route below it owns the screen, and the map is where
/// the rider goes to *check* rather than to work.
class _TextAction extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback onTap;
const _TextAction({
required this.label,
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'See the whole route on a map',
excludeSemantics: true,
// ── A text action, which is what the class is called ──
//
// It was a filled brand-tinted pill. Two lines above, its own note says
// the eyebrow and this "are not the same kind of thing — one names the
// section, one does something — so they are set differently rather than
// both shouting", and then it shouted: a tinted ground, a brand glyph
// and a brand w700 label, all for a secondary way into a screen the
// route below already navigates to stop by stop.
//
// It also spent the page's one loud colour. Home now marks the group the
// rider is riding to with a brand node, and that node has to be the
// thing the eye finds first — it cannot compete with a pill in the
// corner wearing the same red.
//
// So the ground goes and the ink stays: brand, still obviously tappable,
// no longer a block. The 44pt target is unchanged.
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 8.h),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 17.sp, color: ColorConstants.primary),
SizedBox(width: 6.w),
Text(
label,
style: MilerType.label.copyWith(
fontWeight: FontWeight.w600,
color: ColorConstants.primary,
),
),
],
),
),
),
),
);
}
}