284 lines
11 KiB
Dart
284 lines
11 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/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<String, String> _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<String, HandoverHub> _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<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] ?? '';
|
|
}
|
|
|
|
/// 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<String, String> byId) {
|
|
_byId = Map<String, String>.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<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;
|
|
}
|
|
|
|
// ── 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 = <String, String>{};
|
|
final bases = <String, HandoverHub>{};
|
|
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<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 {};
|
|
_bases = 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;
|
|
}
|
|
}
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// 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<void> 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;
|
|
}
|