Files
doormile_customer_app/lib/state/app_state.dart
Thiru-tenext c7a74c57b8 BOOK NOW, OpenStreetMap, and one segmented control instead of two
── The circle says what pressing it does ──

"ONE TOUCH" named the *mechanism* — one gesture, no form — which is something
the product team knows and a customer has to be taught. Nobody arrives at that
screen wanting a touch; they want a parcel collected. The caption under the
sphere still carries what makes it different from the form below.

Renamed in the comments too. A codebase explaining "One Touch" against a button
that says BOOK NOW is a trap for whoever reads it next.

── OpenStreetMap everywhere ──

One line: the default provider was CARTO, it is `osm`. Nothing else moves —
`DmMapTiles` already reads the template, subdomains, retina flag and
attribution off the provider, so the credit line follows on its own.

One thing recorded on the provider itself rather than left to be discovered:
these are donated servers and the OSM Foundation's tile policy does not permit
a distributed app to lean on them. A block looks like every tile turning into
the ground colour at once, with no other symptom. Moving off it is one define —
`DM_MAP_PROVIDER=carto|maptiler|stadia`, all serving OpenStreetMap data — and
the map_config test now asserts the identifying User-Agent rather than only the
URL, because that is what attributable traffic depends on.

── Orders had a second copy of the segmented control ──

Its own `_Tab`, a pill radius, 3pt of padding and the count folded into the
label's text — beside the pickup window's day switcher, which is DmChoiceChip
in a rounded groove with 4pt of padding and the count in a bubble. Two controls
doing one job, drifting apart a padding value at a time.

It is the same control now, and `_Tab` is gone. DmChoiceChip's horizontal
padding drops 12 → 9: three of them split a 390pt phone and "Cancelled"
truncated to "Cancell…" at the old value. The day switcher has two chips and
acres of room, so it loses nothing.
2026-09-30 11:19:30 +05:30

1021 lines
37 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import '../data/app_config.dart';
import '../data/doormile_api.dart';
import '../data/location_service.dart';
import '../data/models.dart';
/// Single source of truth for the customer app.
///
/// Screens read from here and call these methods; nothing mutates a [Booking]
/// directly.
///
/// **Every booking on screen came from the server.** This class has no way to
/// invent one and no way to move one along: a stage changes when the backend
/// says it changed and [refreshBooking] reads it back.
class AppState extends ChangeNotifier {
AppState({DoormileApi? api, LocationService? location})
: api = api ?? DoormileApi.instance,
location = location ?? LocationService.instance {
this.api.onSessionLost = onSessionLost;
loadLimits();
}
final DoormileApi api;
final LocationService location;
// ------------------------------------------------------------- session
Customer? customer;
/// **The authoritative pickup point.** Display address *and* the coordinates
/// the Miler is sent to, in one object.
///
/// All three ways of choosing a pickup — the device fix, a map drag, and a
/// search result — write this single field, so nothing downstream has to
/// reconcile "the address" against "the pin".
Place? pickup;
/// True once we have both an address and coordinates to send a Miler to.
/// The booking step gates on this rather than on the address alone.
bool get hasPickupPoint => pickup?.hasLocation ?? false;
/// True while a pin is being turned into an address. The pickup card shows a
/// placeholder rather than a stale street name.
bool resolvingPickup = false;
/// Why we could not read the device location, if we could not. Null when the
/// pin came from GPS or from the customer.
LocationDenial? locationDenial;
/// Where the **customer** is, as opposed to where the pickup pin is.
///
/// Kept separate on purpose: the pin moves when they drag the map, this does
/// not. It is what the blue dot renders at, and it survives a drag so they
/// can always see how far they have moved the pin from themselves.
double? deviceLat;
double? deviceLng;
/// Horizontal accuracy in metres, when the platform reported it — the halo
/// around the dot.
double? deviceAccuracy;
bool get hasDeviceLocation => deviceLat != null && deviceLng != null;
/// Guards against an earlier reverse-geocode landing after a later drag.
int _pinSeq = 0;
/// Where the map should open: the current pin, else the service-area default.
double get mapLat => pickup?.lat ?? LocationService.fallbackLat;
double get mapLng => pickup?.lng ?? LocationService.fallbackLng;
bool get isSignedIn => customer != null;
/// True until the stored session has been looked for. The app shows its
/// launch state rather than the sign-in screen while this is set, so a
/// returning customer never sees a login form flash past.
bool restoringSession = true;
void signIn(Customer c) {
customer = c;
notifyListeners();
// ── Every sign-in does the same three things ──
//
// This used to start `refreshOrders` alone, and the OTP screen called
// `detectPickupLocation` itself afterwards to make up the difference. That
// held exactly as long as there was one sign-in screen: PIN sign-in
// arrived, did not know about the extra call, and Home opened with no
// pickup and no serviceable cities.
//
// A sign-in is a sign-in wherever it happened, so the work belongs here
// and not in whichever screen happened to be last.
_afterSignIn();
}
/// Looks for a persisted session at launch.
///
/// Never throws: being offline at launch is not being signed out, and the
/// stored customer stands until a call actually comes back 401.
Future<void> restoreSession() async {
try {
final restored = await api.restoreSession();
if (restored != null) {
customer = restored;
_afterSignIn();
} else if (AppConfig.devAutoLogin) {
// ── Offline builds open on Home, not on a login screen ──
//
// The fake signs anybody in — there is no server to refuse them — so
// stopping at the entrance would ask for a phone number and a code
// that mean nothing, purely to arrive somewhere the build was always
// going to allow. Any 4-digit code still works if someone wants to walk
// the screen; this is the default rather than the only way through.
//
// Real builds are untouched: `useDevData` is false in every one of
// them, so the login screen a customer meets is the real one.
customer = await api.verifyOtp('+91 9876543210', '1234');
_afterSignIn();
} else if (AppConfig.hasAutoLogin) {
await _autoSignIn();
}
} catch (e) {
// Same rule as the limits call: this decides which screen opens, so it
// fails to "signed out", never to a crash.
debugPrint('[SESSION] restore failed: $e');
} finally {
restoringSession = false;
notifyListeners();
}
}
/// Signs in for real, without the login screen — `DM_LOGIN_AS`.
///
/// This is a genuine sign-in, not a bypass: it runs the same two calls the
/// login and code screens run, and what it ends up holding is a token the
/// server issued. That is the whole point of it existing next to
/// the deleted offline mock — a build that skips the entrance still has to
/// end up as a real customer, or every booking it makes is a fake one.
///
/// Failure is not fatal and never throws: the app simply lands on the login
/// screen, which is exactly where someone whose code was refused should be.
Future<void> _autoSignIn() async {
final identifier = AppConfig.autoLoginIdentifier;
final code = AppConfig.autoLoginCode;
// ── Verify first, request second ──
//
// Requesting a code is not free: with no SMS gateway registered the server
// generates a fresh one, writes it to its log and REPLACES whatever was in
// Redis. So a code someone had just read out of that log would be
// invalidated by the very call meant to use it.
//
// So this tries the code it was given first. That path is the one that
// works with a log-read code, and with a fixed `CX_STAGING_OTP` it simply
// fails once on a cold Redis and falls through to the request below.
try {
customer = await api.verifyOtp(identifier, code);
_afterSignIn();
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
return;
} catch (_) {
// Expected when no code has been issued for this identifier yet.
}
try {
await api.sendOtp(identifier);
customer = await api.verifyOtp(identifier, code);
_afterSignIn();
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier after a request');
} catch (e) {
debugPrint(
'[AUTH] DM_LOGIN_AS failed for $identifier: $e — '
'the code was refused, so the login screen opens instead',
);
}
}
/// The two things every signed-in session needs, whichever door it came in
/// through.
///
/// Finding the customer's own location was the OTP screen's job, which meant
/// it only happened on the one path that shows an OTP screen. A restored
/// session, a dev auto-login and `DM_LOGIN_AS` all skipped it — so the app
/// opened on Home reading "Set a pickup address", never asked for the
/// location permission, and the pickup map had nothing to centre on. It is
/// not the entrance's job; it is the session's.
void _afterSignIn() {
unawaited(refreshOrders());
unawaited(detectPickupLocation());
// ── The serviceable set, before anybody asks for it ──
//
// Two things read it, and both want it already there. Home's reach line
// states how far Doormile goes and is simply absent until the cities are
// known — so warming it only when the booking sheet opens meant the line
// appeared *after* the one moment it was written to inform. And the sheet
// itself skips its loading skeleton when [cachedCities] is populated.
//
// It is small, it is the same list every customer gets, and it is the
// answer to a question asked on the first screen.
unawaited(_warmServiceArea());
}
/// Loads the serviceable cities and tells the screens they arrived.
///
/// The notify is the point. [loadCities] fills [statesCache] and
/// [districtCache] and returns — it changes no observable field, so nothing
/// rebuilds, and Home's reach line stayed absent while the data it needed sat
/// in the cache beside it. A silent warm-up is only a warm-up for whoever
/// asks next.
Future<void> _warmServiceArea() async {
try {
await loadCities();
} on ApiException catch (e) {
debugPrint('[AREA] could not prefetch the serviceable cities: $e');
return;
}
notifyListeners();
}
/// Called by the API layer when a refresh fails — the chain is dead, so the
/// customer is signed out wherever they happen to be standing.
void onSessionLost() {
if (customer == null) return;
debugPrint('[SESSION] lost, signing out');
signOut();
}
Future<void> signOut() async {
customer = null;
orders.clear();
pickup = null;
deviceLat = null;
deviceLng = null;
deviceAccuracy = null;
locationDenial = null;
ordersCursor = null;
clearCaches();
notifyListeners();
try {
await api.signOut();
} on ApiException catch (e) {
// Local sign-out has already happened and must stand.
debugPrint('[SESSION] sign-out call failed: $e');
}
}
/// Reads the device location and turns it into an address.
///
/// Never throws and never leaves the customer stuck: a refused permission
/// falls back to the service-area centre with [locationDenial] set, so the
/// screen can explain it and offer search instead.
Future<void> detectPickupLocation() async {
final seq = ++_pinSeq;
resolvingPickup = true;
notifyListeners();
final fix = await location.current();
if (seq != _pinSeq) return;
locationDenial = fix.denial;
if (fix.hasFix) {
deviceLat = fix.lat;
deviceLng = fix.lng;
deviceAccuracy = fix.accuracy;
}
final lat = fix.lat ?? pickup?.lat ?? LocationService.fallbackLat;
final lng = fix.lng ?? pickup?.lng ?? LocationService.fallbackLng;
await _resolvePin(lat, lng, seq);
}
/// Called when the map settles under the pin. The newest drag always wins.
Future<void> setPickupFromPin(double lat, double lng) async {
final seq = ++_pinSeq;
resolvingPickup = true;
notifyListeners();
await _resolvePin(lat, lng, seq);
}
Future<void> _resolvePin(double lat, double lng, int seq) async {
Place resolved;
try {
resolved = await api.reverseGeocode(lat: lat, lng: lng);
} on ApiException {
resolved = Place(
title: 'Pinned location',
sub: 'Address unavailable — the Miler will confirm it at pickup',
lat: lat,
lng: lng,
);
}
if (seq != _pinSeq) return;
pickup = resolved;
resolvingPickup = false;
notifyListeners();
}
/// Adopts a place chosen from search. Cancels any in-flight pin resolution,
/// so the newest customer intent always wins — same rule as a drag.
void setPickup(Place place) {
_pinSeq++;
// A search result without coordinates cannot be a pin. The backend
// contract requires them (§6.2); if one arrives without, keep the address
// and hold the previous pin rather than dropping the customer somewhere
// else on the map.
assert(
place.hasLocation,
'searchPlaces returned a Place with no coordinates: ${place.title}',
);
pickup = place.hasLocation
? place
: Place(
title: place.title,
sub: place.sub,
lat: pickup?.lat,
lng: pickup?.lng,
);
resolvingPickup = false;
locationDenial = null;
notifyListeners();
}
/// Opens OS settings after a permanent refusal.
Future<void> openLocationSettings() => location.openSettings();
// ------------------------------------------------- service delegation
//
// Screens call these, never `DoormileApi.instance`. That keeps the injected
// instance authoritative and means no widget can tell whether an answer came
// from the backend.
Future<OtpChallenge> sendOtp(String identifier) => api.sendOtp(identifier);
Future<OtpChallenge> signUp({
required String name,
required String phone,
String? email,
}) => api.signUp(name: name, phone: phone, email: email);
/// Verifies the code and adopts the session in one step, so no screen has to
/// remember to call [signIn] afterwards.
Future<Customer> verifyOtp(
String identifier,
String code, {
String? name,
}) async {
final verified = await api.verifyOtp(identifier, code, name: name);
signIn(verified);
return verified;
}
// ── PIN sign-in ──
//
// Same shape as [verifyOtp]: the session is adopted here so no screen has to
// remember to call [signIn] afterwards, and every sign-in path ends in the
// same place.
Future<PhoneCheck> checkPhone(String phone) => api.checkPhone(phone);
Future<Customer> setPin({
required String phone,
required String pin,
String? name,
}) async {
final created = await api.setPin(phone: phone, pin: pin, name: name);
signIn(created);
return created;
}
Future<Customer> verifyPin({
required String phone,
required String pin,
}) async {
final verified = await api.verifyPin(phone: phone, pin: pin);
signIn(verified);
return verified;
}
/// Biased towards the pickup point the customer is working with, so a search
/// for a street name answers with the one in their city first.
Future<List<Place>> searchPlaces(String query) {
final near = draftPickup ?? pickup;
return api.searchPlaces(query, lat: near?.lat, lng: near?.lng);
}
/// Backend policy on cancellation; the UI only asks.
bool isCancellable(JourneyStage stage) => api.isCancellable(stage);
/// The window a booking was placed in.
///
/// Served from whatever [loadSlots] last returned, which is why the index is
/// never cleared: a booking made an hour ago must still be able to name its
/// window after the slot list has rolled over.
PickupSlot? slotById(String? id) =>
id == null ? null : _slotIndex[id] ?? api.slotById(id);
final Map<String, PickupSlot> _slotIndex = {};
// --------------------------------------------------------------- limits
/// Caps on one pickup. Backend-owned; the UI never hardcodes them.
BookingLimits limits = const BookingLimits();
/// Caps are allowed to vary by city, so the pickup point goes with the ask.
Future<void> loadLimits() async {
try {
limits = await api.getBookingLimits(pickup: pickup);
notifyListeners();
} catch (e) {
// The safe cap is already in place; a failed fetch must not block
// booking. Catching everything, not just ApiException: this runs from
// the constructor, and a malformed response is not worth a dead app.
debugPrint('[LIMITS] keeping defaults: $e');
}
}
// -------------------------------------------------------- booking draft
/// The in-progress pickup. One destination is the common case; the customer
/// can add more, all collected in the same visit.
///
/// A setter rather than a field, because the map editor writes it and then
/// pops: as a plain field the send screen underneath kept its last frame and
/// went on showing the address the customer had just corrected.
Place? get draftPickup => _draftPickup;
set draftPickup(Place? place) {
if (_draftPickup == place) return;
_draftPickup = place;
notifyListeners();
}
Place? _draftPickup;
List<DestinationGroup> draftDestinations = [DestinationGroup()];
String? draftSlotId;
FareEstimate? draftFare;
int get draftTotalPackages =>
draftDestinations.fold(0, (sum, d) => sum + d.packageCount);
bool get canAddDestination =>
draftDestinations.length < limits.maxDestinations &&
draftTotalPackages < limits.maxPackages &&
draftDestinations.every((d) => d.isComplete);
bool canAddPackageTo(int index) => draftTotalPackages < limits.maxPackages;
/// Every destination has a serviceable state and district.
bool get draftReady =>
draftDestinations.isNotEmpty &&
draftDestinations.every((d) => d.isComplete);
/// One key per booking *intent*, held across every retry of it.
///
/// A retried `POST /customer/bookings` carrying the same key replays the
/// original response instead of creating a second pickup; a new key is a new
/// booking. So it is minted once here, when the customer starts filling the
/// form, and cleared only when a booking actually lands.
String? _bookingIdempotencyKey;
/// Whether this booking asks the customer for the drop address.
///
/// ── Two ways to book the same parcel ──
///
/// **BOOK NOW** asks three things: the state, the district and a pickup
/// window. Nothing else — no door number, no recipient, no weight. Those are
/// filled in by the Miler standing at the address with the parcel in their
/// hand, which is the only moment anybody actually knows them.
///
/// **The full form** is for the customer who already knows the door and
/// would rather type it than have it asked for later. It is the same
/// booking with `details` populated on the wire.
///
/// The contract has always taken both: [DestinationGroup.toBookingJson]
/// omits whatever is blank, and an omitted field is precisely the "not added
/// yet" state the Miler completes. This flag only decides what the app asks.
bool draftDetailed = false;
void startBooking({bool detailed = false}) {
draftDetailed = detailed;
draftPickup = pickup;
draftDestinations = [DestinationGroup()];
draftSlotId = null;
draftFare = null;
districtCache.clear();
// Slots carry their own date. A session left open across midnight would
// otherwise offer yesterday's windows, and booking one is refused — so the
// list is re-read for every booking rather than kept for the session.
_invalidateSlots();
_bookingIdempotencyKey = null;
notifyListeners();
}
/// Whether "Send to another place" is offered at all.
///
/// Multi-destination is server-gated while the Miler app still keys its work
/// on a booking-level id: a fanned-out pickup would give the rider one stop
/// for three parcels. The backend advertises the cap, the client obeys it,
/// and a cap of 1 hides the control rather than showing a dead one.
bool get allowsMultipleDestinations => limits.allowsMultipleDestinations;
void selectStateFor(int index, ServiceArea area) {
draftDestinations[index].destination
..stateCode = area.code
..stateName = area.name
..districtCode = null
..districtName = null;
draftDestinations[index].district = null;
draftFare = null;
notifyListeners();
}
void selectDistrictFor(int index, District district) {
draftDestinations[index]
..destination.districtCode = district.code
..destination.districtName = district.name
..district = district;
notifyListeners();
_loadFare();
}
void setPackageCount(int index, int count) {
final group = draftDestinations[index];
final others = draftTotalPackages - group.packageCount;
group.packageCount = count.clamp(1, limits.maxPackages - others);
notifyListeners();
_loadFare();
}
/// Returns false when the caps say no, so the caller can explain why.
bool addDestination() {
if (!canAddDestination) return false;
draftDestinations.add(DestinationGroup());
notifyListeners();
return true;
}
void removeDestination(int index) {
if (draftDestinations.length <= 1) return;
draftDestinations.removeAt(index);
notifyListeners();
_loadFare();
}
/// Republish after something mutated the draft in place (the details sheet).
void touch() => notifyListeners();
void selectSlot(PickupSlot slot) {
draftSlotId = slot.id;
notifyListeners();
}
void setDraftPin(int index, MapPin? pin) {
draftDestinations[index].details.pin = pin;
notifyListeners();
}
/// Priced as soon as the route is known, so Review has it ready.
Future<void> _loadFare() async {
final from = draftPickup ?? pickup;
if (from == null || !draftReady) return;
try {
draftFare = await api.estimateFare(
pickup: from,
destinations: draftDestinations,
);
notifyListeners();
} on ApiException {
// A missing estimate is not worth blocking the booking over.
}
}
/// Who the Miler asks for at the pickup door.
///
/// Null means the signed-in customer, which is the answer nearly every time
/// — so the field on the pickup screen opens prefilled with their number and
/// this stays null until they change it.
/// Who the Miler rings at the door, when it is not the account holder.
///
/// ── What the rider actually receives ──
///
/// Nothing the app sends on `pickup` reaches them. `GET /miler/bookings`
/// returns exactly one phone field, `customerphone` — verified against
/// production and written down in the Miler app's own `stop_contact.dart` —
/// and the backend derives it from this booking's **account**. So the number
/// on the rider's screen is the number the customer signed in with, always.
///
/// There is no second contact field in the contract to put this in. It
/// travels in `remarks`, which the contract does store and the console does
/// show, so a human sees it even though the rider's call button will still
/// dial the account. Until the backend carries a real handover contact, that
/// is the honest ceiling — and the booking screen says so rather than
/// implying the rider will ring this number.
String? draftContactPhone;
/// The handover person's name, so a rider ringing an unfamiliar number knows
/// who they are asking for. A number with no name is a cold call.
String? draftContactName;
/// Records who is handing the parcel over, and tells the screens.
///
/// The two fields were being assigned directly, which is why Review showed
/// nothing after the pickup screen popped back to it: `Navigator.pop` does
/// not rebuild the route it reveals, so the contact card kept the build it
/// had from before the customer typed anything.
void setHandoverContact({String? name, String? phone}) {
draftContactName = name;
draftContactPhone = phone;
notifyListeners();
}
Future<Booking> confirmBooking() async {
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
try {
final booking = await api.createBooking(
pickup: draftPickup ?? pickup!,
destinations: draftDestinations,
slotId: draftSlotId,
fare: draftFare,
contactPhone: draftContactPhone,
contactName: draftContactName,
idempotencyKey: key,
);
// The intent is spent. A further booking needs a new key or the server
// would replay this one.
_bookingIdempotencyKey = null;
orders.insert(0, booking);
activeReference = booking.reference;
notifyListeners();
return booking;
} on ApiException catch (e) {
// The slot went stale — filled up, or its window passed while the form
// was open. Both recover by re-reading the list, so drop the cached one
// here and let the screen send them back to slot selection.
if (e.needsFreshSlots) {
_invalidateSlots();
draftSlotId = null;
notifyListeners();
}
rethrow;
}
}
String _newIdempotencyKey() {
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
final salt = Object().hashCode.toRadixString(36);
return '$now-$salt';
}
// -------------------------------------------------------------- orders
final List<Booking> orders = [];
String? activeReference;
BookingStatus ordersTab = BookingStatus.active;
Booking? byReference(String? reference) {
if (reference == null) return null;
for (final o in orders) {
if (o.reference == reference) return o;
}
return null;
}
Booking? get trackedBooking => byReference(activeReference);
List<Booking> get activeBookings =>
orders.where((o) => o.status == BookingStatus.active).toList();
List<Booking> ofStatus(BookingStatus status) =>
orders.where((o) => o.status == status).toList();
/// Orders as the customer owns them: one row per pickup before collection,
/// one row per destination once the backend has created those orders.
List<OrderEntry> entriesOf(BookingStatus status) => [
for (final booking in ofStatus(status))
if (!booking.hasOrders)
OrderEntry(booking)
else
for (var i = 0; i < booking.destinations.length; i++)
OrderEntry(booking, i),
];
List<OrderEntry> get recentEntries => [
for (final booking in orders.where((o) => o.status != BookingStatus.active))
if (!booking.hasOrders)
OrderEntry(booking)
else
for (var i = 0; i < booking.destinations.length; i++)
OrderEntry(booking, i),
].take(3).toList();
/// Everything no longer running, newest first — Home's "Recent".
List<Booking> get recent =>
orders.where((o) => o.status != BookingStatus.active).take(3).toList();
/// Keyset cursor for the next page of orders; null when the last page has
/// been read or none has been read yet.
String? ordersCursor;
/// True while [loadMoreOrders] is in flight, so a scroll cannot start two.
bool loadingMoreOrders = false;
bool get hasMoreOrders => ordersCursor != null && ordersCursor!.isNotEmpty;
/// Pull-to-refresh on Home and Orders — reads the first page again.
///
/// The list is the server's: whatever comes back replaces what is on screen,
/// including an empty page, which is a real answer and not a failure.
Future<void> refreshOrders() async {
try {
final page = await api.getBookingPage();
// An empty first page is a real answer: this customer has no bookings.
// The list is the server's, so it is adopted whole rather than merged.
orders
..clear()
..addAll(page.bookings);
ordersCursor = page.nextCursor;
} on ApiException {
// A failed refresh leaves the last good list on screen.
}
notifyListeners();
}
/// Appends the next keyset page. Keyset rather than offset, so a booking
/// arriving at the top mid-scroll cannot push a row into a second page and
/// show it twice.
Future<void> loadMoreOrders() async {
if (!hasMoreOrders || loadingMoreOrders) return;
loadingMoreOrders = true;
notifyListeners();
try {
final page = await api.getBookingPage(cursor: ordersCursor);
final seen = orders.map((o) => o.reference).toSet();
orders.addAll(page.bookings.where((b) => !seen.contains(b.reference)));
ordersCursor = page.nextCursor;
} on ApiException {
// Keep the rows already on screen; the cursor stays put so the next
// scroll retries the same page rather than skipping it.
} finally {
loadingMoreOrders = false;
notifyListeners();
}
}
void openTracking(String reference) {
activeReference = reference;
notifyListeners();
unawaited(refreshBooking(reference));
}
/// Re-reads one booking. Null means the server answered `304` — nothing
/// moved — so the copy already on screen stands.
Future<void> refreshBooking(String reference) async {
try {
final fresh = await api.getBooking(reference);
if (fresh == null) return;
final at = orders.indexWhere((o) => o.reference == reference);
if (at == -1) {
orders.insert(0, fresh);
} else {
orders[at] = fresh;
}
notifyListeners();
} on ApiException {
// Keep showing what we have.
}
}
void setOrdersTab(BookingStatus status) {
ordersTab = status;
notifyListeners();
}
/// Sends the optional address and recipient for an existing booking.
///
/// Accepted until the parcels are collected; after that the server answers
/// `409` and the Miler's record at the door is the one that counts.
Future<void> saveDestinationDetails(Booking booking, int index) async {
if (index < 0 || index >= booking.destinations.length) return;
final group = booking.destinations[index];
await api.updateDestinationDetails(
booking.reference,
// The server's own position for this stop, which is what the route
// addresses. The list position is only a fallback for a payload that
// did not send one.
group.index ?? index,
group.details,
);
notifyListeners();
}
// ---------------------------------------------------------------- push
/// Registers this device for milestone notifications.
///
/// Nothing calls this yet: no push SDK is wired in, so the app has no token
/// to hand over. The endpoint is reached through here so that adding one is
/// a two-line change rather than a new path through the state layer.
Future<void> registerPushToken(String token) async {
try {
await api.registerDevice(token);
} on ApiException catch (e) {
debugPrint('[PUSH] could not register device: $e');
}
}
Future<void> unregisterPushToken(String token) async {
try {
await api.unregisterDevice(token);
} on ApiException catch (e) {
debugPrint('[PUSH] could not unregister device: $e');
}
}
// ------------------------------------------------- stage transitions
/// What the tracking screen's QA stepper calls.
///
/// It asks the server's own helper to move the booking, then re-reads it —
/// so what appears on screen is the stage the **server** recorded, never one
/// the client applied to its own copy.
Future<void> advanceStage(Booking booking, JourneyStage target) async {
try {
await api.setStage(booking.reference, target);
await refreshBooking(booking.reference);
} on ApiException catch (e) {
debugPrint('[OPS] stage override refused: $e');
}
}
Future<void> cancelBooking(Booking booking, String? reason) async {
await api.cancelBooking(booking.reference, reason);
booking
..status = BookingStatus.cancelled
..cancelReason = reason
..cancellable = false;
notifyListeners();
}
// ------------------------------------------------- serviceability cache
List<ServiceArea>? statesCache;
final Map<String, List<District>> districtCache = {};
List<PickupSlot>? slotsCache;
/// Only states we can actually serve reach the picker. A state with nothing
/// open is not an option the customer should have to read past.
Future<List<ServiceArea>> loadStates({bool refresh = false}) async {
if (refresh) statesCache = null;
final all = statesCache ??= await api.getServiceableStates();
return all.where((s) => s.hasOpenDistricts).toList();
}
/// Districts the picker offers: serviceable ones only. The full response is
/// cached so [upcomingDistricts] can name what is opening without putting
/// disabled tiles in front of anyone.
Future<List<District>> loadDistricts(
String stateCode, {
bool refresh = false,
}) async {
if (refresh) districtCache.remove(stateCode);
final all = districtCache[stateCode] ??= await api.getServiceableDistricts(
stateCode,
);
return all.where((d) => d.available).toList();
}
/// Every serviceable city, flat, for the send screen's city strip.
///
/// ── Why this exists rather than a state picker ──
///
/// The serviceability API is a hierarchy — states, then districts — because
/// that is how coverage is administered. It is not how anyone thinks about
/// sending a parcel: nobody picks "Tamil Nadu" on the way to picking
/// "Chennai", and on a network this size the state step is a screen that asks
/// a question with one useful answer.
///
/// So the hierarchy is flattened here, once, into the list the screen wants.
/// The districts come back in parallel — five states is five calls, not a
/// waterfall — and each city carries its state so [selectCity] can set both
/// codes the booking contract needs.
/// The cities already in hand, or null when any part still has to be fetched.
///
/// The synchronous half of [loadCities], for a caller that would otherwise
/// render a loading state over data it already has. Null unless *every*
/// state's districts are cached, because a partial answer shown as a
/// complete one is a list of cities with some quietly missing.
List<CityOption>? get cachedCities {
final states = statesCache;
if (states == null) return null;
// ── Both caches hold more than the picker offers ──
//
// [statesCache] is the whole response, closed states included, and
// [loadCities] only ever fetches districts for the open ones — so walking
// every cached state looking for its districts finds a hole and concludes
// nothing is cached. That is exactly what happened: `loadCities` returned
// eleven cities and this getter returned null beside it.
//
// [districtCache] is the whole response too. The same two filters
// `loadStates` and `loadDistricts` apply have to be applied here, or this
// would offer a district the picker will not show.
final out = <CityOption>[];
for (final state in states.where((s) => s.hasOpenDistricts)) {
final districts = districtCache[state.code];
if (districts == null) return null;
for (final district in districts.where((d) => d.available)) {
out.add(CityOption(state: state, district: district));
}
}
return out;
}
Future<List<CityOption>> loadCities({bool refresh = false}) async {
if (refresh) {
statesCache = null;
districtCache.clear();
}
final states = await loadStates();
final lists = await Future.wait([
for (final state in states) loadDistricts(state.code),
]);
return [
for (var i = 0; i < states.length; i++)
for (final district in lists[i])
CityOption(state: states[i], district: district),
];
}
/// Starts a new draft carrying over what a previous booking already answered.
///
/// ── What is carried, and what deliberately is not ──
///
/// The **pickup** is carried. It is the same door; asking for it again is
/// asking a customer to confirm where they are standing.
///
/// The **destinations** are carried only when the caller asks — "send again"
/// from a past order means the same route, "send another from here" means
/// the same door and a new route.
///
/// The **window is never carried.** A slot fills up, and a second booking
/// silently pinned to one that is now full would be refused at confirm with
/// nothing the customer could act on. It is also the one field that is
/// genuinely a fresh decision: the first parcel going at 2pm says nothing
/// about when they want the next visit.
void startBookingFrom(
Booking previous, {
bool detailed = false,
bool keepDestinations = false,
}) {
startBooking(detailed: detailed);
draftPickup = previous.pickup;
if (keepDestinations && previous.destinations.isNotEmpty) {
draftDestinations = [
for (final group in previous.destinations.take(limits.maxDestinations))
DestinationGroup(
destination: group.destination.copy(),
packageCount: group.packageCount,
),
];
}
notifyListeners();
}
/// Picks a city on the draft's only destination — both codes at once, since
/// the contract wants `stateCode` and `districtCode` together.
void selectCity(CityOption city, {int index = 0}) {
selectStateFor(index, city.state);
selectDistrictFor(index, city.district);
}
/// Replaces the draft's destinations with exactly these, in order.
///
/// The destination sheet answers with a list — one place, or several out of
/// one Miler visit — and this is the only thing that writes it onto the
/// draft. Clamped to [BookingLimits.maxDestinations] rather than trusted:
/// the sheet already obeys the cap, but the cap is a server answer that can
/// change between the sheet opening and this being called, and a draft that
/// exceeds it is a booking the network will refuse.
void setDestinations(List<CityOption> cities) {
if (cities.isEmpty) return;
final take = cities.take(limits.maxDestinations).toList();
draftDestinations = [for (var i = 0; i < take.length; i++) DestinationGroup()];
for (var i = 0; i < take.length; i++) {
selectCity(take[i], index: i);
}
}
/// Names of districts in this state that are not open yet, for a quiet
/// "Coming soon" line. Empty until the districts have been fetched.
List<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])
.where((d) => !d.available)
.map((d) => d.name)
.toList();
/// Bumped whenever [slotsCache] is dropped, so a slot list already on screen
/// re-reads instead of showing what it fetched the first time.
int slotsEpoch = 0;
void _invalidateSlots() {
slotsCache = null;
slotsEpoch++;
}
Future<List<PickupSlot>> loadSlots({bool refresh = false}) async {
if (refresh) slotsCache = null;
final slots = slotsCache ??= await api.getPickupSlots(pickup: draftPickup);
for (final slot in slots) {
_slotIndex[slot.id] = slot;
}
return slots;
}
void clearCaches() {
statesCache = null;
districtCache.clear();
_invalidateSlots();
}
}