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>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -2,6 +2,9 @@ import 'dart:math' as math;
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
import 'package:miler/data/route_order.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/Models/stop_status.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
@@ -24,6 +27,147 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
/// and safe to call from `build`.
/// ─────────────────────────────────────────────────────────────────────────
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH TRIP A STOP IS ON
///
/// A rider's day is three trips, and which one an order lands on is decided by
/// **when the work is due**, not by how many buckets happen to have filled up:
///
/// morning → Trip 1
/// afternoon → Trip 2
/// evening → Trip 3
///
/// ── What this replaces ──
///
/// Stops were bucketed into rolling three-hour windows aligned to the clock —
/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a
/// day, folded down to three at the end. Two consequences, both wrong on a
/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run*
/// and landed on different trips, and which trip a stop appeared on depended
/// on what else was in the day. A rider with only afternoon work saw it as
/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things.
///
/// The boundaries are fixed here rather than derived, because they are a
/// business fact about the shift and not something to infer from the data.
/// ─────────────────────────────────────────────────────────────────────────
enum DayPart {
morning('Morning'),
afternoon('Afternoon'),
evening('Evening');
const DayPart(this.label);
/// What this part of the day is called, on a tab and in a sentence.
final String label;
/// Afternoon begins at noon.
static const int afternoonFromHour = 12;
/// Evening begins at 5pm.
static const int eveningFromHour = 17;
/// The day part [at] falls in.
static DayPart of(DateTime at) {
if (at.hour < afternoonFromHour) return DayPart.morning;
if (at.hour < eveningFromHour) return DayPart.afternoon;
return DayPart.evening;
}
/// The window this part covers on [day].
static (DateTime, DateTime) windowOn(DateTime day, DayPart part) {
final midnight = DateTime(day.year, day.month, day.day);
return switch (part) {
DayPart.morning => (
midnight,
midnight.add(const Duration(hours: afternoonFromHour)),
),
DayPart.afternoon => (
midnight.add(const Duration(hours: afternoonFromHour)),
midnight.add(const Duration(hours: eveningFromHour)),
),
DayPart.evening => (
midnight.add(const Duration(hours: eveningFromHour)),
midnight.add(const Duration(days: 1)),
),
};
}
/// True when [from]–[to] is exactly one day part's window, which is how
/// [Trip.slotLabel] knows to say "Morning" instead of a clock range.
static bool spans(DateTime from, DateTime to) {
final (start, end) = windowOn(from, of(from));
return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end);
}
}
/// The three slots of a day, whether or not the rider has work in each.
///
/// Trip 2 is the afternoon even on a day with no morning work — the number is
/// the day part, so the rider's "Trip 2" and the hub's are the same run.
extension TripSlots on List<Trip> {
/// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the
/// evening, and an empty slot is a part of the day with no work in it.
///
/// Dated trips claim their own part. Anything undated — a tenant that sends
/// no times, demo data, a trip whose stops carry only addresses — falls back
/// to the first free slot, in order, because a trip that cannot be placed
/// still has to be reachable. Guessing a part from the wall clock instead
/// would quietly move such a trip from Trip 1 to Trip 2 at noon.
List<Trip?> get slots {
final out = List<Trip?>.filled(DayPart.values.length, null, growable: true);
// ── Day parts place the trips only when they can place all of them ──
//
// A trip claims the slot its part names — that is what makes an
// afternoon-only day read as Trip 2 rather than Trip 1. It works because
// the common day is one run per part.
//
// When two trips want the *same* part it stops working, and the first
// attempt at patching it made things worse: the second trip took the first
// free slot anywhere, so on an evening with two evening runs the tabs came
// out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted,
// and only between 5pm and midnight. A layout that depends on the hour the
// rider opened the screen is worse than one that ignores day parts.
//
// So it is all or nothing. Every trip has its own part, or the run is laid
// out in the order it arrived — which is already sorted, and which is the
// honest answer when the data does not fit the model.
final parts = <int>{};
var distinct = true;
for (final t in this) {
final p = t.dayPart?.index;
if (p == null || !parts.add(p)) {
distinct = false;
break;
}
}
if (distinct) {
for (final t in this) {
out[t.dayPart!.index] = t;
}
return out;
}
for (var i = 0; i < length; i++) {
if (i < out.length) {
out[i] = this[i];
} else {
out.add(this[i]);
}
}
return out;
}
/// The trip in slot [index] (0 = morning), or null when nothing is assigned
/// to that part of the day.
Trip? tripAt(int index) {
final s = slots;
return index >= 0 && index < s.length ? s[index] : null;
}
}
class Trip {
/// Stable identity for this trip (backend slot/trip id, or a derived key).
final String id;
@@ -82,6 +226,10 @@ class Trip {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return "Today's route";
// A day part is named, not spelled out as a clock range: "Morning" is what
// the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the
// same thing in seven characters more and one reading step.
if (DayPart.spans(s, e)) return DayPart.of(s).label;
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
@@ -167,8 +315,43 @@ class Trip {
rejectedIds: rejectedIds,
).any((s) => s.needsDecision);
/// Short tab label — `Trip 1`.
static String tabLabel(int index) => 'Trip ${index + 1}';
/// Short tab label for a **slot** — `Trip 1`.
///
/// The slot is the day part, not a running count: slot 0 is the morning
/// whether or not the rider has morning work. See [DayPart].
static String tabLabel(int index) =>
'Trip ${index + 1}'; // slot index is the day part's index
/// Which part of the day this trip belongs to — **null when nothing on it is
/// dated**.
///
/// Taken from the slot the grouping put it in. A trip the backend named with
/// its own `tripid` still gets one, from whatever window its stops fall in.
///
/// Null matters: a trip with no times at all cannot be placed by day part,
/// and guessing one from the wall clock would move it between tabs as the
/// afternoon wore on. Those keep their position instead — see
/// [TripSlots.slots].
DayPart? get dayPart {
final at = slotStart ?? _earliestDue;
return at == null ? null : DayPart.of(at);
}
/// `1`, `2` or `3` — the number the rider and the hub both use. Zero when
/// this trip has no time to place it by.
int get tripNumber => (dayPart?.index ?? -1) + 1;
DateTime? get _earliestDue {
DateTime? best;
for (final s in stops) {
final due = _parseTimestamp(
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
);
if (due == null) continue;
if (best == null || due.isBefore(best)) best = due;
}
return best;
}
/// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller.
@@ -211,13 +394,27 @@ class Trip {
/// Estimated ride time from the previous point to stop [index]. For index 0
/// that is the hub → stop 1 leg.
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
Duration travelTimeToStop(
int index, {
double? hubLat,
double? hubLng,
// ── The rail's legs run door to door, not counter to counter ──
//
// On the Deliveries rail every stop is the delivery leg, and this walked
// the route over the PICKUP pairs — so the remaining-time estimate was a
// tour of the kitchens the rider had already left. The flag mirrors
// `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit
// point and this stop's target are both the drop when the leg is a
// delivery, with the pickup pair as the payload fallback. Default false —
// Home's pickup-leg arithmetic is untouched.
bool deliveryLeg = false,
}) {
if (index < 0 || index >= stops.length) return Duration.zero;
final ({double lat, double lng})? from = index == 0
? _origin(hubLat, hubLng)
: _coordsOf(stops[index - 1]);
final to = _coordsOf(stops[index]);
: _coordsOf(stops[index - 1], toDrop: deliveryLeg);
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
if (from == null || to == null) return Duration.zero;
final meters = RouteMetricsHelper.distanceMeters(
@@ -356,7 +553,10 @@ class Trip {
static List<Trip> groupIntoTrips(
List<Map<String, dynamic>> stops, {
int windowHours = 3,
/// No longer used for bucketing — stops are grouped by [DayPart]. Kept so
/// existing call sites compile; passing it changes nothing.
@Deprecated('Trips are bucketed by DayPart') int windowHours = 3,
double? hubLat,
double? hubLng,
DateTime? now,
@@ -403,10 +603,14 @@ class Trip {
stop['eta'],
);
if (due != null) {
final blockHour = (due.hour ~/ windowHours) * windowHours;
from = DateTime(due.year, due.month, due.day, blockHour);
to = from.add(Duration(hours: windowHours));
key = 'win:${from.toIso8601String()}';
// Morning, afternoon or evening — see [DayPart]. Not a rolling
// window: two stops due an hour apart are on the same run, and the
// trip a stop belongs to must not depend on what else is in the day.
final part = DayPart.of(due);
final (partFrom, partTo) = DayPart.windowOn(due, part);
from = partFrom;
to = partTo;
key = 'part:${due.year}-${due.month}-${due.day}:${part.name}';
} else {
from = null;
to = null;
@@ -436,6 +640,11 @@ class Trip {
if (as == null && bs == null) return 0;
if (as == null) return 1; // undated trips last
if (bs == null) return -1;
// Day part first, so Trip 1 is always the morning even on a day that
// starts at two in the afternoon. Within a part, earliest first.
final pa = a.dayPart?.index ?? DayPart.values.length;
final pb = b.dayPart?.index ?? DayPart.values.length;
if (pa != pb) return pa.compareTo(pb);
return as.compareTo(bs);
});
@@ -489,37 +698,39 @@ class Trip {
/// Puts stops in the admin's intended order.
///
/// `step` is authoritative — it IS the admin's solved sequence, and the app
/// must never second-guess it. Only when `step` is absent do we fall back to
/// the booked time, then to the order the backend sent. Sorting by distance
/// would be re-optimising the route, which the rider is not allowed to do.
/// must never second-guess it. Only when no stop carries one do we fall back
/// to the booked time, then to the order the backend sent. **Distance is not
/// one of the options here**: sorting by it would be re-optimising a route
/// the hub has planned, which the rider is not allowed to do.
///
/// The rule itself lives in [RouteOrder] — one implementation, shared with
/// the Deliveries tab, which used to keep its own and disagreed. This is the
/// pickup leg's door onto it.
static List<Map<String, dynamic>> sortStops(
List<Map<String, dynamic>> stops,
) {
final indexed = <(int, Map<String, dynamic>)>[
for (var i = 0; i < stops.length; i++) (i, stops[i]),
];
) => orderStops(stops).$1;
indexed.sort((a, b) {
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
if (stepA > 0 && stepB <= 0) return -1;
if (stepB > 0 && stepA <= 0) return 1;
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
if (dueA != null && dueB != null && dueA != dueB) {
return dueA.compareTo(dueB);
}
return a.$1 - b.$1; // stable: keep backend order
});
return [for (final e in indexed) e.$2];
}
/// [sortStops], plus which rule produced the order.
static (List<Map<String, dynamic>>, RouteOrderSource) orderStops(
List<Map<String, dynamic>> stops,
) => RouteOrder.sort(
stops,
bookedTimeOf: (s) => _parseTimestamp(
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
),
);
// ── helpers ────────────────────────────────────────────────────────────
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
static ({double lat, double lng})? _coordsOf(
Map<String, dynamic> stop, {
bool toDrop = false,
}) {
if (toDrop) {
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng);
}
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
if (lat == 0 || lng == 0) return null;
@@ -540,12 +751,6 @@ class Trip {
return DateTime.tryParse(v.toString().trim());
}
static int _toInt(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toInt();
return int.tryParse(v.toString().trim()) ?? 0;
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
@@ -634,6 +839,14 @@ enum StopState {
/// Accepted, not yet started. Lives on the Bookings tab.
accepted,
/// Loaded from its source and physically in the rider's hands.
///
/// Service routes only. The parcel ladder has no equivalent — for a parcel,
/// collecting it from the customer *is* the job — so this state exists only
/// where accepting and carrying are two different days' work apart. See
/// [ServiceProfile.handoffAt] and the collected store.
collected,
/// The rider is physically on this stop right now.
active,
@@ -660,17 +873,46 @@ extension StopStateX on StopState {
/// Committed to: he has taken it and owes the customer a visit.
bool get isCommitted =>
this == StopState.accepted ||
this == StopState.collected ||
this == StopState.active ||
this == StopState.skipped;
String get label => switch (this) {
StopState.pending => 'Awaiting your decision',
StopState.accepted => 'Accepted',
StopState.active => 'In progress',
StopState.done => 'Completed',
StopState.skipped => 'Skipped',
StopState.rejected => 'Rejected',
};
/// In the rider's hands right now.
bool get isCollected => this == StopState.collected;
/// What this state is called on a card.
///
/// ── Two vocabularies, one state machine ──
///
/// The Doormile wording is written for a rider who is being *asked* something:
/// "Awaiting your decision" is true because the card under it carries Accept
/// and Reject. A service rider is asked nothing — the whole assignment came as
/// one commitment — so the same state means "the hub has given you this, it is
/// waiting for you to go and collect it", and calling that a decision invites
/// him to look for a button that is not there.
///
/// `Delivered` rather than `Completed` for the same reason: on a meal run the
/// last thing that happens at a door is a hand-over, and naming it is worth
/// more than a word that covers every stop type in the app.
String get label => ServiceProfile.active.acceptsPerStop
? switch (this) {
StopState.pending => 'Awaiting your decision',
StopState.accepted => 'Accepted',
StopState.collected => 'Collected',
StopState.active => 'In progress',
StopState.done => 'Completed',
StopState.skipped => 'Skipped',
StopState.rejected => 'Rejected',
}
: switch (this) {
StopState.pending => 'Pending',
StopState.accepted => 'Accepted',
StopState.collected => 'Collected',
StopState.active => 'Out for delivery',
StopState.done => 'Delivered',
StopState.skipped => 'Skipped',
StopState.rejected => 'Cancelled',
};
}
/// Derives a stop's state from its backend status plus the local
@@ -684,6 +926,10 @@ StopState stopStateOf(
Map<String, dynamic> stop, {
required Set<String> acceptedIds,
required Set<String> rejectedIds,
/// Orders the rider is carrying. Optional so every existing parcel call site
/// is unchanged — a Doormile route never populates it.
Set<String> collectedIds = const <String>{},
}) {
final id = (stop['orderid'] ?? '').toString();
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
@@ -704,14 +950,29 @@ StopState stopStateOf(
if (raw == 'active' || raw == 'arrived') return StopState.active;
if (raw == 'skipped') return StopState.skipped;
// 3. Local decisions next, and they beat the server's accept/reject.
// 2b. Released for delivery — which `pickup-complete` does by itself on
// hyperlocal work, in the same call that records the collection. It says
// the consignment may be delivered, not that the rider has set off, so it
// resolves to *carrying* and never to [StopState.active]. Asked ahead of
// the local sets because it must also hold after a reinstall, when the
// collected set is empty and this row would otherwise fall through to
// `pending` — putting a bag already in his box back on Home as work to
// accept. See `MilkRun.stageOf`.
if (raw == 'outfordelivery') return StopState.collected;
// 3. Carrying it beats every remaining local view. He has the food; no
// later tap on this screen can make that untrue, and a refetch that still
// reports `accepted` must not put the card back on Home.
if (collectedIds.contains(id)) return StopState.collected;
// 4. Local decisions next, and they beat the server's accept/reject.
// They are strictly newer: the rider just tapped, and the server view is
// at best one poll behind. Without this, un-rejecting a stop would be
// undone by the next refetch still reporting `rejected`.
if (rejectedIds.contains(id)) return StopState.rejected;
if (acceptedIds.contains(id)) return StopState.accepted;
// 4. Finally the server's own view.
// 5. Finally the server's own view.
if (raw == 'rejected') return StopState.rejected;
if (raw == 'accepted') return StopState.accepted;
return StopState.pending;
@@ -732,6 +993,41 @@ enum StopProgress {
skipped,
}
/// How one stop's lifecycle rung reads on the route rail.
///
/// ── "Physically on" is a question about BOTH legs ──
///
/// This lived inline in `_MyPickupsState._tripProgress` and tested
/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is
/// read on the Deliveries tab, where every stop is past `pickup-complete` by
/// definition, so on a milk run the rung the rider is actually standing on is
/// [StopStatus.deliveryArrived], which matched neither. The customer's door he
/// was at *that moment* drew grey — indistinguishable from the eleven he had
/// not ridden to yet — and the scooter fell through to the caller's "promote
/// the first pending one" fallback, parking it somewhere up the route behind
/// him.
///
/// The rail's whole job is *where am I*. Answering it from one leg's
/// vocabulary, on the tab that only ever shows the other leg, is why the answer
/// was wrong all round.
///
/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware:
/// `picked` ends a logistics stop and is the middle of the morning on a round.
///
/// Pure, and out here rather than on the State, because `_MyPickupsState`
/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside
/// it can only ever be verified by reading it. See `trip_progress_test.dart`.
StopProgress stopProgressFor(StopStatus status) {
if (status.isWorkComplete) return StopProgress.done;
if (status.isSkipped) return StopProgress.skipped;
if (status.isActive ||
status == StopStatus.arrived ||
status == StopStatus.deliveryArrived) {
return StopProgress.current;
}
return StopProgress.pending;
}
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
String formatTripDuration(Duration d) {
if (d.inMinutes < 1) return '—';