miler map

This commit is contained in:
2026-09-09 12:55:23 +05:30
parent 074cc0eccf
commit 127fa062ed
143 changed files with 4315 additions and 2291 deletions

View File

@@ -3,6 +3,7 @@ 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';
/// ─────────────────────────────────────────────────────────────────────────
@@ -49,6 +50,21 @@ abstract final class PickupLocations {
/// [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;
@@ -68,6 +84,18 @@ abstract final class PickupLocations {
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;
@@ -96,23 +124,31 @@ abstract final class PickupLocations {
return;
}
final res = await MilerApi.tenantLocations(tenantId);
// ── 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) {
// 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.',
'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));
@@ -124,6 +160,10 @@ abstract final class PickupLocations {
'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',
@@ -142,6 +182,7 @@ abstract final class PickupLocations {
}
_byId = table;
_bases = bases;
_settled = true;
debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named');
if (table.isEmpty && kDebugMode) {
@@ -172,6 +213,7 @@ abstract final class PickupLocations {
@visibleForTesting
static void reset() {
_byId = const {};
_bases = const {};
_settled = false;
_loading = null;
}
@@ -183,3 +225,59 @@ abstract final class PickupLocations {
_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;
}