production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -1,8 +1,11 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/miler_api.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// WHICH LINE OF WORK THIS RIDER IS ON
@@ -476,27 +479,108 @@ class ServiceProfile {
///
/// A free function rather than a controller method so it can be tested without
/// a GetX container — see the note on [ServiceProfile.active].
/// Re-reads the rider's tenant from `GET /miler/profile` and persists it.
///
/// ── Why a signed-in rider needed this ──
///
/// `tenantname` is written at verify-pin, so it only ever reached a device by
/// way of a **login**. A rider already signed in when the backend began
/// returning the field would never see it: his prefs carry no name, resolution
/// falls back to the tenant id, and an id this app has not been told about
/// lands him on logistics — no **Start delivery** button, and a parcel that
/// strands at `Collected_By_Miler` the moment collected-state is enabled.
///
/// The fix is not to make every rider sign out. `GET /miler/profile` returns
/// the same pair, so the tenant can be refreshed in place on launch.
///
/// ── What it costs, and when ──
///
/// Nothing, for a rider whose name is already known: he resolves from prefs on
/// this launch and picks up any change on the next one, with no first frame
/// spent waiting on a network call. A rider with **no** stored name is the case
/// this exists for, and that one is worth a bounded wait.
///
/// Every failure is silent by design. A launch with no signal must land on the
/// same screen it always did rather than on an error, and the stored pair is
/// still there to resolve from.
Future<void> refreshTenantFromProfile() async {
final prefs = await SharedPreferences.getInstance();
if ((prefs.getString('authtoken') ?? '').trim().isEmpty) return;
final known = (prefs.getString(TenantController.kTenantName) ?? '').trim();
final fetch = _fetchTenantIntoPrefs();
if (known.isNotEmpty) {
unawaited(fetch);
return;
}
await fetch.timeout(const Duration(seconds: 4), onTimeout: () {});
}
Future<void> _fetchTenantIntoPrefs() async {
try {
final res = await MilerApi.getProfile();
if (!res.ok) return;
final map = res.map;
// The backend returns the pair top-level *and* inside `user`. Read both, so
// a handler that later moves them cannot silently stop resolving riders.
final user = map['user'] is Map ? map['user'] as Map : const {};
Object? pick(String k) => map[k] ?? user[k];
final prefs = await SharedPreferences.getInstance();
final name = (pick('tenantname') ?? pick('tenantcode') ?? '')
.toString()
.trim();
if (name.isNotEmpty) {
await prefs.setString(TenantController.kTenantName, name);
}
final id = int.tryParse('${pick('tenantid') ?? ''}'.trim());
if (id != null && id > 0) {
await prefs.setInt(TenantController.kTenantId, id);
}
} catch (_) {
// Offline, timed out, malformed — all the same answer: keep what we have.
}
}
Future<ServiceProfile> resolveServiceProfile() async {
final prefs = await SharedPreferences.getInstance();
// ── What the rider's account says ──
//
// Name first: it is the field a human can check against the admin console,
// and it means a new tenant does not need an app release. An unrecognised
// name is not an answer, so it falls through to the id.
// Both halves of the pair are read before either is acted on, because which
// one answers depends on what the other said. See the note below.
final name = (prefs.getString(TenantController.kTenantName) ?? '')
.trim()
.toLowerCase();
if (name.isNotEmpty) {
final byName = TenantController.profileForName(name);
if (byName != null) return byName;
}
final byName = name.isEmpty ? null : TenantController.profileForName(name);
final id = prefs.getInt(TenantController.kTenantId) ?? 0;
if (id != 0) {
final byId = TenantController.profileForId(id);
if (byId != null) return byId;
}
final byId = id == 0 ? null : TenantController.profileForId(id);
// ── When the two disagree, the id wins ──
//
// Name-first is right for the case it was written for: an unrecognised name
// falls through to the id, so a tenant this build has never heard of costs no
// release. That property is untouched below — a tenant absent from
// [TenantController.milkManTenantIds] is still decided by its name alone.
//
// What it did not survive is the two lists *contradicting each other*. The id
// list names one specific tenant on purpose; the name list is a pattern, and
// patterns collide. Tenant 13 carrying a display name that normalises to
// `doormile` — which is, after all, the company that owns the tenant record —
// resolved that rider to Logistics and never consulted the id at all. On the
// Logistics line `_unreleased` is empty by design, so the **Start round** bar
// does not render, and once `MILER_COLLECTED_STATE_ENABLED` is on his
// collected parcels strand at `Collected_By_Miler` with no control to release
// them. A global flag gets one safe shot, and this was the loose end in it.
//
// A deliberate statement about a known tenant outranks a match on a word
// nobody on either side of the API controls the spelling of.
if (byId != null) return byId;
if (byName != null) return byName;
// ── The tenant the session's own token claims ──
//
@@ -618,11 +702,34 @@ class TenantController extends GetxController {
/// One lookup used by both the account path and the build override, so the
/// two can never disagree about what a name means.
static ServiceProfile? profileForName(String name) {
if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan;
if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel;
final key = _nameKey(name);
if (key.isEmpty) return null;
if (milkManTenantNames.any((n) => _nameKey(n) == key)) {
return ServiceProfile.milkMan;
}
if (logisticsTenantNames.any((n) => _nameKey(n) == key)) {
return ServiceProfile.parcel;
}
return null;
}
/// A tenant name reduced to the letters and digits in it.
///
/// ── Why separators cannot be allowed to decide a rider's day ──
///
/// This was an exact-string lookup against a lower-cased name, which meant
/// `DailyGrubs` resolved and `Daily Grubs` did not — and the difference
/// between them is whether the rider gets a **Start delivery** button at all.
/// Nobody on either side of the API controls how a client's display name was
/// typed into the tenant record, so matching on it was a coin flip we had no
/// reason to take: `daily-grubs`, `DAILY_GRUBS` and `Daily Grubs` are the
/// same client by any reading, and all three missed.
///
/// Applied to both sides of the comparison, so the literals above stay
/// readable as the words they are.
static String _nameKey(String raw) =>
raw.toLowerCase().replaceAll(RegExp('[^a-z0-9]'), '');
/// The profile a tenant *id* means, or null when this app does not know it.
static ServiceProfile? profileForId(int id) {
if (milkManTenantIds.contains(id)) return ServiceProfile.milkMan;