import 'app_config.dart'; import 'dev_doormile_api.dart'; import 'live_doormile_api.dart'; import 'models.dart'; export 'api_exception.dart'; /// The service surface the app is written against. /// /// **[LiveDoormileApi] is what ships,** against `/customer/*` on /// `api.doormile.com`. When the server cannot be reached the screens show their /// designed error state, which is the only honest answer. /// /// [DevDoormileApi] answers instead when [AppConfig.useDevData] is set — under /// `FLUTTER_TEST`, or a debug build given `--dart-define=DM_MOCK=true`. That /// getter is `false` in a release whatever is passed, so a shipped build has /// one implementation and a booking a customer makes is one the server /// accepted. /// /// ── Why this is guarded so tightly ── /// /// An offline build was deleted from this app on 15 Sep 2026 after it cost two /// rounds of hunting for bookings in the admin console that had never left the /// phone. What made that expensive was not the fake existing — it was that the /// fake was reachable without announcing itself, and that nothing stopped it /// reaching a release. Both are closed now: the release guard above, and the /// Account screen printing DEV DATA (offline) the whole time it is on. /// /// Screens never see this type at all — they call [AppState], which holds it. /// /// A method the backend owns but no screen drives yet has a default here that /// does nothing rather than throwing. abstract class DoormileApi { DoormileApi(); static DoormileApi? _instance; /// The API this build talks to. /// /// [LiveDoormileApi] unless the build asked for offline data, which a /// release build cannot do — see [AppConfig.useDevData]. The check is here /// rather than at the call sites so there is exactly one place that decides, /// and nothing downstream has to know which one it got. static DoormileApi get instance => _instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi(); /// Replaces the singleton. Tests inject their double through here. static void overrideInstance(DoormileApi? api) => _instance = api; /// Called when the refresh chain dies and the customer must sign in again. /// Set by [AppState]. void Function()? onSessionLost; // --------------------------------------------------------------------- auth /// Asks for a code. The answer describes the challenge — how many digits it /// has and how long before it can be resent — so the screen is built from /// what the server said rather than from constants. Future sendOtp(String identifier); /// Creates the account **and** sends the first code, so it answers with the /// same challenge [sendOtp] does. Future signUp({ required String name, required String phone, String? email, }); /// [name] is set when verifying a freshly created account. Future verifyOtp(String identifier, String code, {String? name}); // ── PIN sign-in ── // // The paid SMS gateway was switched off, so phone OTP issues codes that // reach only the server log. These three replace it for phone numbers. Email // OTP still works and is still offered — see `LoginScreen`. // // `setPin` and `verifyPin` return the same session `verifyOtp` does, so // everything after sign-in — token refresh, restore, logout — is untouched. /// Which of the three PIN screens this number leads to. Future checkPhone(String phone); /// Sets the **first** PIN on a number, creating the account when it is new. /// /// [name] is required only for a number with no account. Throws /// [ApiException.pinAlreadySet] when there is already a PIN. Future setPin({ required String phone, required String pin, String? name, }); /// Signs in with an existing PIN. Throws [ApiException.invalidPin] for a /// wrong PIN *or* an unknown number, and [ApiException.pinNotSet] when the /// account has none yet. Future verifyPin({required String phone, required String pin}); /// Restores a persisted session at launch, or null when there is none. Future restoreSession() async => null; /// Revokes the refresh token and unregisters the push token. Future signOut() async {} /// Re-reads the signed-in customer — `GET /customer/auth/me`. Future me() async => null; // ----------------------------------------------------------- serviceability Future> getServiceableStates(); Future> getServiceableDistricts(String stateCode); /// Synchronous district lookup for a code we already hold, backed by /// whatever [getServiceableDistricts] last returned. District? districtByCode(String? code); // -------------------------------------------------------------------- slots Future> getPickupSlots({Place? pickup}); PickupSlot? slotById(String? id); // ----------------------------------------------------------------- location Future reverseGeocode({double? lat, double? lng}); /// An empty query returns the customer's saved and recent places, which is /// what the search sheet shows the moment it opens. /// /// [lat]/[lng] bias the results towards where the customer is looking — /// "MG Road" is in most Indian cities, and the one they mean is the near one. Future> searchPlaces(String query, {double? lat, double? lng}); // ------------------------------------------------------------------- limits Future getBookingLimits({Place? pickup}); // --------------------------------------------------------------------- fare Future estimateFare({ required Place pickup, required List destinations, }); // ------------------------------------------------------------------ booking /// Creates the pickup booking. No tracking number yet — those are minted per /// destination when the Miler completes the pickup. /// /// [idempotencyKey] must be **held across retries of the same intent**. A new /// key is a new booking, which is exactly what a double tap must not create. /// /// [contactPhone] is who the Miler calls at the pickup door. Null means the /// signed-in customer, which is what it was always sending — the parameter /// exists because the person handing over the parcel is not always the /// person who booked it. Future createBooking({ required Place pickup, required List destinations, required String? slotId, FareEstimate? fare, String? contactPhone, String? contactName, String? idempotencyKey, }); Future cancelBooking(String reference, String? reason); /// One page of the customer's bookings, newest first. Future getBookingPage({ BookingStatus? status, String? cursor, int limit = 20, }); /// One booking, fresh. Null means the server answered `304` — nothing has /// changed since the last read, so the caller keeps what it has. Future getBooking(String reference); /// One order by its tracking number — for a push deep link. Future getOrder(String trackingId) async => null; /// Adds the optional address and recipient after booking, up to collection. Future updateDestinationDetails( String reference, int index, DeliveryDetails details, ) async {} /// Backend policy decides this; the UI only asks. bool isCancellable(JourneyStage stage); // ------------------------------------------------------------------ ops QA /// Moves a booking to [stage] through the backend's own non-production QA /// helper — `POST /customer/ops/bookings/{reference}/stage`. /// /// It exists so the tracking screen's stepper can walk a **real** staging /// booking. The stage that then appears on screen is the one the server /// recorded, not one the client wished for. Refused on production. Future setStage(String reference, JourneyStage stage) async {} // ------------------------------------------------------------------ devices Future registerDevice(String token) async {} Future unregisterDevice(String token) async {} // ------------------------------------------------------- profile & addresses Future getProfile() async => null; Future updateProfile({String? name, String? email}) async => null; Future> getSavedLocations() async => const []; Future addSavedLocation(Place place, {String? label}) async => null; Future updateSavedLocation(SavedPlace place) async => null; Future deleteSavedLocation(String id) async {} }