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:
187
lib/views/Dashboard/activity/activity_format.dart
Normal file
187
lib/views/Dashboard/activity/activity_format.dart
Normal 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
1504
lib/views/Dashboard/activity/delivery_details_page.dart
Normal file
1504
lib/views/Dashboard/activity/delivery_details_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user