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>
1813 lines
79 KiB
Dart
1813 lines
79 KiB
Dart
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));
|
||
}
|
||
}
|
||
}
|