808 lines
36 KiB
Dart
808 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 `_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 ──
|
|
//
|
|
// 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;
|
|
}
|
|
}
|