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>
This commit is contained in:
106
lib/data/api_exception.dart
Normal file
106
lib/data/api_exception.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
/// 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]'}';
|
||||
}
|
||||
Reference in New Issue
Block a user