/// Thrown for every failed call so the UI can show one consistent error state. /// /// [code] is the contract's machine-readable key and is the **only** thing /// callers may branch on. [message] is customer-safe English written by the /// server and is rendered verbatim — never parsed. class ApiException implements Exception { ApiException( this.code, [ String? message, this.status, this.requestId, String? serverCode, ]) : message = message ?? 'Something went wrong', serverCode = serverCode ?? code; final String code; final String message; /// Exactly what the server put in `error.code` — `SLOT_CAPACITY_FULL`, /// `UNSERVICEABLE_PINCODE`, and so on. [code] is that value translated into /// the vocabulary below; this one is kept for the log line and for a support /// report, and nothing branches on it. final String serverCode; /// HTTP status, when there was one. Null for a transport failure. final int? status; /// The server's `X-Request-Id`, so a support report can be correlated. final String? requestId; /// The client vocabulary: the contract's codes translated by [normalise], /// plus the ones the client raises itself. static const String network = 'network'; static const String invalid = 'invalid'; static const String invalidName = 'invalid_name'; static const String invalidOtp = 'invalid_otp'; static const String unauthorized = 'unauthorized'; static const String forbidden = 'forbidden'; static const String notFound = 'not_found'; static const String conflict = 'conflict'; static const String unserviceable = 'unserviceable'; static const String rateLimited = 'rate_limited'; static const String serverError = 'server_error'; /// The slot is gone — filled up, or its window passed. Its own code because /// the recovery is specific: re-read the slots and go back to picking one. static const String slotUnavailable = 'slot_unavailable'; /// The Miler has already arrived, so the cancel window has closed. static const String notCancellable = 'not_cancellable'; /// Translates the contract's `error.code` into the vocabulary above. /// /// The wire spells its codes in capitals; the client's are lowercase, and /// mapping them is not cosmetic — an untranslated `UNAUTHORIZED` does not /// satisfy [isAuthFailure], which is what triggers the token refresh, so a /// signed-in customer would have been dropped at the first expired token /// instead of silently getting a new one. /// /// A code that is already lowercase is passed through untouched, and an /// unrecognised one falls back to whatever the HTTP status means. static String normalise(String raw, String Function() fromStatus) { final trimmed = raw.trim(); if (trimmed.isEmpty) return fromStatus(); if (trimmed != trimmed.toUpperCase()) return trimmed; return switch (trimmed) { 'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized, 'FORBIDDEN' => forbidden, 'NOT_FOUND' => notFound, 'INVALID_INPUT' || 'VALIDATION_ERROR' => invalid, 'SLOT_UNAVAILABLE' || 'SLOT_EXPIRED' || 'SLOT_CAPACITY_FULL' => slotUnavailable, 'BOOKING_NOT_CANCELLABLE' => notCancellable, 'UNSERVICEABLE_PINCODE' => unserviceable, 'RATE_LIMITED' => rateLimited, 'INTERNAL_ERROR' => serverError, _ => fromStatus(), }; } /// True when retrying the identical request could plausibly succeed. bool get isTransient => code == network || code == serverError || code == rateLimited; /// The session is gone; the customer has to sign in again. bool get isAuthFailure => code == unauthorized; /// The slot the customer picked is no longer bookable — either it filled up /// or its window has passed. Both recover the same way: re-fetch the slots /// and put them back on slot selection. /// /// The server distinguishes them (`409 conflict` for a genuine capacity race, /// `400 invalid` for a window that has passed) but the customer's next action /// is identical, so the client does not. bool get needsFreshSlots => code == slotUnavailable || code == conflict || (code == invalid && message.toLowerCase().contains('pickup')); @override String toString() => 'ApiException($code' '${serverCode == code ? '' : '/$serverCode'}' '${status == null ? '' : ' $status'}): $message' '${requestId == null ? '' : ' [req $requestId]'}'; }