Files
doormile_milderapp/lib/views/Dashboard/activity/activity_page.dart
2026-09-09 12:55:23 +05:30

2129 lines
95 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, dynamic> 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<ActivityPage> createState() => _ActivityPageState();
}
class _ActivityPageState extends State<ActivityPage>
with WidgetsBindingObserver {
final PickupProvider _provider = PickupProvider();
List<Map<String, dynamic>> _done = <Map<String, dynamic>>[];
/// 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<Map<String, dynamic>> _skipped = <Map<String, dynamic>>[];
/// Order ids with a resume in flight, so one cannot be fired twice.
final Set<String> _busyIds = <String>{};
/// 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<Map<String, dynamic>> 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 = <Map<String, dynamic>>[];
_skipped = <Map<String, dynamic>>[];
});
_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<void> _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<String, Map<String, dynamic>> merged = {};
for (final row in results[0].whereType<Map<String, dynamic>>()) {
merged[_keyOf(row)] = row;
}
for (final row in results[1].whereType<Map<String, dynamic>>()) {
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<List<dynamic>> _safe(Future<List<dynamic>> Function() call) async {
try {
return await call();
} catch (e) {
debugPrint('[ACTIVITY] endpoint failed: $e');
return <dynamic>[];
}
}
String _keyOf(Map<String, dynamic> 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<String, dynamic> 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<double>(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 = <String>[
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<Map<String, dynamic>> 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 = <Widget>[];
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<Widget> _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 = <String>[
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<Widget> 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<String, dynamic> 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<String, dynamic> 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<void> _openDetails(BuildContext context, Map<String, dynamic> 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<String, dynamic> 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<String, dynamic>.from(stop['proof'] as Map)
: const <String, dynamic>{};
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<void> _resume(Map<String, dynamic> 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));
}
}
}