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