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 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 _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 _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 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 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 setPickupFromPin(double lat, double lng) async { final seq = ++_pinSeq; resolvingPickup = true; notifyListeners(); await _resolvePin(lat, lng, seq); } Future _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 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 sendOtp(String identifier) => api.sendOtp(identifier); Future 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 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 checkPhone(String phone) => api.checkPhone(phone); Future 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 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> 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 _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 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 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 _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 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 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 get activeBookings => orders.where((o) => o.status == BookingStatus.active).toList(); List 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 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 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 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 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 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 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 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 registerPushToken(String token) async { try { await api.registerDevice(token); } on ApiException catch (e) { debugPrint('[PUSH] could not register device: $e'); } } Future 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 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 cancelBooking(Booking booking, String? reason) async { await api.cancelBooking(booking.reference, reason); booking ..status = BookingStatus.cancelled ..cancelReason = reason ..cancellable = false; notifyListeners(); } // ------------------------------------------------- serviceability cache List? statesCache; final Map> districtCache = {}; List? 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> 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> 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? 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 = []; 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> 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 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 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> 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(); } }