Files
doormile_milderapp/lib/views/Dashboard/activity/activity_page.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

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