Files
doormile_customer_app/lib/state/app_state.dart
Thiru-tenext 06fa6b797a Redesign: Poppins, a two-step destination, and a splash that says what the app does
The effort pass, end to end. Every screen was run through one test — if I
remove this sentence, does the customer make a worse decision? — and the parts
that failed it are gone.

The flow

  Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked

BOOK opens a sheet, not a form. The destination is browsed state-then-district
because a flat list of every serviceable district survives twelve and not
sixty, and search cuts across states because somebody who knows they are
sending to Chennai should not have to know which state it is in. Districts
multi-select, but only where the server allows it: BookingLimits advertises
maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet
that ignored that would sell a booking the network cannot complete.

The pickup window is now a step the customer answers rather than a slot chosen
for them. A pickup window is a promise about somebody's afternoon.

What the screens stopped saying

Home lost the orb caption for returning customers and a four-cell live card.
Send lost the city strip, both address fields, the optional disclosure and
three sentences about charging — the route, the packages and the button are
what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone
count, a "Step 2 of 7" and a sentence describing the screen you were looking
at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed
evening handover".

Type

Poppins, which has no variable release — four static cuts, and the sans styles
set fontWeight alone because fontVariations on a static font is ignored in
silence. Every weight dropped a step and the tracking went deeper: Poppins is
built on near-circles and carries more ink than the humanist faces before it.

Objects

One lit sphere on Home, and the primary button now takes its gradient and rim
because a committing action that is not lit like the hero reads as a different
material. The tracking rail's connector is crimson as far as the parcel has
come, so the line is the progress bar. Confirmation is a white tick on green:
crimson is this app's action colour and that screen has nothing left to do.

Bugs found on the way

The OTP screen dropped digits. Four fields passing focus along lose a keystroke
that arrives mid-transition, so "1234" became "124" and the screen answered
"That code did not match" — blaming the customer for its own race. One field
now, four boxes that only draw.

Nothing ever asked for the customer's location: detectPickupLocation was the
OTP screen's job, so a restored session or an auto-login never triggered the
permission prompt and the pickup map had nothing to centre on.

The launcher icon and both splash screens pointed at a house drawn as two
vector paths — a placeholder that shipped.

The splash clock started when the widget was built rather than when it was
visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash.
It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a
binding that never reports one cannot strand the app.

Also: design/screens/ holds all 19 screens under readable names, tool/ has the
scripts that refresh them and rebrand the Lottie, and DESIGN.md is current.

flutter analyze clean. 88 tests, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-22 17:45:54 +05:30

837 lines
30 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();
unawaited(refreshOrders());
}
/// 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());
}
/// 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;
}
/// 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;
void startBooking() {
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.
String? draftContactPhone;
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,
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.
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),
];
}
/// 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();
}
}