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>
107 lines
4.4 KiB
Dart
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]'}';
|
|
}
|