import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/data/stop_area.dart'; import 'package:miler/data/accepted_store.dart'; import 'package:miler/data/work_scope.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/milk_run.dart'; import 'package:miler/data/stop_compliance.dart'; import 'package:miler/views/Dashboard/activity/activity_format.dart'; import 'package:miler/views/Dashboard/activity/delivery_details_page.dart'; import 'package:miler/views/helpers/widgets/page_transitions.dart'; import 'package:miler/providers/pickup/pickup_provider.dart'; import 'package:miler/views/Dashboard/pickups/pickups.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/widgets/app_widgets.dart'; import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/data/service_day.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/widget/Bottom_page.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ACTIVITY — the stops the rider has finished /// /// ── Why this tab exists ── /// /// Home used to keep a stop after it was completed, greyed out with a /// "Completed" chip. It was the wrong place for it twice over: Home is the /// screen where the rider decides what to take, so a finished stop is the one /// kind of row he can do nothing about — and there is no upper bound on them. /// Six stops into a shift he was scrolling past six dead cards to reach the one /// live one, so the screen got harder to use in exact proportion to how much /// work he had done. /// /// Finished work is still worth showing — it is how he checks what he did, and /// how he answers "did that drop actually go through?" — it just is not /// *decision* material. So it moves here, to the tab that used to be Earnings. /// Earnings did not need a permanent tab: it is a once-or-twice-a-day read, and /// it now opens from Account. What he did today is the thing he glances at /// between stops. /// /// ── What counts as finished ── /// /// [StopStatusX.isFinishedPickup] — picked up or cancelled — which is the same /// test Bookings uses to drop a stop off its working list. So every stop that /// leaves Bookings arrives here, and the two can never disagree about what /// "done" means. /// /// ── And what is parked ── /// /// Skipped stops are here too, in their own group above the finished ones, with /// the reason the rider gave and a Resume control. They used to sit on Home, /// among the stops still waiting to be accepted, and that was the wrong screen /// for the same reason finished stops were: Home answers "what am I taking?", /// and a skip is not on offer — it is something that already happened at a /// door. It also accumulated. By mid-shift the accept list carried a permanent /// tail of stops the rider had already put down, and he had to read past his /// own morning to find the afternoon's work. /// /// They lead this tab rather than trailing it because they are the one thing on /// it that is not finished: a skipped stop is a promise to go back, and the /// rider has until the end of the shift to keep it. /// ───────────────────────────────────────────────────────────────────────── /// The slices of a day the rider ever asks for. /// /// Four, because four is what fits on a phone without scrolling and because /// every one of them has a real predicate over data the page already holds — /// there is no chip here standing in for a query the app cannot answer. /// /// **Active** is the parked work: stops he walked away from and still owes a /// return visit. It is the only slice on this page carrying an action, which is /// why it sits second, next to All, rather than at the end with the settled /// ones. enum _ActivityFilter { all('All', LucideIcons.layers, 'Nothing here yet.'), active( 'Active', LucideIcons.circleSlash, 'Nothing waiting on a return visit.', ), completed('Completed', LucideIcons.packageCheck, 'Nothing finished yet.'), cancelled('Cancelled', LucideIcons.ban, 'Nothing was cancelled.'); const _ActivityFilter(this.label, this.glyph, this.emptyLine); final String label; final IconData glyph; /// What the page says when this slice is empty but the day is not. Phrased as /// a fact about the slice, never as an apology. final String emptyLine; /// Whether a row belongs in this slice. /// /// The four are exhaustive and disjoint over what [_ActivityPageState._all] /// can hold: a stop is parked, cancelled/declined, or settled. bool admits(Map stop) { final s = stopStatusOf(stop); return switch (this) { _ActivityFilter.all => true, _ActivityFilter.active => s.isSkipped, _ActivityFilter.cancelled => s.isCancelled || s.isRejected, _ActivityFilter.completed => s.isFinishedPickup && !s.isCancelled && !s.isRejected, }; } } class ActivityPage extends StatefulWidget { const ActivityPage({super.key}); @override State createState() => _ActivityPageState(); } class _ActivityPageState extends State { final PickupProvider _provider = PickupProvider(); List> _done = >[]; /// Stops parked for a return visit, newest first. Kept apart from [_done] /// because they are the opposite kind of row: unfinished, and the only ones /// on this page that still carry an action. List> _skipped = >[]; /// Order ids with a resume in flight, so one cannot be fired twice. final Set _busyIds = {}; /// Which slice of the day is on screen. Held on the state, not derived: it is /// the rider's own choice and it must survive a poll, a pull-to-refresh and /// the tab going away and coming back. _ActivityFilter _filter = _ActivityFilter.all; bool _firstLoadDone = false; bool _failed = false; bool _fetching = false; /// Which trip tab is open. Held across refreshes; clamped at read time, so a /// trip disappearing between polls cannot leave the page pointing past the /// end of the list. /// Everything this page accounts for: what he put down, then what he /// finished. One list so the trip grouping sees the whole run. List> get _all => [..._skipped, ..._done]; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); // ── Re-read whenever the tab comes forward ── // // The shell keeps all four tabs alive in an `IndexedStack`, so this // `initState` runs once at app launch — before the rider has completed // anything. Without this listener the tab showed that first, empty result // for the rest of the session: he finished a stop, came here, and found // nothing, which is exactly the bug this page was added to prevent. BottomPage.currentTab.addListener(_onTabChanged); } @override void dispose() { BottomPage.currentTab.removeListener(_onTabChanged); super.dispose(); } void _onTabChanged() { if (BottomPage.currentTab.value == _activityTabIndex && mounted) _load(); } /// This page's slot in the shell. See [BottomPage.goToTab]. static const int _activityTabIndex = 2; /// Today's finished stops, from the same two endpoints Bookings reads. /// /// Both are asked for, not just the "picked" one: v3 carries the completed /// records and v1 carries the live statuses, and when they disagree about a /// stop the live one is right — the same precedence Bookings applies, for the /// same reason. Future _load() async { if (_fetching) return; _fetching = true; try { final prefs = await SharedPreferences.getInstance(); final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; final results = await Future.wait([ _safe(() => _provider.getPickupQueuesPicked()), _safe(() => _provider.getCurrentPickups()), ]); final Map> merged = {}; for (final row in results[0].whereType>()) { merged[_keyOf(row)] = row; } for (final row in results[1].whereType>()) { final key = _keyOf(row); merged[key] = {...?merged[key], ...row}; } // ── The local record wins ── // // Finishing a stop is optimistic: the rider is moved on whether or not // the status call landed, and the queue endpoints will not report the // stop as finished for a poll or two — on demo data, never. Reading only // the API meant a rider could complete a booking and find this tab empty, // which is precisely what it exists to disprove. // // So the completed store is merged in last and overwrites: it is the // newest thing the app knows, it carries the completion time the API does // not, and it is what the Bookings tab acted on when it dropped the stop. // // (The store is scoped to this rider/tenant/line at its key — see // [WorkScope] — so what comes back is already only this session's.) for (final row in await getCompletedBookings()) { final key = _keyOf(row); merged[key] = {...?merged[key], ...row}; } // ── The rider's own half of the history ── // // Accepted, arrived, picked, set off: four moments the backend keeps no // event feed for, stamped locally as they happened. Merged onto the row // so the details page reads one object rather than joining two stores // itself. Absent keys stay absent — a moment with no stamp is not drawn. // See [OrderEvent]. for (final row in merged.values) { final id = (row['orderid'] ?? '').toString(); if (id.isEmpty) continue; final events = await getOrderEvents(id); if (events.isNotEmpty) row.addAll(events); } unawaited(pruneOrderEvents()); // Skips last of all, and for the same reason: the store is the newest // thing the app knows about one, it carries the reason and the time the // API has no field for, and on demo data it is the *only* record. A stop // resumed since is not in it, so it cannot re-park something the rider // has taken back. final skippedStore = await getSkippedBookings(); for (final row in skippedStore) { final key = _keyOf(row); // ── Which skips can actually be taken back ── // // Only these. A skip parked from the *pickup* leg is resumable: the // stop goes back to accepted and the rider drives to it again. // // A skip taken at a customer's door is not. `resumeSkippedStop` posts // an **accept** against the booking, which is the wrong object and the // wrong rung for a parcel that has already been collected and // converted — and the delivery flow has, by then, dropped the stop from // the rider's hands and forgotten its consignment mapping. Offering // Resume there is a button that cannot do what it says. The record is // still shown, with its reason; what it does not carry is a promise. merged[key] = {...?merged[key], ...row, 'canresume': true}; } // ── The ownership rule, applied once, at the data boundary ── // // Everything above is merged from four sources; this is the single gate // they all pass through before Activity renders anything. A row that // proves it belongs to another rider, tenant or line is dropped — // identity only, never a display string like a kitchen name or the word // "Milk". Rows carrying no identity are kept: the API returned them for // *this* authenticated session, and the local stores are already keyed // to it. See [WorkScope]. final scope = await WorkScope.current(); merged.removeWhere((_, row) => scope.excludes(row)); // ── Today's shift, and only today's ── // // Activity is an operational log, not an archive: what it answers is // "how has today gone". The local stores already prune themselves to the // current day, but the API half does not — `GET /miler/bookings` returns // the rider's whole open set, so a booking finished yesterday and still // listed came through and sat among this morning's work. // // Filtered at the boundary rather than in the widget, so every count, // every trip summary and the day's cash total are all computed off the // same set the list draws. Nothing is deleted — see [ServiceDay] for why // this is a read-time question and not a scheduled purge. final serviceDay = ServiceDay.today; final before = merged.length; merged.removeWhere( (_, row) => !ServiceDay.belongsToToday(row, now: serviceDay), ); if (before != merged.length) { debugPrint( '[ACTIVITY] $serviceDay — kept ${merged.length} of $before rows', ); } final skippedIds = skippedStore .map((r) => (r['orderid'] ?? '').toString()) .toSet(); // Resuming writes the stop into the accepted store, so that is the record // that says "taken back" while the queue endpoint still reports 'skipped'. final acceptedIds = await getAcceptedOrderIds(); final rows = merged.values.toList(); // Newest first: the stop he just finished is the one he is most likely // checking, and it should not be at the bottom of the day. final done = rows.where((o) => stopStatusOf(o).isFinishedPickup).toList() ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); final skipped = rows.where((o) { if (!stopStatusOf(o).isSkipped) return false; final id = (o['orderid'] ?? '').toString(); // In the store → parked, full stop. Otherwise trust the backend // unless the rider has since taken it back: a stop the queue still // calls 'skipped' but which sits in the accepted store has been // resumed, and offering Resume on it again would be nonsense. return skippedIds.contains(id) || !acceptedIds.contains(id); }).toList()..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); if (!mounted) return; setState(() { _done = done; _skipped = skipped; _failed = false; _firstLoadDone = true; }); } catch (e) { debugPrint('[ACTIVITY] load failed: $e'); if (!mounted) return; setState(() { _failed = true; _firstLoadDone = true; }); } finally { _fetching = false; } } Future> _safe(Future> Function() call) async { try { return await call(); } catch (e) { debugPrint('[ACTIVITY] endpoint failed: $e'); return []; } } String _keyOf(Map o) { final id = (o['orderid'] ?? '').toString(); return id.isNotEmpty ? id : (o['pickupid'] ?? o.hashCode).toString(); } /// Best available "when did this happen" time, epoch-comparable. /// /// One implementation, shared with the details page — [happenedAt] reads the /// same key order and goes through [parseStamp], which is what makes a /// backend timestamp carrying a false `Z` sort where its own printed clock /// says it should. This used to be a second copy with a bare /// `DateTime.tryParse` in it, and the two disagreed by five and a half hours /// on exactly the rows the backend had touched. DateTime _happenedAt(Map o) => happenedAt(o); @override Widget build(BuildContext context) { final bool hasWork = _all.isNotEmpty; return Scaffold( // The brand shows through the sheet's two top corners — see [MilerSheet]. backgroundColor: ColorConstants.primary, appBar: const MilerAppBar(title: 'Activity'), body: MilerSheet( child: RefreshIndicator( onRefresh: _load, color: ColorConstants.primary, // ── The chrome is pinned; only the answer under it swaps ── // // The strapline and the filter bar are the page's own furniture: they // say what this screen is and which slice of it he is looking at, and a // filter that scrolled away would leave the rider reading a partial // list with nothing on screen explaining why. They are hidden entirely // on a day with no work, because there is nothing to filter and an // empty state should own the screen it is explaining. // ── Stretch, or the furniture is centred instead of guttered ── // // A `Column` cross-aligns `center` by default, so this one handed // `_chrome` loose width: it shrink-wrapped to its widest child and // was then centred. The heading and the filter bar therefore sat on // whatever edge the centring happened to leave — measured at 48pt — // while the trip rows below stood correctly on the 20pt gutter. // // Every `_gutter` inside the chrome was being applied to a box that // was not the width of the page, which is why the numbers in the // code and the pixels on the screen disagreed. child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_firstLoadDone && hasWork) _chrome(), Expanded( child: SmoothSwap( child: !_firstLoadDone ? _skeleton() : _failed && _all.isEmpty ? ErrorRetry( key: const ValueKey('activity-error'), onRetry: _load, ) : !hasWork ? _empty() : _list(), ), ), ], ), ), ), ); } // ── The shift record, as a page ─────────────────────────────────────────── // // Activity ← app bar // Your recent delivery activity ← what this screen is // [ All ] Active Completed Cancelled ← which slice of it // ───────────────────────────────────────── // 4 of 5 on time · ₹2,450 collected ← how the day scored // TODAY // ● Delivered 11:42 AM // Vidhya Kitchen → Joe Mathew // 2 orders · 4.6 km · 1h 27m // YESTERDAY // … // // Nothing here is a card. A card is a container you put round a thing that // needs separating from the things next to it, and every row on this page is // the same kind of thing arriving in one order — which is a list, and a list // is separated by rhythm. The only surface on the whole screen is the paper. /// The page's single left edge. Every row, every heading and every figure /// column starts here, which is what lets the eye run straight down a day's /// work instead of stepping in and out of a card's padding on every stop. static double get _gutter => 20.w; /// The padding a filter chip carries inside itself. Named because the bar /// has to subtract exactly this much to put its first label on [_gutter]. static double get _chipInset => 8.w; // ══════════════════════════════════════════════════════════════════════ // CHROME — what this screen is, and which slice of it // ══════════════════════════════════════════════════════════════════════ Widget _chrome() { // ── The gutter is a line, not a padding value ── // // Padding this whole column put the heading's text on the gutter but the // first tab's *label* eight points right of it, because the chip carries // its own padding inside. Two left edges, eight apart, one under the other // — close enough to look like a mistake rather than a margin. // // So the column is unpadded and each child lands its own ink on the line: // the overview by plain padding, the bar by subtracting the padding its // chips already have. The chips keep their full tap width. return Padding( padding: EdgeInsets.symmetric(vertical: 2.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.symmetric(horizontal: _gutter), child: _shiftOverview(), ), SizedBox(height: 16.h), _filterBar(), ], ), ); } /// ── How the shift has gone, in three lines ── /// /// The strapline said *Your recent delivery activity*, under an app bar that /// said **Activity**. One of those two was spending a line to repeat the /// other, and neither answered the question a rider opens this screen with /// between stops: **how am I doing today**. /// /// Typography, not statistic cards. Boxed figures are for a dashboard — /// something you compare across days, at a desk. This is a shift log read /// one-handed at a gate, and three lines of plain text are faster to take in /// than three bordered cells and cost no vertical space to draw. /// /// **Only what the data already holds.** Completed and needing-attention are /// counts of the rows on screen; cash comes from the same figures the stops /// carry; on-time comes from [ComplianceSummary], which is what the day /// footer already computed. Nothing is derived that the record cannot prove, /// and a metric with nothing behind it is not printed at all — a shift with /// no measured stops has no on-time line rather than a confident `0%`. Widget _shiftOverview() { final rows = _all; final done = rows.where((s) => stopStatusOf(s).isFinishedPickup).toList(); final attention = rows.where((s) => stopStatusOf(s).isSkipped).length; final cash = done.fold(0, (s, o) => s + stopCollectionAmount(o)); final c = ComplianceSummary.from(done); // Each half carries its own colour, so the pair is built as (text, owed?) // rather than as one string — see the header below for why. // The completed count is the numeral above now, so this line carries // only what the numeral cannot: the exception. final second = <(String, bool)>[ if (attention > 0) ('$attention ${attention == 1 ? 'needs' : 'need'} attention', true), ]; final third = [ if (ServiceProfile.active.collectsCash && cash > 0) '${rupees(cash)} collected', if (c.measured > 0) '${((c.onTime / c.measured) * 100).round()}% on time', ]; // ── The day's figure leads, the way Home's does ── // // "Today" at 19sp over a grey sentence was a heading over a caption — // nothing on the page was the *result*. The completed count is the fact a // rider opens this screen for, so it takes the numeral treatment the app // now uses everywhere a figure is the headline: big, tabular, captioned // small. "Today" steps up to being the eyebrow. With nothing finished yet // the numeral is withheld — a page that opens with a giant 0 tells a // rider he has done nothing, which is Home's own lead-with-remainder rule // read from the other end. // No `TODAY` eyebrow — this page has a standing rule (and a test) that // the all-caps date-band vocabulary never returns; the word rides in the // numeral's caption instead, lowercase, where it is a fact and not a // band. return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (done.isNotEmpty) ...[ SizedBox(height: 2.h), MediaQuery.withClampedTextScaling( maxScaleFactor: 1.3, child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( '${done.length}', style: TextStyle( fontSize: 28.sp, fontWeight: FontWeight.w800, letterSpacing: -1, height: 1.0, fontFeatures: const [FontFeature.tabularFigures()], color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 7.w), Flexible( child: Padding( padding: EdgeInsets.only(top: 6.h), child: Text( '${ServiceProfile.active.completionVerb.toLowerCase()}' ' today', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ), ], ), ), ] else ...[ SizedBox(height: 4.h), Text( 'Today', style: MilerType.body.copyWith( fontSize: 19.sp, fontWeight: FontWeight.w700, letterSpacing: -0.4, ), ), ], if (second.isNotEmpty) ...[ SizedBox(height: 4.h), // ── Amber is a word, not a line ── // // This used to colour the joined string, so one skipped stop turned // "4 delivered" amber too — the shift's good news rendered in the // warning ink. A rider glancing at it read the whole day as a // problem. // // Colouring per segment is what the rule always meant: amber marks // the clause that is genuinely owed, and everything beside it stays // neutral. With nothing owed the line has no colour at all, which is // the point — amber has to be rare to still mean something. Text.rich( TextSpan( children: [ for (var i = 0; i < second.length; i++) ...[ if (i > 0) TextSpan( text: ' · ', style: TextStyle(color: ColorConstants.secondaryText), ), TextSpan( text: second[i].$1, style: TextStyle( color: second[i].$2 ? ColorConstants.warning : ColorConstants.secondaryText, ), ), ], ], ), style: MilerType.body.copyWith( fontSize: 14.sp, fontWeight: FontWeight.w500, ), ), ], if (third.isNotEmpty) ...[ SizedBox(height: 2.h), Text( third.join(' · '), style: MilerType.micro.copyWith(fontSize: 13.sp), ), ], ], ); } /// ── One filled chip, three words ── /// /// The selected filter is the only one wearing a container. That is the whole /// design: a bar where every chip is outlined is four boxes competing to say /// which one is on, and on a page whose brief is *containers recede* it would /// be the loudest object on the screen. Filled-vs-nothing is unambiguous at /// arm's length, in sunlight, and costs one container instead of four. /// /// No counts. A number beside each word doubles the text in the bar to answer /// a question the list underneath answers better by simply being that long. /// /// Scrollable rather than wrapped: at large text scales four chips do not fit /// a phone's width, and a bar that reflows to two lines moves the list under /// the rider's thumb. /// Stops still owing the rider a return visit. Drives the one count in the /// filter bar, and computed from the same rows the list draws. int get _attentionCount => _all.where((s) => stopStatusOf(s).isSkipped).length; Widget _filterBar() { // ── Sized by its own type, never by a fixed box ── // // A `SizedBox(height:)` around a row of words clips them the moment the // rider turns his text size up, and this app is read at 2.0x on a bike. The // bar takes its height from the label plus symmetric padding, and the // scroll view is told to shrink-wrap that rather than to fill a number. return SingleChildScrollView( scrollDirection: Axis.horizontal, // `_chipInset` back off the leading edge so the first label — not the // chip box around it — sits on the same line as the heading above. padding: EdgeInsets.only( left: _gutter - _chipInset, right: _gutter - _chipInset, ), physics: const ClampingScrollPhysics(), child: Row( children: [ for (final (i, f) in _ActivityFilter.values.indexed) ...[ if (i > 0) SizedBox(width: _chipInset), Builder( builder: (context) { final selected = f == _filter; return Semantics( button: true, selected: selected, label: f.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: selected ? null : () => setState(() => _filter = f), child: Padding( // Horizontal padding only from the gap between words; // the vertical keeps a 48dp target under a 13.5sp label. padding: EdgeInsets.symmetric( horizontal: _chipInset, vertical: 12.h, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Text( f.label, maxLines: 1, style: MilerType.body.copyWith( fontSize: 14.sp, fontWeight: selected ? FontWeight.w700 : FontWeight.w500, letterSpacing: -0.1, color: selected ? ColorConstants.primary : ColorConstants.secondaryText, ), ), // ── A count only where it changes a decision ── // // On *Active* it does: it is the number of stops // the rider still owes somebody, and seeing it // without opening the slice is the reason to // open the slice. Everywhere else the list's own // length answers better, and a number beside // every word doubles the text in the bar. if (f == _ActivityFilter.active && _attentionCount > 0) ...[ SizedBox(width: 5.w), Text( '$_attentionCount', style: MilerType.body.copyWith( fontSize: 13.sp, fontWeight: FontWeight.w700, color: ColorConstants.warning, ), ), ], ], ), SizedBox(height: 6.h), // The whole selected state: a 2pt brand rule. Four // pills were four containers arguing about which one // was on, on a page whose brief is that containers // recede. AnimatedContainer( duration: DesignConstants.motionState, curve: Curves.easeOutCubic, height: 2.h, width: selected ? 18.w : 0, decoration: BoxDecoration( // Transparent when it is not the selected one: // a zero-width rule still *has* a colour, and a // brand-coloured box nobody can see is the kind // of thing that reads as four selected filters // to anything inspecting the tree. color: selected ? ColorConstants.primary : Colors.transparent, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), ), ), ], ), ), ), ); }, ), ], ], ), ); } // ══════════════════════════════════════════════════════════════════════ // THE LIST // ══════════════════════════════════════════════════════════════════════ /// Rows the selected filter admits, newest first. /// /// Sorted once here rather than per section: the date headers are cut out of /// this order, so the order is the thing that has to be right. List> get _filtered { final rows = _all.where(_filter.admits).toList() ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); return rows; } /// Which trip is open. Null means all collapsed, which is where the page /// starts on a day with more than one trip. int? _openTrip; /// ───────────────────────────────────────────────────────────────────── /// TODAY, IN THREE TRIPS /// /// A rider's day is not a stream of stops; it is one to three runs, and the /// question he actually asks this screen is "how did Trip 2 go". The list /// answered a different one — everything he has ever finished, newest first, /// cut into date bands — which on a three-run day is forty rows he has to /// scroll to reconstruct a grouping the hub already assigned him. /// /// So the grouping is the hub's, not the clock's: [Trip.groupIntoTrips] /// buckets on `tripid`/`slotid` when the backend sends one and on the day /// part only when it does not, and [Trip.sortStops] holds the admin sequence /// inside each. **Nothing here reorders a route** — this screen shows how a /// planned run went, so the order it shows has to be the planned order. /// /// One trip open at a time. Three collapsed headers and one expanded body is /// a screen the rider can hold in his head; three expanded bodies is the /// list he already could not read. /// ───────────────────────────────────────────────────────────────────── Widget _list() { final rows = _filtered; if (rows.isEmpty) return _noneInFilter(); // The day's score is the *finished* work's score — a parked stop has not // been measured against anything yet — so it is computed off the settled // rows regardless of which slice is on screen. // ── The day's figures moved to the top of the page ── // // They used to head the list: on-time, on-route and cash, as a dotted line // above the first stop. That is the same question `_shiftOverview` now // answers in the chrome, where it belongs — a summary of the shift is not // the first item *of* the shift, and printing it twice left a band of dead // space between the filter and the first trip. final children = []; final slots = Trip.groupIntoTrips(rows).slots; final live = [ for (var i = 0; i < slots.length; i++) if (slots[i] != null && slots[i]!.stops.isNotEmpty) (i, slots[i]!), ]; // A day that produced one run needs no accordion: a single header the // rider must tap to see the only thing on the page is a lock on an empty // door. It opens as a plain list under its own heading. if (live.length <= 1) { if (live.isEmpty) return _listView(children); final (slot, trip) = live.single; children.add(_tripHeader(slot, trip, expanded: true, tappable: false)); children.addAll(_tripBody(trip)); return _listView(children); } for (final (slot, trip) in live) { final open = _openTrip == slot; children.add(_tripHeader(slot, trip, expanded: open, tappable: true)); children.add( // AnimatedSize over an if/else: the body has to *reveal*, and a // subtree that appears at full height reads as the page jumping rather // than as a drawer opening. Curve and duration are the app's own — // see [DesignConstants.motionState]. ClipRect( child: AnimatedSize( duration: DesignConstants.motionState, curve: Curves.easeOutCubic, alignment: Alignment.topCenter, child: open // The same well Home's kitchens open into: a canvas-toned // inset that makes the open trip read as *contents*, not as // more page. One system, both accordions. ? Container( margin: EdgeInsets.only(bottom: 6.h), padding: EdgeInsets.fromLTRB(10.w, 6.h, 10.w, 0), decoration: BoxDecoration( color: MilerSurface.canvas, borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: _tripBody(trip), ), ) : SizedBox(width: double.infinity, height: 0), ), ), ); } return _listView(children); } /// The stops of one trip, in the order the hub assigned them. List _tripBody(Trip trip) => [ for (final (i, stop) in trip.stops.indexed) _row(stop, first: i == 0, last: i == trip.stops.length - 1), SizedBox(height: 10.h), ]; /// `Trip 2 · 6 delivered · 1 skipped`, and the chevron that opens it. /// /// The summary is the point of a collapsed header: it has to answer "did /// that run go well" without being opened, or the rider opens all three /// anyway and the accordion has bought nothing. Widget _tripHeader( int slot, Trip trip, { required bool expanded, required bool tappable, }) { final finished = trip.stops .where((s) => stopStatusOf(s).isFinishedPickup) .toList(); // ── A logistics day has two kinds of finished stop ── // // Collections and deliveries both land in this list, because a line that // ends at a hub counts `picked` as done. Summing them under one verb told // the rider "6 picked up" for a run that was three collections and three // hand-overs — the two halves of his day flattened into whichever word the // profile happened to name. final collected = finished .where((s) => !MilkRun.workingKind(s).isDelivery) .length; final handedOver = finished.length - collected; final skipped = trip.stops.where((s) => stopStatusOf(s).isSkipped).length; final clean = skipped == 0; return Padding( padding: EdgeInsets.only(top: 14.h, bottom: 4.h), child: Material( color: Colors.transparent, child: InkWell( onTap: tappable ? () => setState(() => _openTrip = expanded ? null : slot) : null, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), child: Padding( // Vertical only. A horizontal inset here bought nothing — the row // is already full-width, so it added no tap area — and cost the // disc its place on the gutter the headings stand on. padding: EdgeInsets.symmetric(vertical: 10.h), child: Row( children: [ // A settled run reads green at a glance; one with a stop still // owed reads amber. The disc is the only colour on the header, // so it carries the verdict on its own. Container( width: 34.w, height: 34.w, alignment: Alignment.center, decoration: BoxDecoration( color: (clean ? ColorConstants.acceptGreen : ColorConstants.warning) .withValues(alpha: 0.12), shape: BoxShape.circle, ), child: Icon( clean ? LucideIcons.check : LucideIcons.rotateCcw, size: 16.sp, color: clean ? ColorConstants.acceptGreen : ColorConstants.warning, ), ), SizedBox(width: 11.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( 'Trip ${trip.tripNumber == 0 ? slot + 1 : trip.tripNumber}', style: MilerType.body.copyWith( fontSize: 16.sp, fontWeight: FontWeight.w700, letterSpacing: -0.3, ), ), SizedBox(height: 2.h), Text( _tripSummaryLine( collected: collected, handedOver: handedOver, skipped: skipped, ), maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.micro, ), ], ), ), if (tappable) AnimatedRotation( turns: expanded ? 0.5 : 0, duration: DesignConstants.motionState, curve: Curves.easeOut, child: Icon( LucideIcons.chevronDown, size: 18.sp, color: ColorConstants.secondaryText, ), ), ], ), ), ), ), ); } /// The one line a collapsed trip gets. Counts, in the rider's words — never /// a status enum, and never the internal name of the line he is on. /// `3 collected · 3 delivered · 1 skipped`. /// /// Names each half only when there is one. A meal run is deliveries all the /// way down and reads `6 delivered`; a logistics day that did both says so; /// a run with nothing on it says nothing rather than a row of zeroes. String _tripSummaryLine({ required int collected, required int handedOver, required int skipped, }) { final verb = ServiceProfile.active.completionVerb.toLowerCase(); final parts = [ if (collected > 0) '$collected collected', if (handedOver > 0) '$handedOver $verb', if (skipped > 0) '$skipped skipped', ]; if (parts.isEmpty) return 'Nothing recorded'; return parts.join(' · '); } Widget _listView(List children) => ListView( key: const ValueKey('activity-list'), physics: const AlwaysScrollableScrollPhysics( parent: BouncingScrollPhysics(), ), padding: EdgeInsets.fromLTRB( _gutter, 6.h, _gutter, BottomPage.bottomInset(context) + 16.h, ), children: children, ); /// Heads the one band that is not a date. /// // ── The date headers are gone, and with them `UNDATED` ── // // Activity is today's shift, so a date band above every row said the same // word all the way down the page. The trips say something the rider does not // already know; the date did not. // // The `UNDATED` header went with them, and that is the part worth recording. // It fired for any record whose timestamp would not parse, which is a defect // in a payload — not a fact about the rider's morning, not anything he can // act on, and not something that belongs on a screen he reads at a gate. // Such a record is now simply kept in today and shown like any other; see // [ServiceDay.belongsToToday]. /// `a · b · c`, each piece keeping its own colour. /// /// A `Wrap` of separate [Text]s rather than one `RichText`: the pieces are /// independently coloured, they have to break onto a second line at large /// text scales rather than ellipsing away the last figure, and a real `Text` /// per piece is what keeps them individually addressable — by a screen /// reader, and by the tests that assert an exception is drawn in amber. Widget _dotted(List<(String, Color)> parts, {double size = 13}) { final style = MilerType.micro.copyWith( fontSize: size.sp, fontWeight: FontWeight.w600, ); return Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ for (final (i, part) in parts.indexed) ...[ if (i > 0) Text( ' · ', style: style.copyWith(color: ColorConstants.borderStrong), ), Text( part.$1, maxLines: 1, overflow: TextOverflow.ellipsis, style: style.copyWith(color: part.$2), ), ], ], ); } /// The slice is empty but the day is not — so this is about the filter, not /// about the rider's morning, and it says so in one line with the way out /// directly under it. Widget _noneInFilter() { return ListView( key: ValueKey('activity-none-${_filter.name}'), physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB(_gutter, 60.h, _gutter, 0), children: [ Icon(_filter.glyph, size: 34.sp, color: ColorConstants.borderStrong), SizedBox(height: 14.h), Text( _filter.emptyLine, textAlign: TextAlign.center, style: MilerType.body.copyWith( fontSize: 15.sp, fontWeight: FontWeight.w600, color: ColorConstants.secondaryText, ), ), SizedBox(height: 12.h), Center( child: SizedBox( width: 150.w, child: MilerButton( label: 'Show all', variant: MilerButtonVariant.tonal, color: ColorConstants.primary, height: ButtonSizes.compact, expand: false, onPressed: () => setState(() => _filter = _ActivityFilter.all), ), ), ), ], ); } // ══════════════════════════════════════════════════════════════════════ // STATES // ══════════════════════════════════════════════════════════════════════ /// A loading state shaped like the page it is standing in for. /// /// The shared [SkeletonList] draws generic 40pt-icon cards, which is the /// shape this tab stopped being: the rider watched four cards shimmer and /// then resolve into something that was not cards, and the swap read as the /// screen changing its mind. These bones are the strapline, the filter bar, /// the day line and four rows — node, status, route, meta — in the places /// they will actually land. Widget _skeleton() { // Aligned, because a `ListView` hands its children a *tight* cross-axis // constraint — a bare `SkeletonBone` with a width in it comes out full // width, which is how a skeleton stops looking like the text it stands in // for and starts looking like a stack of grey bars. Widget bone(double w, double h, {double radius = 5}) => Align( alignment: AlignmentDirectional.centerStart, child: SkeletonBone(width: w, height: h, radius: radius), ); Widget row() => Padding( padding: EdgeInsets.only(bottom: 22.h), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBone(width: 24.w, height: 24.w, radius: 12.w), SizedBox(width: 12.w), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ bone(96.w, 13.h), SizedBox(height: 9.h), bone(190.w, 15.h), SizedBox(height: 8.h), bone(140.w, 11.h), ], ), ), SizedBox(width: 10.w), SkeletonBone(width: 54.w, height: 12.h, radius: 5), ], ), ); // ── The bones are white; the shimmer is what colours them ── // // [SkeletonBone] paints in `pureSurface`, which is the page. It is only // visible inside [MilerShimmer], whose `Shimmer.fromColors` recolours its // whole subtree as the sweep passes. Without the wrapper this state renders // as a blank screen that looks exactly like a load that has hung. return MilerShimmer( child: ListView( key: const ValueKey('activity-skeleton'), physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB(_gutter, 10.h, _gutter, 0), children: [ bone(180.w, 13.h), SizedBox(height: 16.h), Row( children: [ SkeletonBone(width: 64.w, height: 30.h, radius: 15.h), SizedBox(width: 8.w), SkeletonBone(width: 74.w, height: 30.h, radius: 15.h), SizedBox(width: 8.w), SkeletonBone(width: 92.w, height: 30.h, radius: 15.h), ], ), SizedBox(height: 20.h), bone(210.w, 13.h), SizedBox(height: 24.h), bone(52.w, 11.h), SizedBox(height: 14.h), for (var i = 0; i < 4; i++) row(), ], ), ); } /// ── The empty day ── /// /// One glyph, one line, one way out. The illustration that used to head this /// screen was 300pt wide on a 390pt phone — a picture of a clipboard, larger /// than anything the page ever draws when it *does* have something to say, /// standing in for the absence of six short rows. An empty state is a caption /// on a screen with nothing on it; it is not a screen of its own. Widget _empty() { return ListView( key: const ValueKey('activity-empty'), // A scrollable, so the pull-to-refresh still works on an empty day. physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB(_gutter, 72.h, _gutter, 0), children: [ // ── The artwork says it; the words repeated it ── // // The glyph, the headline and the sentence under it were three // statements of one fact — that nothing has been finished yet — on a // screen whose emptiness already says so. The image carries it, and // the button underneath is the only thing here the rider can act on. // // Sized well under the old 300pt illustration this screen once had: an // empty state is a caption on a page with nothing on it, not a page of // its own. Center( child: Image.asset( 'assets/images/no_activity_yet.png', // Keyed so a test can name *this* artwork rather than "an Image", // which on a page that also draws proof photos and button glyphs // is not a unique thing to ask for. key: const ValueKey('activity-empty-art'), width: 330.w, fit: BoxFit.contain, // A missing asset must not take the empty state down with it — // the button below is the point of the screen. errorBuilder: (_, _, _) => SizedBox(height: 120.h), ), ), SizedBox(height: 24.h), Center( child: SizedBox( width: 190.w, child: MilerButton( label: 'Go to ${ServiceProfile.active.workTabLabel}', color: ColorConstants.primary, height: ButtonSizes.compact, expand: false, onPressed: () => BottomPage.goToTab(1), ), ), ), ], ); } // ══════════════════════════════════════════════════════════════════════ // ONE ACTIVITY ITEM // ══════════════════════════════════════════════════════════════════════ /// ── What a rider reads in under a second ── /// /// ``` /// ● Delivered 11:42 AM ⌄ /// │ Vidhya Kitchen → Joe Mathew /// │ 2 orders · 4.6 km · 1h 27m · Late 7m /// │ /// │ [the record, when it is open] /// ``` /// /// **The outcome leads.** The row used to open with the customer's name, /// which is the one fact on it a rider cannot act on and the one he is not /// scanning for — history is opened to answer "did that go through?", and the /// answer to that is a word. The name is still the second line, where it /// identifies *which* stop the outcome belongs to. /// /// **The route is one line, not two.** `Vidhya Kitchen → Joe Mathew` says /// where it came from and where it went in the space the address used to take /// to say neither. The address itself is a detail for one stop and lives in /// the record. /// /// **The measurements are one line of running text**, not three columns and /// not four chips. Each figure is a `Text` of its own so an exception can be /// amber inside a grey line without becoming a container — see [_dotted]. /// /// Everything else — the timings, the compliance, the parcels, the money — is /// a *specific* question asked rarely, usually because the office is asking, /// and it is one tap under the row. Uber, Rapido and every banking app the /// rider uses put trip detail exactly here, for exactly this reason. /// ── What a rider recognises, anchored ── /// /// ``` /// ╭────╮ Thiru Test 2 /// │ ✓ │ Delivered · 11:57 AM /// ╰────╯ 4.6 km · 1h 27m › /// ──────────────────────────────────────────────────────── /// #DM-BK-16DA518D-6381 Details › /// ``` /// /// ── Three labels came off ── /// /// The card carried `ORDER ID`, `CUSTOMER` and `COLLECTED AT` as eyebrows /// over their three values: six lines to say three things, and the loudest of /// them was `#DM-BK-16DA518D-6381` — twenty-one characters of routing /// reference set as the headline of a card a rider scans by *name*. He does /// not know a stop by its booking id; he knows it by who was at the door. /// /// So the name leads, the outcome and the clock share the line under it, and /// the reference drops to a footer in the size a lookup key deserves. It is /// still on the card — somebody in the office reads it down a phone at him — /// it just is not pretending to be the subject. /// /// ── The tile is the anchor ── /// /// A 44pt status-tinted square with the state's glyph in it. Every history /// list the rider already uses has one, and it is doing real work here: it /// gives the eye a fixed left edge to run down at speed, and it carries the /// outcome in shape and colour before a single word is read. The status is /// then a *word* beside the clock rather than a second badge in the corner — /// a tile and a pill saying the same thing is one of them too many. /// ── One line of a shift log, not a card ── /// /// Every finished stop wore a `GlassCard`: a surface, a border, a shadow and /// a 44dp tinted tile with a glyph in it, repeated down the page. Twelve /// stops was twelve boxes, and the effect of giving every completed delivery /// its own container is that none of them recedes — a settled stop, which is /// the overwhelming majority of this screen, should be the quietest thing on /// it. /// /// So the container is gone and the rail replaces it. A small marker on a /// hairline connects the stops of a run into one visible sequence, which is /// also the shape the record page uses for its journey: the rider sees the /// same picture of his run in both places. /// /// Ranked: the customer is the strongest text (it is how he knows the stop), /// the outcome is the second, the clock is right-aligned where the eye can /// run down it, and the route and figures are the quiet lines beneath. The /// booking reference is **not** here — it is a lookup key for an office, it /// lives on the record page, and a rider should not be scanning past /// database identifiers to find a name. Widget _row( Map stop, { bool first = false, bool last = false, }) { final id = (stop['orderid'] ?? '').toString(); final status = stopStatusOf(stop); final at = _happenedAt(stop); final hasTime = at.millisecondsSinceEpoch > 0; final c = StopCompliance.of(stop); final bool parked = status.isSkipped && stop['canresume'] == true; // A door-step skip: shown like a parked stop, minus the control. See the // merge in `_load`. final bool attempted = status.isSkipped && !parked; final bool closed = status.isCancelled || status.isRejected; // A skip of either kind is not a late delivery — there was no delivery. final bool late = !closed && !status.isSkipped && c.onTime == false; // The word, the colour and the glyph come from the canonical list — // [StopStatusX], next to the parser that produces the states — never a // switch written here. final Color mark = status.color; final ends = routeEndsOf(stop); final reason = (parked || attempted) ? (stop['skipreason'] ?? stop['notes'] ?? '').toString().trim() : ''; final meta = _meta(stop, late: late, c: c); final proof = stop['proof']; final double rowCash = (proof is Map && proof['amountcollected'] is num) ? (proof['amountcollected'] as num).toDouble() : stopCollectionAmount(stop); final who = (ends.to ?? ends.from).trim(); return Semantics( button: id.isNotEmpty, label: '${status.label}, ${routeLineOf(stop)}', hint: id.isEmpty ? null : 'Opens the delivery record', child: Material( color: Colors.transparent, child: InkWell( onTap: id.isEmpty ? null : () => _openDetails(context, stop), child: IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _railColumn( mark: mark, icon: status.icon, first: first, last: last, ), SizedBox(width: 12.w), Expanded( child: Padding( // ── The gap answers to what is actually in the row ── // // Seen on a device: a logistics record carries no // completion clock, no route line and no measurements — // `/miler/bookings` returns none of them — so most rows // are a name and a word. At a fixed 18 of separation that // is a row which is mostly padding, and seven of them fill // a phone that should hold twelve. // // The wide gap is there to separate *two-and-three-line* // rows on a meal run. A short row does not need it. padding: EdgeInsets.only( top: 8.h, bottom: last ? 8.h : (meta.isEmpty && ends.to == null ? 10.h : 18.h), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Text( who.isEmpty ? 'Customer' : who, maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 16.sp, fontWeight: FontWeight.w600, letterSpacing: -0.3, ), ), ), // ── The figures give ground, and stop growing ── // // A fixed money column plus a fixed clock beside a // flexible name overflows the row from about 1.6x // system text — 122pt off a 320pt phone at 2.0x, // which is a black-and-yellow band across the one // line that says who the stop was. // // Clamped rather than shrunk: `₹2,450` and `5:08 // PM` are read at a glance, not studied, so they // stop scaling at 1.3 while the name — the thing // the rider is actually looking for — keeps // scaling all the way up. if (rowCash > 0 || hasTime) MediaQuery.withClampedTextScaling( maxScaleFactor: 1.3, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (rowCash > 0) ...[ SizedBox(width: 8.w), Text( rupees(rowCash), maxLines: 1, style: MilerType.figure( 13.5, color: ColorConstants.moneyGreen, ).copyWith(fontWeight: FontWeight.w700), ), ], if (hasTime) ...[ SizedBox(width: 10.w), Text( RouteMetricsHelper.formatClock(at), maxLines: 1, style: MilerType.figure( 13, color: ColorConstants.secondaryText, ).copyWith(fontWeight: FontWeight.w500), ), ], ], ), ), ], ), SizedBox(height: 2.h), // ── The word does not repeat the rail ── // // The rail disc beside this row already carries the // outcome in shape and colour, and the card's own rule // is that a tile and a pill saying the same thing is // one too many. Painted green, a day of 25 delivered // stops was a column of 25 green words — and a page // where everything is coloured has no colour left for // the row that went wrong. // // So the norm is quiet ink and the exception keeps the // status colour. And on a logistics row — which the // payload leaves as a bare name and a word — the line // earns its keep by saying *where*: `Delivered · // Peelamedu` when there is no route line to say it. Text( [ status.label, if (ends.to == null) if (areaOf(stop) case final a when a.isNotEmpty) a, ].join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.body.copyWith( fontSize: 13.5.sp, fontWeight: FontWeight.w600, color: (status.isSkipped || status.isCancelled || status.isRejected) ? mark : ColorConstants.secondaryText, ), ), // The route, and only when there are two ends to it — // a collection has one place and printing it twice // reads as a journey that went nowhere. if (ends.to != null && ends.from.trim().isNotEmpty) ...[ SizedBox(height: 3.h), Text( routeLineOf(stop), maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith(fontSize: 12.5.sp), ), ], if (meta.isNotEmpty) ...[ SizedBox(height: 4.h), Row( children: [ Flexible(child: _dotted(meta, size: 12.5)), if (id.isNotEmpty) ...[ SizedBox(width: 8.w), Icon( LucideIcons.chevronRight, size: 14.sp, color: ColorConstants.borderStrong, ), ], ], ), ], // ── The one row that still owes something ── // // A finished stop has nothing left to do and stays // quiet. A parked one has exactly one thing, and it // gets a compact control rather than a full-width // button: a page of history is not a page of actions, // and a big button on every row would make the // exception look like the rule. if (reason.isNotEmpty) ...[ SizedBox(height: 5.h), Text( reason, maxLines: 2, overflow: TextOverflow.ellipsis, style: MilerType.micro.copyWith( fontSize: 13.sp, fontWeight: FontWeight.w600, color: ColorConstants.warning, ), ), ], if (parked) ...[ SizedBox(height: 4.h), _resumeAction(stop, id), ], ], ), ), ), ], ), ), ), ), ); } /// The rail and this stop's marker. /// /// 22dp, tinted, with the status glyph — a quarter of the area the old tile /// took. It is an anchor for the eye and a colour cue, not a badge. Widget _railColumn({ required Color mark, required IconData icon, required bool first, required bool last, }) { return SizedBox( width: 24.w, child: Column( children: [ SizedBox( height: 8.h, child: first ? null : Center( child: Container( width: 1.5, color: ColorConstants.borderSubtle, ), ), ), Container( width: 22.w, height: 22.w, alignment: Alignment.center, decoration: BoxDecoration( color: mark.withValues(alpha: 0.13), shape: BoxShape.circle, ), child: Icon(icon, size: 12.sp, color: mark), ), if (!last) Expanded( child: Center( child: Container( width: 1.5, color: ColorConstants.borderSubtle, ), ), ), ], ), ); } /// `Resume stop →`, at a real tap target and with the guards intact. /// /// Compact, but the row it sits in is 48dp tall including its padding, and /// the loading/disabled protection is the same the full-width button had: /// an id already in flight cannot be pressed twice. The resume rules /// themselves are untouched — only a stop the data layer marks resumable /// reaches this at all. See `_load`'s merge and `MyPickups.resumeSkippedStop`. Widget _resumeAction(Map stop, String id) { final busy = _busyIds.contains(id); return Align( alignment: Alignment.centerLeft, child: Material( color: Colors.transparent, child: InkWell( onTap: busy ? null : () => _resume(stop), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), child: Padding( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 9.h), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (busy) SizedBox( width: 12.w, height: 12.w, child: CircularProgressIndicator( strokeWidth: 1.8, valueColor: AlwaysStoppedAnimation( ColorConstants.primary, ), ), ) else Icon( LucideIcons.rotateCcw, size: 13.sp, color: ColorConstants.primary, ), SizedBox(width: 6.w), Text( 'Resume stop', style: MilerType.body.copyWith( fontSize: 13.5.sp, fontWeight: FontWeight.w700, color: ColorConstants.primary, ), ), ], ), ), ), ), ); } /// Opens one stop's record on its own page. /// /// A real route push, not a sheet: it has its own back button, its own scroll /// position and its own place in the history stack, which is what lets a /// rider read a long record without losing where he was in the day. Future _openDetails(BuildContext context, Map stop) => openScreen(context, DeliveryDetailsPage(stop: stop)); /// The measurements line: what was carried, how far, how long, what was /// taken, and the one exception that gets a word. /// /// Every piece is dropped when the app did not measure it. A stop closed in /// bulk from Home has no departure time and therefore no duration, and /// printing `0m` for it would be an invention that cannot later be caught — /// same rule [StopCompliance] follows. List<(String, Color)> _meta( Map stop, { required bool late, required StopCompliance c, }) { final grey = ColorConstants.secondaryText; final started = stampOf(stop, const ['startedat']); final done = stampOf(stop, const [ 'completedat', 'pickedtime', 'picked_time', ]); final took = (started != null && done != null) ? done.difference(started) : null; final kind = MilkRun.workingKind(stop); final int parcels = kind.isDelivery ? deliveryParcelCount(stop) : pickupParcelCount(stop); final cash = stopCollectionAmount(stop); // ── What a collection produced ── // // On logistics the rider's day is *both* legs — he collects shipments and // he delivers them — and Activity lists both, because // `StopStatusX.isWorkComplete` treats `picked` as finished on a line that // ends at a hub. What the record did not say is what a collection actually // *created*: the rider raised a shipment at that door, weighed it and // priced it, and the row reported only that he had been there. // // The weight is the figure that makes it a shipment rather than a visit — // it is what the consignment is billed on and the one number an office // asks him about later. Read from the proof the verify screen wrote, so a // stop that was never weighed shows nothing rather than a zero. final proof = stop['proof'] is Map ? Map.from(stop['proof'] as Map) : const {}; final weight = (proof['weight'] ?? stop['weight'] ?? '').toString().trim(); final showWeight = !kind.isDelivery && ServiceProfile.active.pricesShipment && weight.isNotEmpty && weight != '0'; return <(String, Color)>[ if (parcels > 0) ( // A collection *raised* the orders; a delivery *handed them over*. // The same figure means two different things and the line says // which, because on this line both are in the same list. kind.isDelivery ? '$parcels ${parcels == 1 ? 'order' : 'orders'}' : '$parcels ${parcels == 1 ? 'order' : 'orders'} raised', grey, ), if (showWeight) ('$weight kg', grey), if (c.actualKm != null && c.actualKm! > 0) ('${kmText(c.actualKm!)} km', grey), if (took != null && took.inSeconds > 0) (shortDuration(took), grey), if (ServiceProfile.active.collectsCash && cash > 0) (rupees(cash), ColorConstants.moneyGreen), // The norm says nothing; the exception is the only thing that gets a // word, in the warning colour, on the row it happened to. if (late) ( 'Late ${shortDuration(c.lateBy ?? Duration.zero)}', ColorConstants.warning, ), ]; } /// Puts a parked stop back into the working queue and takes the rider there. /// /// The wire call, the two stores and the poller's skip cache are all handled /// by [MyPickups.resumeSkippedStop] — this is only the screen's half: keep the /// button from firing twice, drop the row, and follow the stop to the work /// tab, because a rider who taps Resume is going there next either way. Future _resume(Map stop) async { final id = (stop['orderid'] ?? '').toString(); if (id.isEmpty || _busyIds.contains(id)) return; setState(() => _busyIds.add(id)); try { final ok = await MyPickups.resumeSkippedStop(stop); if (!mounted) return; if (!ok) { AppFeedback.error(context, 'Could not resume this stop'); return; } setState(() { _skipped = _skipped .where((s) => (s['orderid'] ?? '').toString() != id) .toList(); }); // The tab's own name, not a literal. It reads "Deliveries" on a meal // run, and a message naming a tab the rider cannot see is the same // mistake the page title carried until it started reading the profile. AppFeedback.success( context, 'Back on your ${ServiceProfile.active.workTabLabel} tab', ); BottomPage.goToTab(1); } finally { if (mounted) setState(() => _busyIds.remove(id)); } } }