import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/next_leg.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHAT THE PICKUP IS CALLED, FROM THE TENANT'S OWN LOCATION LIST /// /// ── The name on the card was a person ── /// /// The route card's heading is the biggest type on Home, and it read /// `Sudharsan` — a contact person — above a distance and an ETA to a place /// that name does not identify. It comes from `sourcename` on the booking row, /// which the backend fills from `providercompany` / `providerlocation`, and /// what lands there is whoever is on the account rather than the counter. /// /// The app cannot tell the two apart. `Sudharsan` and `Sri Balaji Stores` are /// both just strings, so there is no rule that fixes the bad ones without /// breaking every stop where the field is right. /// /// ── The join that does answer it ── /// /// The booking already carries the location's **id** (`pickuplocationid` / /// `sourceid`), and the tenant's locations are a list with proper names on /// them: /// /// ``` /// GET /admin/tenants/:tenantid/locations /// stop.pickuplocationid ──▶ location.name /// ``` /// /// So the name is looked up rather than read off the booking. One request per /// session for the whole tenant, held in memory, and every screen that names a /// pickup reads it through [MilkRun.sourceNameOf]. /// /// ── Why every failure here is silent ── /// /// The route is under `/admin`, not `/miler`. Whether a rider's token is /// accepted on it is the backend's decision and not something this app should /// depend on. A 401, a 403, a shape this build cannot read, a dead network — /// all resolve to an empty table, and an empty table means the booking's own /// `sourcename` is used exactly as it is today. **Nothing on this path may /// ever stop a rider working.** /// ───────────────────────────────────────────────────────────────────────── abstract final class PickupLocations { /// `locationid` → the name a rider can read off a sign. Empty until /// [ensureLoaded] has run and the backend has answered. static Map _byId = const {}; /// The full base record by id — name, address and the coordinates a hand-over /// is navigated to. Empty until [ensureLoaded] has answered. static Map _bases = const {}; /// The base for an id, or null when this build has none. /// /// Read by the hand-over card: a base it cannot resolve is drawn with the /// name the server put on `next_hub` and no Navigate button, which is honest. /// Guessing a nearby base would be worse than saying nothing. static HandoverHub? baseFor(Object? id) { final key = id?.toString().trim() ?? ''; if (key.isEmpty || key == '0') return null; return _bases[key]; } /// In-flight load, so a screen rebuilding mid-fetch joins the request that /// is already running instead of starting a second one. static Future? _loading; /// True once a load has completed, however it went. A tenant with no /// locations and a tenant whose locations we were refused look the same from /// here, and both mean "stop asking". static bool _settled = false; /// The name for a location id, or `''` when this build has none. /// /// Synchronous on purpose: it is read from `build`, and a name that arrives /// one frame late is better than a widget tree that has to await. static String nameFor(Object? locationId) { final id = locationId?.toString().trim() ?? ''; if (id.isEmpty || id == '0') return ''; return _byId[id] ?? ''; } /// Fills the table directly, for tests and for nothing else. /// /// The real load goes through `/admin/tenant/:id/locations`, which a test has /// no business standing up. Marked settled so [ensureLoaded] does not then /// overwrite the seed with a live answer. @visibleForTesting static void seedForTest(Map byId) { _byId = Map.from(byId); _settled = byId.isNotEmpty; _loading = null; } /// True when the table holds anything at all. static bool get isLoaded => _byId.isNotEmpty; /// Loads the rider's tenant's locations, once. /// /// Safe to call on every queue fetch — after the first completed attempt it /// returns immediately. [force] re-asks, for a rider who has just changed /// tenant. static Future ensureLoaded({bool force = false}) { if (force) { _settled = false; _loading = null; } if (_settled) return Future.value(); return _loading ??= _load(); } static Future _load() async { try { final prefs = await SharedPreferences.getInstance(); final tenantId = prefs.getInt(TenantController.kTenantId) ?? 0; if (tenantId <= 0) { // No tenant on this device yet — not a failure, just too early. Left // unsettled so the next fetch tries again once the login has landed. _loading = null; return; } // ── The rider-readable route, at last ── // // This called `GET /admin/tenants/:id/locations` and logged a gap every // time it came back 401. The backend team settled it: that route is a // **different dataset** — a client's own sites, not bases — and `/admin/*` // requires roles 1/3/4 while a rider is role 5. The refusal was by design // and the gap report was wrong. // // `GET /miler/bases` is the one a rider may read, and it carries the // address and coordinates the old route never did. Naming a base is no // longer the whole job: a hand-over has to be navigated to. final res = await MilerApi.bases(); if (!res.ok) { ApiConfig.logGap( 'miler/bases', 'bases came back ${res.status} ${res.message}. Pickup headings will ' 'fall back to the booking row\'s own name, and a base hand-over ' 'will have no coordinates to navigate to.', ); _settled = true; return; } final table = {}; final bases = {}; for (final row in res.list) { if (row is! Map) continue; final m = row.map((k, v) => MapEntry(k.toString(), v)); final id = _first(m, const [ 'pickuplocationid', 'pickupLocationId', 'locationid', 'locationId', 'location_id', 'id', ]); // The whole base, kept beside the name — [HandoverHub] is what the // hand-over card and its Navigate button read. final base = HandoverHub.from(m); if (base != null && id.isNotEmpty) bases[id] = base; final name = _first(m, const [ 'locationname', 'locationName', 'location_name', 'name', 'branchname', 'branchName', 'storename', 'storeName', 'kitchenname', 'kitchenName', 'title', ]); if (id.isEmpty || name.isEmpty) continue; table[id] = name; } _byId = table; _bases = bases; _settled = true; debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named'); if (table.isEmpty && kDebugMode) { // The one thing that makes a shape mismatch diagnosable without // another round trip. Debug-only: a response body is not something to // write into a release log. debugPrint('[LOCATIONS] no id/name pair recognised in: ${res.raw}'); } } catch (e) { debugPrint('[LOCATIONS] could not load: $e'); _settled = true; } finally { _loading = null; } } static String _first(Map m, List keys) { for (final k in keys) { final v = m[k]; if (v == null || v is Map || v is List) continue; final s = v.toString().trim(); if (s.isNotEmpty && s.toLowerCase() != 'null' && s != '0') return s; } return ''; } /// Drops the table. For sign-out and for tests. @visibleForTesting static void reset() { _byId = const {}; _bases = const {}; _settled = false; _loading = null; } /// Seeds the table directly, for tests that must not touch the network. @visibleForTesting static void seed(Map byId) { _byId = Map.from(byId); _settled = true; } } /// ───────────────────────────────────────────────────────────────────────── /// THE RIDER'S OWN HUB /// /// A logistics day starts and ends at one building. The hub assigns the day's /// bookings — collections, drops and both — hands the rider the route, and he /// accepts the lot before he leaves. So on Home the whole run belongs to that /// building, and the card at the head of it is named after it. /// /// ── Why this is not read off a booking ── /// /// The obvious source is the stop's own `pickuplocationid`, and it is right for /// exactly half the run. A **drop** is loaded at the hub, so its pickup /// location *is* the hub; a **collection** is picked up at the customer's door, /// so its pickup location is the customer. Naming the head card from the /// bookings therefore titled it with whichever address happened to sort first — /// a customer's street on a run that starts at a depot. /// /// The hub is a fact about the **rider**, not about any one booking. It is his /// `locationid`, written at login from `applocationid` falling back to `hubid`, /// and resolved to something he can read off a sign through [PickupLocations]. /// /// ── Everything here degrades to a name, never to a blocked screen ── /// /// The id may be missing, the locations table may be empty or refused, and the /// name may simply not be in it. Each of those yields `''`, and the caller /// falls back to what it drew before. Nothing on this path may stop a rider /// working — the same rule [PickupLocations] is built on. abstract final class RiderHub { static int _id = 0; /// The hub's location id, or 0 before login has landed. static int get id => _id; /// The name a rider can read off the building, or `''` when this build has /// none. Synchronous: it is read from `build`. static String get name => PickupLocations.nameFor(_id); /// Reads the id the login persisted. Cheap, and safe on every queue fetch. static Future ensureLoaded() async { if (_id > 0) return; try { final prefs = await SharedPreferences.getInstance(); // `locationid` is written as `applocationid ?? hubid` — see // `auth_provider.dart`. Both name the building this rider works out of. _id = prefs.getInt('locationid') ?? 0; } catch (_) { // A device that cannot read its own prefs is a device with no hub name, // which is a caption, not a capability. _id = 0; } } /// For tests, and for a rider who changes hub without reinstalling. static void resetForTest([int id = 0]) => _id = id; }