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:
476
lib/data/api_client.dart
Normal file
476
lib/data/api_client.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_exception.dart';
|
||||
import 'app_config.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// One decoded response: the `data` payload plus the envelope's metadata.
|
||||
class ApiResponse {
|
||||
const ApiResponse({
|
||||
this.data,
|
||||
this.etag,
|
||||
this.total,
|
||||
this.nextCursor,
|
||||
this.notModified = false,
|
||||
this.requestId,
|
||||
});
|
||||
|
||||
/// The `data` member of the envelope. `null` on a 304 or a 204.
|
||||
final dynamic data;
|
||||
|
||||
/// `ETag` from the response, to be replayed as `If-None-Match`.
|
||||
final String? etag;
|
||||
|
||||
/// Total rows behind a filtered list, when the endpoint sends one.
|
||||
final int? total;
|
||||
|
||||
/// Keyset cursor for the next page. Null on the last page.
|
||||
final String? nextCursor;
|
||||
|
||||
/// True when the server answered `304` — the caller keeps what it has.
|
||||
final bool notModified;
|
||||
|
||||
final String? requestId;
|
||||
|
||||
Map<String, dynamic> get map =>
|
||||
data is Map<String, dynamic> ? data as Map<String, dynamic> : const {};
|
||||
|
||||
/// The rows of a list endpoint.
|
||||
///
|
||||
/// `data` is the list itself on most of this surface, but a paged endpoint
|
||||
/// wraps it — `data: { items: [...], nextCursor }` — and the contract does
|
||||
/// not say which of the two spellings a given route uses. Reading through the
|
||||
/// wrapper costs one lookup; guessing wrong shows the customer an empty
|
||||
/// Orders tab and no error at all, which is the failure that cannot be
|
||||
/// diagnosed from a screenshot.
|
||||
List<Map<String, dynamic>> get rows =>
|
||||
(_list ?? const []).whereType<Map<String, dynamic>>().toList();
|
||||
|
||||
List<dynamic>? get _list {
|
||||
final payload = data;
|
||||
if (payload is List) return payload;
|
||||
if (payload is Map<String, dynamic>) {
|
||||
for (final key in _listKeys) {
|
||||
final value = payload[key];
|
||||
if (value is List) return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static const List<String> _listKeys = [
|
||||
'items',
|
||||
'results',
|
||||
'rows',
|
||||
'data',
|
||||
'bookings',
|
||||
'orders',
|
||||
'states',
|
||||
'districts',
|
||||
'slots',
|
||||
'places',
|
||||
'locations',
|
||||
];
|
||||
}
|
||||
|
||||
/// The app's only HTTP caller.
|
||||
///
|
||||
/// Everything the contract asks for on the wire lives here and nowhere else:
|
||||
/// the `data` envelope, the nine error codes, bearer auth with silent refresh,
|
||||
/// the client identity headers, idempotency keys, `ETag` revalidation, keyset
|
||||
/// paging and the retry policy. A screen that wanted to add a header would have
|
||||
/// to come through this class, which is the point.
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
http.Client? httpClient,
|
||||
SessionStore? sessions,
|
||||
this.onSessionLost,
|
||||
}) : _http = httpClient ?? http.Client(),
|
||||
_sessions = sessions ?? SecureSessionStore();
|
||||
|
||||
final http.Client _http;
|
||||
final SessionStore _sessions;
|
||||
|
||||
/// Called when a refresh fails and the session is dropped, so [AppState] can
|
||||
/// return the customer to sign-in rather than leaving them on a dead screen.
|
||||
/// Settable because the state that handles it is built after this client.
|
||||
void Function()? onSessionLost;
|
||||
|
||||
SessionStore get sessions => _sessions;
|
||||
|
||||
Session? _session;
|
||||
bool _restored = false;
|
||||
|
||||
/// Single-flight refresh: ten calls hitting 401 together must produce one
|
||||
/// refresh, not ten — the token rotates, so the other nine would be replaying
|
||||
/// a token the server has already revoked and would kill the whole chain.
|
||||
Future<Session?>? _refreshing;
|
||||
|
||||
final Random _rng = Random();
|
||||
|
||||
// ------------------------------------------------------------------ session
|
||||
|
||||
Future<Session?> currentSession() async {
|
||||
if (!_restored) {
|
||||
_session = await _sessions.read();
|
||||
_restored = true;
|
||||
}
|
||||
return _session;
|
||||
}
|
||||
|
||||
Future<void> adoptSession(Session session) async {
|
||||
_session = session;
|
||||
_restored = true;
|
||||
await _sessions.write(session);
|
||||
}
|
||||
|
||||
Future<void> dropSession() async {
|
||||
_session = null;
|
||||
_restored = true;
|
||||
await _sessions.clear();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ requests
|
||||
|
||||
Future<ApiResponse> get(
|
||||
String path, {
|
||||
Map<String, String>? query,
|
||||
String? ifNoneMatch,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
}) => _send(
|
||||
'GET',
|
||||
path,
|
||||
query: query,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
// A GET carries no side effect, so replaying one is always safe.
|
||||
retries: 2,
|
||||
);
|
||||
|
||||
Future<ApiResponse> post(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
String? idempotencyKey,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
}) => _send(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout ?? AppConfig.writeTimeout,
|
||||
);
|
||||
|
||||
Future<ApiResponse> patch(
|
||||
String path, {
|
||||
Object? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('PATCH', path, body: body, authenticated: authenticated);
|
||||
|
||||
Future<ApiResponse> put(
|
||||
String path, {
|
||||
Object? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('PUT', path, body: body, authenticated: authenticated);
|
||||
|
||||
Future<ApiResponse> delete(String path, {bool authenticated = true}) =>
|
||||
_send('DELETE', path, authenticated: authenticated);
|
||||
|
||||
/// A fresh idempotency key. Held by the caller across retries of the *same*
|
||||
/// intent — a new key means a new booking.
|
||||
String newIdempotencyKey() {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
|
||||
final salt = List.generate(
|
||||
10,
|
||||
(_) => alphabet[_rng.nextInt(alphabet.length)],
|
||||
).join();
|
||||
return '$now-$salt';
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ internals
|
||||
|
||||
Future<ApiResponse> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? query,
|
||||
String? ifNoneMatch,
|
||||
String? idempotencyKey,
|
||||
bool authenticated = true,
|
||||
Duration? timeout,
|
||||
int retries = 0,
|
||||
bool allowRefresh = true,
|
||||
}) async {
|
||||
final uri = Uri.parse(
|
||||
AppConfig.url(path),
|
||||
).replace(queryParameters: (query?.isEmpty ?? true) ? null : query);
|
||||
|
||||
ApiException? lastTransient;
|
||||
|
||||
for (var attempt = 0; attempt <= retries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future<void>.delayed(Duration(milliseconds: 300 * attempt * attempt));
|
||||
}
|
||||
try {
|
||||
final response = await _once(
|
||||
method,
|
||||
uri,
|
||||
body: body,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
);
|
||||
return response;
|
||||
} on ApiException catch (e) {
|
||||
// 401 once, then refresh and try again — but never on the refresh call
|
||||
// itself, which would recurse.
|
||||
if (e.isAuthFailure && authenticated && allowRefresh) {
|
||||
final refreshed = await _refreshSession();
|
||||
if (refreshed != null) {
|
||||
return _send(
|
||||
method,
|
||||
path,
|
||||
body: body,
|
||||
query: query,
|
||||
ifNoneMatch: ifNoneMatch,
|
||||
idempotencyKey: idempotencyKey,
|
||||
authenticated: authenticated,
|
||||
timeout: timeout,
|
||||
retries: 0,
|
||||
allowRefresh: false,
|
||||
);
|
||||
}
|
||||
await dropSession();
|
||||
onSessionLost?.call();
|
||||
rethrow;
|
||||
}
|
||||
if (!e.isTransient || attempt == retries) rethrow;
|
||||
lastTransient = e;
|
||||
}
|
||||
}
|
||||
throw lastTransient ??
|
||||
ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
}
|
||||
|
||||
Future<ApiResponse> _once(
|
||||
String method,
|
||||
Uri uri, {
|
||||
Object? body,
|
||||
String? ifNoneMatch,
|
||||
String? idempotencyKey,
|
||||
required bool authenticated,
|
||||
Duration? timeout,
|
||||
}) async {
|
||||
final headers = <String, String>{
|
||||
'Accept': 'application/json',
|
||||
'X-Client': AppConfig.clientHeader,
|
||||
'X-Platform': AppConfig.platformHeader,
|
||||
if (body != null) 'Content-Type': 'application/json',
|
||||
'If-None-Match': ?ifNoneMatch,
|
||||
'Idempotency-Key': ?idempotencyKey,
|
||||
};
|
||||
|
||||
if (authenticated) {
|
||||
final session = await currentSession();
|
||||
if (session != null) {
|
||||
// Refresh proactively rather than spending a 401 to discover it.
|
||||
final usable = session.isExpired
|
||||
? (await _refreshSession() ?? session)
|
||||
: session;
|
||||
headers['Authorization'] = 'Bearer ${usable.accessToken}';
|
||||
}
|
||||
}
|
||||
|
||||
final request = http.Request(method, uri)..headers.addAll(headers);
|
||||
if (body != null) request.body = jsonEncode(body);
|
||||
|
||||
http.Response response;
|
||||
try {
|
||||
final streamed = await _http
|
||||
.send(request)
|
||||
.timeout(timeout ?? AppConfig.requestTimeout);
|
||||
response = await http.Response.fromStream(streamed);
|
||||
} on TimeoutException {
|
||||
throw ApiException(
|
||||
ApiException.network,
|
||||
'Doormile took too long to answer',
|
||||
);
|
||||
} on SocketException {
|
||||
throw ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
} on http.ClientException {
|
||||
throw ApiException(ApiException.network, 'We could not reach Doormile');
|
||||
}
|
||||
|
||||
return _decode(response, uri);
|
||||
}
|
||||
|
||||
ApiResponse _decode(http.Response response, Uri uri) {
|
||||
final requestId = response.headers['x-request-id'];
|
||||
final status = response.statusCode;
|
||||
|
||||
if (status == 304) {
|
||||
return ApiResponse(
|
||||
notModified: true,
|
||||
etag: response.headers['etag'],
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
dynamic decoded;
|
||||
if (response.body.isNotEmpty) {
|
||||
try {
|
||||
decoded = jsonDecode(response.body);
|
||||
} catch (_) {
|
||||
// An HTML error page or a truncated body. The contract forbids both,
|
||||
// so treat it as the server being unreachable rather than guessing.
|
||||
_log(uri, status, requestId, 'unparseable body');
|
||||
throw ApiException(
|
||||
status >= 500 ? ApiException.serverError : ApiException.network,
|
||||
'Something went wrong',
|
||||
status,
|
||||
requestId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final envelope = decoded is Map<String, dynamic> ? decoded : const {};
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
// `success: false` with a 200 would be a contract violation, but reading
|
||||
// it costs nothing and beats rendering a failure as data.
|
||||
if (envelope['success'] == false) {
|
||||
throw _errorFrom(envelope, status, requestId, uri);
|
||||
}
|
||||
final payload = envelope.containsKey('data') ? envelope['data'] : decoded;
|
||||
// Keyset metadata sits beside `data` on some routes and inside it on the
|
||||
// paged ones. Both are read, envelope first.
|
||||
final inner = payload is Map<String, dynamic>
|
||||
? payload
|
||||
: const <String, dynamic>{};
|
||||
return ApiResponse(
|
||||
data: payload,
|
||||
etag: response.headers['etag'],
|
||||
total: (envelope['total'] ?? inner['total']) is num
|
||||
? ((envelope['total'] ?? inner['total']) as num).toInt()
|
||||
: null,
|
||||
nextCursor:
|
||||
(envelope['nextCursor'] ?? inner['nextCursor']) as String?,
|
||||
requestId: requestId,
|
||||
);
|
||||
}
|
||||
|
||||
throw _errorFrom(envelope, status, requestId, uri);
|
||||
}
|
||||
|
||||
ApiException _errorFrom(
|
||||
Map<dynamic, dynamic> envelope,
|
||||
int status,
|
||||
String? requestId,
|
||||
Uri uri,
|
||||
) {
|
||||
final error = envelope['error'];
|
||||
final serverCode = (error is Map && error['code'] is String)
|
||||
? error['code'] as String
|
||||
: '';
|
||||
final code = ApiException.normalise(
|
||||
serverCode,
|
||||
() => _codeForStatus(status),
|
||||
);
|
||||
final message = envelope['message'] is String &&
|
||||
(envelope['message'] as String).trim().isNotEmpty
|
||||
? envelope['message'] as String
|
||||
: (error is Map && error['message'] is String
|
||||
? error['message'] as String
|
||||
: _messageForStatus(status));
|
||||
_log(uri, status, requestId, serverCode.isEmpty ? code : serverCode);
|
||||
return ApiException(code, message, status, requestId, serverCode);
|
||||
}
|
||||
|
||||
static String _codeForStatus(int status) => switch (status) {
|
||||
400 => ApiException.invalid,
|
||||
401 => ApiException.unauthorized,
|
||||
403 => ApiException.forbidden,
|
||||
404 => ApiException.notFound,
|
||||
409 => ApiException.conflict,
|
||||
422 => ApiException.unserviceable,
|
||||
429 => ApiException.rateLimited,
|
||||
_ => status >= 500 ? ApiException.serverError : ApiException.invalid,
|
||||
};
|
||||
|
||||
static String _messageForStatus(int status) => switch (status) {
|
||||
401 => 'Please sign in again',
|
||||
403 => 'You do not have access to this',
|
||||
404 => 'We could not find that',
|
||||
409 => 'That is no longer available',
|
||||
429 => 'Too many attempts. Try again in a minute',
|
||||
_ => 'Something went wrong',
|
||||
};
|
||||
|
||||
void _log(Uri uri, int status, String? requestId, String detail) {
|
||||
debugPrint(
|
||||
'[API] $status ${uri.path} · $detail'
|
||||
'${requestId == null ? '' : ' · req $requestId'}',
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ refresh
|
||||
|
||||
/// Rotates the session. Returns null when the refresh itself failed, which
|
||||
/// means the chain is dead and the customer must sign in again.
|
||||
Future<Session?> _refreshSession() {
|
||||
final inFlight = _refreshing;
|
||||
if (inFlight != null) return inFlight;
|
||||
|
||||
final future = _doRefresh();
|
||||
_refreshing = future;
|
||||
return future.whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<Session?> _doRefresh() async {
|
||||
final session = await currentSession();
|
||||
if (session == null) return null;
|
||||
try {
|
||||
final response = await _send(
|
||||
'POST',
|
||||
'/auth/refresh',
|
||||
body: {'refreshToken': session.refreshToken},
|
||||
authenticated: false,
|
||||
allowRefresh: false,
|
||||
);
|
||||
final data = response.map;
|
||||
final access = data['accessToken'] as String?;
|
||||
if (access == null || access.isEmpty) return null;
|
||||
final rotated = Session(
|
||||
accessToken: access,
|
||||
// A server that does not rotate returns no new refresh token; keeping
|
||||
// the old one is then correct. If it does rotate, this is the only
|
||||
// copy that still works, so it is persisted before anything uses it.
|
||||
refreshToken: (data['refreshToken'] as String?) ?? session.refreshToken,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600),
|
||||
),
|
||||
customer: session.customer,
|
||||
);
|
||||
await adoptSession(rotated);
|
||||
return rotated;
|
||||
} on ApiException catch (e) {
|
||||
debugPrint('[API] refresh failed: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void close() => _http.close();
|
||||
}
|
||||
Reference in New Issue
Block a user