Files
doormile_customer_app/lib/data/api_client.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

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();
}