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>
477 lines
14 KiB
Dart
477 lines
14 KiB
Dart
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();
|
|
}
|