Files
doormile_milderapp/lib/data/stop_area.dart
2026-08-28 15:07:30 +05:30

234 lines
9.8 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 '';
}
/// Where a stop is **collected from**, as the payload writes it, or `''`.
///
/// ── Why this is not [areaOf] with `preferDrop: false` ──
///
/// [areaOf] answers "which neighbourhood", and deliberately throws away
/// everything that is not a locality name — which is the right answer for a
/// list of twenty stops and the wrong one for the counter the rider is riding
/// to. He needs the street to find the door.
///
/// Three screens were each reading the same four key names for this and one of
/// them, the route card, was reading none of them at all. One reader, so a
/// payload that spells it `pickup_address` cannot answer on the preview sheet
/// and stay blank on the card behind it.
///
/// Returns `''` rather than a placeholder: a card prints nothing where there is
/// no address, which is honest, where "Address not available" is a line of
/// furniture saying the app has failed.
String pickupAddressOf(Map<String, dynamic> stop) {
for (final key in const [
'pickupaddress',
'PickupAddress',
'pickup_address',
'address',
]) {
final raw = (stop[key] ?? '').toString().trim();
if (raw.isNotEmpty && raw.toLowerCase() != 'null') return raw;
}
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(', ');
}