import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:miler/views/Dashboard/home/route_brief.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/Dashboard/home/stop_card.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/Dashboard/home/route_timeline.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_brief_strip.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/bag_manifest.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// TRIP CARD — one slot's whole route, start to finish. /// /// The hub manager assigns a whole slot's route to one miler, so Home is /// trip-shaped rather than a loose list of bookings. The card shows the route /// exactly as it will be ridden: /// /// ⌂ START · HUB /// ① PICKUP Priya Sharma ~5 min [Reject] [Accept] /// ② DELIVERY Arun Venkatesh ~5 min ✓ Accepted /// ③ BOTH Meena Krishnan ~10 min ✓ Completed /// ⌂ RETURN · HUB /// /// Two things this card gets right that the old loose-booking list did not: /// /// • **The trip stays after it is accepted.** It does not vanish to Bookings. /// The rider watches the same card fill up — a completion ring in the /// header, stops turning green underneath — so Home stays a live picture of /// his day instead of emptying out the moment he commits. /// /// • **Accept is per stop AND all at once.** A rider can take stops 1, 2 and /// 4 and decline 3 — a hub can route him past an address he knows is shut. /// So every pending stop carries its own Accept/Reject, and the bar at the /// bottom does the remaining ones in one tap for the common case. /// ───────────────────────────────────────────────────────────────────────── class TripCard extends StatelessWidget { final Trip trip; /// Ids the rider has accepted / rejected, so every stop renders its own /// state. Optimistic — see [stopStateOf]. final Set acceptedIds; final Set rejectedIds; /// Order ids the rider has ticked. /// /// Selection is now the *only* way a stop is chosen — the per-row Accept and /// Reject buttons, and the whole-trip accept button under the route, are all /// gone. He ticks what he wants and takes it from the floating bar. final Set selectedIds; /// Toggles one stop's tick. final void Function(String orderId)? onToggleSelect; /// Accepts exactly the ticked stops. final Future Function(List orderIds)? onAcceptSelected; /// Replaces the whole selection — used by the select-all row. final void Function(List orderIds)? onSelectAll; /// Returns a rejected stop to undecided, so it can be ticked again. /// /// Deliberately not "accept it now": accepting is one deliberate press on the /// floating bar, and a per-row control that quietly commits work would be the /// per-stop Accept button coming back through the side door. final Future Function(Map stop)? onUnrejectStop; /// Opens a single stop's detail sheet (map + full order details). final void Function(Map stop)? onStopTap; /// One order's sheet, from a row inside an expanded place — see /// [RouteTimeline.onOrderTap]. Falls back to [onStopTap] when unset. final void Function(Map stop)? onOrderTap; /// Rings the customer for this stop. final void Function(Map stop)? onCallStop; /// Opens the whole route on a map. Null hides the action. final VoidCallback? onViewRoute; /// Takes the rider back into the stop he is already working. /// /// This is the way in that used to be a red banner floating over the foot of /// the page — see the note on the live row in [_stopRow]. Null renders the /// active row as a plain statement instead. final void Function(Map stop)? onContinueStop; /// Shift window, cash, parcels and pace for the rider's whole day. /// /// Supplied by the page because it spans every trip, not just this one, and /// [buildSummary] needs it: the shift window and the trip's own slot are two /// different time ranges and the brief is where they are finally shown side /// by side. Null renders the summary without the shift lines. final RouteBrief? brief; /// Order ids with an action in flight, so a stop cannot be re-ticked while /// its acceptance is on the wire. final Set busyStopIds; /// Orders the rider is carrying. Service routes only — see [StopState.collected]. final Set collectedIds; /// Opens turn-by-turn navigation for one stop. /// /// On the row itself, not only in the detail sheet: setting off is the single /// most common thing a rider does from this list, and making him open a sheet /// first is a tap per stop on a screen he uses ten times a morning. Where it /// points follows the stop's stage — see [MilkRun.navigationTarget]. final void Function(Map stop)? onNavigateStop; /// Walks one stop up its next rung — accepted → arrived → picked — through a /// slide-to-confirm sheet. /// /// The selection bar does this for a ticked set; this is the single-stop path /// for a rider standing at one counter with one card in front of him. final void Function(Map stop)? onAdvanceStop; /// Opens the pickup preview for a whole place — the leg to it and the load /// waiting on it. See [PickupPreviewSheet]. final void Function(RouteGroup group)? onPreviewGroup; /// Where the rider is, for "how far is the next pickup". /// /// Optional, and the timeline degrades cleanly without it: no fix means no /// distance line rather than a fabricated one. See [RouteGroup.meters]. final double? riderLat; final double? riderLng; const TripCard({ super.key, required this.trip, this.acceptedIds = const {}, this.rejectedIds = const {}, this.brief, this.selectedIds = const {}, this.onToggleSelect, this.onAcceptSelected, this.onSelectAll, this.onUnrejectStop, this.onStopTap, this.onOrderTap, this.onCallStop, this.onViewRoute, this.onContinueStop, this.busyStopIds = const {}, this.collectedIds = const {}, this.onNavigateStop, this.onAdvanceStop, this.onPreviewGroup, this.riderLat, this.riderLng, }); StopState _stateOf(Map stop) => stopStateOf( stop, acceptedIds: acceptedIds, rejectedIds: rejectedIds, collectedIds: collectedIds, ); String _idOf(Map stop) => (stop['orderid'] ?? '').toString(); /// ── The tick outlives the accept ── /// /// On a parcel route the box is only for choosing what to take on, so it goes /// once the stop is taken. A service rider keeps ticking the same stops /// afterwards: he selects every order from one kitchen and walks them through /// *arrived* and *picked* together, which is what the bar's ladder acts on. static bool _isChoosable(StopState state) => state == StopState.pending || (ServiceProfile.active.handsOffAtCollection && (state == StopState.accepted || state.isAtSource)); @override Widget build(BuildContext context) { // No frame around the trip. // // The card used to be one bordered, shadowed white box holding a second // bordered box per stop — a frame inside a frame inside a slate page. The // outer one was the weaker of the two: it grouped things the tabs above // had already grouped, and it charged 16px of page margin plus 16px of its // own padding plus a border for the privilege, on the narrowest axis the // rider has. So it is gone, and the stops below take the boxes instead — // they are what he actually picks between. Summary and footer now sit // directly on the page, and the seam dividers went with the frame. return Padding( // ── The horizontal scale for this whole screen ── // // Every inset on Home is one of two values, and both are 4pt steps off // this base: // // **28 — the page inset.** The shift card, the three stat cards, the // pinned tab strip and the accept bar. These are full-width page // elements and they all begin and end here. // // **32 — the route inset.** The rail's numbered node and the stop // card's right edge. One step further in, because the rail-plus-card is // an indented list item *under* the summary rather than another card in // the same column, and it should read that way. // // They arrived at 16, 18, 20 and 28 respectively — patched one complaint // at a time — which is what a gutter looks like when nobody writes the // scale down. `card_density_test.dart` now holds both values. // // Home is wider than Bookings' 16 on purpose: it is a much denser screen — // shift card, three stat cards, a pinned tab strip and a rail, all above // the route — and at 16 the column read as pressed against the border. // Horizontal inset is zero here now: each section stands on its own // [MilerPanel], which carries `MilerSurface.panelGutter`. Keeping 16 as // well would inset the panels by 28 in this whole-card path while Home — // which places the three sections directly — drew them at 12, so the // reference shots would not be pictures of the screen. padding: EdgeInsets.symmetric(vertical: 2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [buildSummary(), buildStops(), buildActionBar()], ), ); } // ═══════════════════════════════════════════════════════════════════════ // THE CARD IN THREE PIECES // // Home does not use [build]; it places these three sections as separate // slivers so that the Trip 1 / 2 / 3 bar can be a *pinned* sliver sitting // between the summary and the stops. A pinned header has to be a sliver in // its own right, which is impossible from inside one `SliverToBoxAdapter` // holding the whole card. // // They stay methods on this widget rather than three separate widgets because // the stop list alone needs nine of this class's fields — splitting would mean // maintaining that list twice. All three are pure and take no `BuildContext`, // so calling them from the page is safe. // // [build] still composes them in order, so any caller wanting the whole card // as one widget — and the widget tests — keep working unchanged. // ═══════════════════════════════════════════════════════════════════════ /// The collapsed trip brief: progress, time left, cash — and everything else /// one tap away. See [TripBriefStrip] for what this used to be and why the /// four surfaces it replaced were the wrong price for the information. Widget buildSummary() => TripBriefStrip( trip: trip, // What the four figures describe: the collections still owed, measured // from where the rider is standing — the same origin the page used to // build the trip in the first place, so the recount and the original are // on the same footing. See [Trip.outstanding]. remaining: trip.outstanding( collectedIds: collectedIds, originLat: riderLat, originLng: riderLng, ), brief: brief ?? RouteBrief.empty, percent: trip.completionPercent( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ), onViewRoute: onViewRoute, ); /// HUB → every stop in order → HUB. Widget buildStops() => _timeline(); // ── The "NEXT ACTION" head is gone, and its job moved down one line ─────── // // It was a glass card above the route carrying the place name at 25sp, the // load, the travel time and a full-width red **Navigate to pickup** — and the // group for that same place, forty points below, carried the identical button // over the identical name. Two primary actions, one destination, and the // rider had to read both to find out they were the same. // // Everything it said is now said once, by the group node that owns it: the // name, the state chip, `5 orders · 5 bags`, the distance and ETA, and one // journey button. That is the whole point of a timeline — the head *is* the // first node — and it gave back ~190pt of the viewport. // // What is not lost: [_nextGroup] still resolves the current unit of work for // the callers below. /// The next place the rider owes work to, and every order he has there. /// /// Ordered by *rung*, not by route position: a counter he has already reached /// outranks one he has only accepted, because a rider standing at a shelf is /// mid-handover and everything else can wait. Within a rung it is the first /// stop in route order, which is the order the hub built. ({ List> stops, String place, StopState state, int index, })? _nextGroup() { // Arrived outranks active: he is at that counter now, and a stop he has // only been routed to can wait. for (final rung in const [ StopState.arrived, StopState.active, StopState.accepted, ]) { for (var i = 0; i < trip.stops.length; i++) { if (_stateOf(trip.stops[i]) != rung) continue; final stop = trip.stops[i]; final key = MilkRun.sourceKeyOf(stop); final grouped = ServiceProfile.active.sourceIsKitchen && key.isNotEmpty; final stops = grouped ? [ for (final s in trip.stops) if (MilkRun.sourceKeyOf(s) == key && _stateOf(s) == rung) s, ] : [stop]; final place = grouped ? stopSourceName(stop) : MilkRun.navigationLabel(stop); if (place.trim().isEmpty) continue; return (stops: stops, place: place, state: rung, index: i); } } return null; } /// Accept-remaining / selection bar, or the finished confirmation. Widget buildActionBar() => _footerBar( trip.pendingOrderIds(acceptedIds: acceptedIds, rejectedIds: rejectedIds), ); // ── The run, as a timeline ─────────────────────────────────────────────── // // This replaced `_sequence()`: a select-all strip, a hub row, a heading per // kitchen, one [StopCard] per order and a closing hub row. The cards were the // cost — 138pt each, so five orders off one counter came to ~690pt against a // ~600pt viewport, and a rider working one kitchen could not see one kitchen. // // The stops are the same stops and the state is the same state; what changed // is that the *place* became the node and the orders became milestones under // it. See [RouteTimeline]. Widget _timeline() { final groups = routeGroups(); if (groups.isEmpty) { return Padding( padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h), child: trip.stops.isEmpty ? const SizedBox.shrink() : _allTakenNote(), ); } // ── The route is the screen's working region ── // // Same move as the brief above it: Home's ground is the canvas, and a // spine of stops drawn straight onto it is grey on grey. [MilerPanel] // gives the route a white layer-1 surface to stand on — one panel for the // whole route, not one per kitchen, which is the distinction the earlier // pass was after when it removed the card-per-kitchen. return MilerPanel( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ RouteTimeline( groups: groups, selectedIds: selectedIds, busyIds: busyStopIds, onSelectAll: onSelectAll, onToggleSelect: onToggleSelect, onStopTap: onStopTap, onOrderTap: onOrderTap ?? onStopTap, // One journey action per place. Five Navigate buttons pointing at // one counter was the largest single source of clutter on the old // screen, and the rider had to work out whether they differed. onNavigateGroup: onNavigateStop, // Ringing the kitchen is an action on the *place*, so it sits on // the pickup card beside Navigate — not on five order rows that all // dial the same counter. onCallGroup: onCallStop, // The card's own tap, and what Navigate goes through. Supplied by // the page rather than built here: these three methods are pure and // take no `BuildContext` — Home calls them directly as slivers — // and a sheet needs one. onPreviewGroup: onPreviewGroup, // The way back into the stop he is already inside. This is what // replaced the red banner that used to float over the foot of the // page — see [PickupTimelineGroup._action]. onContinue: onContinueStop, onUnreject: onUnrejectStop == null ? null : (stop) => onUnrejectStop!(stop), onViewRoute: onViewRoute, ), // A meal run does not come back: the rider finishes at the last // subscriber's door and goes home. A parcel day is a loop, so it // still ends where it started. if (!ServiceProfile.active.sourceIsKitchen) ...[ SizedBox(height: 6.h), _returnNote(), ], if (trip.stops.isNotEmpty && !trip.stops.any((s) => _showsOnHome(_stateOf(s)))) _allTakenNote(), ], ), ); } /// The run's pickup places, in the order the route reaches them. /// /// On a line with sources this is one group per kitchen. On a parcel route /// there are none, so every stop becomes its own single-order group — the /// same timeline one level shallower, which is honest: a parcel rider really /// does ride to each address for one consignment. /// /// A group whose orders have all left Home — collected, and now on the work /// tab — emits nothing at all. Its heading would be a title over a gap. @visibleForTesting List routeGroups() { final order = []; final buckets = >{}; final grouped = ServiceProfile.active.sourceIsKitchen; // Every stop is bucketed, not only the ones Home still draws: a group's bag // numbers are counted against the whole group the rider was handed, so the // collected ones have to stay in the arithmetic even once they have left // the screen. See [RouteGroup.allStops]. for (var i = 0; i < trip.stops.length; i++) { final stop = trip.stops[i]; // Key on the id where there is one, falling back to the name: the id is // what the hub controls, but a payload that only carries a name still has // to group rather than collapsing every stop into one nameless pile. final key = grouped ? (stopSourceId(stop).isNotEmpty ? stopSourceId(stop) : stopSourceName(stop)) : 'stop:${_idOf(stop)}'; if (!buckets.containsKey(key)) { buckets[key] = []; order.add(key); } buckets[key]!.add(i); } final groups = []; for (final key in order) { final indices = buckets[key]!; final all = [for (final i in indices) trip.stops[i]]; final visible = [ for (final s in all) if (_showsOnHome(_stateOf(s))) s, ]; // ── A finished place settles; it does not vanish ── // // A group with nothing left on Home is one the rider has collected in // full. It stays as one settled line — a green node and a "Collected" // chip — so the run still reads as a sequence and he can see 01 is behind // him. The work itself has moved to the work tab; what is left here is // the fact that it did. A group that is neither visible nor collected // (all rejected, all finished on the server) emits nothing. // **Collected**, not merely finished. A stop the rider is carrying has // moved to the work tab and the place he took it from is behind him — a // fact worth one settled line. A stop that is *done* is finished work and // belongs to Activity; drawing its place here would put yesterday's // counters on today's run. final settled = visible.isEmpty && all.every((s) => _stateOf(s) == StopState.collected); if (visible.isEmpty && !settled) continue; groups.add(() { final stops = visible; final first = all.first; final flat = all.length == 1 && (!grouped || stopSourceName(first).isEmpty); // A grouped place is named by its counter. A flat one *is* the stop, so // it is named by the customer — `MilkRun.navigationLabel` answers // "pickup" for a parcel that has not been collected yet, which is a // description of the leg rather than a name the rider can read off a // gate. final name = flat ? BagManifest.customerOf(first) : (grouped ? stopSourceName(first) : MilkRun.navigationLabel(first)); return RouteGroup( key: key, // A route with no source names still groups — into one unnamed // group — and a heading reading "· 5 orders" would be worse than // the customer's own name. name: name.trim().isEmpty ? MilkRun.navigationLabel(first) : name.trim(), stops: stops, allStops: all, firstIndex: indices.first, state: stops.isEmpty ? StopState.collected : _groupRung(stops), stopStates: [for (final s in stops) _stateOf(s)], // The whole bucket's states, so the card can tell an order that is // still this counter's business from one already delivered — see // [RouteGroup.groupSize]. allStopStates: [for (final s in all) _stateOf(s)], travel: trip.travelTimeToStop(indices.first), // ── Measured, or absent ── // // Rider → this place, from a live fix where there is one and from // whatever the booking carries otherwise. `metersToStop` returns null // rather than zero when it has nothing to work with, and the header // prints nothing rather than "0 m" — a distance is a thing the rider // plans around, so a wrong one is worse than a missing one. meters: RouteMetricsHelper.metersToStop( first, riderLat: riderLat, riderLng: riderLng, ), // One order at one address with no counter above it worth naming: // the header is the stop. Always true on a parcel route, where the // rider rides to each customer for one consignment — a header plus a // single child would print the same name twice. flat: flat, ); }()); } return groups; } /// The rung a whole group is on: the state of its first order that the rider /// can still act on, falling back to the first order's own state. /// /// A selection spanning two rungs makes the bar fall back to Accept, so the /// group only ever gathers one — the same rule the old kitchen heading used. StopState _groupRung(List> stops) { for (final s in stops) { final state = _stateOf(s); if (_isChoosable(state)) return state; } return _stateOf(stops.first); } /// The closing leg of a parcel day, as a line rather than a node-and-band. Widget _returnNote() => Padding( padding: EdgeInsets.only(left: 38.w, top: 2.h), child: Row( children: [ Icon( LucideIcons.warehouse, size: 14.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 8.w), // Flexible: a tracked-out eyebrow at 2.0× text scale on a 320pt phone // is wider than the row it sits in, and a bare `Text` in a `Row` has no // give. `trip_brief_layout_test.dart` sweeps for exactly this. Flexible( child: Text( ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME', maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.eyebrow, ), ), ], ), ); // ── HUB → stops → HUB ────────────────────────────────────────────────── // // Retained only for the tests that still pin the old composition. Nothing on // Home calls it — see [_timeline]. // ignore: unused_element Widget _sequence() { // Computed once for the whole trip, not per stop — it is a comparison // across all of them. final addressTail = trip.sharedAddressTail; // ── One journey button per screen ── // // The head above the list already offers "Navigate to pickup" for the // current place. Repeating it on each of that place's five cards is five // more buttons that do the identical thing, on the screen whose whole // premise is that there is exactly one next action — and the rider then has // to work out whether they differ. They do not, so only the head has it. // // Cards for *other* places keep theirs: those are journeys the head is not // offering. final head = _nextGroup(); final headIds = { if (head != null) for (final s in head.stops) _idOf(s), }; return Padding( // ── The stop rows sit inside the summary cards, symmetrically ── // // The whole route block is inset 8 further than the shift and stat cards // above it: the rail's numbered node starts at 28 and the stop card's // right edge lands at 28, against the summary's 20. // // Both sides moved together on purpose. Insetting only the right made the // card's own margins even but left the rail closer to the border than // anything else on the screen, so the row still read as pushed against // the edge — just from the other side. A list indents as a block or it // does not indent at all. // // And the block genuinely is one: a rail plus its card is an indented list // item under the summary, not another card in the same column, so it is // correct that it does not share a page edge with the cards above. padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Select-all leads the list it selects ── // // This sat at the *foot* of the route, under the last hub row. On a // six-stop trip that is a full screen of scrolling away from the // first stop, and it is the control a rider reaches for **before** // reading the list, not after: "take the lot" is the common case, and // he was made to scroll past everything he had just decided to take // in order to say so. // // At the head of the list it is the first thing under the trip tabs, // it reads as a heading for the rows beneath it — which is what a // select-all is — and the running count sits where his eye already is // when he ticks a row. _selectAllRow(), _hubRow(isStart: true), // ── One screen, one question ── // // Home lists the stops that still need something *from him here*: // // • **pending** — undecided, waiting to be ticked and accepted. // • **rejected** — declined, kept visible so he can see he handled // it rather than wondering where it went, and can take it back. // // Everything else has moved to the tab where it is acted on: accepted // and in-progress stops to Bookings, finished ones to Activity. Both // used to sit here as rows the rider could do nothing with, and there // is no bound on how many of those a shift produces — six stops in, // he was scrolling past six dead cards to reach one live one, so the // screen got harder to use in exact proportion to how much work he // had done. // // **Skipped** stops went the same way, to Activity. A skip is a // record of something that happened at a door — like a completed // stop, and unlike a pending one — and Home is the screen for the one // question "what am I taking?". Keeping skips here meant the accept // list grew a permanent tail of stops that were not on offer, and the // rider had to read past his own morning to find the afternoon's // work. Activity carries them with the reason he gave and the same // Resume control. // // They are filtered at the *row*, not removed from [trip]: the // brief's ring and its "2 of 6 stops done" are computed from the // trip, and a trip that shed its accepted and finished stops would // report 0 of 1 and empty its own ring every time he took something. // The indices are the trip's own too, so the numbering does not // renumber itself as the day goes on. // ── Grouped by source, when there is one ── // // A service rider's day is two stops then ten doors, and a flat list // of ten hides that shape completely: he cannot see that five of them // come from one kitchen and five from another until he opens each // card. Grouping states it, and gives the bulk collect somewhere to // live that is unambiguously about *those five*. if (ServiceProfile.active.sourceIsKitchen) ..._groupedRows(addressTail, headIds) else for (var i = 0; i < trip.stops.length; i++) if (_showsOnHome(_stateOf(trip.stops[i]))) _stopRow(i, addressTail, headIds), // A trip whose every stop has been taken or finished still has hub // rows and a brief, and would otherwise show an unexplained gap // between them. if (trip.stops.isNotEmpty && !trip.stops.any((s) => _showsOnHome(_stateOf(s)))) _allTakenNote(), // ── A meal run does not come back ── // // The parcel day is a loop: leave the hub empty, fill up at customer // doors, return with the consignments — so "RETURN · HUB" is the last // real thing on the route and the rider is not finished until he is // there. // // A meal rider finishes at the last subscriber's door and goes home. // Drawing him a return leg to a warehouse he has never been to states // a journey nobody is making, and puts an un-completable step at the // end of a route he has in fact completed. if (!ServiceProfile.active.sourceIsKitchen) _hubRow(isStart: false), ], ), ); } /// Stop rows under a heading per source, in route order. /// /// Groups are emitted in the order their first stop appears, so the sections /// follow the route the hub built rather than an alphabetical order that /// would send the rider to the wrong kitchen first. A group whose stops have /// all left Home — collected, and now on Bookings — emits nothing at all; /// its heading would be a title over an empty space. List _groupedRows(List addressTail, Set headIds) { final order = []; final groups = >{}; for (var i = 0; i < trip.stops.length; i++) { if (!_showsOnHome(_stateOf(trip.stops[i]))) continue; // Key on the id where there is one, falling back to the name: the id is // what the hub controls, but a payload that only carries a name still has // to group rather than collapsing every stop into one nameless pile. final stop = trip.stops[i]; final key = stopSourceId(stop).isNotEmpty ? stopSourceId(stop) : stopSourceName(stop); if (!groups.containsKey(key)) { groups[key] = []; order.add(key); } groups[key]!.add(i); } return [ for (final key in order) ...[ _kitchenHeader(key, groups[key]!), for (final i in groups[key]!) _stopRow(i, addressTail, headIds), SizedBox(height: 6.h), ], ]; } /// `🏪 Kitchen 1 · 5 meals` with the one action that group affords. Widget _kitchenHeader(String key, List indices) { final first = trip.stops[indices.first]; final name = stopSourceName(first); // A route with no source names at all still groups — into one unnamed // group — and a heading reading "· 5 meals" would be worse than none. if (name.isEmpty) return const SizedBox.shrink(); final states = [for (final i in indices) _stateOf(trip.stops[i])]; final pending = states.where((s) => s == StopState.pending).length; final accent = ColorConstants.serviceAccent; // ── Every order off one counter, in one tick ── // // This is the selection the ladder is actually made of. He does not arrive // at an order, he arrives at a kitchen — so the control that gathers "all // of this kitchen's orders" belongs on the kitchen's own heading, and the // bar underneath then speaks for exactly that counter. // // The set is the group's orders **on one rung**: whichever rung the first // choosable stop is on. A selection spanning two rungs makes the bar fall // back to Accept (see `_selectionAction`), which would be a heading that // ticks five cards and then offers the wrong verb for three of them. final groupIds = []; StopState? rung; for (final i in indices) { final state = _stateOf(trip.stops[i]); if (!_isChoosable(state)) continue; rung ??= state; if (state != rung) continue; final id = _idOf(trip.stops[i]); if (id.isNotEmpty) groupIds.add(id); } final picked = groupIds.where(selectedIds.contains).length; final allPicked = groupIds.isNotEmpty && picked == groupIds.length; return Padding( padding: EdgeInsets.only(top: 10.h, bottom: 8.h), child: Row( children: [ if (onSelectAll != null && groupIds.isNotEmpty) SelectBox( selected: allPicked, partial: picked > 0 && !allPicked, accent: ColorConstants.primary, label: 'Select every order from $name', circular: true, onTap: () => onSelectAll!(allPicked ? const [] : groupIds), ) else Icon(LucideIcons.store, size: 16.sp, color: accent), SizedBox(width: 7.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), // ── One order, one bag ── // // This counted "meals", which is a word for the thing inside // the bag rather than the thing the rider carries — and it left // him comparing "5 meals" on the phone against a shelf of bags. // The count he can *check* is orders against bags, and under // this workflow they are the same number by definition. See // [BagManifest]. Text( pending > 0 ? '${BagManifest.countLabel(indices.length)} · $pending to accept' : BagManifest.countLabel(indices.length), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, fontWeight: FontWeight.w700, letterSpacing: -0.1, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), // ── One bulk flow at a counter, not two ── // // A `Collect N` button used to sit here and open a second sheet with // its own tick-list, alongside the tick-plus-bar ladder below. Two // ways to do one physical thing is two mental models for a rider // holding a crate, and the two wrote different state: one posted // PICKED, the other kept collection local. // // The ladder won — it is the same gesture on every rung and every // line of work — and the load sheet's one irreplaceable feature, the // missing-bag report, moved into the pickup confirmation where the // handover is actually confirmed. See [StopActionSheet]. ], ), ); } /// The band the hub node and its label share. /// /// Tall enough for the tallest thing on the row, which is the MAP action, not /// the node — see [_hubRow]. Both sides are centred in this one height, so /// they line up by construction at any text scale instead of by a nudge that /// only holds at one. double get _hubBand => 36.h; Widget _hubRow({required bool isStart}) { return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( children: [ // The node, centred in the shared band rather than sitting at the // top of it. SizedBox( height: _hubBand, child: Center( child: Container( width: 22.w, height: 22.w, decoration: BoxDecoration( color: _hubColor.withValues(alpha: 0.14), shape: BoxShape.circle, border: Border.all(color: _hubColor, width: 1.5), ), child: Icon( // Where this rider's day actually begins. A warehouse // glyph on a meal run points at a building he will never // visit; his first address is a kitchen counter. ServiceProfile.active.sourceIsKitchen ? LucideIcons.store : LucideIcons.warehouse, size: 12.sp, color: _hubColor, ), ), ), ), // No connector under the node any more. It used to run down into // a rail of numbered stop nodes drawn *outside* the cards; the // journey thread now lives inside each card, where it connects // the two ends of one order, and a stub of line pointing at a // card edge would be a rail with nothing on it. ], ), SizedBox(width: 8.w), Expanded( // ── The label sits level with the node, not near it ── // // This was a 1dp top padding, which is a guess at where a 11.5sp // line lands inside a 22dp circle — wrong at every text scale but // the one it was eyeballed at, so the hub icon read as floating // above its own label. // // It was then boxed to the *node's* height, 22, which fixed the // alignment and broke the row: the MAP action beside the label is a // tap target with its own padding and a chevron, comfortably taller // than 22, so it was cropped through the middle. Half a control is // worse than a misaligned icon. // // Both sides are now centred in one band sized for the tallest // thing on the row ([_hubBand]). Nothing is clipped and the two // centres coincide by construction. The bottom padding stays // outside the band so the connector below still gets its gap. child: Padding( padding: EdgeInsets.only(bottom: isStart ? 10.h : 0), child: SizedBox( height: _hubBand, child: Row( mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, // `spaceBetween` with a [Flexible] label rather than a // `Spacer`: a Spacer is flex-1, so it would have split the free // space evenly with a flexible label and squeezed the text for // no reason. This way the label takes its natural width, the // action takes its own, and only a genuinely narrow screen // makes the label give ground. mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( isStart ? (ServiceProfile.active.sourceIsKitchen ? 'START · KITCHEN' : 'START · BASE') // ── A milk run does not return anything ── // // He loads at the kitchen and his last address is a // customer's door: there is no base at the end of // his day and nothing to carry back to one. The // logistics rider genuinely does return collected // shipments to the depot, so the label follows the // capability rather than the screen position. : (ServiceProfile.active.endsAtHub ? 'RETURN · BASE' : 'END · HOME'), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.7, color: _hubColor, fontFamily: FontConstants.fontFamily, ), ), ), // The long caption ("Leave the hub with the delivery // parcels") was read once and then never again — it is the // same on every trip, every day. The label alone carries it. // ── Why the map lives on this row ── // // It was buried at the bottom of the trip brief, which is the // wrong place twice over: it is an *action* among a list of // read-only figures, and it was two taps deep behind a strip // the rider has no other reason to open. // // The route starts here. "Before you leave the hub, here is // the whole run on a map" is the one moment the map answers a // question the rider is actually asking, and this row is // where his eye already is when he starts. It also had a slot // going spare — the caption that used to fill it is gone. if (isStart && onViewRoute != null) _ViewRouteAction(onTap: onViewRoute!), ], ), ), ), ), ], ), ); } /// "Select all · N of M selected", at the head of the route. /// /// Rendered only when there is more than one undecided stop — with one stop /// its own tick already is the select-all — and only when the page supplies a /// selection handler. /// /// It is a tinted strip rather than a bare row: it sits between the trip tabs /// and the first hub row, both of which are strong horizontal marks, and an /// unbacked tick out here read as a stray control belonging to neither. Widget _selectAllRow() { final pendingIds = trip.pendingOrderIds( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ); if (onToggleSelect == null || pendingIds.length < 2) { return const SizedBox.shrink(); } final picked = pendingIds.where(selectedIds.contains).toList(); final all = picked.length == pendingIds.length; final some = picked.isNotEmpty && !all; return Padding( padding: EdgeInsets.only(bottom: 8.h), child: Material( color: some || all ? ColorConstants.primary.withValues(alpha: 0.06) : ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: InkWell( // The whole strip toggles, not just the 28pt box — this is the one // control on the screen the rider reaches for without looking. onTap: () => onSelectAll?.call(all ? const [] : pendingIds), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Padding( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), child: Row( children: [ SelectBox( selected: all, partial: some, accent: ColorConstants.primary, label: all ? 'Clear the selection' : 'Select every pending stop', onTap: () => onSelectAll?.call(all ? const [] : pendingIds), ), SizedBox(width: 5.w), Expanded( child: Text( picked.isEmpty ? 'Select all ${pendingIds.length} stops' : '${picked.length} of ${pendingIds.length} selected', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: picked.isEmpty ? ColorConstants.secondaryText : ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), if (picked.isNotEmpty) Padding( padding: EdgeInsets.only(right: 8.w), child: Text( all ? 'CLEAR' : 'ALL', style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), ), ); } /// Which states earn a row on Home. See the note at the call site. /// /// ── Accepted is Home's problem too, on a service run ── /// /// For a parcel, accepting is the whole decision and everything after it /// happens at a customer's door, so an accepted stop belongs on Bookings. /// /// Accepting a subscriber's lunch changes nothing physical: the food is still /// in a kitchen he has not reached. Handing the card over at that moment /// empties Home the instant he starts his shift and leaves him working a /// Bookings list of meals he is not carrying. So Home keeps it until he is — /// see [ServiceProfile.handoffAt]. /// ── The stop he is on never leaves this screen ── /// /// An active stop stays on Home for every tenant now. It used to be hidden on /// a parcel route and announced instead by a red banner floating over the /// bottom of the page — a second, separate object saying "you have a job /// running" about a stop the list underneath was refusing to show. The row /// itself is the better place for it: it already has the customer, the /// address, the parcels and the position in the route, and it is where the /// rider is looking. /// ── A declined stop is history, not a row ── /// /// `rejected` used to belong here, so a stop the rider had turned down stayed /// on Home struck through with an UNDO beside it — a row he had finished with, /// occupying the screen he uses to decide what is next, for the rest of the /// shift. On a run where he declines three, that is three permanent rows of /// work that is not his. /// /// Declining is a decision, and decisions are recorded on Activity. It files /// there the moment he makes it — see `_rejectStop` — and the undo goes with /// it: an action that reverses itself from the page it was performed on is /// what kept the row here in the first place. static bool _showsOnHome(StopState state) => state == StopState.pending || // Arrived belongs here for the same reason active does, and it is the // reason this reads as a predicate: see [StopStateX.isAtSource]. state.isAtSource || // Accepted stays only where the rider works that rung from this screen; // on a parcel route it moves to Bookings the moment he takes it. (state == StopState.accepted && ServiceProfile.active.handsOffAtCollection); /// The lifecycle status a Home row's state corresponds to. /// /// [StopState] is the screen's vocabulary and [StopStatus] is the lifecycle's; /// this is the one place they are mapped, so the row's button and the sheet's /// heading cannot end up describing different rungs. StopStatus _rungOf(StopState state) => switch (state) { StopState.accepted => StopStatus.accepted, // Both: this answers *which rung is the stop on*, and it is the same for // either — he is at the counter and the next thing he does is collect. StopState.arrived || StopState.active => StopStatus.arrived, _ => StopStatus.unknown, }; /// Shown in place of the stop list when every stop in the trip has been taken /// or finished — so the gap between the two hub rows says what happened /// instead of looking like a failed load. /// /// An illustration now, per the user's direction (2026-08-20) — the sentence /// it replaces ("Every stop on this trip is taken — work them from …") lives /// on as the image's semantic label, so a screen reader still gets the fact /// and the handoff pill below still names the tab to go to. Widget _allTakenNote() { return Padding( padding: EdgeInsets.symmetric(vertical: 38.h, horizontal: 8.w), child: Center( child: Semantics( label: 'Every stop on this trip is taken — work them from ' '${ServiceProfile.active.workTabLabel}', image: true, child: Image.asset( 'assets/images/bookings_taken.png', width: 280.w, height: 280.w, fit: BoxFit.contain, // A missing asset must degrade to nothing, never to the grey // missing-image box in the middle of Home. errorBuilder: (_, _, _) => const SizedBox.shrink(), ), ), ), ); } /// One stop, as one card. /// /// Everything about *shape* now lives in [StopCard] — the journey thread, the /// two legs, the footer. What stays here is the trip's own state: which /// controls this stop has earned, and what each of them does. Widget _stopRow( int index, List addressTail, [ Set headIds = const {}, ]) { final stop = trip.stops[index]; final state = _stateOf(stop); final id = _idOf(stop); final stopBusy = busyStopIds.contains(id); final phone = (stop['pickupcontactno'] ?? '').toString().trim(); // City / state / pincode stripped — see [commonAddressTail]. What is left is // the only part that tells this stop from the next one. Unless the design // trial is on, in which case the raw address is used; see // [kFullAddressTrial]. final address = kFullAddressTrial ? (stop['pickupaddress'] ?? '').toString() : trip.shortAddress(stop, addressTail); final choosable = _isChoosable(state); // ── The next rung, only on the stop he is standing at ── // // The route the rider is meant to take is: navigate to the counter, tick // the orders that come off it, and claim the rung once for the lot from // the bar. So an *accepted* card offers the journey (see [StopCard]) and // not the claim — a per-card "Arrived" beside a Navigate button is two // answers to one question, and the wrong one is a wrong claim to the hub. // // An **arrived** stop keeps a card-level rung, because by then the batch // question is settled: he is at the counter, and the sheet it opens acts // on every order from that counter anyway ([_advanceStopWithSheet]). final advance = state.isAtSource ? (MilkRun.nextActionLabel(_rungOf(state)) == null ? null : 'Mark as picked') : null; return StopCard( stop: stop, // The route's own numbering, which does not renumber itself as stops // leave the screen — see the filter note in [_sequence]. index: index + 1, state: state, address: address, service: roundTripDuration(Trip.serviceTimeFor(stop)), bag: _bagOf(stop), // The kitchen heading above already names where this came from. groupedUnderSource: ServiceProfile.active.sourceIsKitchen, selected: selectedIds.contains(id), busy: stopBusy, // Tap opens the sheet, anywhere on the card. Selection keeps the tick, // which is the visible, explicit control for it. onTap: onStopTap == null ? null : () => onStopTap!(stop), onToggleSelect: (choosable && onToggleSelect != null) ? () => onToggleSelect!(id) : null, // Calling ahead is the one action a rider takes from the LIST rather than // at the door — "are you open?", "I'm five minutes away". onCall: (phone.isEmpty || onCallStop == null) ? null : () => onCallStop!(stop), // Hidden when the stop has no coordinates for the leg it is on: a // Navigate that opens a map on nothing is worse than none, because the // rider only finds out after committing to follow it. onNavigate: (onNavigateStop == null || MilkRun.navigationTarget(stop) == null || // Already offered, once, by the head above the list. headIds.contains(id)) ? null : () => onNavigateStop!(stop), // A rejected stop is on Home precisely because the rider might change his // mind. Undoing returns it to undecided, where it is ticked and accepted // like any other — nothing here accepts anything by itself. onUnreject: onUnrejectStop == null ? null : () => onUnrejectStop!(stop), // The way back into the stop he is already working. This used to be a red // banner floating over the foot of the page. // // Offered only for a stop that is genuinely worked on its own screen. On // a kitchen line a collection is not: its rungs are the slide sheet and // the floating bar on this very page, so a Continue that opened the // single-stop map/arrive/confirm flow was a second route up the same // ladder — and the one that ends in a sheet asking him to confirm a // delivery for a bag still on a counter. See [MilkRun.worksOnOwnScreen]. onContinue: (onContinueStop == null || !MilkRun.worksOnOwnScreen(stop, collectedIds: collectedIds)) ? null : () => onContinueStop!(stop), onAdvance: (advance == null || onAdvanceStop == null) ? null : () => onAdvanceStop!(stop), advanceLabel: advance, ); } // `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See // [_MetaLine], which says the same three things on one uncoloured line. /// Which bag this order travels in. /// /// Derived from its position in **its own pickup group**, not from the whole /// route: the rider counts bags off one counter at a time, so the third order /// from Kitchen 2 is *its* Bag 3 and not the route's ninth. A label the /// kitchen actually printed always wins — see [BagManifest]. String _bagOf(Map stop) { if (!ServiceProfile.active.sourceIsKitchen) return stopBagLabel(stop); final key = MilkRun.sourceKeyOf(stop); final group = [ for (final s in trip.stops) if (MilkRun.sourceKeyOf(s) == key) s, ]; return BagManifest.bagFor(stop, group); } // ── Footer: accept remaining, or go work the trip ────────────────────── Widget _footerBar(List pendingIds) { final finished = trip.isFinished( acceptedIds: acceptedIds, rejectedIds: rejectedIds, ); if (finished) { return Padding( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), child: Container( height: ButtonSizes.primary, alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.acceptGreen.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(ButtonSizes.radius), border: Border.all( color: ColorConstants.acceptGreen.withValues(alpha: 0.4), width: 1.5, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.badgeCheck, size: 20.sp, color: ColorConstants.acceptGreen, ), SizedBox(width: 8.w), // Flexible for the same reason as [_LockedNote]'s: this sentence // is wider than the bar it sits in once the type scales up, and // an unflexed Text in a centred Row overflows rather than // shrinking. Flexible( child: Text( 'Trip complete · head back to base', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, color: ColorConstants.acceptGreen, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ); } // Nothing left to decide. The way on to the work is the accepted-count pill // floating over the page — one route to Bookings, always in the same place, // rather than a button that appears at the foot of a trip only once that // trip happens to be fully decided. if (pendingIds.isEmpty) return const SizedBox.shrink(); // Only ticks on stops that are still this trip's and still undecided. final picked = pendingIds.where((id) => selectedIds.contains(id)).toList(); // ── One accept, one place ── // // An "Accept all N stops" button used to sit here, under the last hub row. // It was a second bulk action over a *different* set of stops from the // floating bar's — and the difference between the two sets was exactly the // stops the rider had just decided he did not want. Now there is one accept // in the whole screen, on the floating bar, and it always means "the ones I // ticked". Taking a whole trip is the select-all tick at the head of the // list followed by that same button. // // What is left down here is one line telling him where the action went. The // select-all itself moved to the top — see [_selectAllRow]. return Padding( padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), // Full width, or `textAlign` has nothing to align within: this sits in a // `CrossAxisAlignment.start` column, so the Text shrink-wrapped to its // own words and centring them inside their own box moved nothing. The // caption has asked to be centred for as long as it has been left. child: SizedBox( width: double.infinity, child: Text( // Verb-first and half the length. A hint under a list is read at a // glance or not at all; twelve words set as a sentence was furniture. picked.isNotEmpty ? 'Accept your picked stops below' : 'Tick stops to accept them', textAlign: TextAlign.center, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ); } } /// "MAP ›" on the START · HUB row. /// /// The label was "VIEW ROUTE", which spent three syllables and most of the row /// saying what the map pin beside it and the destination screen both already /// say. "MAP" is the noun the rider uses for the thing he is opening. /// /// A text action rather than a button: it sits on a label row inside the route /// list, and a filled control there would out-rank the Accept bar that carries /// the trip's actual decision. Text plus a chevron is the standard way to say /// "there is more of this elsewhere" without claiming to be the main thing on /// the screen. /// /// The visible text is tight against the row's right edge while the tap target /// extends past it in every direction, so it clears the 44dp floor without the /// label appearing to float away from the margin. class _ViewRouteAction extends StatelessWidget { final VoidCallback onTap; const _ViewRouteAction({required this.onTap}); @override Widget build(BuildContext context) { return Semantics( button: true, label: 'View the whole route on a map', child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Padding( padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 7.h), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.map, size: 14.sp, color: ColorConstants.primary), SizedBox(width: 5.w), Text( 'MAP', style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w700, letterSpacing: 0.6, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), Icon( LucideIcons.chevronRight, size: 16.sp, color: ColorConstants.primary, ), ], ), ), ), ); } } /// Hub / collection-centre accent, matching the Bookings route rail. final Color _hubColor = ColorConstants.tertiary; /// ───────────────────────────────────────────────────────────────────────── /// THE LOADING ROUTE — the real timeline, in grey /// /// ── Why this is not the app's generic `SkeletonList` ── /// /// Home used to load as three shimmering rounded rectangles, which is a /// skeleton of *a list of cards* — a screen Home stopped being when the route /// became a timeline. So the placeholder promised one layout and the arrival /// delivered another, and every redesign of the route silently made the lie /// bigger, because nothing links the two. /// /// `skeletonizer` (2.1.3, MIT, 2.3k likes) removes the link problem rather than /// fixing this instance of it: it takes the **real widget tree** — this real /// [TripCard], built over placeholder stops — and paints its text and shapes as /// bones. The loading state is therefore the actual node, spine, load line and /// journey column, at the actual sizes, and it cannot drift from the screen it /// is standing in for. Changing the timeline changes the skeleton. /// /// It earns the dependency for the same reason: the alternative is a /// hand-drawn second copy of a layout that is still moving. /// /// The placeholder route is deliberately one kitchen with three orders — the /// commonest shape — and the strings are never read: `Skeletonizer` replaces /// every one of them with a bone before paint. class HomeRouteSkeleton extends StatelessWidget { const HomeRouteSkeleton({super.key}); static Map _stop(int i) => { 'orderid': 'skeleton-$i', 'type': 'pickup', 'pickupcustomer': 'Loading customer', 'pickupaddress': 'Loading address, Coimbatore', 'kitchenid': 'skeleton-kitchen', 'kitchenname': 'Loading kitchen', 'Quantity': '1', }; @override Widget build(BuildContext context) { return Skeletonizer( // The app's own shimmer feel: one soft sweep, not a strobe. A loading // screen a rider sees forty times a shift is the last place for motion // that asks to be watched. effect: ShimmerEffect( baseColor: ColorConstants.borderSubtle, highlightColor: ColorConstants.neutralLight, duration: const Duration(milliseconds: 1100), ), child: TripCard( trip: Trip.fromStops( id: 'skeleton', stops: [for (var i = 0; i < 3; i++) _stop(i)], ), ).buildStops(), ); } }