Files
doormile_customer_app/lib/data/api_exception.dart
Thiru-tenext 0d66627c3c Replace the customer app with Doormile CX
Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 16:07:33 +05:30

107 lines
4.4 KiB
Dart

/// 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]'}';
}