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:
203
lib/data/stop_area.dart
Normal file
203
lib/data/stop_area.dart
Normal file
@@ -0,0 +1,203 @@
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHERE A STOP IS, IN ONE WORD
|
||||
///
|
||||
/// A rider scanning twenty stops is deciding between *areas* — is Joe on the
|
||||
/// way to Priya, or the opposite side of the city. He is not reading twenty
|
||||
/// street strings, and he certainly is not reading twenty street strings cut
|
||||
/// off mid-word: `SEQTEST Gandhipur…` answers nothing that a blank line would
|
||||
/// not have answered, and costs a line to do it.
|
||||
///
|
||||
/// So the collapsed queue shows the locality and the full address lives where
|
||||
/// the rare question is asked — the detail sheet, and the map at the door.
|
||||
///
|
||||
/// ── Why this is one function and not two ──
|
||||
///
|
||||
/// Home and Deliveries both needed it and both had their own. The rule is
|
||||
/// subtle enough to get wrong in exactly the same way twice: the first version
|
||||
/// skipped a component only when it was *entirely* digits, so
|
||||
/// `12 SNS Colony, Peelamedu, Coimbatore 641004` returned `12 SNS Colony` — a
|
||||
/// street with a door number on it, presented as an area. Two copies of that
|
||||
/// bug is two places to find it.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
library;
|
||||
|
||||
/// The locality component of a stop's address, or `''` when it has none.
|
||||
///
|
||||
/// Prefers the drop, because on a delivery leg the area that matters is where
|
||||
/// the bag is going. Falls back to the pickup for a stop that carries no drop,
|
||||
/// and returns nothing rather than inventing a placeholder.
|
||||
String areaOf(Map<String, dynamic> stop, {bool preferDrop = true}) {
|
||||
final keys = preferDrop
|
||||
? const [
|
||||
'dropaddress',
|
||||
'DropAddress',
|
||||
'deliveryaddress',
|
||||
'pickupaddress',
|
||||
'PickupAddress',
|
||||
]
|
||||
: const [
|
||||
'pickupaddress',
|
||||
'PickupAddress',
|
||||
'dropaddress',
|
||||
'DropAddress',
|
||||
'deliveryaddress',
|
||||
];
|
||||
|
||||
for (final key in keys) {
|
||||
final raw = (stop[key] ?? '').toString().trim();
|
||||
if (raw.isEmpty) continue;
|
||||
|
||||
final parts = raw
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
// ── An area is a NAME, not a description ──
|
||||
//
|
||||
// Three versions of this rule, each corrected by a real address:
|
||||
//
|
||||
// 1. "Skip a part only when it is entirely digits" printed `12 SNS
|
||||
// Colony` — a door number presented as an area.
|
||||
// 2. "Skip a part containing any digit" fixed that, but then
|
||||
// `12 Peelamedu, Coimbatore` lost `12 Peelamedu` wholesale and answered
|
||||
// with the *city* — and every stop on a Coimbatore route is in
|
||||
// Coimbatore, so the queue said nothing at all. Seen on a device.
|
||||
// 3. Now the digits are stripped from *inside* the part instead of taking
|
||||
// the part with them: `12 Peelamedu` yields the name `Peelamedu`.
|
||||
//
|
||||
// What separates a locality from a landmark is length in words — names
|
||||
// are short (Gandhipuram, Peelamedu), landmarks and streets are phrases
|
||||
// (`Coimbatore Gandhipuram Mofussil Bus Stand`, verified live). And the
|
||||
// parts are scanned in order because an Indian address runs specific →
|
||||
// general: the earliest name is the most local one, which is exactly what
|
||||
// beats `Coimbatore` when both are one-word names.
|
||||
//
|
||||
// The word left when a number is removed is sometimes administrative
|
||||
// noise, not a place — `Ward 54` is not the ward's name — so those
|
||||
// residues are discarded by a small stop list.
|
||||
// `Ward 54` is not a place called Ward, and `4 Cross` — Indian street
|
||||
// numbering — is not a place called Cross. Both from live addresses.
|
||||
const noise = {
|
||||
'ward', 'zone', 'no', 'door', 'flat', 'floor', 'plot', 'block', //
|
||||
'cross', 'main', 'street', 'road',
|
||||
};
|
||||
String? nameOf(String part) {
|
||||
final words = [
|
||||
for (final w in part.split(RegExp(r'\s+')))
|
||||
if (!RegExp(r'[0-9]').hasMatch(w)) w,
|
||||
];
|
||||
if (words.isEmpty) return null;
|
||||
// `RS Puram RS Puram` — the geocoder stammer [compactAddress] already
|
||||
// collapses — reaches here too, seen live as `Gandhipuram Gandhipuram`
|
||||
// on the queue. A name whose two halves are the same name is one name.
|
||||
if (words.length.isEven && words.length >= 2) {
|
||||
final half = words.length ~/ 2;
|
||||
final a = words.sublist(0, half).join(' ');
|
||||
final b = words.sublist(half).join(' ');
|
||||
if (a.toLowerCase() == b.toLowerCase()) {
|
||||
words.removeRange(half, words.length);
|
||||
}
|
||||
}
|
||||
final name = words.join(' ');
|
||||
if (words.length == 1 &&
|
||||
(name.length < 3 || noise.contains(name.toLowerCase()))) {
|
||||
return null;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// ── The city rung is named, because position cannot find it ──
|
||||
//
|
||||
// "Drop the last part" was tried and is structurally undecidable: an
|
||||
// address that ends at the city (`2 Saibaba Colony, Coimbatore`) and one
|
||||
// that ends at the locality (`12, Cross Cut Road, Gandhipuram`) look
|
||||
// identical from the end. Both are live addresses, and each broke the
|
||||
// heuristic the other needed.
|
||||
//
|
||||
// So the rung is knowledge, like the noise words above: the region this
|
||||
// tenant operates in, plus the state/country tails geocoders append. A
|
||||
// city name distinguishes nothing on a route that is entirely inside it,
|
||||
// so it ranks last — but it still stands when it is all the address has,
|
||||
// because an honest rung beats a blank.
|
||||
bool isCity(String n) => const {
|
||||
'coimbatore', 'coimbatore north', 'coimbatore south', //
|
||||
'tamil nadu', 'india', 'chennai', 'tiruppur', 'erode', 'salem',
|
||||
'madurai', 'pollachi', 'mettupalayam',
|
||||
}.contains(n.toLowerCase());
|
||||
|
||||
// A two-word name ending in a thoroughfare word is a street, not a
|
||||
// locality — `Mettupalayam Road` must lose to the `RS Puram` behind it,
|
||||
// exactly as the longer landmark phrases already do.
|
||||
bool isStreet(String n) => const {
|
||||
'road', 'street', 'salai', 'lane', 'veedhi', 'highway', //
|
||||
}.contains(n.split(' ').last.toLowerCase());
|
||||
|
||||
final names = [
|
||||
for (final p in parts)
|
||||
if (nameOf(p) case final n?) n,
|
||||
];
|
||||
final local = [
|
||||
for (final n in names)
|
||||
if (!isCity(n)) n,
|
||||
];
|
||||
|
||||
// In rank order: the first one-word name — a locality is a name and
|
||||
// names are short; then the first short non-street name (RS Puram,
|
||||
// Saibaba Colony); then any phrase left; and only then the city.
|
||||
for (final n in local) {
|
||||
if (!n.contains(' ')) return n;
|
||||
}
|
||||
for (final n in local) {
|
||||
if (n.split(' ').length <= 2 && !isStreet(n)) return n;
|
||||
}
|
||||
if (local.isNotEmpty) return local.first;
|
||||
if (names.isNotEmpty) return names.first;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// A full address, minus the boilerplate a geocoder appends.
|
||||
///
|
||||
/// The record page prints the address in full — it is the one screen whose
|
||||
/// job is the specifics — and on live data that full address arrived as
|
||||
/// `RS Puram RS Puram, Ward 24, West Zone, Perur, Coimbatore South,
|
||||
/// Coimbatore, Tamil Nadu, 641002, India`, wrapped to two lines and cut with
|
||||
/// an ellipsis. The trimmed tail was the useful half; the kept head began by
|
||||
/// stammering.
|
||||
///
|
||||
/// This strips only what carries no information at a door in this region:
|
||||
/// the state and country rungs, a part that is nothing but a pincode, an
|
||||
/// immediate word-for-word repeat inside a part, and a part that repeats the
|
||||
/// one before it. Everything that names a place stays, in order.
|
||||
String compactAddress(String raw) {
|
||||
final parts = raw
|
||||
.split(',')
|
||||
.map((p) => p.trim())
|
||||
.where((p) => p.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
const tail = {'tamil nadu', 'india'};
|
||||
|
||||
String dedupWords(String part) {
|
||||
final w = part.split(RegExp(r'\s+'));
|
||||
if (w.length.isEven && w.length >= 2) {
|
||||
final half = w.length ~/ 2;
|
||||
final a = w.sublist(0, half).join(' ');
|
||||
final b = w.sublist(half).join(' ');
|
||||
if (a.toLowerCase() == b.toLowerCase()) return a;
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
final kept = <String>[];
|
||||
for (final p in parts) {
|
||||
final cleaned = dedupWords(p);
|
||||
final lower = cleaned.toLowerCase();
|
||||
if (tail.contains(lower)) continue;
|
||||
if (RegExp(r'^[0-9]{6}$').hasMatch(cleaned)) continue;
|
||||
if (kept.isNotEmpty && kept.last.toLowerCase() == lower) continue;
|
||||
kept.add(cleaned);
|
||||
}
|
||||
return kept.join(', ');
|
||||
}
|
||||
Reference in New Issue
Block a user