Files
doormile_milderapp/lib/data/pickup_locations.dart
2026-08-28 11:13:15 +05:30

186 lines
7.0 KiB
Dart

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<String, String> _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<void>? _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<void> ensureLoaded({bool force = false}) {
if (force) {
_settled = false;
_loading = null;
}
if (_settled) return Future<void>.value();
return _loading ??= _load();
}
static Future<void> _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 = <String, String>{};
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<String, dynamic> m, List<String> 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<String, String> byId) {
_byId = Map<String, String>.from(byId);
_settled = true;
}
}