Files
doormile_milderapp/lib/data/service_profile.dart
2026-08-28 15:07:30 +05:30

810 lines
36 KiB
Dart

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
///
/// One company — **Doormile** — running two lines of work out of one rider app.
///
/// • **Parcel.** First-mile logistics: collect a consignment from a customer,
/// carry it to the hub, cash on delivery, proof at every door. The app's
/// original business.
///
/// • **Meals.** Subscription food runs for clients with cloud kitchens: load a
/// crate of lunches at the kitchen, drop them to subscribers who have already
/// paid by the month, go home. No money at any door, no chain of custody, a
/// different shape of day entirely.
///
/// The two used to be two separate rider apps. They are one app now, and which
/// line a rider is on is decided by the **tenant id on his login** — the hub
/// assigns it, the app reads it once and never asks again.
///
/// ── Why the tenant and not the trip ──
///
/// The earlier design note for mixed routes argued for keying behaviour off the
/// *stop*, because one rider can hold a pickup and a delivery in the same hour.
/// That argument does not apply here: the two lines have separate riders,
/// separate hubs and separate clients. A rider belongs to one of them for the
/// life of his account, and `tenantid` is already on the login response — so it
/// resolves once, at login, and nothing downstream has to ask again.
///
/// ── The one rule for using this ──
///
/// Screens read a **capability**, never a line. `if (profile.collectsCash)`,
/// not `if (line == ServiceLine.meals)`. Capabilities are why signing a third
/// kind of client later is a new [ServiceProfile] rather than a third UI: the
/// day a food client wants cash on delivery, or a parcel client wants crate
/// loading, the screens are already asking the right question.
/// ─────────────────────────────────────────────────────────────────────────
enum ServiceLine {
/// Parcel logistics. The app's original and largest business.
parcel,
/// **Milk Man delivery** — load a crate at a source (a kitchen), then drop
/// to the customers who ordered from it.
///
/// Named `milkMan` after the operation the hub runs; the code called this
/// `meals` while it had one food client, and the shape of the work — collect
/// in bulk from one or more sources, then a round of prepaid drops — is the
/// same whatever is in the crate.
milkMan,
/// Last-mile delivery: carry consignments out of the hub and hand them to
/// customers. The Xpress-rider app's business.
///
/// ── Why this one is different from the other two ──
///
/// [parcel] and [meals] are the *same screens* with different capabilities
/// switched on. This one is not: it has its own screens, ported verbatim from
/// the Xpress-rider app and living under `lib/xpress`. A delivery rider gets
/// that app — its home, its deliveries list, its summary, its four-tab bottom
/// bar — wearing Doormile's colours.
///
/// So the capabilities on [ServiceProfile.delivery] are close to decorative:
/// almost nothing reads them, because the delivery screens do not ask the
/// capability questions the parcel screens ask. What this line is *for* is the
/// routing decision in `helpers/rider_shell.dart`, which is the only place
/// that has to know.
delivery,
}
/// The moment a stop leaves Home for the work tab. See [ServiceProfile.handoffAt].
enum HandoffPoint {
/// As soon as the rider accepts it.
accepted,
/// Only once he is physically carrying it.
collected,
}
/// What this line of work requires of the rider at the door.
@immutable
class ServiceProfile {
final ServiceLine line;
/// Shown wherever the rider needs to know which work this is.
///
/// Both lines are Doormile — the rider's employer does not change with the
/// crate he is carrying — so this names the *work*, not a company.
final String label;
/// ── The one noun the whole app uses for a job ──
///
/// The two rider apps this one replaces disagreed about what a job is called:
/// the parcel app said **booking**, the meal app said **delivery**. Merging
/// them meant picking one, and there is no single honest answer — a rider who
/// collects parcels for the hub does not "deliver" them, and a rider handing
/// somebody lunch has not taken a "booking".
///
/// So the noun follows the line, and every string that names a job reads it
/// from here: the tab in the nav bar, the accepted-count pill, the sentence on
/// a taken stop, the empty states. A rider only ever belongs to one line, so
/// he only ever sees one word — and the code has one path, not two.
///
/// Capitalised where a label needs it; see [workTabLabel].
final String jobNoun;
/// Plural of [jobNoun] — English is not regular enough to derive it.
final String jobNounPlural;
/// What the nav bar calls the tab holding accepted work.
///
/// "Bookings" on a parcel route, "Deliveries" on a meal run. Title case
/// because it is a proper destination in the app, not a word in a sentence.
String get workTabLabel =>
jobNounPlural[0].toUpperCase() + jobNounPlural.substring(1);
/// The verb for finishing a stop, in the rider's voice: "Picked up" when he
/// is taking something away, "Delivered" when he is handing it over.
///
/// A meal run's stops are all drops, so its rider never sees "Picked up" at a
/// customer's door — that word belongs to the kitchen, where he loads.
final String completionVerb;
/// Money changes hands at the stop. False for a subscription service — the
/// client is billed monthly, so the rider never sees an amount.
///
/// Read through [stopCollectionAmount], which returns 0 when this is false,
/// so every existing "is anything owed?" branch already does the right thing.
final bool collectsCash;
/// The full proof-of-work page — parcel ticks, weight, condition, OTP,
/// photo, review. Right for a parcel with a chain of custody; wrong for a
/// lunch box, where it is a two-minute form standing between a rider and a
/// doorbell.
///
/// **Nothing reads this today.** The page is off on both tenants: arriving
/// goes straight to the confirmation sheet, which asks the one question a
/// stop actually has an answer to — picked up, not picked, or skipped. The
/// capability and [StopVerificationPage] both survive because the form is
/// what a chain-of-custody client would need on the day one asks for it, and
/// this is the switch that would turn it back on for that tenant alone.
final bool needsVerification;
/// A camera step on the final status change.
final bool needsProofPhoto;
/// The rider decides stop by stop. False for a service run: he cannot
/// decline one subscriber's lunch, so the whole assignment is accepted at
/// once and the per-row Accept/Reject controls come off the card.
final bool acceptsPerStop;
/// Collection is grouped by source (a kitchen), not done per customer.
///
/// Also what makes Home group its stops under a heading per source and offer
/// one bulk collect per group: a rider standing at a counter with a crate is
/// doing one thing, not five.
final bool sourceIsKitchen;
/// When a stop stops being Home's problem and becomes the work tab's.
///
/// A parcel route hands off at **accept**: taking a booking is the decision,
/// and everything after it happens at the customer's door.
///
/// A meal run hands off at **collect**, because accepting changes nothing
/// physical — the rider still has to go to a kitchen and be given the food.
/// Moving the card at accept would empty Home the moment he arrived for his
/// shift and leave him working a list of meals he is not carrying. So the rule
/// is: **Home holds an order until it is in his hands.**
final HandoffPoint handoffAt;
/// One name for every stop's work type, or null to let each stop name its own
/// (`PICKUP` / `DELIVERY` / `P&D`).
///
/// A meal run is one job repeated — collect a crate, drop the boxes — and
/// labelling half of a rider's day PICKUP and half DELIVERY invites him to
/// look for a difference in handling that does not exist. The stop's real
/// kind is still tracked underneath, because the collection gate in P3 needs
/// it; this is only what the chip says.
final String? workTypeLabel;
/// Loading is done crate-at-a-counter, so it takes one photo for the whole
/// load rather than one per order.
///
/// A rider standing at a kitchen hatch with fifteen boxes cannot photograph
/// each one, and the thing worth photographing is the *crate* — what he was
/// handed, in one frame, at one time. False on a parcel route, where proof is
/// per consignment because each one has its own chain of custody.
final bool bulkLoadProof;
/// The rider asks the customer where this shipment is coming from and going
/// to, and the answers are written back to the booking.
///
/// ── Why this is a rider's job at all ──
///
/// A logistics booking often arrives half-addressed: the customer raised it
/// from a map pin and described the destination as a phone number and a
/// landmark. The rider is the first person standing in front of the sender
/// who can ask. Those answers decide the consignment's routing and its
/// pricing zone, so they have to be captured before the order is initiated.
///
/// False on a milk run, where both ends were fixed when the customer
/// subscribed and there is nothing for the rider to establish.
final bool capturesShipmentAddresses;
/// The fare is computed at the door from the real shipment, and shown to the
/// customer before he pays.
///
/// Follows [capturesShipmentAddresses] — a price needs a from, a to and a
/// weight, and a line that does not collect the first two cannot quote.
final bool pricesShipment;
/// Collection ends by converting the booking into a live shipment with a
/// tracking number — the moment the order really exists.
///
/// On a milk run collection is the *middle* of the day, not the end of a
/// transaction, so there is nothing to initiate.
final bool initiatesShipment;
/// The rider carries the load on to the customers himself.
///
/// True for a milk run: collection is followed by a round of drops he makes
/// personally. False for logistics, whose collected shipment goes to the hub
/// and is delivered by somebody else on another day — which is why that line
/// ends at a warehouse and this one ends at the last door.
final bool deliversToCustomer;
/// Delivery does not begin until every assigned pickup is in the rider's
/// hands, and it begins on an explicit press.
///
/// ── Why the whole load, and why a press ──
///
/// A milk-run rider works two or three sources before he starts driving to
/// customers. Letting each collected order drift into the delivery list on
/// its own gives him a half-built round: he sets off after kitchen one, and
/// kitchen two's five orders appear behind him. So the round is held until
/// the load is complete and then released in one gesture, which is also the
/// moment the shipments legitimately become "out for delivery" server-side.
final bool startsDeliveryAfterFullLoad;
const ServiceProfile({
required this.line,
required this.label,
required this.jobNoun,
required this.jobNounPlural,
required this.completionVerb,
required this.collectsCash,
required this.needsVerification,
required this.needsProofPhoto,
required this.acceptsPerStop,
required this.sourceIsKitchen,
required this.handoffAt,
this.bulkLoadProof = false,
this.capturesShipmentAddresses = false,
this.pricesShipment = false,
this.initiatesShipment = false,
this.deliversToCustomer = false,
this.startsDeliveryAfterFullLoad = false,
this.workTypeLabel,
});
/// First-mile parcel logistics — the app as it has always been. Also the
/// fallback for an unrecognised tenant, see [TenantController.load].
static const ServiceProfile parcel = ServiceProfile(
line: ServiceLine.parcel,
label: 'Doormile',
jobNoun: 'booking',
jobNounPlural: 'bookings',
completionVerb: 'Picked up',
collectsCash: true,
needsVerification: true,
needsProofPhoto: true,
acceptsPerStop: true,
sourceIsKitchen: false,
handoffAt: HandoffPoint.accepted,
// The full shipment desk at the customer's door: where is it from, where
// is it going, what does it weigh, what does that cost, and take the money
// — then turn it into a real consignment bound for the hub.
capturesShipmentAddresses: true,
pricesShipment: true,
initiatesShipment: true,
// The collected shipment goes to the hub; somebody else delivers it.
deliversToCustomer: false,
// Each stop names its own kind: a parcel route genuinely mixes them.
workTypeLabel: null,
);
/// **Milk Man delivery.** Load a crate at one or more sources, then a round
/// of prepaid drops. No money, no verification form, no per-stop decision.
static const ServiceProfile milkMan = ServiceProfile(
line: ServiceLine.milkMan,
label: 'Doormile Delivery',
jobNoun: 'delivery',
jobNounPlural: 'deliveries',
completionVerb: 'Delivered',
collectsCash: false,
needsVerification: false,
needsProofPhoto: false,
acceptsPerStop: false,
sourceIsKitchen: true,
handoffAt: HandoffPoint.collected,
bulkLoadProof: true,
// None of the shipment desk: these orders were booked and paid for before
// the rider's shift started.
capturesShipmentAddresses: false,
pricesShipment: false,
initiatesShipment: false,
// He collects the load and delivers it himself, once it is all aboard.
deliversToCustomer: true,
startsDeliveryAfterFullLoad: true,
// One word, deliberately. The chip is a fixed slot beside the state tag and
// a two-word label is the one that gives way — "MILK RU…" on every card in
// the list. `test/service_card_test.dart` holds this.
// ── Never the internal name of the line ──
//
// This was `MILK`, and it was printed on the rider's cards and on his
// Activity record as `Type: MILK`. That is the app's own vocabulary for a
// *service profile* — an implementation detail he did not choose, cannot
// change and gains nothing from. What he is actually doing at every stop
// on this line is a delivery, so that is what the mark says.
//
// The profile itself stays exactly as it was: it is what separates one
// rider's records from another's and decides which capabilities exist.
// That separation is a data rule, and it belongs in the data, not on a
// badge.
workTypeLabel: 'DELIVERY',
);
/// The old name for [milkMan], from when the line had one food client.
///
/// Kept so the existing call sites and tests that name it this way keep
/// compiling; there is one profile, under two names.
static const ServiceProfile meals = milkMan;
/// Last-mile delivery — the ported Xpress-rider flow.
///
/// The capability values here describe the work honestly (a delivery rider
/// does collect cash on COD, does photograph a doorstep, does take jobs one at
/// a time), but almost nothing reads them: this line renders its own screens,
/// so it does not go through the capability branches the parcel screens use.
/// They matter only if a delivery rider is ever routed into a shared screen.
static const ServiceProfile delivery = ServiceProfile(
line: ServiceLine.delivery,
label: 'Doormile',
jobNoun: 'delivery',
jobNounPlural: 'deliveries',
completionVerb: 'Delivered',
collectsCash: true,
needsVerification: false,
needsProofPhoto: true,
acceptsPerStop: true,
sourceIsKitchen: false,
handoffAt: HandoffPoint.accepted,
workTypeLabel: 'DELIVERY',
);
/// True when an accepted stop stays on Home until the rider is carrying it.
bool get handsOffAtCollection => handoffAt == HandoffPoint.collected;
/// True when every stop wears the same work-type mark — see [workTypeLabel].
bool get usesSingleWorkType => workTypeLabel != null;
/// True on the Milk Man line — collect at a source, then deliver.
bool get isMilkMan => line == ServiceLine.milkMan;
/// The old name for [isMilkMan].
bool get isMeals => isMilkMan;
/// ── Whether the backend can serve this line's work at all ──
///
/// True on every line, and that is a correction, not a shortcut. This read
/// `!sourceIsKitchen`, on the reasoning that the `/miler/*` contract serves
/// bookings and consignments while kitchens, crates and subscribers are not
/// concepts it has — so a milk-man rider could have no endpoint to ask.
///
/// **The hub does not dispatch that way.** The console's dispatch board reads
/// the milk round from `GET /admin/bookings` and puts a rider on a stop with
/// `POST /hub/bookings/:id/auto-assign`; the rider-facing read of those very
/// rows is `GET /miler/bookings`. A milk round *is* bookings today. The
/// kitchen vocabulary describes how this app **renders** the work — load at a
/// source, hand off at collection, one MILK chip per card — not where the
/// work comes from.
///
/// While the two were conflated, a rider on the milk-man line could not be
/// given work at all: [WorkRepository] returns `LoadUnavailable` on a false
/// answer here and never issues the request, so Home, Bookings and Activity
/// were all blank however many bookings the hub had assigned him. That is how
/// it was found — Rajan A (userid 38, tenant 13) was assigned a booking in the
/// console and the app showed him nothing, with no failure anywhere to
/// explain it.
///
/// The flag stays because the state it feeds is worth keeping: a line the
/// backend genuinely cannot answer for should render "your hub has not
/// enabled this yet" rather than an empty list that reads as a quiet day. It
/// is now something a future line sets deliberately, not something a rider
/// inherits from how his round is shaped.
///
/// It must never be satisfied by [MealRunMock]. That fixture is an opt-in
/// development tool behind `--dart-define=MOCK_BACKEND=true`; wiring it in
/// here would put fabricated business data on a production path.
bool get hasBookingsEndpoint => true;
/// True when the rider's day ends back at the depot.
///
/// A logistics rider collects shipments at customers' doors and carries them
/// to the hub, so the last leg of his route is a building. A **milk-man**
/// round is the other way up: he loads at the kitchen at the start and his
/// last address is a customer's door, so there is nothing to return and
/// nowhere to return it to — his day ends when the round does.
///
/// Derived from [deliversToCustomer] rather than stored, because they are the
/// same fact: a line that hands goods to the customer has already delivered
/// its load by the time it finishes.
bool get endsAtHub => !deliversToCustomer;
/// True on the Logistics line — the shipment desk at the customer's door.
bool get isLogistics => line == ServiceLine.parcel;
/// The old name for [isLogistics].
bool get isParcel => isLogistics;
/// True when this rider gets the ported Xpress-rider screens rather than the
/// parcel ones. Read by `helpers/rider_shell.dart` and by nothing else — see
/// the note on [ServiceLine.delivery].
bool get isDelivery => line == ServiceLine.delivery;
// ── Reading the active profile ──
//
// A plain static, deliberately, and not a GetX lookup.
//
// [stopCollectionAmount] is a pure map helper called from model code and from
// tests with no widget tree at all. Routing it through `Get.put` made it
// initialise a real `WidgetsFlutterBinding` as a side effect of asking "is
// any money owed here?" — which is enough to stop an unrelated widget test in
// another file from starting at all. Data code must not be able to boot the
// framework.
//
// [TenantController] still owns loading and still publishes an `Rx` for
// screens that want to rebuild; this is the synchronous read everything else
// uses.
static ServiceProfile _active = parcel;
/// The signed-in rider's profile.
///
/// Parcel until [TenantController.load] says otherwise — see that method for
/// why the fallback runs in that direction.
static ServiceProfile get active => _active;
/// Sets the active profile. Called by [TenantController.load]; also the seam
/// tests use to put the app on one line or the other.
static void setActive(ServiceProfile profile) => _active = profile;
}
/// Resolves the rider's line of work from what login persisted.
///
/// Order: **the rider's own tenant** (name, then id) → build override →
/// logistics.
///
/// ── Why the server now wins over the build flag ──
///
/// This used to read the `TENANT` dart-define first, because `verify-pin`
/// returned no tenant at all and the flag was the only signal there was. That
/// is no longer true: the login response carries `tenantid` and `tenantname`,
/// so the rostered answer exists and the app can simply ask.
///
/// Which way round these two go is a real decision, not a detail. "Which work
/// am I doing today?" must be answered by the hub that rostered the rider, not
/// by whoever compiled the APK — one build serves every rider, and a flag that
/// outranks the account means one wrong build puts every rider on the wrong
/// flow. So the flag is now what it should always have been: a fallback for
/// builds signed in against a backend that cannot answer, and a way to demo a
/// line without a matching account.
///
/// 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 ──
//
// 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();
final byName = name.isEmpty ? null : TenantController.profileForName(name);
final id = prefs.getInt(TenantController.kTenantId) ?? 0;
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 a rider has no customer round at all — he carries what he
// collects to the hub — so nothing in the app will ever call
// `start-delivery` for him. Once `MILER_COLLECTED_STATE_ENABLED` is on, a
// meal rider resolved onto Logistics has his collected orders strand at
// `Collected_By_Miler` with no control anywhere that releases 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 ──
//
// Read after the persisted pair and before the build flag, and it exists for
// two cases the pair does not cover.
//
// The first is a backend whose `verify-pin` does not return the tenant in its
// body. It signs one into every token regardless, so the answer is in the
// app's hands either way — this is the same fact from the same source.
//
// The second is the rider who is **already signed in**. His prefs were
// written by an older build that stored tenant 0, and nothing would correct
// that until he happened to log out; reading his live session instead means
// the next launch resolves him properly with no action from him at all.
//
// Not a security decision — see [ApiConfig.tenantIdFromToken].
final claimed = ApiConfig.tenantIdFromToken(prefs.getString('authtoken'));
if (claimed != 0) {
final byClaim = TenantController.profileForId(claimed);
if (byClaim != null) return byClaim;
}
// ── The build's declared line, as a fallback ──
//
// Reached only when the account said nothing this app recognises. Unset
// means logistics, so an ordinary build is unchanged.
if (TenantController.buildOverride.isNotEmpty) {
final override = TenantController.buildOverride.trim().toLowerCase();
final byOverride = TenantController.profileForName(override);
if (byOverride != null) return byOverride;
// The ported Xpress line is reachable ONLY from the build flag — never
// from a tenant name or id off the wire. It is a reference implementation
// that lives beside this app, not one of the two operations the hub runs,
// and a tenant that happened to be called "delivery" must not tip a live
// rider into a different application. See `helpers/rider_shell.dart`.
if (TenantController.deliveryTenantNames.contains(override)) {
return ServiceProfile.delivery;
}
}
return ServiceProfile.parcel;
}
/// Resolves the signed-in rider's line of work, once, and hands out its profile.
///
/// Follows [DutyController]: a permanent GetX singleton with a `.to` accessor
/// and a `load()` that reads persisted prefs, so any screen can read the
/// profile synchronously without an await in `build`.
class TenantController extends GetxController {
/// Parcel until proven otherwise — see [load] for why that direction.
final Rx<ServiceProfile> profile = ServiceProfile.parcel.obs;
/// Persisted by the login response. See `auth_provider.dart`.
static const String kTenantId = 'tenantid';
static const String kTenantName = 'tenantname';
/// Tenant ids whose riders work the **Milk Man** line.
///
/// **This is the switch the hub actually operates.** A rider is put on the
/// milk-run flow by being given one of these tenant ids at login and nothing
/// else — no separate app, no separate build, no flag on his phone.
///
/// **13** is the tenant the Milk Man riders are on — the account Rajan A
/// (userid 38) signs in with, confirmed from the `tenantid` claim on a live
/// login against `api.doormile.com`.
///
/// ── Why an id is pinned here at all ──
///
/// The name is the better switch and stays the first thing checked. But the
/// deployed backend does not return `tenantname` yet (the handler that does
/// is written and not released), so today the id is the only signal a real
/// rider carries. When the deploy lands, the name will match first and this
/// becomes a belt-and-braces second answer rather than the load-bearing one.
///
/// **If tenant 13 is not the milk-run client**, this line is the whole fix:
/// remove the 13 and a rider on it goes back to logistics.
static const Set<int> milkManTenantIds = <int>{13};
/// The old name for [milkManTenantIds].
static const Set<int> mealTenantIds = milkManTenantIds;
/// Name/code match, case-insensitive, against `tenantname` on the login.
///
/// Cheaper than a release for every new tenant id, and it is the field a
/// human can actually verify against the admin console.
static const Set<String> milkManTenantNames = <String>{
'milkman',
'milk man',
'milk-man',
'milkrun',
'milk run',
'meals',
'dailygrubs',
};
/// The old name for [milkManTenantNames].
static const Set<String> mealTenantNames = milkManTenantNames;
/// Tenant ids whose riders work the **Logistics** line.
///
/// Logistics is also the fallback, so this list exists to make a tenant
/// *explicitly* logistics rather than merely unrecognised — which matters
/// when a name would otherwise be ambiguous.
static const Set<int> logisticsTenantIds = <int>{};
/// The names that mean the Logistics line.
static const Set<String> logisticsTenantNames = <String>{
'doormile',
'logistics',
'parcel',
};
/// The old name for [logisticsTenantNames].
static const Set<String> parcelTenantNames = logisticsTenantNames;
/// The profile a tenant *name* means, or null when this app does not know
/// the name. Case-insensitive; callers pass an already-lowercased string.
///
/// 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) {
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;
if (logisticsTenantIds.contains(id)) return ServiceProfile.parcel;
return null;
}
/// Tenant ids that would mean the ported Xpress-rider screens under
/// `lib/xpress`.
///
/// **Permanently empty, deliberately.** That subtree is a *reference*
/// implementation kept beside this app — it is not one of the two operations
/// the hub runs, and the Milk Man flow is now implemented natively in Miler
/// rather than by routing riders into it.
///
/// Nothing off the wire may reach it: a tenant that happened to be named
/// "delivery" must not tip a live rider into a different application with a
/// different bottom bar and a different set of endpoints. The only way in is
/// the [buildOverride] flag, for looking at it. See `resolveServiceProfile`.
static const Set<int> deliveryTenantIds = <int>{};
/// Build-override names that open the ported Xpress screens. Never matched
/// against a tenant off the login — see [deliveryTenantIds].
static const Set<String> deliveryTenantNames = <String>{
'delivery',
'xpress',
'express',
'doormile xpress',
'doormilexpress',
};
/// Account partitions that mean the ported delivery line. Retained for the
/// build-override path only; nothing off the wire is matched against it.
static const Set<int> deliveryConfigIds = <int>{6};
/// Which line this build declares, **when the rider's account does not say**.
///
/// flutter run --dart-define=TENANT=milkman
/// flutter run --dart-define=TENANT=logistics
/// flutter run --dart-define=TENANT=delivery # the Xpress reference
///
/// ── This is a testing aid, not the production switch ──
///
/// A production rider's line comes from his own tenant, which `verify-pin`
/// now returns. This flag is read only when the account carries a tenant this
/// app does not recognise, so it cannot override a rostered rider — see the
/// ordering note on [resolveServiceProfile].
///
/// Two flags, and they answer different questions:
///
/// • `TENANT` picks the **profile**, i.e. which screens the rider gets.
/// • `TENANT_ID` is the **number sent to the API** — see [MilerApi.tenantId].
///
/// Unset means logistics, so an ordinary build is unchanged.
static const String buildOverride = String.fromEnvironment('TENANT');
static TenantController get to => Get.isRegistered<TenantController>()
? Get.find<TenantController>()
: Get.put(TenantController(), permanent: true);
/// Resolves the tenant and publishes it, both to [ServiceProfile.active] for
/// synchronous reads and to [profile] for screens that rebuild on it.
///
/// The fallback direction is the point: an unconfigured or unrecognised
/// tenant lands on **logistics**. The worst case that way is a milk-run rider
/// seeing a payment prompt he can dismiss; the other direction takes the
/// cash-collection screen away from a live logistics rider at a door.
Future<ServiceProfile> load() async {
final resolved = await resolveServiceProfile();
ServiceProfile.setActive(resolved);
profile.value = resolved;
debugPrint('[TENANT] resolved ${resolved.label}');
return resolved;
}
}