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>
204 lines
8.6 KiB
Dart
204 lines
8.6 KiB
Dart
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// 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(', ');
|
|
}
|