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/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 {}; /// 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] ?? ''; } /// 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; } final res = await MilerApi.tenantLocations(tenantId); if (!res.ok) { // The expected outcome if riders are not admitted to `/admin`. Logged // as a gap rather than an error: it is a question for the backend, and // the app is already correct without it. ApiConfig.logGap( 'admin/tenants/:id/locations', 'tenant $tenantId locations came back ${res.status} ${res.message}. ' 'Pickup headings will use the booking row\'s own `sourcename`, ' 'which is a contact person on some rows. If riders are meant to ' 'read this route, it needs to accept a miler token.', ); _settled = true; return; } final table = {}; 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', ]); 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; _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 {}; _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; } }