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 restoreSession() async { try { final restored = await api.restoreSession(); if (restored != null) { customer = restored; unawaited(refreshOrders()); } 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'); unawaited(refreshOrders()); } 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); unawaited(refreshOrders()); 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); unawaited(refreshOrders()); 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', ); } } /// 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; } /// 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; 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 _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. } } Future confirmBooking() async { final key = _bookingIdempotencyKey ??= _newIdempotencyKey(); try { final booking = await api.createBooking( pickup: draftPickup ?? pickup!, destinations: draftDestinations, slotId: draftSlotId, fare: draftFare, 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. 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), ]; } /// 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); } /// 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(); } }