3197 lines
143 KiB
Dart
3197 lines
143 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
|
||
import 'package:miler/data/order_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 [OrderManifest.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 street the counter is on — where [name] actually is.
|
||
///
|
||
/// ── Why a name was not enough ──
|
||
///
|
||
/// The card gave the place a name, a load and a distance, and then expected
|
||
/// the rider to know where "Vidhya Kitchen" is. He often does; on his first
|
||
/// week, or on a kitchen the hub added yesterday, he does not — and the only
|
||
/// way to the address was to tap through to the preview sheet, which is a
|
||
/// sheet and a dismiss to answer *where am I going*, the plainest question
|
||
/// the card exists for.
|
||
///
|
||
/// It sits directly above the load, so the header reads as one descending
|
||
/// statement: **what this place is, where it is, what is waiting there**.
|
||
/// Put below the load it would have separated the counts from the journey
|
||
/// figures they are read against.
|
||
///
|
||
/// Empty is normal and prints nothing — a payload without an address, or a
|
||
/// flat stop whose own name already *is* the address.
|
||
final String location;
|
||
|
||
/// 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 [OrderManifest].
|
||
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 [allStops]' own state, in the same order.
|
||
///
|
||
/// The visible half is [stopStates]; this is the whole bucket, and it is what
|
||
/// lets [groupSize] tell an order that is still this counter's business from
|
||
/// one that has finished and left it. Empty when a caller does not track it,
|
||
/// in which case [groupSize] falls back to the raw count.
|
||
final List<StopState> allStopStates;
|
||
|
||
/// 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;
|
||
|
||
/// The denominator of `Accepted 1/4` — **this counter's load**.
|
||
///
|
||
/// ── Why it is not `allStops.length` ──
|
||
///
|
||
/// It was, and the reasoning was sound as far as it went: an order the rider
|
||
/// has collected leaves the visible list but did not leave the counter, so
|
||
/// counting only what is on screen would renumber the place under him.
|
||
///
|
||
/// What that missed is that [allStops] is every order the trip ever bucketed
|
||
/// under this source — including the ones already **delivered**, which are
|
||
/// Activity's business and not this card's, and the ones he **declined**,
|
||
/// which he has finished with. On a full day off one kitchen that is most of
|
||
/// them: a rider with ten orders in the dropdown and one accepted read
|
||
/// `Accepted 1/63`, where 63 was the whole day and 10 was the question in
|
||
/// front of him. A fraction whose denominator the rider cannot see anywhere
|
||
/// on the screen is worse than no fraction.
|
||
///
|
||
/// So the load is stated the way he would state it: **what is still in the
|
||
/// dropdown, plus what he has already taken off this counter**. Collected
|
||
/// orders keep their place in the arithmetic, which was the original point;
|
||
/// finished and declined ones drop out of it, which was the bug.
|
||
///
|
||
/// Skipped orders are out too. One is still owed at the counter, but it is
|
||
/// parked for a return visit and it is not part of the accept decision this
|
||
/// chip is a fraction of — see [onRungCount], which counts the accepted rung.
|
||
int get groupSize {
|
||
// A caller that does not supply the states gets the old arithmetic rather
|
||
// than a wrong number: better to over-count than to invent a denominator.
|
||
if (allStopStates.length != allStops.length) {
|
||
return allStops.isNotEmpty ? allStops.length : stops.length;
|
||
}
|
||
final carried = allStopStates
|
||
.where((s) => s == StopState.collected)
|
||
.length;
|
||
return stops.length + carried;
|
||
}
|
||
|
||
/// 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,
|
||
this.location = '',
|
||
required this.stops,
|
||
required this.allStops,
|
||
required this.firstIndex,
|
||
required this.state,
|
||
required this.stopStates,
|
||
this.allStopStates = const [],
|
||
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<ManifestLine> get manifest {
|
||
final shown = {for (final s in stops) (s['orderid'] ?? '').toString()};
|
||
return [
|
||
for (final line in OrderManifest.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 => OrderManifest.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.isAtSource;
|
||
}
|
||
|
||
/// 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 a **place's** detail sheet — a flat group's header, where one stop
|
||
/// is one location. Progressive disclosure for the address, the phone and
|
||
/// everything else a step deliberately does not carry.
|
||
final ValueChanged<Map<String, dynamic>>? onStopTap;
|
||
|
||
/// Opens **one order's** detail sheet, from a row inside an expanded place.
|
||
///
|
||
/// Separate from [onStopTap] because they are about different subjects, and
|
||
/// the sheet has to know which. Every order under a kitchen shares that
|
||
/// kitchen's address, so a sheet opened from an order row that pins the
|
||
/// kitchen draws the same map once per order and answers nothing about the
|
||
/// one the rider tapped — what distinguishes it is the door it is going to.
|
||
/// The place's own map is on the card's preview, beside the call.
|
||
///
|
||
/// Falls back to [onStopTap] when unset, so a caller that does not care keeps
|
||
/// the old single-callback behaviour.
|
||
final ValueChanged<Map<String, dynamic>>? onOrderTap;
|
||
|
||
/// 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.onOrderTap,
|
||
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.arrived,
|
||
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,
|
||
onOrderTap: widget.onOrderTap ?? 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(right: scoped ? 2.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.
|
||
// ── The scoped one closes the row; the run head opens it ──
|
||
//
|
||
// Inside a group the select-all is a *control over a list*, and a
|
||
// list's own controls sit on the right — the same edge the bag
|
||
// labels and the chevrons below it already use. On the left it
|
||
// headed the list like a label, which is what made it read as a
|
||
// stray tick above the first name rather than as the thing that
|
||
// ticks all five.
|
||
//
|
||
// The run-head version has no list beside it and no right edge to
|
||
// close: it is the first thing above the route, so it keeps the
|
||
// reading edge.
|
||
mainAxisAlignment: scoped
|
||
? MainAxisAlignment.end
|
||
: 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 goes where the words went ──
|
||
//
|
||
// The scoped row's tick was drawn by the rail, in the node
|
||
// column on the far left, while the label it belongs to sits on
|
||
// the right edge. A control split across the full width of a
|
||
// card is two controls as far as the eye is concerned: the tick
|
||
// read as a mark on the spine and the words as a caption, and
|
||
// nothing said they were one thing you press.
|
||
//
|
||
// Both versions carry it inline now, immediately before the
|
||
// words, and the rail draws nothing for this row.
|
||
_glyph(all, some, accent),
|
||
SizedBox(width: 8.w),
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: scoped ? TextAlign.right : 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;
|
||
|
||
/// One order's sheet — see [RouteTimeline.onOrderTap].
|
||
final ValueChanged<Map<String, dynamic>>? onOrderTap;
|
||
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.onOrderTap,
|
||
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.isAtSource));
|
||
|
||
/// 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: 12.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, 0),
|
||
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 == 1 ? '1 collected' : '$n 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,
|
||
if (!group.flat &&
|
||
group.pendingCount > 0 &&
|
||
group.pendingCount < group.stops.length)
|
||
'${group.pendingCount} to accept',
|
||
// ── How much of this counter is still waiting on him ──
|
||
//
|
||
// The chip beside it states the group's **own rung** with a fraction —
|
||
// `Pending 8/8` on an untouched counter, `Accepted 1/2` on one he has
|
||
// started. That answers the all-pending case, which used to say nothing
|
||
// at all: a fresh morning where the card read `Pending` and `10 bags` and
|
||
// left the rider to join them into *ten decisions are waiting on me*.
|
||
//
|
||
// It cannot answer the **mixed** case, and that is what this clause is
|
||
// for. A group holding one accepted order and one pending one is on the
|
||
// accepted rung, so the chip says `Accepted 1/2` — true, and silent about
|
||
// the one still to decide. The two never overlap: this appears only when
|
||
// some but not all of the counter is pending, which is precisely when the
|
||
// chip is showing a different rung.
|
||
// ── 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;
|
||
|
||
// Suppressed on a place the rider has finished with and on one he declined:
|
||
// the address is wayfinding, and there is nowhere left to go.
|
||
final location = rejected || group.isSettled ? '' : group.location;
|
||
|
||
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,
|
||
if (location.isNotEmpty) location,
|
||
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, 8.h, 6.w, 8.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.
|
||
// ── Where the counter is, above what is on it ──
|
||
//
|
||
// Between the name and the load, because that is
|
||
// the order the questions arrive in: which place,
|
||
// where is it, how much is waiting. A pin rather
|
||
// than a label — "Pickup location" would be a word
|
||
// spent saying what the glyph and the position
|
||
// already say.
|
||
//
|
||
// Two lines, matching the name above it: real
|
||
// Coimbatore addresses are long, and an address cut
|
||
// to `Gandhipuram Main…` answers nothing a blank
|
||
// line would not have answered. Set in the page's
|
||
// quiet grey — it is orientation, not a figure the
|
||
// rider acts on.
|
||
if (location.isNotEmpty) ...[
|
||
SizedBox(height: 3.h),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.only(top: 1.h),
|
||
child: Icon(
|
||
LucideIcons.mapPin,
|
||
size: 12.sp,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
SizedBox(width: 5.w),
|
||
Flexible(
|
||
child: Text(
|
||
location,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: MilerType.micro.copyWith(
|
||
fontSize: 12.5.sp,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
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 (group.flat) ...[
|
||
SizedBox(width: 4.w),
|
||
Icon(
|
||
LucideIcons.chevronRight,
|
||
size: 20.sp,
|
||
color: ColorConstants.borderStrong,
|
||
),
|
||
],
|
||
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 chevron is gone; the journey takes the corner ──
|
||
//
|
||
// It came back once as an *indicator* — a passive glyph
|
||
// inside the header's own InkWell — because a collapsed
|
||
// place showed nothing that said "there is a list under
|
||
// me". That argument held while the header's right edge
|
||
// was empty.
|
||
//
|
||
// It is not empty: the distance and the ride time live
|
||
// there, and they are the reason a rider looks at this
|
||
// corner in the first place. The chevron was standing
|
||
// between them and the card's edge, so the one column he
|
||
// scans down a multi-kitchen run — *which of these is
|
||
// nearest* — was pushed 24 points off the right margin
|
||
// and no longer lined up with anything.
|
||
//
|
||
// What the chevron was signalling is still signalled: an
|
||
// open group is visibly open (its orders are on screen,
|
||
// indented under it), and a flat stop is the one case
|
||
// with nothing to open, so it keeps a pointing glyph.
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// 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 has its own amber, and it is not the warning one ──
|
||
//
|
||
// It was grey, and before that it was [ColorConstants.warning]. Both were
|
||
// half right. Grey said "nothing has gone wrong", which is true; warning
|
||
// said "this wants something from you", which is also true — and warning
|
||
// is the hue this app spends on a skip, a short payment, a bag that never
|
||
// came, so a morning where every kitchen is pending opened the whole screen
|
||
// in exception and taught the rider to stop reading the colour.
|
||
//
|
||
// [ColorConstants.pendingAmber] is the third answer: a gold that reads as
|
||
// *waiting* rather than as *wrong*, far enough from the burnt orange of
|
||
// warning that Pending and Skipped are two distinct marks down a run. The
|
||
// words differ too; this is what makes them differ at a glance.
|
||
// ── And it says how many ──
|
||
//
|
||
// A bare `Pending` made the rider join two facts to get the one he wants.
|
||
// The card said `Pending` and, twenty points above, `10 orders · 10 bags`;
|
||
// *ten decisions are waiting on me* was the arithmetic between them, and on
|
||
// a fresh morning — every group pending, nothing accepted — that was the
|
||
// whole screen asking him to do it.
|
||
//
|
||
// Stated as a fraction because that is what Accepted has always done here,
|
||
// and the two must read as one vocabulary: `Pending 10/10` on a counter
|
||
// untouched, `Pending 2/4` on one half-taken, `Accepted 2/4` from the other
|
||
// side of the same decision. The bare word survives where a fraction would
|
||
// be noise — a flat parcel stop is one order, and `Pending 1/1` says
|
||
// nothing `Pending` does not.
|
||
StopState.pending => _StateWord(
|
||
label: group.groupSize > 1
|
||
? 'Pending ${group.pendingCount}/${group.groupSize}'
|
||
: 'Pending',
|
||
color: ColorConstants.pendingAmber,
|
||
),
|
||
// ── "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(
|
||
// ── Accepted is green ──
|
||
//
|
||
// It was brand red on the group being ridden to and slate on the rest,
|
||
// which made the word carry two facts at once — *taken on* and *this is
|
||
// the one you are working* — and the second was the louder of them. A
|
||
// rider glancing down a run read the red as the state.
|
||
//
|
||
// Green says the one thing the word is for: this is yours, it is settled,
|
||
// nothing here needs deciding. Which one he is *on* is already said three
|
||
// ways that do not need a hue — the lit node, the larger title, and the
|
||
// Navigate button that only the current place carries.
|
||
//
|
||
// `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: ColorConstants.acceptGreen,
|
||
),
|
||
// ── 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.
|
||
// Both rungs take the live mark — from this rail's distance the useful
|
||
// fact is *this is the one he is on* — and each says which rung it is.
|
||
StopState.arrived => const LiveMark(label: 'Arrived'),
|
||
StopState.active => const LiveMark(),
|
||
// ── Picked is blue, now that green means accepted ──
|
||
//
|
||
// It was green, back when green was free. It cannot stay green beside an
|
||
// accepted state that is also green: the two rungs a rider most needs to
|
||
// tell apart on one counter — *taken on* and *in my hands* — would be one
|
||
// colour and two words.
|
||
//
|
||
// Blue is not an arbitrary third: it is [ColorConstants.deliveryAccent],
|
||
// what this app already paints the delivery leg in, and a picked bag has
|
||
// become exactly that. The colour says where the work went.
|
||
StopState.collected || StopState.done => const _StateWord(
|
||
label: 'Collected',
|
||
color: ColorConstants.pickedBlue,
|
||
),
|
||
// ── 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<ManifestLine> manifest, int chosen) {
|
||
final ids = _choosableIds;
|
||
final selectable = _isChoosable(group.state) && onToggleSelect != null;
|
||
|
||
// ── The orders are the kitchen's contents, and sit inside it ──
|
||
//
|
||
// They were flush with the group's own left edge, which put a stop's rail
|
||
// on the same axis as the kitchen heading above it — so an open group read
|
||
// as two lists that happen to be adjacent rather than as one thing
|
||
// containing another. A small indent is the whole signal; the spine does
|
||
// the grouping, so it does not need a fill or a box to go with it.
|
||
return Padding(
|
||
padding: EdgeInsets.zero,
|
||
child: Container(
|
||
decoration: null,
|
||
// 8 → 4. Enough that the rail is visibly inside the group rather than
|
||
// on the same axis as its heading, and not so much that the stops read
|
||
// as a second column. The indent is the whole signal; it does not need
|
||
// to be large to be read.
|
||
padding: EdgeInsets.only(left: 4.w, top: 4.h, bottom: 4.h),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
if (_railRow(context, ids, chosen) != null)
|
||
_railed(
|
||
_railRow(context, ids, chosen)!,
|
||
line: false,
|
||
// No node: the tick travels with its own label now — see the
|
||
// note in [SelectAllAction].
|
||
node: null,
|
||
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,
|
||
first: i == 0,
|
||
last: i == manifest.length - 1,
|
||
lineColor: _lineColor,
|
||
onTap: () => onToggleSelect?.call(line.orderId),
|
||
// The order's own sheet, not the place's — see
|
||
// [RouteTimeline.onOrderTap].
|
||
onOpen: onOrderTap == null
|
||
? null
|
||
: () => onOrderTap!(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.
|
||
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 => 0;
|
||
|
||
/// 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 ManifestLine 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: Container(
|
||
decoration: BoxDecoration(
|
||
border: last
|
||
? null
|
||
: Border(
|
||
bottom: BorderSide(
|
||
color: ColorConstants.borderSubtle,
|
||
width: 1.0,
|
||
),
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: EdgeInsets.only(bottom: 4.h, top: 4.h, right: 2.w),
|
||
child: SizedBox(
|
||
width: double.infinity,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Semantics(
|
||
button: true,
|
||
checked: selectable ? selected : null,
|
||
label: selectable ? 'Select $customer' : customer,
|
||
excludeSemantics: true,
|
||
child: InkWell(
|
||
onTap: selectable && !busy
|
||
? onTap
|
||
: (canOpen ? onOpen : null),
|
||
borderRadius: BorderRadius.circular(
|
||
DesignConstants.radiusLg,
|
||
),
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(8.w, 6.h, 8.w, 6.h),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
customer,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
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,
|
||
),
|
||
),
|
||
if (dropArea.isNotEmpty) ...[
|
||
SizedBox(height: 2.h),
|
||
Text(
|
||
dropArea,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: MilerType.micro.copyWith(
|
||
fontSize: 12.5.sp,
|
||
color: rejected
|
||
? ColorConstants.borderStrong
|
||
: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
_OrderOpenBlock(
|
||
// The counter's own printed label when there is one,
|
||
// and the word `Rejected` where the row has been
|
||
// declined. Never an invented number — see
|
||
// [OrderManifest.labelFor].
|
||
label: rejected ? 'Rejected' : line.label,
|
||
orderId: line.orderId,
|
||
selected: selected,
|
||
onTap: canOpen ? onOpen : null,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The step's way into its detail sheet: the counter's printed label, when
|
||
/// there is one, and the chevron that opens the order.
|
||
///
|
||
/// ── It used to manufacture a reference ──
|
||
///
|
||
/// The tag read `Bag 1 … Bag n`, falling back to the order's position in its
|
||
/// group whenever the payload printed nothing — so on the common route, where
|
||
/// no counter prints labels, every row carried a shelf reference the shelf had
|
||
/// never heard of. It was the app numbering its own list and drawing the result
|
||
/// as a physical fact, two columns from a rail that numbers the same rows.
|
||
///
|
||
/// What the counter really printed is a fact about an object in the rider's
|
||
/// hands, so that still shows, verbatim. Where nothing was printed the corner
|
||
/// carries the chevron alone, which is the affordance the block existed for.
|
||
/// 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 {
|
||
/// The counter's own printed label, or `''` when it printed none.
|
||
final String label;
|
||
|
||
final String orderId;
|
||
final bool selected;
|
||
final VoidCallback? onTap;
|
||
|
||
const _OrderOpenBlock({
|
||
required this.label,
|
||
required this.orderId,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final id = displayOrderId(orderId);
|
||
|
||
return Semantics(
|
||
button: onTap != null,
|
||
label: [
|
||
if (label.isNotEmpty) label,
|
||
if (id.isNotEmpty) 'order $id',
|
||
].join(', '),
|
||
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: [
|
||
if (label.isNotEmpty)
|
||
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 ──
|
||
//
|
||
// A printed label 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 — the one item
|
||
// on the row the brief says must yield last.
|
||
//
|
||
// Clamped at 1.25×, which keeps it 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: 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,
|
||
),
|
||
),
|
||
// ── 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 the boldest thing on every
|
||
// line was a shelf reference, and the eye read a column of
|
||
// them before it read a single person. It steps to w600 in
|
||
// the mid-tone instead, one rung under the undecided name,
|
||
// and follows the row up to slate the moment it is taken.
|
||
child: Text(
|
||
label,
|
||
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 order id is not on the row ──
|
||
//
|
||
// It is what the office reads down a phone once, when something
|
||
// has gone wrong. The line it occupied 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.isAtSource;
|
||
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.isAtSource && !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 [OrderManifest]. 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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|