import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/api_config.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]. Future 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. final name = (prefs.getString(TenantController.kTenantName) ?? '') .trim() .toLowerCase(); if (name.isNotEmpty) { final byName = TenantController.profileForName(name); if (byName != null) return byName; } final id = prefs.getInt(TenantController.kTenantId) ?? 0; if (id != 0) { final byId = TenantController.profileForId(id); if (byId != null) return byId; } // ── 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 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 milkManTenantIds = {13}; /// The old name for [milkManTenantIds]. static const Set 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 milkManTenantNames = { 'milkman', 'milk man', 'milk-man', 'milkrun', 'milk run', 'meals', 'dailygrubs', }; /// The old name for [milkManTenantNames]. static const Set 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 logisticsTenantIds = {}; /// The names that mean the Logistics line. static const Set logisticsTenantNames = { 'doormile', 'logistics', 'parcel', }; /// The old name for [logisticsTenantNames]. static const Set 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) { if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan; if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel; return null; } /// 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 deliveryTenantIds = {}; /// Build-override names that open the ported Xpress screens. Never matched /// against a tenant off the login — see [deliveryTenantIds]. static const Set deliveryTenantNames = { '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 deliveryConfigIds = {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() ? Get.find() : 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 load() async { final resolved = await resolveServiceProfile(); ServiceProfile.setActive(resolved); profile.value = resolved; debugPrint('[TENANT] resolved ${resolved.label}'); return resolved; } }