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/narrative.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 with WidgetsBindingObserver { 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; /// Notices when the day underneath the page has moved on. /// /// [_load] filters against `ServiceDay.today` *as read at fetch time*, so the /// list is only ever as fresh as the last fetch. See [ServiceDayRollover]. final ServiceDayRollover _day = ServiceDayRollover(); /// Fires at the next local midnight. See [_armRollover]. Timer? _rollover; /// 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); WidgetsBinding.instance.addObserver(this); _armRollover(); } @override void dispose() { BottomPage.currentTab.removeListener(_onTabChanged); WidgetsBinding.instance.removeObserver(this); _rollover?.cancel(); super.dispose(); } /// ── The day ends on screen, not just in the filter ── /// /// [ServiceDay] is deliberately a read-time question rather than a scheduled /// purge, and that is right for the *data*. It is not sufficient for the /// *screen*, because this page only re-reads when the tab comes forward: a /// rider who leaves Activity open through 23:59, or backgrounds the app on it /// overnight and picks the phone up at six, is looking at a list that was /// filtered against a day which has since ended. Nothing is wrong in the /// store; the pixels are just stale. /// /// Two triggers, because a phone gives no single reliable one: /// /// • **A timer to the next local midnight**, for the app left in the /// foreground across the boundary. Android and iOS both suspend timers in /// the background, so this one is the *only* thing it is asked to cover — /// it is not load-bearing, and nothing breaks when it does not fire. /// • **Resume**, for every other case — which is most of them. The check is /// a string compare against [_loadedDay], so a resume inside the same day /// costs nothing and a resume the next morning turns the page over before /// the rider has read it. /// /// Both land in [_rolloverCheck], and it is idempotent: whichever gets there /// first does the work and the other finds the day already current. @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) _rolloverCheck(); } void _armRollover() { _rollover?.cancel(); _rollover = Timer(_day.untilNextDay, _rolloverCheck); } /// Turns the page over if the service day has moved on since [_load] ran. /// /// Yesterday's rows are dropped from state **before** the refetch rather than /// after it. A new day starts empty even with no signal — the alternative is /// a rider standing in a dead spot at 6am reading yesterday's completed stops /// under a heading that says Today, which is the exact failure this is for. /// Anything that genuinely belongs to the new day (a stop closed at 00:10 on /// an evening shift) comes straight back from the completed store, which /// stamped it with the new day when it was written. void _rolloverCheck() { if (!mounted) return; _armRollover(); if (!_day.rollIfNeeded()) return; setState(() { _done = >[]; _skipped = >[]; }); _load(); } 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. // The day this fetch filters against, adopted so a later rollover check // compares against what is actually on screen. _day.rollIfNeeded(); final serviceDay = _day.day; final before = merged.length; var undated = 0; merged.removeWhere( (_, row) => !ServiceDay.belongsToToday( row, now: serviceDay, // ── An undated row is excluded, and said out loud ── // // Delivered, Skipped and Cancelled all pass through here, and the // rule is the same for all three: a record that cannot prove it // belongs to today does not appear on a screen headed Today. It is // not deleted — both local stores and the backend keep it — it is // simply not this view's business. // // The local stores stamp `completedday` / `skippedday` at the moment // the rider acts (see `accepted_store.dart`), so a stop finished in // this session carries its own proof and is unaffected. What this // catches is a backend row with no timestamp at all, which is a data // defect worth counting rather than absorbing. onUndated: (row) { undated++; ServiceDay.logUndated(row); }, ), ); if (before != merged.length) { debugPrint( '[ACTIVITY] $serviceDay — kept ${merged.length} of $before rows' '${undated > 0 ? ' ($undated undated)' : ''}', ); } 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( // ── No maroon under the bar ── // // This was [ColorConstants.primary], from the old arrangement where the // sheet clipped its own top corners and the brand showed through them. // That arrangement is gone — the bar rounds its own bottom and paints the // page colour into the corners itself (see [milerBrandSurface]) — but the // red Scaffold stayed, and the sheet was still being pushed down by // [_heroLift] to make room for the floating figure card. // // What that left was a 30pt band of brand red spanning the full width // directly under the app bar: partly covered by the card in the middle, // bare down both gutters, and on a day with nothing finished — when the // card is not built at all — a solid red stripe across the whole screen. // It read as a second, broken app bar. // // The page's ground is white, so the Scaffold is white. Nothing can show // through anywhere, which is the only way to be sure of it. backgroundColor: ColorConstants.pureSurface, // `pageColor` is the surface directly under the bar, and that is what // makes its bottom curve visible — see [milerBrandSurface]. appBar: const MilerAppBar( title: 'Activity', pageColor: ColorConstants.pureSurface, ), body: Stack( clipBehavior: Clip.none, children: [ // ── The ground is the canvas, because the page has objects on it ── // // This sheet was the working white with everything drawn straight // onto it. That is right for a page of rows and wrong for a page of // cards: white stops on white cannot be seen, which is the whole // reason the surface ladder has a ground at all. Positioned.fill( child: MilerSheet( // ── This page belongs to the Narrative system now ── // // A light canvas with bordered, lifted white cards, rather than // the app's darker ground with borderless ones. Both buy the same // separation; what does not work is half of each, so the page // takes this one whole. See [Narrative]. // ── White, like Home ── // // It was [Narrative.canvas], on the reasoning that a page of // cards needs a ground to separate from. The cards carry a // hairline and a lift of their own, so the grey was a second // separator under things that already had one — and the two // screens a rider moves between all day were a shade apart for // no reason either of them could state. color: ColorConstants.pureSurface, 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: [ // Clears the floating count card. Its height is fixed and // its type is clamped (see [_heroCard]) precisely so this // can be arithmetic rather than a guess that goes wrong at // 2.0x system text. SizedBox( height: _firstLoadDone && hasWork ? _heroTop + _heroHeight + Narrative.gapBlock.h : 6.h, ), 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 day's figure floats over the seam ── // // It used to be the first line *inside* the page, which put the one // fact a rider opens this screen for in the same plane as the filter // bar under it. Lifted onto its own card straddling the brand and the // canvas, it reads as the page's answer rather than as its first row // — and the seam between the app bar and the list, which was a bare // curve, now has something on it. if (_firstLoadDone && hasWork) Positioned( top: _heroTop, left: _cardGutter, right: _cardGutter, child: _heroCard(), ), ], ), ); } /// How far below the app bar the floating count card starts. static double get _heroTop => 4.h; /// The card's own height. Fixed, with its type clamped, so the spacer that /// clears it below cannot drift out of step at a large text scale. static double get _heroHeight => 74.h; /// The floating card: the day's figure, and nothing else. Widget _heroCard() { return Container( height: _heroHeight, padding: EdgeInsets.symmetric(horizontal: 20.w), alignment: Alignment.centerLeft, decoration: BoxDecoration( color: Narrative.card, borderRadius: BorderRadius.circular(Narrative.radiusPill), border: Border.all(color: Narrative.cardBorder, width: 1), // The one thing on the page that sits *over* another surface rather // than on it, so it takes the deeper of the two lifts. boxShadow: Narrative.floatShadow, ), child: MediaQuery.withClampedTextScaling( maxScaleFactor: 1.2, child: _heroFigure(), ), ); } // ── 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 => (Narrative.gutter + 2).w; /// The margin a stop card leaves either side of itself, so the canvas shows /// down both edges of the list and the cards read as objects on a ground. /// /// Narrower than [_gutter] on purpose: the card carries 12 of padding /// *inside* it, so its content lands on 24 while the page's own furniture — /// the heading, the filter bar — stands on 20. The two edges a rider /// actually sees are the card's, and they are straight. static double get _cardGutter => Narrative.gutter.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.only(bottom: 4.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.symmetric(horizontal: _gutter), child: _shiftNotes(), ), SizedBox(height: 10.h), _filterBar(), // ── The hairline under the bar is gone ── // // It was there because the tabs were an *underlined* segmented // control: the selected tab's marker was a rule, and a rule needs a // rail under it or it reads as a stray mark floating between the card // above and the list below. // // The tabs are filled pills now. A pill carries its own boundary, so // the rail has nothing left to support — and a full-width line across // a white page is the loudest thing on it, drawn to separate two // groups that whitespace already separates. Air does it, and air is // what the rest of this screen uses. SizedBox(height: 14.h), ], ), ); } /// ── 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%`. /// The numeral and its caption — the whole content of the floating card. /// /// Split out of [_shiftNotes] when the figure moved onto its own surface: the /// card holds the answer, and the qualifiers that used to sit under it stay /// on the page, above the filter bar, where they can wrap freely. /// ── The card is two halves, and each answers one question ── /// /// It was one left-aligned row — a big count, its caption, then whatever was /// left of the width — with the kilometres bolted onto the end. That reads as /// a headline with something appended, and on a narrow phone the appended /// thing is the part that gets squeezed. /// /// Two equal halves with a hairline between them. `34 delivered` and `26.9 km /// ridden` are the same *kind* of fact at the same rank — what the shift /// produced, and what it cost — so they are set the same way and given the /// same room, and neither can crowd the other as the numbers grow. Widget _heroFigure() { final done = _all.where((s) => stopStatusOf(s).isFinishedPickup).toList(); if (done.isEmpty) { return Align( alignment: Alignment.centerLeft, child: Text( 'Today', style: MilerType.body.copyWith( fontSize: 19.sp, fontWeight: FontWeight.w800, letterSpacing: -0.4, ), ), ); } return Row( children: [ Expanded( child: _heroHalf( figure: '${done.length}', // ── The count wears the brand ── // // It is the one figure the page is *answering with*. The distance // beside it is context for it, so that one stays slate — two brand // figures side by side would be two answers. figureColour: Narrative.burgundy, caption: ServiceProfile.active.completionVerb.toLowerCase(), ), ), // ── Both halves, always ── // // The distance half used to be hidden when the figure was zero, which // meant a rider whose rows carried no measurement never saw the slot at // all — and could not tell the difference between "the app does not // report this" and "I have not ridden anywhere". A card that is a pair // has to stay a pair. // // An absent figure prints the em dash, which is what every other // unknown in this app prints. Container(width: 1, height: 34.h, color: Narrative.cardBorder), Expanded( child: _heroHalf( figure: _kmToday > 0 ? kmText(_kmToday) : '\u2014', figureColour: _kmToday > 0 ? Narrative.ink : Narrative.inkMuted, caption: 'km ridden', ), ), ], ); } /// One half of the hero: a figure over what it counts. Widget _heroHalf({ required String figure, required Color figureColour, required String caption, }) { return Padding( padding: EdgeInsets.symmetric(horizontal: 12.w), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text( figure, maxLines: 1, style: TextStyle( // The brief's `display-lg`, which is what a figure the page is // answering with is meant to be set at. fontSize: 30.sp, fontWeight: FontWeight.w900, letterSpacing: -1, height: 1.0, fontFeatures: const [FontFeature.tabularFigures()], color: figureColour, fontFamily: FontConstants.fontFamily, ), ), ), SizedBox(height: 4.h), Text( caption, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.sp, height: 1.0, fontWeight: FontWeight.w600, color: Narrative.inkSoft, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } /// ── How far he rode today ── /// /// The day's other headline figure, and the one the app had nowhere to put: /// distance was on every individual row and nowhere in aggregate, so a rider /// who wanted to know what he had covered had to add up forty of them. /// /// It belongs on this card because this card is the day: the count of what he /// finished, and the distance he covered finishing it, are the two facts a /// shift is described by. Set quieter than the count — slate, not brand — /// because the count is what the page is *answering with* and two brand /// figures side by side is two answers. /// /// Summed from the same per-stop measurement each row prints, so the card and /// the rows can never disagree. double get _kmToday { var total = 0.0; for (final stop in _all) { if (!stopStatusOf(stop).isFinishedPickup) continue; final km = StopCompliance.of(stop).actualKm; if (km != null && km > 0) total += km; } return total; } Widget _shiftNotes() { 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. if (second.isEmpty && third.isEmpty) return const SizedBox.shrink(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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 ? Narrative.amber : Narrative.inkMuted, ), ), ], ], ), style: MilerType.body.copyWith( fontSize: 13.5.sp, fontWeight: FontWeight.w600, ), ), ], if (third.isNotEmpty) ...[ SizedBox(height: 3.h), Text( third.join(' · '), style: MilerType.micro.copyWith( fontSize: 13.sp, color: Narrative.inkMuted, ), ), ], ], ); } /// ── 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; /// ── A segmented control with a physical indicator ── /// /// It was four words on a rail with a 2pt rule sliding under the selected /// one. That reads as a *tab bar*, and a tab bar says "these are four pages"; /// this is one page with a filter on it. The brief asks for the other idiom — /// a track holding a pill that moves — and it is the right one: the pill is a /// physical object the eye can follow, the track says the four are exclusive, /// and nothing has to be underlined to be legible in sun. /// /// The pill travels rather than fading in and out. Same reason as the trip /// tabs and the nav bar: two pills cross-fading have nothing occupying the /// space between them, so the selection appears to teleport. /// /// Scrollable, because at 2.0x system text four labels do not fit a phone and /// a bar that wraps to two lines moves the list under the rider's thumb. /// ── Four pills, not a segmented track ── /// /// It was a tonal track with one white pill sliding along it, four equal /// slots wide. Two things were wrong with that on a white page. The track is /// a container drawn around a control that already marks its own selection, /// which is the nesting this app keeps pulling out of everything else; and /// equal slots force `Cancelled` and `All` to the same width, so the type had /// to come down to 12.5 to stop the long one touching its own edges. /// /// Separate pills, each as wide as its label. The selected one is a **filled /// brand pill with white type** — the inverse of the trip tabs on Home, and /// deliberately so: this is the only control on the page, where those sit /// above a card with an action of its own and must not outrank it. /// /// Scrollable, because four labels plus a count will not fit a 320pt phone at /// a large system font, and a filter that clips its last option is a filter /// the rider cannot reach. Widget _filterBar() { final filters = _ActivityFilter.values; return SizedBox( height: 40.h, child: ListView.separated( scrollDirection: Axis.horizontal, padding: EdgeInsets.symmetric(horizontal: _gutter), physics: const ClampingScrollPhysics(), itemCount: filters.length, separatorBuilder: (_, _) => SizedBox(width: 8.w), itemBuilder: (context, i) => _filterChip(filters[i], filters[i] == _filter), ), ); } Widget _filterChip(_ActivityFilter f, bool selected) { return Semantics( button: true, selected: selected, label: f.label, excludeSemantics: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: selected ? null : () => setState(() => _filter = f), child: AnimatedContainer( duration: DesignConstants.motionState, curve: Curves.easeOut, alignment: Alignment.center, padding: EdgeInsets.symmetric(horizontal: 18.w), decoration: BoxDecoration( color: selected ? Narrative.burgundy : ColorConstants.tint(ColorConstants.slateText, 0.055), borderRadius: BorderRadius.circular(Narrative.radiusPill), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( f.label, maxLines: 1, style: MilerType.body.copyWith( // 13.5, back up from the 12.5 the equal-slot track forced: // a pill is as wide as its own label, so `Cancelled` no // longer has to fit the width `All` was given. fontSize: 13.5.sp, fontWeight: selected ? FontWeight.w800 : FontWeight.w700, letterSpacing: -0.1, color: selected ? Colors.white : Narrative.inkMuted, ), ), // ── 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: 6.w), Text( '$_attentionCount', style: MilerType.body.copyWith( fontSize: 13.5.sp, fontWeight: FontWeight.w900, color: selected ? Colors.white : Narrative.amber, ), ), ], ], ), ), ), ); } // ══════════════════════════════════════════════════════════════════════ // 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. // The canvas-toned well that used to hold an open trip is // gone: the page's own ground is that tone now, so the inset // was a grey box drawn on grey. The cards are the contents. ? 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, 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; // ── A run that opens is an object; a run that is just a label is not ── // // With several trips in a day the header is a control — press it and a // drawer opens — so it takes a card, like everything else on this page you // can press. With one trip there is nothing to open (see [_list]), the // header is a heading, and a card drawn round a heading is a box around // nothing. return Container( margin: EdgeInsets.fromLTRB( _cardGutter, Narrative.gapBlock.h, _cardGutter, Narrative.gapLine.h + 2.h, ), decoration: tappable ? Narrative.cardBox() : null, child: Material( color: Colors.transparent, child: InkWell( onTap: tappable ? () => setState(() => _openTrip = expanded ? null : slot) : null, borderRadius: BorderRadius.circular(Narrative.radiusCard), child: Padding( padding: EdgeInsets.symmetric( vertical: tappable ? Narrative.cardPad.h - 4.h : 6.h, horizontal: tappable ? Narrative.cardPad.w : 0, ), 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) // ── 12% → 18% ── // // A dark accent at 12% is a grey with an opinion: // `acceptGreen` is #047354, and a twelfth of it // over white is a tone a rider reads as "off" // rather than as "done". Six points more is what // makes the disc read as the colour it is naming. .withValues(alpha: 0.18), 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.w800, 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(), ), // No horizontal padding: the cards carry their own [_cardGutter] and the // headers carry theirs, so nothing is inset twice. A list-level gutter on // top of a card margin is what put the cards 32 off the edge and made the // page read as a column of narrow slips. padding: EdgeInsets.fromLTRB( 0, 2.h, 0, 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.w700, ); 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.w700, 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. /// ── A stop is a card now, not a rung ── /// /// This page ran as rows on a spine: a timeline rail down the left, every /// stop drawn straight onto the paper, and the argument for it was that a /// list of the same kind of thing is separated by rhythm rather than by /// containers. /// /// It reads better as cards, for a reason the rail could not answer. Each /// row here is a *finished piece of work you can open* — it has a name, a /// clock, an outcome and a page behind it — and the rail said the opposite: /// that these were beads on one thread, to be read down rather than picked /// out. A rider looking for the stop somebody is asking him about scans for /// an object, and a card is an object. /// /// The spine has not gone; it moved up a level. The trip header still gathers /// its stops, so the day still reads as runs, and the cards are what is /// inside one. Widget _row(Map stop, {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, status: status); 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: Container( margin: EdgeInsets.fromLTRB( _cardGutter, 0, _cardGutter, last ? 4.h : Narrative.gapLine.h + 4.h, ), // Level 1: white, a hairline, and a lift so soft it is felt rather // than seen. The canvas under it is light enough that the card needs // both — see [Narrative], and the note there about not mixing the two // separation recipes. decoration: Narrative.cardBox(), child: Material( color: Colors.transparent, child: InkWell( onTap: id.isEmpty ? null : () => _openDetails(context, stop), borderRadius: BorderRadius.circular(Narrative.radiusCard), child: Padding( padding: EdgeInsets.fromLTRB( Narrative.cardPad.w, Narrative.cardPad.h - 4.h, (Narrative.cardPad - 6).w, Narrative.cardPad.h - 4.h, ), // Centred, not top-aligned. The card's three storeys are one // block; a mark and a disclosure pinned to the first line of it // sit high and read as belonging to the name rather than to the // card. The text column keeps its own start alignment inside. child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ // The mark, as a tile rather than a bead on a rail. Tonal, // like every disc in the app — the outcome is carried by the // glyph and the tint, and the word repeats neither because it // is on the line below saying something the tile cannot. Container( width: 40.w, height: 40.w, alignment: Alignment.center, decoration: BoxDecoration( // 18%, not 12: a dark accent at a twelfth is a grey with // an opinion. These marks and the trip header's disc are // the same object at two sizes and must not drift apart. color: mark.withValues(alpha: 0.18), borderRadius: BorderRadius.circular(Narrative.radiusChip), ), child: Icon(status.icon, size: 19.sp, color: mark), ), SizedBox(width: Narrative.gapBlock.w - 4.w), Expanded( 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( // `title-lg`: the card's own heading. fontSize: 16.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: Narrative.ink, ), ), ), // ── 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 and // allowed to give: `₹2,450` and `5:08 PM` are read // at a glance, not studied, so they stop scaling // while the name keeps going. if (rowCash > 0 || hasTime) Flexible( child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: 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.w800, ), ), ], if (hasTime) ...[ SizedBox(width: 10.w), Text( RouteMetricsHelper.formatClock(at), maxLines: 1, style: MilerType.figure( 12.5, color: Narrative.inkMuted, ).copyWith( fontWeight: FontWeight.w600, ), ), ], ], ), ), ), ), ], ), // 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: 13.sp, color: Narrative.inkSoft, ), ), ], // ── One storey fewer ── // // The outcome word had a line of its own, directly // under the name, and the measurements had another // under that. Three storeys per row, one of them // carrying a single word — which is why twelve stops // filled a screen and a day did not. // // The word is the first item on the measurements line // now. Same rule as before it moved: the norm is quiet // ink, the exception keeps the status colour, and the // tile beside the row is what carries the outcome in // shape. See [_meta]. SizedBox(height: 3.h), _dotted(meta, size: 12.5), // ── 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.w700, color: ColorConstants.warning, ), ), ], if (parked) ...[ SizedBox(height: 6.h), _resumeAction(stop, id), ], ], ), ), // ── The disclosure is the card's ── // // It used to sit inline after the last figure — `14m ›` — so // it read as a control belonging to that line, and a stop // with no measurements lost its affordance entirely while // still being tappable. Centred against the whole card, it is // the passive "this goes somewhere" mark every Account row // wears. if (id.isNotEmpty) Padding( padding: EdgeInsets.only(left: 6.w), child: Center( child: Icon( LucideIcons.chevronRight, size: 17.sp, color: Narrative.inkMuted.withValues(alpha: 0.6), ), ), ), ], ), ), ), ), ), ); } 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.w800, 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, required StopStatus status, }) { final grey = Narrative.inkMuted; 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'; final bool exception = status.isSkipped || status.isCancelled || status.isRejected; return <(String, Color)>[ // ── The outcome is coloured, and it leads the line ── // // It was quiet ink on the norm, on the rule that a day of 25 delivered // stops must not be a column of 25 green words. That rule was written // for a page where every row was drawn on the same white paper and // colour was the only thing that could separate them. // // The rows are cards now, each on its own surface, so the separation is // structural and the word is free to say what it means. The brief asks // for it directly: emerald *is* the delivered state in this system, and // a status the eye can pick out without reading is the difference // between scanning a day and reading one. ( status.label, exception ? Narrative.amber : status.isFinishedPickup ? Narrative.emerald : grey, ), // On a logistics row the payload leaves nothing but a name and a word, // so the line earns its keep by saying *where* — `Delivered · // Peelamedu` — when there is no route line above it to say it. if (routeEndsOf(stop).to == null) if (areaOf(stop) case final a when a.isNotEmpty) (a, grey), 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)); } } }