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

@@ -0,0 +1,187 @@
/// ─────────────────────────────────────────────────────────────────────────
/// HOW A FINISHED STOP IS WRITTEN DOWN
///
/// The Activity list and the Delivery Details page describe the same records
/// from two distances — one for scanning, one for investigating — and both have
/// to write a clock, a duration, a distance, a rupee figure and a route the
/// same way. A rider who reads `1h 27m` on a row and `1:27` on the page it
/// opens has two facts to reconcile where there is one.
///
/// So the formatters live here, once, as plain functions. No widgets, no
/// state, no I/O — testable without a widget tree, and impossible to
/// accidentally fork by copying a screen.
/// ─────────────────────────────────────────────────────────────────────────
library;
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
/// First parseable timestamp among [keys], or null.
///
/// Null is a real answer: a stop closed in bulk from Home never opened the map,
/// so it genuinely has no departure time. Callers drop what they cannot say.
DateTime? stampOf(Map<String, dynamic> stop, List<String> keys) {
for (final k in keys) {
final parsed = parseStamp(stop[k]);
if (parsed != null) return parsed;
}
return null;
}
/// One timestamp, read as the wall-clock it actually is.
///
/// ── The false `Z` ──
///
/// Doormile timestamps are IST wall-clock in Postgres `timestamp without time
/// zone` columns. Some responses come back with a trailing `Z` anyway — a known
/// Go + pgx footgun where a naive DB timestamp loads into `time.Time` under the
/// UTC location and marshals with a zone marker it never had. The console hit
/// this first and strips it the same way; see `doormileTimestamp.js`.
///
/// `DateTime.tryParse` believes the `Z`, so `11:43Z` became a real UTC instant
/// — and that broke the tracking rail in the one way it cannot survive: an
/// `Order placed 11:43` landed *below* two 11:57 events, because as an absolute
/// instant it is 17:13 IST. A timeline whose whole meaning is the order of its
/// rungs was rendering them out of order, while each rung's own clock still
/// printed the right digits, so nothing on screen explained why.
///
/// Stripping the marker before parsing makes both shapes — truly naive, and
/// naive-with-a-false-`Z` — read as the digits the backend meant. A stamp the
/// app wrote itself carries no marker and is unaffected.
DateTime? parseStamp(dynamic raw) {
final s = (raw ?? '').toString().trim();
if (s.isEmpty) return null;
final stripped = s.replaceFirst(RegExp(r'(Z|[+-]\d{2}:?\d{2})$'), '');
return DateTime.tryParse(stripped) ?? DateTime.tryParse(s);
}
/// Best available "when did this happen", epoch-comparable.
///
/// The app's own stamps first: they are written at the moment the rider acts,
/// so they are both the most accurate and the only ones present on a stop the
/// backend has not caught up with yet. Falls back to zero so a record with no
/// timestamp sinks rather than jumping to the top of the day.
DateTime happenedAt(Map<String, dynamic> stop) =>
stampOf(stop, const [
'completedat',
'skippedat',
'pickedtime',
'picked_time',
'deliverytime',
'updatedon',
'modifiedon',
'expected_pickup_time',
]) ??
DateTime.fromMillisecondsSinceEpoch(0);
/// `14:05` → `2:05 PM`. The same shape as the clock the map sheet's ETA prints,
/// so a time read on the way to a stop and the time recorded against it
/// afterwards are written the same way.
String clockOf(DateTime t) {
final hour12 = t.hour % 12 == 0 ? 12 : t.hour % 12;
final minute = t.minute.toString().padLeft(2, '0');
return '$hour12:$minute ${t.hour < 12 ? 'AM' : 'PM'}';
}
/// A duration at a glance: `6m`, `1h 12m`.
///
/// Seconds are never shown — nothing on these screens is decided to the second,
/// and they only make the figure harder to compare against the one on the row
/// below it.
String shortDuration(Duration d) {
final total = d.inMinutes;
if (total < 1) return '<1m';
final hours = total ~/ 60;
final minutes = total % 60;
if (hours == 0) return '${minutes}m';
return minutes == 0 ? '${hours}h' : '${hours}h ${minutes}m';
}
/// `4.6`, `12`. One decimal under ten kilometres and none over it: the second
/// digit stops meaning anything at that distance and costs width on a row.
String kmText(double v) =>
v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1);
/// `₹1,240`.
///
/// Grouped the Indian way — the last three digits, then twos — because that is
/// how the figure is read back to a hub clerk, and an ungrouped `₹112450` is
/// the one number on the page a rider has to count.
String rupees(double v) {
final whole = v.truncate();
final fraction = v - whole;
final digits = whole.abs().toString();
String grouped;
if (digits.length <= 3) {
grouped = digits;
} else {
final last3 = digits.substring(digits.length - 3);
var rest = digits.substring(0, digits.length - 3);
final parts = <String>[];
while (rest.length > 2) {
parts.insert(0, rest.substring(rest.length - 2));
rest = rest.substring(0, rest.length - 2);
}
if (rest.isNotEmpty) parts.insert(0, rest);
grouped = '${parts.join(',')},$last3';
}
final sign = v < 0 ? '-' : '';
final tail = fraction == 0 ? '' : '.${(fraction * 10).round().clamp(0, 9)}';
return '$sign₹$grouped$tail';
}
/// The ETA the hub gave for this stop — minutes allowed, or the clock time it
/// was due by, whichever the booking carries. Null when it carries neither.
String? plannedEtaOf(Map<String, dynamic> stop) {
final minutes = int.tryParse((stop['eta'] ?? '').toString().trim());
if (minutes != null && minutes > 0) return '$minutes min allowed';
final due = stampOf(stop, const [
'expected_pickup_time',
'expectedpickuptime',
'slotendtime',
]);
return due == null ? null : 'By ${clockOf(due)}';
}
/// The two ends of a stop's journey, or one end when the payload names only
/// one.
///
/// Which name belongs at which end is a question about the **line**, and
/// getting it wrong reads as a rider having ridden the route backwards:
///
/// • **A round** collects at a source and hands over at a door, so the kitchen
/// leads and the subscriber is the destination.
/// • **A parcel booking** is a first-mile collection *from* the customer, and
/// where it goes afterwards is the hub's problem — there is no second name
/// to print, so the customer stands alone rather than getting an arrow to
/// nowhere.
///
/// Read from [MilkRun] and [ServiceProfile] so this and the delivery card
/// cannot describe one stop as two different journeys.
({String from, String? to}) routeEndsOf(Map<String, dynamic> stop) {
final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? '')
.toString()
.trim();
final source = MilkRun.sourceNameOf(stop);
if (ServiceProfile.active.deliversToCustomer &&
source.isNotEmpty &&
customer.isNotEmpty) {
return (from: source, to: customer);
}
return (from: customer.isEmpty ? 'Stop' : customer, to: null);
}
/// `Vidhya Kitchen to Joe Mathew`, or the bare customer. See [routeEndsOf].
///
/// **For semantics and logs, not for drawing.** The arrow between the two ends
/// is a widget on screen — Poppins carries no U+2192 and the character rendered
/// as a tofu box on the one line of the record a rider reads first. A screen
/// reader wants the word anyway: "Vidhya Kitchen right-arrow Joe Mathew" is not
/// a sentence.
String routeLineOf(Map<String, dynamic> stop) {
final ends = routeEndsOf(stop);
return ends.to == null ? ends.from : '${ends.from} to ${ends.to}';
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff