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();
|
||||
}
|
||||
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]'}';
|
||||
}
|
||||
278
lib/data/app_config.dart
Normal file
278
lib/data/app_config.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Which backend this build talks to.
|
||||
///
|
||||
/// Set at compile time: `flutter run --dart-define=DM_ENV=prod`.
|
||||
enum DoormileEnvironment { dev, staging, prod }
|
||||
|
||||
/// Build-time configuration: which backend this build talks to, and the
|
||||
/// identity it sends on every request.
|
||||
///
|
||||
/// ── The one bypass, and why it is not the Miler app's bypass ──
|
||||
///
|
||||
/// The Miler app shipped a `USE_NEW_API=false` flag that silently swapped
|
||||
/// authentication for a bypass — any four digits logged you in as a fake rider,
|
||||
/// with invented earnings, in a build that could ship.
|
||||
///
|
||||
/// This app has one API implementation that talks to a server, and exactly one
|
||||
/// exception to it: [useDevData], now the **default in a debug build** because
|
||||
/// staging has no SMS gateway and the login screen cannot otherwise be walked.
|
||||
/// Three things keep it from becoming the flag that was deleted — it cannot
|
||||
/// exist in a release build (`!kReleaseMode`, not a define), it is named on the
|
||||
/// Account screen every time it is on, and nothing it does reaches a server.
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
// ---------------------------------------------------------------- environment
|
||||
|
||||
static const String _envName = String.fromEnvironment(
|
||||
'DM_ENV',
|
||||
defaultValue: 'staging',
|
||||
);
|
||||
|
||||
static DoormileEnvironment get environment => switch (_envName) {
|
||||
'prod' || 'production' => DoormileEnvironment.prod,
|
||||
'dev' || 'development' => DoormileEnvironment.dev,
|
||||
_ => DoormileEnvironment.staging,
|
||||
};
|
||||
|
||||
static bool get isProd => environment == DoormileEnvironment.prod;
|
||||
|
||||
// ------------------------------------------------------------------- base URL
|
||||
|
||||
/// Overrides the per-environment default. The staging host is not yet
|
||||
/// confirmed by the backend team, so staging builds are expected to pass
|
||||
/// `--dart-define=DM_API_BASE=https://<staging host>/api/v1`.
|
||||
static const String _baseOverride = String.fromEnvironment('DM_API_BASE');
|
||||
|
||||
static const String _prodBase = 'https://api.doormile.com/api/v1';
|
||||
|
||||
/// Placeholder until the backend team names the staging host. A staging build
|
||||
/// without `DM_API_BASE` therefore talks to production, which is wrong but
|
||||
/// loud — it is better than silently talking to a host that does not exist.
|
||||
static const String _stagingBase = _prodBase;
|
||||
|
||||
static String get baseUrl {
|
||||
if (_baseOverride.isNotEmpty) return _stripTrailingSlash(_baseOverride);
|
||||
return switch (environment) {
|
||||
DoormileEnvironment.prod => _prodBase,
|
||||
DoormileEnvironment.staging => _stagingBase,
|
||||
DoormileEnvironment.dev => _stripTrailingSlash(
|
||||
const String.fromEnvironment(
|
||||
'DM_API_BASE_DEV',
|
||||
defaultValue: 'http://10.0.2.2:8080/api/v1',
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static String _stripTrailingSlash(String s) =>
|
||||
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
||||
|
||||
/// Everything in this document lives under one namespace.
|
||||
static String url(String path) =>
|
||||
'$baseUrl/customer${path.startsWith('/') ? path : '/$path'}';
|
||||
|
||||
// ---------------------------------------------------------- development data
|
||||
//
|
||||
// ── The one exception to "no invented data", and it is now the debug default ──
|
||||
//
|
||||
// The shipping app has one API implementation and talks to a server; a build
|
||||
// that cannot reach one shows its error state rather than inventing a
|
||||
// booking, a slot or a price. [useDevData] is the single, loud exception.
|
||||
//
|
||||
// **Off by default again.** It was briefly the debug default so the app could
|
||||
// be opened at all, and the cost showed up immediately: a booking made in
|
||||
// that mode reaches no server and never appears in the admin console, which
|
||||
// looks exactly like a broken integration. Skipping the login screen is worth
|
||||
// a flag; faking every booking behind it is not.
|
||||
//
|
||||
// For a session without typing a code, use [autoLoginIdentifier] instead —
|
||||
// it signs in against the **real** API, so everything after it is real.
|
||||
//
|
||||
// --dart-define=DM_MOCK=true offline fake, for UI work with no server
|
||||
// --dart-define=DM_DEV_LOGIN=false keep the fake, but walk the entrance
|
||||
//
|
||||
// The guard has not moved: `!kReleaseMode`, so no define puts this in a
|
||||
// release build. When it is on the app talks to [DevDoormileApi] instead of
|
||||
// the network, `9876543210` + any 4-digit code signs in, and the Account
|
||||
// screen says `DEV DATA (offline)`. **Nothing it does reaches a server or the
|
||||
// admin console** — a booking made in this mode is not a booking.
|
||||
static const bool _devDataRequested = bool.fromEnvironment(
|
||||
'DM_MOCK',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// True under `flutter test`, where the widget tests drive the whole journey
|
||||
/// against [DevDoormileApi] and there is no server to reach.
|
||||
static bool get isTest {
|
||||
if (kIsWeb) return false;
|
||||
try {
|
||||
return Platform.environment.containsKey('FLUTTER_TEST');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// True only when dev data was asked for — or we are under test — **and**
|
||||
/// this is not a release build. There is no define that can turn it on in a
|
||||
/// release.
|
||||
static bool get useDevData => (_devDataRequested || isTest) && !kReleaseMode;
|
||||
|
||||
/// Opens the app already signed in, skipping the login screen.
|
||||
///
|
||||
/// On by default wherever [useDevData] is, which is the point of the pair:
|
||||
/// one `flutter run` and you are looking at Home. Only meaningful alongside
|
||||
/// dev data — there is no one to sign in as without it, so a build talking to
|
||||
/// the real API still signs in properly.
|
||||
///
|
||||
/// To walk the entrance itself — the sign-in, sign-up and verify screens —
|
||||
/// turn this off and keep the dev data: `--dart-define=DM_DEV_LOGIN=false`,
|
||||
/// where `9876543210` and any 4-digit code gets you in.
|
||||
///
|
||||
/// Never under `flutter test`: the widget tests drive the real entrance —
|
||||
/// phone, code, and the guard that refuses to open the code screen when no
|
||||
/// code was sent — and a session waiting for them at launch would delete
|
||||
/// that coverage silently.
|
||||
static bool get devAutoLogin =>
|
||||
useDevData &&
|
||||
!isTest &&
|
||||
const bool.fromEnvironment('DM_DEV_LOGIN', defaultValue: true);
|
||||
|
||||
// --------------------------------------------------------------- auto sign-in
|
||||
//
|
||||
// ── Skipping the login SCREEN without faking the login ──
|
||||
//
|
||||
// This is the flag to reach for when you want the app to open on Home and
|
||||
// still be a real customer: at launch, with no stored session, the app runs
|
||||
// the actual `POST /customer/auth/otp/request` + `/verify` pair for you. The
|
||||
// token it gets back is the server's, every call after it is authorised
|
||||
// normally, and a booking made in this build lands in `pickupbookings` and
|
||||
// shows up in the admin console.
|
||||
//
|
||||
// --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234
|
||||
//
|
||||
// The code has to be one the server will accept. Two ways to have one:
|
||||
// set `CX_STAGING_OTP` on a non-production backend (a fixed code, refused
|
||||
// outright when ENV=production), or read the code the backend logged — with
|
||||
// no SMS gateway registered its `logSender` writes every code to the
|
||||
// application log and reports the send as successful.
|
||||
//
|
||||
// It only has to work once per install: the session is persisted, so later
|
||||
// launches restore it and these defines stop mattering.
|
||||
//
|
||||
// `!kReleaseMode`, like everything else here, and named on the Account
|
||||
// screen whenever it is on.
|
||||
|
||||
/// The phone number or email to sign in as.
|
||||
static String get autoLoginIdentifier =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_AS');
|
||||
|
||||
/// The verification code that identifier will accept.
|
||||
static String get autoLoginCode =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_CODE');
|
||||
|
||||
static bool get hasAutoLogin =>
|
||||
autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty;
|
||||
|
||||
// ------------------------------------------------------------- ops QA only
|
||||
|
||||
/// Mirrors the backend's own `CX_ALLOW_STAGE_OVERRIDE`: the QA helper at
|
||||
/// `POST /customer/ops/bookings/{reference}/stage` exists only off
|
||||
/// production, and only when ops has turned it on.
|
||||
///
|
||||
/// Turning it on here does not create the endpoint — a build that asks for
|
||||
/// it against a server that has it disabled gets a refusal, which is the
|
||||
/// right outcome. It only decides whether the control is offered.
|
||||
static bool get allowStageOverride =>
|
||||
!kReleaseMode &&
|
||||
!isProd &&
|
||||
const bool.fromEnvironment(
|
||||
'DM_ALLOW_STAGE_OVERRIDE',
|
||||
defaultValue: false,
|
||||
);
|
||||
|
||||
/// Whether the tracking screen offers its stage stepper. On a dev build it
|
||||
/// walks [DevDoormileApi]'s in-memory booking; on staging it drives the real
|
||||
/// QA endpoint.
|
||||
static bool get showStageStepper => useDevData || allowStageOverride;
|
||||
|
||||
// ------------------------------------------------------- development access
|
||||
//
|
||||
// ── The dev token is not a login bypass ──
|
||||
//
|
||||
// The Miler app shipped one of those: `USE_NEW_API=false` handed you a fake
|
||||
// "Demo Rider" on any four digits, with invented earnings. It was deleted,
|
||||
// and the reason is worth restating — a build flag that swaps authentication
|
||||
// for a bypass is not a development convenience, because the flag ships with
|
||||
// the binary and nothing in the UI says which mode you are in.
|
||||
//
|
||||
// So neither switch below authenticates anybody:
|
||||
//
|
||||
// [devToken] carries a **real, server-issued token** you already hold. The
|
||||
// server still authorises every request; this only spares you the OTP round
|
||||
// trip that staging cannot complete without an SMS gateway. It is not a
|
||||
// sign-in bypass — a revoked token signs the build straight back out.
|
||||
//
|
||||
// It is `!kReleaseMode`, so no define can put it into a release build, and it
|
||||
// is named on the Account screen whenever it is on.
|
||||
|
||||
/// A real access token, pasted in at build time:
|
||||
/// `--dart-define=DM_DEV_TOKEN=eyJ...`
|
||||
static String get devToken =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_TOKEN');
|
||||
|
||||
/// The matching refresh token, if you have one. Without it the session
|
||||
/// simply dies when the access token expires, and you paste a fresh one.
|
||||
static String get devRefreshToken =>
|
||||
kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_REFRESH_TOKEN');
|
||||
|
||||
static bool get hasDevToken => devToken.isNotEmpty;
|
||||
|
||||
/// True when this build got its session from somewhere other than a real
|
||||
/// sign-in — a pasted dev token, or dev data where any code is accepted.
|
||||
static bool get authBypassed => hasDevToken || useDevData || hasAutoLogin;
|
||||
|
||||
// -------------------------------------------------------------- client identity
|
||||
|
||||
/// `X-Client: doormile-cx/1.0.0+1`. Passed in at build time because reading
|
||||
/// it at runtime would mean another plugin for one string.
|
||||
static const String appVersion = String.fromEnvironment(
|
||||
'DM_APP_VERSION',
|
||||
defaultValue: '1.0.0+1',
|
||||
);
|
||||
|
||||
static String get clientHeader => 'doormile-cx/$appVersion';
|
||||
|
||||
static String get platformHeader {
|
||||
if (kIsWeb) return 'web';
|
||||
try {
|
||||
if (Platform.isAndroid) return 'android';
|
||||
if (Platform.isIOS) return 'ios';
|
||||
return Platform.operatingSystem;
|
||||
} catch (_) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ timeouts
|
||||
|
||||
static const Duration requestTimeout = Duration(seconds: 15);
|
||||
|
||||
/// Booking creation is allowed longer: the backend's own budget for it is
|
||||
/// 1.2s p95, but it writes across several tables and we would rather wait
|
||||
/// than orphan a booking the server did create.
|
||||
static const Duration writeTimeout = Duration(seconds: 30);
|
||||
|
||||
/// A one-line summary for the Account screen and for bug reports.
|
||||
///
|
||||
/// Names the dev token whenever one is in use: a build whose session did not
|
||||
/// come from a sign-in must say so on screen.
|
||||
static String get describe =>
|
||||
'${environment.name} · ${useDevData ? 'DEV DATA (offline)' : baseUrl}'
|
||||
'${allowStageOverride ? ' · STAGE OVERRIDE' : ''}'
|
||||
'${hasDevToken ? ' · DEV TOKEN' : ''}'
|
||||
'${hasAutoLogin ? ' · AUTO LOGIN ($autoLoginIdentifier)' : ''}';
|
||||
}
|
||||
789
lib/data/dev_doormile_api.dart
Normal file
789
lib/data/dev_doormile_api.dart
Normal file
@@ -0,0 +1,789 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'doormile_api.dart';
|
||||
import 'models.dart';
|
||||
|
||||
/// In-memory backend for **development and tests only**.
|
||||
///
|
||||
/// It answers every call from data it holds itself — serviceability, slots, a
|
||||
/// fare, and bookings it stores in memory — and it makes no network request, so
|
||||
/// there is no token to obtain and nothing to 401. Any 4-digit code signs in,
|
||||
/// which is the whole point of the dev bypass: `9876543210` + `1234` lands you
|
||||
/// in a working app while the real backend has no SMS gateway.
|
||||
///
|
||||
/// **It cannot reach a release build.** [DoormileApi.instance] returns it only
|
||||
/// when [AppConfig.useDevData] is set, and that getter is `false` in a release
|
||||
/// whatever the defines say — the same guard the original mock shipped behind.
|
||||
/// A booking made here lives on this device and reaches no server or admin
|
||||
/// console: developing the flow offline is all it is for.
|
||||
class DevDoormileApi extends DoormileApi {
|
||||
DevDoormileApi();
|
||||
|
||||
/// Behaviour switches for exercising the hard-to-reach states. They live on
|
||||
/// this class, not on the interface, so nothing on the live path sees them.
|
||||
final DevFlags flags = DevFlags();
|
||||
|
||||
final Random _rng = Random();
|
||||
|
||||
static const _fast = Duration(milliseconds: 380);
|
||||
static const _slow = Duration(milliseconds: 1600);
|
||||
|
||||
Duration get _latency => flags.slow ? _slow : _fast;
|
||||
|
||||
/// Resolves like a network call would; throws when the error flag is on.
|
||||
Future<T> _respond<T>(
|
||||
T Function() producer, {
|
||||
bool networkSensitive = true,
|
||||
}) async {
|
||||
await Future<void>.delayed(_latency);
|
||||
if (flags.networkError && networkSensitive) {
|
||||
throw ApiException('network', 'We could not reach Doormile');
|
||||
}
|
||||
return producer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- mock data
|
||||
|
||||
/// Stands in for `GET /serviceability` — the backend owns this entirely.
|
||||
static const List<Map<String, Object>> _serviceAreas = [
|
||||
{
|
||||
'code': 'TN',
|
||||
'name': 'Tamil Nadu',
|
||||
'transitTag': 'Ultra-fast transit',
|
||||
'districts': [
|
||||
{'code': 'TN-CBE', 'name': 'Coimbatore', 'available': true,
|
||||
'hub': 'Coimbatore Central Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 11.0168, 'lng': 76.9558},
|
||||
{'code': 'TN-MAA', 'name': 'Chennai', 'available': true,
|
||||
'hub': 'Chennai Guindy Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 13.0827, 'lng': 80.2707},
|
||||
{'code': 'TN-ERD', 'name': 'Erode', 'available': true,
|
||||
'hub': 'Erode Perundurai Hub', 'promise': '2-day delivery',
|
||||
'lat': 11.341, 'lng': 77.7172},
|
||||
{'code': 'TN-SLM', 'name': 'Salem', 'available': true,
|
||||
'hub': 'Salem Fairlands Hub', 'promise': '2-day delivery',
|
||||
'lat': 11.6643, 'lng': 78.146},
|
||||
{'code': 'TN-TUP', 'name': 'Tiruppur', 'available': true,
|
||||
'hub': 'Tiruppur Avinashi Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 11.1085, 'lng': 77.3411},
|
||||
{'code': 'TN-TRZ', 'name': 'Tiruchirappalli', 'available': true,
|
||||
'hub': 'Trichy Central Hub', 'promise': '2-day delivery',
|
||||
'lat': 10.7905, 'lng': 78.7047},
|
||||
{'code': 'TN-MDU', 'name': 'Madurai', 'available': false, 'note': 'Opening soon',
|
||||
'lat': 9.9252, 'lng': 78.1198},
|
||||
],
|
||||
},
|
||||
{
|
||||
'code': 'KL',
|
||||
'name': 'Kerala',
|
||||
'transitTag': 'Next-day transit',
|
||||
'districts': [
|
||||
{'code': 'KL-PKD', 'name': 'Palakkad', 'available': true,
|
||||
'hub': 'Palakkad Town Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 10.7867, 'lng': 76.6548},
|
||||
{'code': 'KL-TSR', 'name': 'Thrissur', 'available': true,
|
||||
'hub': 'Thrissur Round Hub', 'promise': '2-day delivery',
|
||||
'lat': 10.5276, 'lng': 76.2144},
|
||||
{'code': 'KL-EKM', 'name': 'Ernakulam', 'available': true,
|
||||
'hub': 'Kochi Kakkanad Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 9.9312, 'lng': 76.2673},
|
||||
{'code': 'KL-KKD', 'name': 'Kozhikode', 'available': false, 'note': 'Paused this week',
|
||||
'lat': 11.2588, 'lng': 75.7804},
|
||||
],
|
||||
},
|
||||
{
|
||||
'code': 'KA',
|
||||
'name': 'Karnataka',
|
||||
'transitTag': '2-day transit',
|
||||
'districts': [
|
||||
{'code': 'KA-BLR', 'name': 'Bengaluru Urban', 'available': true,
|
||||
'hub': 'Bengaluru Whitefield Hub', 'promise': 'Next-day delivery',
|
||||
'lat': 12.9716, 'lng': 77.5946},
|
||||
{'code': 'KA-MYS', 'name': 'Mysuru', 'available': true,
|
||||
'hub': 'Mysuru Hebbal Hub', 'promise': '2-day delivery',
|
||||
'lat': 12.2958, 'lng': 76.6394},
|
||||
{'code': 'KA-MNG', 'name': 'Mangaluru', 'available': false, 'note': 'Opening soon',
|
||||
'lat': 12.9141, 'lng': 74.856},
|
||||
],
|
||||
},
|
||||
{
|
||||
// A serviceable state with no open district yet.
|
||||
'code': 'PY',
|
||||
'name': 'Puducherry',
|
||||
'transitTag': 'Opening soon',
|
||||
'districts': <Map<String, Object>>[],
|
||||
},
|
||||
];
|
||||
|
||||
/// Stands in for `GET /pickup-slots?lat=&lng=`.
|
||||
static const List<PickupSlot> _pickupSlots = [
|
||||
PickupSlot(
|
||||
id: 'slot_t_1', day: 'Today', window: '2:00 – 4:00 PM', available: true,
|
||||
tag: 'Fastest pickup', milersNearby: 4, caption: 'Arriving in approx. 45 mins',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_t_2', day: 'Today', window: '4:00 – 6:00 PM', available: true,
|
||||
milersNearby: 2, caption: 'Relaxed evening handover',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_t_3', day: 'Today', window: '6:00 – 8:00 PM', available: false,
|
||||
note: 'Fully booked',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_1', day: 'Tomorrow', window: '9:00 – 11:00 AM', available: true,
|
||||
milersNearby: 5, caption: 'Early first dispatch',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_2', day: 'Tomorrow', window: '11:00 AM – 1:00 PM', available: true,
|
||||
milersNearby: 3, caption: 'Mid-morning route',
|
||||
),
|
||||
PickupSlot(
|
||||
id: 'slot_m_3', day: 'Tomorrow', window: '2:00 – 4:00 PM', available: true,
|
||||
milersNearby: 3, caption: 'Post-lunch route',
|
||||
),
|
||||
];
|
||||
|
||||
static const List<Place> _places = [
|
||||
Place(
|
||||
title: '12 Nehru Street',
|
||||
sub: 'Gandhipuram, Coimbatore 641012',
|
||||
lat: 11.0183,
|
||||
lng: 76.9725,
|
||||
),
|
||||
Place(
|
||||
title: 'Doormile Hub — RS Puram',
|
||||
sub: 'Trichy Road, Coimbatore 641002',
|
||||
lat: 11.0043,
|
||||
lng: 76.9611,
|
||||
),
|
||||
Place(
|
||||
title: 'Brookefields Mall',
|
||||
sub: 'Brookebond Road, Coimbatore 641001',
|
||||
lat: 10.9987,
|
||||
lng: 76.9628,
|
||||
),
|
||||
Place(
|
||||
title: 'Office — Tidel Park',
|
||||
sub: 'Peelamedu, Coimbatore 641014',
|
||||
lat: 11.0286,
|
||||
lng: 77.0021,
|
||||
),
|
||||
Place(
|
||||
title: 'Home — 4B Lake View Apartments',
|
||||
sub: 'Race Course, Coimbatore 641018',
|
||||
lat: 10.9968,
|
||||
lng: 76.9711,
|
||||
),
|
||||
];
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) => _respond(
|
||||
() => const OtpChallenge(codeLength: 4, resendAfterSeconds: 30),
|
||||
networkSensitive: true,
|
||||
);
|
||||
|
||||
/// Registers a new customer, then sends the verification code.
|
||||
@override
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
}) => _respond(() {
|
||||
if (name.trim().length < 2) {
|
||||
throw ApiException('invalid_name', 'Enter your full name');
|
||||
}
|
||||
return const OtpChallenge(codeLength: 4, resendAfterSeconds: 30);
|
||||
});
|
||||
|
||||
/// [name] is set when verifying a freshly created account.
|
||||
@override
|
||||
Future<Customer> verifyOtp(
|
||||
String identifier,
|
||||
String code, {
|
||||
String? name,
|
||||
}) => _respond(() {
|
||||
if (code.length < 4) {
|
||||
throw ApiException('invalid_otp', 'That code did not match');
|
||||
}
|
||||
final isEmail = identifier.contains('@');
|
||||
return Customer(
|
||||
id: 'cust_10241',
|
||||
name: name ?? 'Joe Oommen',
|
||||
phone: isEmail ? '+91 98765 43210' : identifier,
|
||||
email: isEmail ? identifier : 'joe@example.com',
|
||||
);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
@override
|
||||
Future<List<ServiceArea>> getServiceableStates() => _respond(() {
|
||||
if (flags.emptyServiceAreas) return <ServiceArea>[];
|
||||
return _serviceAreas.map((s) {
|
||||
final districts = (s['districts'] as List).cast<Map<String, Object>>();
|
||||
return ServiceArea(
|
||||
code: s['code'] as String,
|
||||
name: s['name'] as String,
|
||||
transitTag: s['transitTag'] as String?,
|
||||
districtCount: districts.where((d) => d['available'] == true).length,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
|
||||
@override
|
||||
Future<List<District>> getServiceableDistricts(String stateCode) => _respond(
|
||||
() {
|
||||
final state = _serviceAreas
|
||||
.where((s) => s['code'] == stateCode)
|
||||
.firstOrNull;
|
||||
if (state == null) {
|
||||
throw ApiException('not_found', 'That state is no longer serviceable');
|
||||
}
|
||||
return (state['districts'] as List)
|
||||
.cast<Map<String, Object>>()
|
||||
.map(
|
||||
(d) => District(
|
||||
code: d['code'] as String,
|
||||
name: d['name'] as String,
|
||||
available: d['available'] as bool,
|
||||
note: d['note'] as String?,
|
||||
hub: d['hub'] as String?,
|
||||
promise: d['promise'] as String?,
|
||||
lat: d['lat'] as double?,
|
||||
lng: d['lng'] as double?,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
},
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
@override
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) =>
|
||||
_respond(() => List<PickupSlot>.from(_pickupSlots));
|
||||
|
||||
@override
|
||||
PickupSlot? slotById(String? id) =>
|
||||
id == null ? null : _pickupSlots.where((s) => s.id == id).firstOrNull;
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
/// Turns a map pin into an address.
|
||||
///
|
||||
/// The mock derives a plausible address from the coordinates so dragging the
|
||||
/// map visibly changes the result — the real endpoint is
|
||||
/// `GET /places/reverse-geocode?lat=&lng=` and returns the same [Place].
|
||||
@override
|
||||
Future<Place> reverseGeocode({double? lat, double? lng}) => _respond(() {
|
||||
if (lat == null || lng == null) return _places.first;
|
||||
|
||||
// Standing on a known place? Return it by name.
|
||||
for (final place in _places) {
|
||||
if (place.hasLocation &&
|
||||
_metresBetween(lat, lng, place.lat!, place.lng!) < 160) {
|
||||
return Place(title: place.title, sub: place.sub, lat: lat, lng: lng);
|
||||
}
|
||||
}
|
||||
|
||||
final area = _nearestArea(lat, lng);
|
||||
final door = 2 + ((lat * 1e4).round() + (lng * 1e4).round()).abs() % 118;
|
||||
final street = _streets[((lng * 1e5).round()).abs() % _streets.length];
|
||||
return Place(
|
||||
title: '$door $street',
|
||||
sub: '${area.area}, ${area.city} ${area.pin}',
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
);
|
||||
}, networkSensitive: false);
|
||||
|
||||
/// Neighbourhoods the mock geocoder names. Real geocoding replaces this.
|
||||
static const List<({double lat, double lng, String area, String city, String pin})>
|
||||
_areas = [
|
||||
(lat: 11.0183, lng: 76.9725, area: 'Gandhipuram', city: 'Coimbatore', pin: '641012'),
|
||||
(lat: 11.0043, lng: 76.9611, area: 'R.S. Puram', city: 'Coimbatore', pin: '641002'),
|
||||
(lat: 11.0286, lng: 77.0021, area: 'Peelamedu', city: 'Coimbatore', pin: '641014'),
|
||||
(lat: 10.9968, lng: 76.9711, area: 'Race Course', city: 'Coimbatore', pin: '641018'),
|
||||
(lat: 11.0281, lng: 76.9455, area: 'Saibaba Colony', city: 'Coimbatore', pin: '641011'),
|
||||
(lat: 11.0069, lng: 77.0281, area: 'Singanallur', city: 'Coimbatore', pin: '641005'),
|
||||
(lat: 10.9878, lng: 76.9600, area: 'Ukkadam', city: 'Coimbatore', pin: '641001'),
|
||||
(lat: 11.0294, lng: 76.9048, area: 'Vadavalli', city: 'Coimbatore', pin: '641041'),
|
||||
(lat: 13.0827, lng: 80.2707, area: 'Guindy', city: 'Chennai', pin: '600032'),
|
||||
(lat: 12.9716, lng: 77.5946, area: 'Indiranagar', city: 'Bengaluru', pin: '560038'),
|
||||
(lat: 9.9312, lng: 76.2673, area: 'Kakkanad', city: 'Kochi', pin: '682030'),
|
||||
];
|
||||
|
||||
static const List<String> _streets = [
|
||||
'Nehru Street',
|
||||
'Bharathi Road',
|
||||
'Kamaraj Road',
|
||||
'4th Cross Street',
|
||||
'Mettupalayam Road',
|
||||
'Trichy Road',
|
||||
'Avinashi Road',
|
||||
'Lake View Road',
|
||||
'2nd Main Road',
|
||||
];
|
||||
|
||||
static ({double lat, double lng, String area, String city, String pin})
|
||||
_nearestArea(double lat, double lng) {
|
||||
var best = _areas.first;
|
||||
var bestMetres = double.infinity;
|
||||
for (final area in _areas) {
|
||||
final d = _metresBetween(lat, lng, area.lat, area.lng);
|
||||
if (d < bestMetres) {
|
||||
bestMetres = d;
|
||||
best = area;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Great-circle distance, good enough for pin snapping and route summaries.
|
||||
static double _metresBetween(
|
||||
double lat1,
|
||||
double lng1,
|
||||
double lat2,
|
||||
double lng2,
|
||||
) {
|
||||
const earth = 6371000.0;
|
||||
double rad(double d) => d * pi / 180;
|
||||
final dLat = rad(lat2 - lat1);
|
||||
final dLng = rad(lng2 - lng1);
|
||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
||||
cos(rad(lat1)) * cos(rad(lat2)) * sin(dLng / 2) * sin(dLng / 2);
|
||||
return earth * 2 * atan2(sqrt(a), sqrt(1 - a));
|
||||
}
|
||||
|
||||
/// Synchronous district lookup, so a screen can resolve the hub it already
|
||||
/// has a code for without another round trip. Backed by whatever
|
||||
/// [getServiceableDistricts] last returned in production.
|
||||
@override
|
||||
District? districtByCode(String? code) {
|
||||
if (code == null) return null;
|
||||
for (final state in _serviceAreas) {
|
||||
for (final d in (state['districts'] as List).cast<Map<String, Object>>()) {
|
||||
if (d['code'] == code) {
|
||||
return District(
|
||||
code: d['code'] as String,
|
||||
name: d['name'] as String,
|
||||
available: d['available'] as bool,
|
||||
note: d['note'] as String?,
|
||||
hub: d['hub'] as String?,
|
||||
promise: d['promise'] as String?,
|
||||
lat: d['lat'] as double?,
|
||||
lng: d['lng'] as double?,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Place search — `GET /customer/places/search?q=&lat=&lng=`.
|
||||
///
|
||||
/// An empty query returns the customer's recent or saved places, which is
|
||||
/// what the search sheet shows the moment it opens.
|
||||
@override
|
||||
Future<List<Place>> searchPlaces(
|
||||
String query, {
|
||||
double? lat,
|
||||
double? lng,
|
||||
}) => _respond(() {
|
||||
if (query.trim().isEmpty) return _places.take(4).toList();
|
||||
final needle = query.toLowerCase();
|
||||
return _places
|
||||
.where((p) => '${p.title} ${p.sub}'.toLowerCase().contains(needle))
|
||||
.toList();
|
||||
}, networkSensitive: false);
|
||||
|
||||
// ------------------------------------------------------------------ limits
|
||||
|
||||
/// Caps for one pickup. Backend-owned so they can change per city or tier.
|
||||
@override
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup}) =>
|
||||
_respond(() => const BookingLimits(maxPackages: 20, maxDestinations: 5));
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
/// Indicative price for a route. Confirmed at pickup once the Miler weighs
|
||||
/// and measures the parcel.
|
||||
/// Combined indicative price for everything collected in one visit. The
|
||||
/// final amount is settled once the Miler verifies the parcels.
|
||||
@override
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
}) => _respond(() {
|
||||
final packages = destinations.fold<int>(0, (n, d) => n + d.packageCount);
|
||||
final stops = destinations.length;
|
||||
return FareEstimate(
|
||||
min: 49 * packages + 20 * (stops - 1),
|
||||
max: 79 * packages + 30 * (stops - 1),
|
||||
paymentMethod: 'UPI · Cash at doorstep',
|
||||
parcel: packages == 1
|
||||
? 'Standard box (up to 3 kg)'
|
||||
: '$packages boxes (up to 3 kg each)',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
/// Creates the pickup **booking**. No shipment, no tracking number yet —
|
||||
/// those are generated by the backend once the Miler completes pickup.
|
||||
/// Creates the pickup **booking**. No shipment and no tracking number yet —
|
||||
/// those are generated per destination once the Miler completes pickup.
|
||||
@override
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
}) => _respond(() {
|
||||
if (destinations.isEmpty ||
|
||||
destinations.any((d) => !d.destination.isComplete)) {
|
||||
throw ApiException(
|
||||
'invalid',
|
||||
'Every destination needs a serviceable state and district',
|
||||
);
|
||||
}
|
||||
if (slotId == null) throw ApiException('invalid', 'Pick a pickup slot');
|
||||
|
||||
final booking = Booking(
|
||||
reference: newReference(),
|
||||
pickup: pickup,
|
||||
destinations: destinations.map((d) => d.copy()).toList(),
|
||||
slotId: slotId,
|
||||
createdAt: DateTime.now(),
|
||||
fare: fare,
|
||||
history: [StageEvent(JourneyStage.booked, DateTime.now())],
|
||||
);
|
||||
_bookings.insert(0, booking);
|
||||
return booking;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> cancelBooking(String reference, String? reason) =>
|
||||
_respond<void>(() {
|
||||
final booking = _find(reference);
|
||||
if (booking == null) return;
|
||||
booking
|
||||
..status = BookingStatus.cancelled
|
||||
..cancelReason = reason
|
||||
..cancellable = false;
|
||||
});
|
||||
|
||||
/// The customer's bookings — `GET /customer/bookings`.
|
||||
///
|
||||
/// This fake owns the list, exactly as the backend does. [AppState] adopts
|
||||
/// whatever comes back, so a test that wants a booking on screen creates one
|
||||
/// here rather than reaching into app state.
|
||||
@override
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
}) => _respond(() {
|
||||
// One page: the fake never has enough bookings to need a second, and a
|
||||
// cursor that led nowhere would only make the paging tests lie.
|
||||
final rows = status == null
|
||||
? _bookings
|
||||
: _bookings.where((b) => b.status == status).toList();
|
||||
return BookingPage(bookings: List.of(rows));
|
||||
});
|
||||
|
||||
/// One booking, fresh — `GET /customer/bookings/{reference}`.
|
||||
@override
|
||||
Future<Booking?> getBooking(String reference) =>
|
||||
_respond<Booking?>(() => _find(reference));
|
||||
|
||||
/// Backend policy decides this; the UI only asks.
|
||||
@override
|
||||
bool isCancellable(JourneyStage stage) =>
|
||||
stage.index < JourneyStage.arrived.index;
|
||||
|
||||
/// The QA stage helper — `POST /customer/ops/bookings/{ref}/stage`.
|
||||
@override
|
||||
Future<void> setStage(String reference, JourneyStage stage) =>
|
||||
_respond<void>(() {
|
||||
final booking = _find(reference);
|
||||
if (booking != null) {
|
||||
applyStage(booking, stage, DateTime.now(),
|
||||
backfillGap: const Duration(minutes: 9));
|
||||
}
|
||||
});
|
||||
|
||||
Booking? _find(String reference) {
|
||||
for (final b in _bookings) {
|
||||
if (b.reference == reference) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- ids
|
||||
|
||||
String newReference() => 'DM-${100000 + _rng.nextInt(899999)}';
|
||||
|
||||
String newTrackingId() => 'DMX${10000000 + _rng.nextInt(89999999)}';
|
||||
|
||||
// ------------------------------------------------------------- stage machine
|
||||
|
||||
/// Everything the **backend** does as a booking moves — assigning a Miler,
|
||||
/// minting a tracking number per destination, weighing the parcels, settling
|
||||
/// the amount paid.
|
||||
///
|
||||
/// It lives here because it is the server's work. It used to live in
|
||||
/// `AppState`, where a stepper in the UI drove it, which meant the app itself
|
||||
/// could mint a tracking number the backend had never issued.
|
||||
void applyStage(
|
||||
Booking booking,
|
||||
JourneyStage target,
|
||||
DateTime now, {
|
||||
required Duration backfillGap,
|
||||
}) {
|
||||
booking.stage = target;
|
||||
|
||||
// Rebuild history so stepping backwards drops the later entries.
|
||||
booking.history = [
|
||||
for (var i = 0; i <= target.index; i++)
|
||||
StageEvent(
|
||||
JourneyStage.at(i),
|
||||
now.subtract(backfillGap * (target.index - i)),
|
||||
),
|
||||
];
|
||||
|
||||
if (target.index >= JourneyStage.assigned.index) {
|
||||
booking.miler ??= const Person(
|
||||
name: 'Arun Kumar',
|
||||
vehicle: 'TN 37 BX 4412',
|
||||
phone: '+91 90000 11223',
|
||||
);
|
||||
}
|
||||
|
||||
switch (target) {
|
||||
case JourneyStage.onTheWay:
|
||||
booking.milerDistanceKm = 1.4;
|
||||
booking.milerEtaMinutes = 12;
|
||||
case JourneyStage.arrived:
|
||||
booking.milerDistanceKm = 0;
|
||||
booking.milerEtaMinutes = 0;
|
||||
case _:
|
||||
booking.milerDistanceKm = null;
|
||||
booking.milerEtaMinutes = null;
|
||||
}
|
||||
|
||||
// The parcels are weighed and photographed at pickup, which is also when
|
||||
// the price settles.
|
||||
if (target.index >= JourneyStage.pickedUp.index) {
|
||||
booking.amountPaid ??= booking.fare?.max ?? 64;
|
||||
for (final group in booking.destinations) {
|
||||
group.verification ??= ParcelVerification(
|
||||
weightKg: 1.2 * group.packageCount + 0.4,
|
||||
photos: [
|
||||
for (var i = 1; i <= group.packageCount; i++)
|
||||
'${group.destination.districtCode}-$i',
|
||||
],
|
||||
capturedAt: now,
|
||||
capturedBy: booking.miler?.name,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
booking.amountPaid = null;
|
||||
for (final group in booking.destinations) {
|
||||
group.verification = null;
|
||||
}
|
||||
}
|
||||
|
||||
booking.deliveredAt =
|
||||
target == JourneyStage.delivered ? (booking.deliveredAt ?? now) : null;
|
||||
|
||||
// The backend turns the pickup into one order per destination here. Only
|
||||
// at this point do the destinations become separately trackable.
|
||||
if (target.index >= JourneyStage.orderCreated.index) {
|
||||
booking.expectedDelivery ??= 'Thu, 12 Sep';
|
||||
for (var i = 0; i < booking.destinations.length; i++) {
|
||||
final group = booking.destinations[i];
|
||||
group.trackingId ??= newTrackingId();
|
||||
group.details.recipientName ??= 'Collected at pickup';
|
||||
group.details.street ??= 'Confirmed by Miler at pickup';
|
||||
// Later destinations trail slightly, so independent journeys are
|
||||
// visible. Real stages come from the backend per order.
|
||||
group.stage = JourneyStage.at(
|
||||
(target.index - i).clamp(
|
||||
JourneyStage.orderCreated.index,
|
||||
JourneyStage.delivered.index,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (final group in booking.destinations) {
|
||||
group.trackingId = null;
|
||||
group.stage = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (target.index >= JourneyStage.outForDelivery.index) {
|
||||
booking.deliveryAgent ??= const Person(
|
||||
name: 'Vishal R',
|
||||
vehicle: 'KL 07 CD 8890',
|
||||
phone: '+91 90000 44556',
|
||||
);
|
||||
}
|
||||
|
||||
booking.status = target == JourneyStage.delivered
|
||||
? BookingStatus.completed
|
||||
: BookingStatus.active;
|
||||
booking.cancellable = isCancellable(target);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- seeded rows
|
||||
|
||||
late final List<Booking> _bookings = _seed();
|
||||
|
||||
/// Clears the account back to its seeded state.
|
||||
void reset() {
|
||||
_bookings
|
||||
..clear()
|
||||
..addAll(_seed());
|
||||
}
|
||||
|
||||
/// One booking per interesting stage, so every tracking state is reachable
|
||||
/// without stepping a booking through the whole journey.
|
||||
List<Booking> _seed() {
|
||||
final now = DateTime.now();
|
||||
|
||||
DestinationGroup group(
|
||||
String stateCode,
|
||||
String stateName,
|
||||
String districtCode,
|
||||
String districtName, {
|
||||
int packages = 1,
|
||||
DeliveryDetails? details,
|
||||
}) => DestinationGroup(
|
||||
destination: Destination(
|
||||
stateCode: stateCode,
|
||||
stateName: stateName,
|
||||
districtCode: districtCode,
|
||||
districtName: districtName,
|
||||
),
|
||||
packageCount: packages,
|
||||
details: details,
|
||||
)..district = districtByCode(districtCode);
|
||||
|
||||
Booking make({
|
||||
required String reference,
|
||||
required JourneyStage stage,
|
||||
required DateTime createdAt,
|
||||
required String slotId,
|
||||
required List<DestinationGroup> destinations,
|
||||
BookingStatus? status,
|
||||
}) {
|
||||
final b = Booking(
|
||||
reference: reference,
|
||||
pickup: const Place(
|
||||
title: '12 Nehru Street',
|
||||
sub: 'Gandhipuram, Coimbatore 641012',
|
||||
),
|
||||
destinations: destinations,
|
||||
slotId: slotId,
|
||||
createdAt: createdAt,
|
||||
fare: const FareEstimate(
|
||||
min: 49,
|
||||
max: 64,
|
||||
paymentMethod: 'UPI · Cash at doorstep',
|
||||
),
|
||||
);
|
||||
applyStage(b, stage, createdAt, backfillGap: const Duration(minutes: 22));
|
||||
if (status != null) {
|
||||
b.status = status;
|
||||
b.cancellable = false;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
return [
|
||||
make(
|
||||
reference: 'DM-482913',
|
||||
stage: JourneyStage.assigned,
|
||||
createdAt: now.subtract(const Duration(minutes: 40)),
|
||||
slotId: 'slot_t_1',
|
||||
destinations: [
|
||||
group(
|
||||
'TN', 'Tamil Nadu', 'TN-MAA', 'Chennai',
|
||||
details: DeliveryDetails(
|
||||
recipientName: 'Meera S',
|
||||
recipientPhone: '9884412210',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// A multi-destination pickup: one visit, three places.
|
||||
make(
|
||||
reference: 'DM-471200',
|
||||
stage: JourneyStage.inTransit,
|
||||
createdAt: now.subtract(const Duration(hours: 26)),
|
||||
slotId: 'slot_m_1',
|
||||
destinations: [
|
||||
group('KL', 'Kerala', 'KL-EKM', 'Ernakulam', packages: 2),
|
||||
group('TN', 'Tamil Nadu', 'TN-CBE', 'Coimbatore'),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-466118',
|
||||
stage: JourneyStage.outForDelivery,
|
||||
createdAt: now.subtract(const Duration(hours: 50)),
|
||||
slotId: 'slot_t_2',
|
||||
destinations: [
|
||||
group(
|
||||
'KA', 'Karnataka', 'KA-BLR', 'Bengaluru Urban',
|
||||
details: DeliveryDetails(
|
||||
street: 'Indiranagar 12th Main',
|
||||
recipientName: 'Divya P',
|
||||
recipientPhone: '9845567712',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-451007',
|
||||
stage: JourneyStage.delivered,
|
||||
createdAt: now.subtract(const Duration(days: 5)),
|
||||
slotId: 'slot_m_2',
|
||||
destinations: [
|
||||
group(
|
||||
'TN', 'Tamil Nadu', 'TN-SLM', 'Salem',
|
||||
details: DeliveryDetails(
|
||||
street: 'Fairlands',
|
||||
recipientName: 'Karthik M',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
make(
|
||||
reference: 'DM-449220',
|
||||
stage: JourneyStage.booked,
|
||||
createdAt: now.subtract(const Duration(days: 8)),
|
||||
slotId: 'slot_t_1',
|
||||
destinations: [group('TN', 'Tamil Nadu', 'TN-ERD', 'Erode')],
|
||||
status: BookingStatus.cancelled,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Behaviour switches for [DevDoormileApi]. These used to sit on the
|
||||
/// `DoormileApi` interface, where shipping code could read them; they are
|
||||
/// dev/test-only now.
|
||||
class DevFlags {
|
||||
bool emptyServiceAreas = false;
|
||||
bool networkError = false;
|
||||
bool slow = false;
|
||||
}
|
||||
|
||||
extension _FirstOrNull<T> on Iterable<T> {
|
||||
T? get firstOrNull => isEmpty ? null : first;
|
||||
}
|
||||
177
lib/data/doormile_api.dart
Normal file
177
lib/data/doormile_api.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'app_config.dart';
|
||||
import 'dev_doormile_api.dart';
|
||||
import 'live_doormile_api.dart';
|
||||
import 'models.dart';
|
||||
|
||||
export 'api_exception.dart';
|
||||
|
||||
/// The service surface the app is written against.
|
||||
///
|
||||
/// **A shipping build has one implementation: [LiveDoormileApi],** against
|
||||
/// `/customer/*` on `api.doormile.com`. When the server cannot be reached the
|
||||
/// screens show their designed error state, which is the only honest answer.
|
||||
///
|
||||
/// The single exception is [DevDoormileApi], reached only when
|
||||
/// [AppConfig.useDevData] is set — a `!kReleaseMode` guard, so no define swaps
|
||||
/// invented data into a release. It exists because the real backend has no SMS
|
||||
/// gateway, so there is otherwise no way to sign in and exercise the flow.
|
||||
///
|
||||
/// Screens never see this type at all — they call [AppState], which holds it.
|
||||
///
|
||||
/// A method the backend owns but no screen drives yet has a default here that
|
||||
/// does nothing rather than throwing.
|
||||
abstract class DoormileApi {
|
||||
DoormileApi();
|
||||
|
||||
static DoormileApi? _instance;
|
||||
|
||||
/// The API this build talks to: the live backend, or [DevDoormileApi] when
|
||||
/// [AppConfig.useDevData] allows it — which is never in a release build.
|
||||
static DoormileApi get instance =>
|
||||
_instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi();
|
||||
|
||||
/// Replaces the singleton. Tests inject their double through here.
|
||||
static void overrideInstance(DoormileApi? api) => _instance = api;
|
||||
|
||||
/// Called when the refresh chain dies and the customer must sign in again.
|
||||
/// Set by [AppState].
|
||||
void Function()? onSessionLost;
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
/// Asks for a code. The answer describes the challenge — how many digits it
|
||||
/// has and how long before it can be resent — so the screen is built from
|
||||
/// what the server said rather than from constants.
|
||||
Future<OtpChallenge> sendOtp(String identifier);
|
||||
|
||||
/// Creates the account **and** sends the first code, so it answers with the
|
||||
/// same challenge [sendOtp] does.
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
});
|
||||
|
||||
/// [name] is set when verifying a freshly created account.
|
||||
Future<Customer> verifyOtp(String identifier, String code, {String? name});
|
||||
|
||||
/// Restores a persisted session at launch, or null when there is none.
|
||||
Future<Customer?> restoreSession() async => null;
|
||||
|
||||
/// Revokes the refresh token and unregisters the push token.
|
||||
Future<void> signOut() async {}
|
||||
|
||||
/// Re-reads the signed-in customer — `GET /customer/auth/me`.
|
||||
Future<Customer?> me() async => null;
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
Future<List<ServiceArea>> getServiceableStates();
|
||||
|
||||
Future<List<District>> getServiceableDistricts(String stateCode);
|
||||
|
||||
/// Synchronous district lookup for a code we already hold, backed by
|
||||
/// whatever [getServiceableDistricts] last returned.
|
||||
District? districtByCode(String? code);
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup});
|
||||
|
||||
PickupSlot? slotById(String? id);
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
Future<Place> reverseGeocode({double? lat, double? lng});
|
||||
|
||||
/// An empty query returns the customer's saved and recent places, which is
|
||||
/// what the search sheet shows the moment it opens.
|
||||
///
|
||||
/// [lat]/[lng] bias the results towards where the customer is looking —
|
||||
/// "MG Road" is in most Indian cities, and the one they mean is the near one.
|
||||
Future<List<Place>> searchPlaces(String query, {double? lat, double? lng});
|
||||
|
||||
// ------------------------------------------------------------------- limits
|
||||
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup});
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
/// Creates the pickup booking. No tracking number yet — those are minted per
|
||||
/// destination when the Miler completes the pickup.
|
||||
///
|
||||
/// [idempotencyKey] must be **held across retries of the same intent**. A new
|
||||
/// key is a new booking, which is exactly what a double tap must not create.
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
});
|
||||
|
||||
Future<void> cancelBooking(String reference, String? reason);
|
||||
|
||||
/// One page of the customer's bookings, newest first.
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
});
|
||||
|
||||
/// One booking, fresh. Null means the server answered `304` — nothing has
|
||||
/// changed since the last read, so the caller keeps what it has.
|
||||
Future<Booking?> getBooking(String reference);
|
||||
|
||||
/// One order by its tracking number — for a push deep link.
|
||||
Future<Booking?> getOrder(String trackingId) async => null;
|
||||
|
||||
/// Adds the optional address and recipient after booking, up to collection.
|
||||
Future<void> updateDestinationDetails(
|
||||
String reference,
|
||||
int index,
|
||||
DeliveryDetails details,
|
||||
) async {}
|
||||
|
||||
/// Backend policy decides this; the UI only asks.
|
||||
bool isCancellable(JourneyStage stage);
|
||||
|
||||
// ------------------------------------------------------------------ ops QA
|
||||
|
||||
/// Moves a booking to [stage] through the backend's own non-production QA
|
||||
/// helper — `POST /customer/ops/bookings/{reference}/stage`.
|
||||
///
|
||||
/// It exists so the tracking screen's stepper can walk a **real** staging
|
||||
/// booking. The stage that then appears on screen is the one the server
|
||||
/// recorded, not one the client wished for. Refused on production.
|
||||
Future<void> setStage(String reference, JourneyStage stage) async {}
|
||||
|
||||
// ------------------------------------------------------------------ devices
|
||||
|
||||
Future<void> registerDevice(String token) async {}
|
||||
|
||||
Future<void> unregisterDevice(String token) async {}
|
||||
|
||||
// ------------------------------------------------------- profile & addresses
|
||||
|
||||
Future<Customer?> getProfile() async => null;
|
||||
|
||||
Future<Customer?> updateProfile({String? name, String? email}) async => null;
|
||||
|
||||
Future<List<SavedPlace>> getSavedLocations() async => const [];
|
||||
|
||||
Future<SavedPlace?> addSavedLocation(Place place, {String? label}) async =>
|
||||
null;
|
||||
|
||||
Future<SavedPlace?> updateSavedLocation(SavedPlace place) async => null;
|
||||
|
||||
Future<void> deleteSavedLocation(String id) async {}
|
||||
|
||||
}
|
||||
603
lib/data/live_doormile_api.dart
Normal file
603
lib/data/live_doormile_api.dart
Normal file
@@ -0,0 +1,603 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'api_client.dart';
|
||||
import 'app_config.dart';
|
||||
import 'doormile_api.dart';
|
||||
import 'models.dart';
|
||||
import 'session_store.dart';
|
||||
|
||||
/// The real customer API — `/customer/*` on `api.doormile.com/api/v1`.
|
||||
///
|
||||
/// Every method is a thin translation between the app's models and the wire
|
||||
/// contract. The transport concerns — envelope, error codes, bearer auth and
|
||||
/// its refresh, idempotency, `ETag` revalidation, retries — all live in
|
||||
/// [ApiClient], so what is left here is the shape of each endpoint and nothing
|
||||
/// else.
|
||||
class LiveDoormileApi extends DoormileApi {
|
||||
LiveDoormileApi({ApiClient? client}) : client = client ?? ApiClient() {
|
||||
// Forwarded rather than passed in: `onSessionLost` is set on the interface
|
||||
// by AppState, which does not exist yet at this point.
|
||||
this.client.onSessionLost = () => onSessionLost?.call();
|
||||
}
|
||||
|
||||
final ApiClient client;
|
||||
|
||||
// ── Caches that exist to answer synchronous questions ────────────────────
|
||||
//
|
||||
// Two screens ask for a district or a slot by the code they already hold,
|
||||
// without a round trip. Neither is a substitute for the fetch: each is
|
||||
// populated only by a real response, so an entry can never be invented.
|
||||
|
||||
final Map<String, District> _districtsByCode = {};
|
||||
final Map<String, PickupSlot> _slotsById = {};
|
||||
|
||||
/// `ETag`s keyed by path, so a revalidated read costs a 304 and no body.
|
||||
final Map<String, String> _etags = {};
|
||||
final Map<String, List<ServiceArea>> _statesCache = {};
|
||||
final Map<String, List<District>> _districtCache = {};
|
||||
final Map<String, List<PickupSlot>> _slotCache = {};
|
||||
|
||||
// --------------------------------------------------------------------- auth
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> sendOtp(String identifier) async {
|
||||
final response = await client.post(
|
||||
'/auth/otp/request',
|
||||
body: {'identifier': _normalisePhone(identifier)},
|
||||
authenticated: false,
|
||||
);
|
||||
return OtpChallenge.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OtpChallenge> signUp({
|
||||
required String name,
|
||||
required String phone,
|
||||
String? email,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/auth/signup',
|
||||
body: {
|
||||
'name': name.trim(),
|
||||
'phone': _normalisePhone(phone),
|
||||
if (email != null && email.trim().isNotEmpty) 'email': email.trim(),
|
||||
},
|
||||
authenticated: false,
|
||||
);
|
||||
return OtpChallenge.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer> verifyOtp(
|
||||
String identifier,
|
||||
String code, {
|
||||
String? name,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/auth/otp/verify',
|
||||
// The field is `code`, NOT `otp`. The written contract says `otp`, but
|
||||
// the server reads `json:"code"` (CxVerifyOtp) and rejects a body without
|
||||
// it as an empty code — "Enter the code we sent you", a 400, on every
|
||||
// single sign-in. Verified against production: `otp` returns that 400,
|
||||
// `code` returns 401 "That code did not match" for a wrong code and a
|
||||
// session for a right one. Follow the server, not the document.
|
||||
//
|
||||
// The name is deliberately not sent: `POST /auth/signup` has already
|
||||
// recorded it, and it is not part of this request.
|
||||
body: {'identifier': _normalisePhone(identifier), 'code': code},
|
||||
authenticated: false,
|
||||
// Verification mints a session. A retry over a flaky network must return
|
||||
// the same one rather than a second, or the first is orphaned.
|
||||
idempotencyKey: client.newIdempotencyKey(),
|
||||
);
|
||||
|
||||
final data = response.map;
|
||||
final access = data['accessToken'] as String?;
|
||||
final refresh = data['refreshToken'] as String?;
|
||||
if (access == null || refresh == null) {
|
||||
throw ApiException(
|
||||
ApiException.serverError,
|
||||
'Something went wrong',
|
||||
response.data == null ? null : 200,
|
||||
response.requestId,
|
||||
);
|
||||
}
|
||||
|
||||
final customer = Customer.fromJson(
|
||||
(data['customer'] as Map<String, dynamic>?) ?? const {},
|
||||
);
|
||||
|
||||
await client.adoptSession(
|
||||
Session(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: DateTime.now().add(
|
||||
Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600),
|
||||
),
|
||||
customer: customer,
|
||||
),
|
||||
);
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> restoreSession() async {
|
||||
await _adoptDevTokenIfGiven();
|
||||
final session = await client.currentSession();
|
||||
if (session == null) return null;
|
||||
// A stored session is a claim, not a fact — the refresh token may have been
|
||||
// revoked from another device. Ask the server who this is; a 401 here means
|
||||
// the chain is dead and ApiClient has already cleared it.
|
||||
try {
|
||||
final me = await this.me();
|
||||
return me ?? session.customer;
|
||||
} on ApiException catch (e) {
|
||||
if (e.isAuthFailure) return null;
|
||||
// Offline at launch is not a signed-out customer. Trust the stored copy
|
||||
// and let the first real call settle it.
|
||||
return session.customer;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> me() async {
|
||||
final response = await client.get('/auth/me');
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
final data = response.map;
|
||||
final customer = Customer.fromJson(
|
||||
(data['customer'] as Map<String, dynamic>?) ?? data,
|
||||
);
|
||||
final session = await client.currentSession();
|
||||
if (session != null) {
|
||||
await client.adoptSession(session.copyWith(customer: customer));
|
||||
}
|
||||
return customer;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
final session = await client.currentSession();
|
||||
try {
|
||||
await client.post(
|
||||
'/auth/logout',
|
||||
body: {'refreshToken': ?session?.refreshToken},
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
// Signing out locally must always succeed. A server that refuses only
|
||||
// means the refresh token outlives the device, which its 60-day TTL
|
||||
// settles anyway.
|
||||
debugPrint('[AUTH] logout call failed, clearing locally: $e');
|
||||
} finally {
|
||||
await client.dropSession();
|
||||
_districtsByCode.clear();
|
||||
_slotsById.clear();
|
||||
_etags.clear();
|
||||
_statesCache.clear();
|
||||
_districtCache.clear();
|
||||
_slotCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopts a token handed in at build time, when there is no stored session.
|
||||
///
|
||||
/// The token is real and server-issued — this skips the OTP round trip that
|
||||
/// staging cannot complete without an SMS gateway, not the authorisation.
|
||||
/// Every request it signs is still checked server-side, and a revoked token
|
||||
/// signs the build straight back out.
|
||||
///
|
||||
/// A stored session always wins: a real sign-in on the device must not be
|
||||
/// silently replaced by whatever was compiled in weeks ago.
|
||||
Future<void> _adoptDevTokenIfGiven() async {
|
||||
if (!AppConfig.hasDevToken) return;
|
||||
if (await client.currentSession() != null) return;
|
||||
debugPrint('[AUTH] using DM_DEV_TOKEN — no sign-in was performed');
|
||||
await client.adoptSession(
|
||||
Session(
|
||||
accessToken: AppConfig.devToken,
|
||||
refreshToken: AppConfig.devRefreshToken,
|
||||
// Without a refresh token there is nothing to rotate, so do not spend
|
||||
// a call discovering that. The token dies when the server says so.
|
||||
expiresAt: DateTime.now().add(
|
||||
AppConfig.devRefreshToken.isEmpty
|
||||
? const Duration(days: 365)
|
||||
: const Duration(hours: 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The client sends `+91 98765 43210` today; the server normalises, but
|
||||
/// sending E.164 costs nothing and removes the ambiguity.
|
||||
static String _normalisePhone(String identifier) {
|
||||
final trimmed = identifier.trim();
|
||||
if (trimmed.contains('@')) return trimmed;
|
||||
final digits = trimmed.replaceAll(RegExp(r'[^0-9+]'), '');
|
||||
if (digits.startsWith('+')) return digits;
|
||||
if (digits.length == 10) return '+91$digits';
|
||||
if (digits.length == 12 && digits.startsWith('91')) return '+$digits';
|
||||
return digits.isEmpty ? trimmed : digits;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- serviceability
|
||||
|
||||
@override
|
||||
Future<List<ServiceArea>> getServiceableStates() async {
|
||||
const path = '/serviceability/states';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
if (response.notModified) return _statesCache[path] ?? const [];
|
||||
|
||||
final states = response.rows.map(ServiceArea.fromJson).toList();
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
_statesCache[path] = states;
|
||||
return states;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<District>> getServiceableDistricts(String stateCode) async {
|
||||
final path = '/serviceability/states/$stateCode/districts';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
if (response.notModified) return _districtCache[path] ?? const [];
|
||||
|
||||
// The full list arrives, unavailable districts included: the picker filters
|
||||
// them out but their names feed the "Coming soon" line.
|
||||
final districts = response.rows.map(District.fromJson).toList();
|
||||
for (final district in districts) {
|
||||
_districtsByCode[district.code] = district;
|
||||
}
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
_districtCache[path] = districts;
|
||||
return districts;
|
||||
}
|
||||
|
||||
@override
|
||||
District? districtByCode(String? code) =>
|
||||
code == null ? null : _districtsByCode[code];
|
||||
|
||||
// -------------------------------------------------------------------- slots
|
||||
|
||||
@override
|
||||
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
|
||||
final query = {
|
||||
if (pickup?.lat != null) 'lat': '${pickup!.lat}',
|
||||
if (pickup?.lng != null) 'lng': '${pickup!.lng}',
|
||||
};
|
||||
// Tagged per zone: the capacity in one zone's windows says nothing about
|
||||
// another's, so the coordinates are part of the cache key.
|
||||
final tag = '/pickup-slots?${query.entries.map((e) => '${e.key}=${e.value}').join('&')}';
|
||||
final response = await client.get(
|
||||
'/pickup-slots',
|
||||
query: query,
|
||||
ifNoneMatch: _etags[tag],
|
||||
);
|
||||
if (response.notModified) return _slotCache[tag] ?? const [];
|
||||
|
||||
final slots = response.rows.map(PickupSlot.fromJson).toList();
|
||||
for (final slot in slots) {
|
||||
_slotsById[slot.id] = slot;
|
||||
}
|
||||
if (response.etag != null) _etags[tag] = response.etag!;
|
||||
_slotCache[tag] = slots;
|
||||
return slots;
|
||||
}
|
||||
|
||||
@override
|
||||
PickupSlot? slotById(String? id) => id == null ? null : _slotsById[id];
|
||||
|
||||
// ----------------------------------------------------------------- location
|
||||
|
||||
@override
|
||||
Future<Place> reverseGeocode({double? lat, double? lng}) async {
|
||||
if (lat == null || lng == null) {
|
||||
throw ApiException(ApiException.invalid, 'No location to look up');
|
||||
}
|
||||
final response = await client.get(
|
||||
'/places/reverse-geocode',
|
||||
query: {'lat': '$lat', 'lng': '$lng'},
|
||||
authenticated: true,
|
||||
);
|
||||
final place = Place.fromJson(response.map);
|
||||
// The endpoint echoes the coordinates, but the pin is the authority on
|
||||
// where the Miler is sent — never let a geocoder move it.
|
||||
return Place(title: place.title, sub: place.sub, lat: lat, lng: lng);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Place>> searchPlaces(String query, {double? lat, double? lng}) async {
|
||||
final response = await client.get(
|
||||
'/places/search',
|
||||
query: {
|
||||
'q': query.trim(),
|
||||
// Biases the results towards where the customer is looking. The
|
||||
// contract takes both and an empty query then answers with their
|
||||
// recent and saved places instead.
|
||||
if (lat != null) 'lat': '$lat',
|
||||
if (lng != null) 'lng': '$lng',
|
||||
},
|
||||
);
|
||||
return response.rows.map(Place.fromJson).toList();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- limits
|
||||
|
||||
@override
|
||||
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
|
||||
final response = await client.get(
|
||||
'/config/booking-limits',
|
||||
query: {
|
||||
if (pickup?.lat != null) 'lat': '${pickup!.lat}',
|
||||
if (pickup?.lng != null) 'lng': '${pickup!.lng}',
|
||||
},
|
||||
);
|
||||
return BookingLimits.fromJson(response.map);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------- fare
|
||||
|
||||
@override
|
||||
Future<FareEstimate> estimateFare({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
}) async {
|
||||
final response = await client.post(
|
||||
'/fare/estimate',
|
||||
body: {
|
||||
// The contract also accepts a `stateCode`/`districtCode` on the pickup.
|
||||
// This app does not have them: the pickup is a map pin, and the
|
||||
// reverse geocoder returns an address, not a serviceable-area code. The
|
||||
// coordinates say the same thing and the server already resolves them.
|
||||
'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng},
|
||||
'destinations': [
|
||||
for (final d in destinations)
|
||||
{
|
||||
'stateCode': ?d.destination.stateCode,
|
||||
'districtCode': ?d.destination.districtCode,
|
||||
// One entry per package, and no `weightKg` on any of them: this
|
||||
// app never asks the customer what a parcel weighs — the Miler
|
||||
// weighs it at the door, which is when the price settles — so the
|
||||
// count is all we honestly know, and the server prices its own
|
||||
// default band from it.
|
||||
'packages': [
|
||||
for (var i = 0; i < d.packageCount; i++) const <String, dynamic>{},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
timeout: null,
|
||||
);
|
||||
final fare = FareEstimate.fromJson(response.map);
|
||||
if (fare == null) {
|
||||
throw ApiException(
|
||||
ApiException.serverError,
|
||||
'We could not price that just now',
|
||||
);
|
||||
}
|
||||
return fare;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ booking
|
||||
|
||||
@override
|
||||
Future<Booking> createBooking({
|
||||
required Place pickup,
|
||||
required List<DestinationGroup> destinations,
|
||||
required String? slotId,
|
||||
FareEstimate? fare,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
if (slotId == null || slotId.isEmpty) {
|
||||
throw ApiException(ApiException.invalid, 'Pick a pickup slot');
|
||||
}
|
||||
// [fare] is deliberately not sent. The contract's create request has no
|
||||
// field for what the customer was quoted, and an undocumented extra on the
|
||||
// one call that must not be rejected is not worth the audit trail. The
|
||||
// server prices the booking itself; the shown band lives in the estimate
|
||||
// call's own log.
|
||||
//
|
||||
// Who the Miler asks for at the door. The contract asks for it on the
|
||||
// pickup, and the signed-in customer is the only answer this app has.
|
||||
final customer = (await client.currentSession())?.customer;
|
||||
|
||||
final response = await client.post(
|
||||
'/bookings',
|
||||
idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(),
|
||||
body: {
|
||||
'slotId': slotId,
|
||||
'pickup': pickup.toBookingJson(
|
||||
contactName: customer?.name,
|
||||
contactPhone: customer?.phone,
|
||||
),
|
||||
'destinations': [for (final d in destinations) d.toBookingJson()],
|
||||
// The delivery instructions the customer typed per destination. The
|
||||
// contract carries one `remarks` line for the whole visit, which is
|
||||
// what the Miler reads, so several are joined rather than dropped.
|
||||
'remarks': ?_remarksFrom(destinations),
|
||||
},
|
||||
);
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
static String? _remarksFrom(List<DestinationGroup> destinations) {
|
||||
final lines = [
|
||||
for (final d in destinations)
|
||||
if (d.details.instructions?.trim().isNotEmpty ?? false)
|
||||
d.details.instructions!.trim(),
|
||||
];
|
||||
return lines.isEmpty ? null : lines.join(' · ');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancelBooking(String reference, String? reason) async {
|
||||
await client.post(
|
||||
'/bookings/$reference/cancel',
|
||||
body: {'reason': ?reason},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<BookingPage> getBookingPage({
|
||||
BookingStatus? status,
|
||||
String? cursor,
|
||||
int limit = 20,
|
||||
}) async {
|
||||
final response = await client.get(
|
||||
'/bookings',
|
||||
query: {
|
||||
if (status != null) 'status': status.name,
|
||||
'limit': '$limit',
|
||||
'cursor': ?cursor,
|
||||
},
|
||||
);
|
||||
return BookingPage(
|
||||
bookings: response.rows.map(_bookingFrom).toList(),
|
||||
nextCursor: response.nextCursor,
|
||||
total: response.total,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Booking?> getBooking(String reference) async {
|
||||
final path = '/bookings/$reference';
|
||||
final response = await client.get(path, ifNoneMatch: _etags[path]);
|
||||
// 304 is the common answer while the tracking screen polls: nothing moved,
|
||||
// so the caller keeps what it already has.
|
||||
if (response.notModified) return null;
|
||||
if (response.etag != null) _etags[path] = response.etag!;
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Booking?> getOrder(String trackingId) async {
|
||||
final response = await client.get('/orders/$trackingId');
|
||||
return _bookingFrom(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateDestinationDetails(
|
||||
String reference,
|
||||
int index,
|
||||
DeliveryDetails details,
|
||||
) async {
|
||||
await client.patch(
|
||||
'/bookings/$reference/destinations/$index',
|
||||
body: details.toPatchJson(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirrors the server's policy so the button can be hidden, but the server
|
||||
/// re-checks and is the authority — this only decides what is offered.
|
||||
///
|
||||
/// The window closes **when the Miler arrives**, so `arrived` is the cutoff
|
||||
/// and is itself no longer cancellable. Offering it one stage too long is not
|
||||
/// a cosmetic slip: the customer taps Cancel with the Miler at the door, the
|
||||
/// server answers `BOOKING_NOT_CANCELLABLE`, and the app has to explain a
|
||||
/// refusal it invited.
|
||||
@override
|
||||
bool isCancellable(JourneyStage stage) =>
|
||||
stage.index < JourneyStage.arrived.index;
|
||||
|
||||
Booking _bookingFrom(Map<String, dynamic> json) {
|
||||
final booking = Booking.fromJson(json);
|
||||
// Attach the hub we already know for each district, so the route map can
|
||||
// draw its line without another round trip.
|
||||
for (final group in booking.destinations) {
|
||||
group.district ??= districtByCode(group.destination.districtCode);
|
||||
}
|
||||
return booking;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ ops QA
|
||||
|
||||
/// Walks a booking through the stages on a non-production backend.
|
||||
///
|
||||
/// This is the server's own QA helper — double-gated behind its
|
||||
/// `CX_ALLOW_STAGE_OVERRIDE` — and it is what the tracking screen's stepper
|
||||
/// drives when the build asks for it against staging. It
|
||||
/// refuses to leave the app on production before the request is made,
|
||||
/// because a stage the client asked for is not a stage that happened.
|
||||
@override
|
||||
Future<void> setStage(String reference, JourneyStage stage) async {
|
||||
if (AppConfig.isProd) {
|
||||
throw ApiException(
|
||||
ApiException.forbidden,
|
||||
'Stage override is not available on production',
|
||||
);
|
||||
}
|
||||
await client.post(
|
||||
'/ops/bookings/$reference/stage',
|
||||
body: {'stage': stage.key},
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ devices
|
||||
|
||||
@override
|
||||
Future<void> registerDevice(String token) async {
|
||||
await client.post(
|
||||
'/devices',
|
||||
body: {
|
||||
'token': token,
|
||||
'platform': _platformName,
|
||||
'appVersion': _appVersion,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unregisterDevice(String token) async {
|
||||
await client.delete('/devices/$token');
|
||||
}
|
||||
|
||||
static String get _platformName => AppConfig.platformHeader;
|
||||
static String get _appVersion => AppConfig.appVersion;
|
||||
|
||||
// ------------------------------------------------------- profile & addresses
|
||||
|
||||
@override
|
||||
Future<Customer?> getProfile() async {
|
||||
final response = await client.get('/profile');
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return Customer.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Customer?> updateProfile({String? name, String? email}) async {
|
||||
final response = await client.put(
|
||||
'/profile',
|
||||
body: {'name': ?name, 'email': ?email},
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return Customer.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SavedPlace>> getSavedLocations() async {
|
||||
final response = await client.get('/locations');
|
||||
return response.rows.map(SavedPlace.fromJson).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedPlace?> addSavedLocation(Place place, {String? label}) async {
|
||||
final response = await client.post(
|
||||
'/locations',
|
||||
body: {...place.toJson(), 'label': ?label},
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return SavedPlace.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SavedPlace?> updateSavedLocation(SavedPlace place) async {
|
||||
final response = await client.put(
|
||||
'/locations/${place.id}',
|
||||
body: place.toJson(),
|
||||
);
|
||||
if (response.data is! Map<String, dynamic>) return null;
|
||||
return SavedPlace.fromJson(response.map);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteSavedLocation(String id) async {
|
||||
await client.delete('/locations/$id');
|
||||
}
|
||||
|
||||
}
|
||||
173
lib/data/location_service.dart
Normal file
173
lib/data/location_service.dart
Normal file
@@ -0,0 +1,173 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
/// Why a location fix is not available. Each value maps to a distinct thing we
|
||||
/// tell the customer, so none of them is a generic failure.
|
||||
enum LocationDenial {
|
||||
/// Device location is switched off entirely.
|
||||
serviceOff,
|
||||
|
||||
/// Permission refused this time; asking again is allowed.
|
||||
denied,
|
||||
|
||||
/// Refused permanently — only Settings can undo it.
|
||||
deniedForever,
|
||||
|
||||
/// Permission granted, but the fix timed out or the platform failed.
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// A resolved device position, or the reason there isn't one.
|
||||
class LocationFix {
|
||||
const LocationFix.ok(this.lat, this.lng, {this.accuracy}) : denial = null;
|
||||
const LocationFix.denied(this.denial)
|
||||
: lat = null,
|
||||
lng = null,
|
||||
accuracy = null;
|
||||
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
|
||||
/// Horizontal accuracy in metres, when the platform reports it. Drawn as the
|
||||
/// halo around the blue dot, so the customer can see how sure we are.
|
||||
final double? accuracy;
|
||||
|
||||
final LocationDenial? denial;
|
||||
|
||||
bool get hasFix => lat != null && lng != null;
|
||||
|
||||
/// What to show the customer when there is no fix. Never a raw error.
|
||||
String get message => switch (denial) {
|
||||
LocationDenial.serviceOff =>
|
||||
'Turn on location to drop the pin for you. You can still search for the address.',
|
||||
LocationDenial.denied =>
|
||||
'Allow location access and we will drop the pin for you.',
|
||||
LocationDenial.deniedForever =>
|
||||
'Location is blocked for Doormile. Enable it in Settings, or search for the address.',
|
||||
_ => 'We could not get your location. Search for the address instead.',
|
||||
};
|
||||
}
|
||||
|
||||
/// The only place the app touches platform GPS.
|
||||
///
|
||||
/// Every failure — permission refused, location off, no plugin (widget tests) —
|
||||
/// comes back as a [LocationFix] with a [LocationDenial]. Nothing throws, so a
|
||||
/// caller never has to guard the booking flow behind a try/catch.
|
||||
class LocationService {
|
||||
LocationService();
|
||||
|
||||
/// Swappable so tests can answer without a platform.
|
||||
static LocationService instance = LocationService();
|
||||
|
||||
/// Coimbatore city centre. Where the map opens before we know better, so the
|
||||
/// customer never stares at a grey ocean tile.
|
||||
static const fallbackLat = 11.0168;
|
||||
static const fallbackLng = 76.9558;
|
||||
|
||||
/// How long to wait on a permission or service answer before giving up.
|
||||
static const _probe = Duration(seconds: 4);
|
||||
|
||||
/// Asks for permission if we do not have it yet, then returns a fix.
|
||||
///
|
||||
/// Every platform call is bounded: a wedged location plugin must never leave
|
||||
/// the customer watching "Getting address…" with no way forward.
|
||||
Future<LocationFix> current({
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async {
|
||||
final enabled = await _guard(Geolocator.isLocationServiceEnabled, _probe);
|
||||
if (enabled == null) {
|
||||
return const LocationFix.denied(LocationDenial.unavailable);
|
||||
}
|
||||
if (!enabled) return const LocationFix.denied(LocationDenial.serviceOff);
|
||||
|
||||
var permission = await _guard(Geolocator.checkPermission, _probe);
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await _guard(Geolocator.requestPermission, timeout);
|
||||
}
|
||||
if (permission == null) {
|
||||
return const LocationFix.denied(LocationDenial.unavailable);
|
||||
}
|
||||
if (permission == LocationPermission.deniedForever) {
|
||||
return const LocationFix.denied(LocationDenial.deniedForever);
|
||||
}
|
||||
if (permission == LocationPermission.denied) {
|
||||
return const LocationFix.denied(LocationDenial.denied);
|
||||
}
|
||||
|
||||
final fix = await _guard(
|
||||
() => Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.high,
|
||||
timeLimit: timeout,
|
||||
),
|
||||
),
|
||||
timeout,
|
||||
// A cached fix beats no fix at all when the precise one times out.
|
||||
) ?? await _guard<Position?>(() => Geolocator.getLastKnownPosition(), _probe);
|
||||
|
||||
return fix == null
|
||||
? const LocationFix.denied(LocationDenial.unavailable)
|
||||
: LocationFix.ok(
|
||||
fix.latitude,
|
||||
fix.longitude,
|
||||
accuracy: fix.accuracy > 0 ? fix.accuracy : null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs a platform call, returning null instead of throwing or hanging.
|
||||
/// Covers MissingPluginException, a refused channel, and a plugin that
|
||||
/// simply never answers.
|
||||
static Future<T?> _guard<T>(Future<T> Function() call, Duration limit) async {
|
||||
try {
|
||||
return await call().timeout(limit);
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the OS settings page so a permanently denied customer can recover.
|
||||
Future<void> openSettings() async {
|
||||
try {
|
||||
await Geolocator.openAppSettings();
|
||||
} on Object {
|
||||
// Nothing useful to do if the platform refuses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers instantly with a known position, without touching the platform.
|
||||
///
|
||||
/// **A test seam, and only that.** Widget tests run on a fake clock that a real
|
||||
/// platform-channel reply never reaches, so every test that renders the pickup
|
||||
/// screen installs one of these as [LocationService.instance]. Nothing in the
|
||||
/// app does, and nothing should: a build that invents where the customer is
|
||||
/// sends a Miler to the wrong door.
|
||||
class FixedLocationService extends LocationService {
|
||||
FixedLocationService({
|
||||
this.lat = LocationService.fallbackLat,
|
||||
this.lng = LocationService.fallbackLng,
|
||||
this.accuracy = 18,
|
||||
this.denial,
|
||||
});
|
||||
|
||||
/// A service that always refuses, for exercising the denied states.
|
||||
FixedLocationService.denied(LocationDenial reason)
|
||||
: lat = LocationService.fallbackLat,
|
||||
lng = LocationService.fallbackLng,
|
||||
accuracy = null,
|
||||
denial = reason;
|
||||
|
||||
final double lat;
|
||||
final double lng;
|
||||
final double? accuracy;
|
||||
final LocationDenial? denial;
|
||||
|
||||
@override
|
||||
Future<LocationFix> current({
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async => denial != null
|
||||
? LocationFix.denied(denial!)
|
||||
: LocationFix.ok(lat, lng, accuracy: accuracy);
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async {}
|
||||
}
|
||||
306
lib/data/map_config.dart
Normal file
306
lib/data/map_config.dart
Normal file
@@ -0,0 +1,306 @@
|
||||
/// Where map tiles come from, what must be credited, and any key needed to ask
|
||||
/// for them.
|
||||
///
|
||||
/// This is the **only** place a tile URL, an attribution string or a map API
|
||||
/// key exists. No widget builds a URL; screens ask for a style and get back
|
||||
/// whatever the configured provider serves. Swapping CARTO for MapTiler, for a
|
||||
/// keyed CARTO plan, or for self-hosted tiles is a build flag or one entry in
|
||||
/// [MapTileProvider.presets] — no map screen changes.
|
||||
///
|
||||
/// Deliberately free of Flutter imports so the choice of provider is testable
|
||||
/// as plain data. `lib/ui/widgets/map_tiles.dart` turns it into a `TileLayer`.
|
||||
library;
|
||||
|
||||
/// The two cartographies the app uses.
|
||||
enum DmMapStyle {
|
||||
/// Streets, labels and landmarks. The pickup map, where the customer is
|
||||
/// looking for their own door.
|
||||
streets,
|
||||
|
||||
/// Quieter, near-monochrome. The route map, where the line is the subject
|
||||
/// and the basemap is context.
|
||||
muted,
|
||||
}
|
||||
|
||||
/// A raster tile source: its URL templates, its credit line, and its key.
|
||||
class MapTileProvider {
|
||||
const MapTileProvider({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.templates,
|
||||
required this.attribution,
|
||||
this.attributionUrl = osmCopyright,
|
||||
this.subdomains = const <String>[],
|
||||
this.keyParam,
|
||||
this.apiKey = '',
|
||||
this.requiresKey = false,
|
||||
this.maxNativeZoom = 20,
|
||||
this.retina = true,
|
||||
});
|
||||
|
||||
/// Stable id, also what `DM_MAP_PROVIDER` selects.
|
||||
final String id;
|
||||
|
||||
/// Human name, for the "map source" line in diagnostics.
|
||||
final String name;
|
||||
|
||||
/// One URL template per style. `{s}` `{z}` `{x}` `{y}` `{r}` as flutter_map
|
||||
/// expects; the key is appended by [urlFor], never written into a template.
|
||||
final Map<DmMapStyle, String> templates;
|
||||
|
||||
/// The credit line that must stay visible on every map using this source.
|
||||
final String attribution;
|
||||
|
||||
/// Where the attribution links, for the licence.
|
||||
final String attributionUrl;
|
||||
|
||||
/// Tile host shards, when the provider uses them.
|
||||
final List<String> subdomains;
|
||||
|
||||
/// Query parameter the key travels in — `api_key` for CARTO and Stadia,
|
||||
/// `key` for MapTiler. Null when the provider takes no key.
|
||||
final String? keyParam;
|
||||
|
||||
/// The key itself. Supplied at build time, never committed.
|
||||
final String apiKey;
|
||||
|
||||
/// True when the provider serves nothing without a key, so a missing key is
|
||||
/// a misconfiguration rather than a downgrade.
|
||||
final bool requiresKey;
|
||||
|
||||
final int maxNativeZoom;
|
||||
|
||||
/// Whether the provider serves `@2x` tiles for the `{r}` placeholder.
|
||||
final bool retina;
|
||||
|
||||
static const osmCopyright = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
bool get hasKey => apiKey.isNotEmpty;
|
||||
|
||||
/// False when this provider cannot serve a tile as configured.
|
||||
bool get usable => !requiresKey || hasKey;
|
||||
|
||||
/// The template for [style], with the key appended when there is one.
|
||||
String urlFor(DmMapStyle style) {
|
||||
final template = templates[style] ?? templates[DmMapStyle.streets]!;
|
||||
if (!hasKey || keyParam == null) return template;
|
||||
final separator = template.contains('?') ? '&' : '?';
|
||||
return '$template$separator$keyParam=$apiKey';
|
||||
}
|
||||
|
||||
MapTileProvider withKey(String key) => MapTileProvider(
|
||||
id: id,
|
||||
name: name,
|
||||
templates: templates,
|
||||
attribution: attribution,
|
||||
attributionUrl: attributionUrl,
|
||||
subdomains: subdomains,
|
||||
keyParam: keyParam,
|
||||
apiKey: key,
|
||||
requiresKey: requiresKey,
|
||||
maxNativeZoom: maxNativeZoom,
|
||||
retina: retina,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------- presets
|
||||
|
||||
/// CARTO's basemaps over OpenStreetMap data. The development default:
|
||||
/// warm, legible, and usable without an account.
|
||||
///
|
||||
/// Anonymous use is **not** a production contract. CARTO issues API keys for
|
||||
/// application use — set `DM_MAP_KEY` and it travels as `api_key` on every
|
||||
/// tile request, no other change required.
|
||||
static const carto = MapTileProvider(
|
||||
id: 'carto',
|
||||
name: 'CARTO basemaps (OpenStreetMap data)',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© OpenStreetMap contributors · © CARTO',
|
||||
subdomains: ['a', 'b', 'c', 'd'],
|
||||
keyParam: 'api_key',
|
||||
);
|
||||
|
||||
/// OpenStreetMap's own tile servers.
|
||||
///
|
||||
/// Their usage policy does not permit a distributed app to lean on these —
|
||||
/// present for local development and as a last-resort fallback only.
|
||||
static const osm = MapTileProvider(
|
||||
id: 'osm',
|
||||
name: 'OpenStreetMap standard tiles',
|
||||
templates: {
|
||||
DmMapStyle.streets: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
DmMapStyle.muted: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
},
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxNativeZoom: 19,
|
||||
retina: false,
|
||||
);
|
||||
|
||||
/// MapTiler — a commercial provider with an India-usable free tier.
|
||||
static const maptiler = MapTileProvider(
|
||||
id: 'maptiler',
|
||||
name: 'MapTiler',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://api.maptiler.com/maps/dataviz-light/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© MapTiler · © OpenStreetMap contributors',
|
||||
keyParam: 'key',
|
||||
requiresKey: true,
|
||||
);
|
||||
|
||||
/// Stadia Maps, serving OpenStreetMap-derived styles.
|
||||
static const stadia = MapTileProvider(
|
||||
id: 'stadia',
|
||||
name: 'Stadia Maps',
|
||||
templates: {
|
||||
DmMapStyle.streets:
|
||||
'https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png',
|
||||
DmMapStyle.muted:
|
||||
'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png',
|
||||
},
|
||||
attribution: '© Stadia Maps · © OpenStreetMap contributors',
|
||||
keyParam: 'api_key',
|
||||
requiresKey: true,
|
||||
);
|
||||
|
||||
/// Everything that ships with the app, by id.
|
||||
static const Map<String, MapTileProvider> presets = {
|
||||
'carto': carto,
|
||||
'osm': osm,
|
||||
'maptiler': maptiler,
|
||||
'stadia': stadia,
|
||||
};
|
||||
}
|
||||
|
||||
/// The resolved map configuration for this build.
|
||||
///
|
||||
/// Read once at startup from `--dart-define`s; [instance] is assignable so a
|
||||
/// test or a staging build can substitute one without touching a screen.
|
||||
class DmMapConfig {
|
||||
const DmMapConfig({
|
||||
required this.provider,
|
||||
required this.userAgent,
|
||||
this.warning,
|
||||
});
|
||||
|
||||
final MapTileProvider provider;
|
||||
|
||||
/// Sent as the `User-Agent` on every tile request, so the provider can
|
||||
/// identify and rate-limit the app properly rather than seeing raw traffic.
|
||||
final String userAgent;
|
||||
|
||||
/// Set when the requested configuration could not be honoured — surfaced in
|
||||
/// debug diagnostics rather than silently swallowed.
|
||||
final String? warning;
|
||||
|
||||
/// Swappable for tests and staging.
|
||||
static DmMapConfig instance = DmMapConfig.fromEnvironment();
|
||||
|
||||
/// The bundle id, which is also what identifies us to a tile provider.
|
||||
static const packageName = 'in.doormile.customer';
|
||||
|
||||
// ---- build-time configuration -------------------------------------------
|
||||
//
|
||||
// flutter run \
|
||||
// --dart-define=DM_MAP_PROVIDER=maptiler \
|
||||
// --dart-define=DM_MAP_KEY=xxxxxxxx
|
||||
//
|
||||
// flutter build appbundle \
|
||||
// --dart-define=DM_MAP_PROVIDER=carto \
|
||||
// --dart-define=DM_MAP_KEY=$CARTO_API_KEY
|
||||
//
|
||||
// # a fully custom or self-hosted style
|
||||
// flutter run \
|
||||
// --dart-define=DM_MAP_PROVIDER=custom \
|
||||
// --dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \
|
||||
// --dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \
|
||||
// --dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors'
|
||||
|
||||
static const _provider = String.fromEnvironment('DM_MAP_PROVIDER');
|
||||
static const _key = String.fromEnvironment('DM_MAP_KEY');
|
||||
static const _url = String.fromEnvironment('DM_MAP_URL');
|
||||
static const _urlMuted = String.fromEnvironment('DM_MAP_URL_MUTED');
|
||||
static const _subdomains = String.fromEnvironment('DM_MAP_SUBDOMAINS');
|
||||
static const _attribution = String.fromEnvironment('DM_MAP_ATTRIBUTION');
|
||||
static const _attributionUrl = String.fromEnvironment('DM_MAP_ATTRIBUTION_URL');
|
||||
static const _maxZoom = int.fromEnvironment('DM_MAP_MAX_ZOOM', defaultValue: 0);
|
||||
static const _appVersion = String.fromEnvironment(
|
||||
'DM_APP_VERSION',
|
||||
defaultValue: '1.0.0',
|
||||
);
|
||||
|
||||
factory DmMapConfig.fromEnvironment() {
|
||||
final id = _provider.isEmpty ? 'carto' : _provider;
|
||||
String? warning;
|
||||
|
||||
MapTileProvider provider;
|
||||
if (id == 'custom') {
|
||||
if (_url.isEmpty) {
|
||||
provider = MapTileProvider.carto;
|
||||
warning =
|
||||
'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.';
|
||||
} else {
|
||||
provider = MapTileProvider(
|
||||
id: 'custom',
|
||||
name: 'Custom tile source',
|
||||
templates: {
|
||||
DmMapStyle.streets: _url,
|
||||
DmMapStyle.muted: _urlMuted.isEmpty ? _url : _urlMuted,
|
||||
},
|
||||
attribution: _attribution.isEmpty
|
||||
? '© OpenStreetMap contributors'
|
||||
: _attribution,
|
||||
attributionUrl: _attributionUrl.isEmpty
|
||||
? MapTileProvider.osmCopyright
|
||||
: _attributionUrl,
|
||||
subdomains: _subdomains.isEmpty
|
||||
? const []
|
||||
: _subdomains.split(',').map((s) => s.trim()).toList(),
|
||||
maxNativeZoom: _maxZoom == 0 ? 20 : _maxZoom,
|
||||
retina: _url.contains('{r}'),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
provider = MapTileProvider.presets[id] ?? MapTileProvider.carto;
|
||||
if (!MapTileProvider.presets.containsKey(id)) {
|
||||
warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.';
|
||||
}
|
||||
}
|
||||
|
||||
if (_key.isNotEmpty) provider = provider.withKey(_key);
|
||||
|
||||
// A keyed provider with no key would render nothing at all. Better a
|
||||
// downgraded map than a grey rectangle in a customer's hands.
|
||||
if (!provider.usable) {
|
||||
warning =
|
||||
'${provider.name} needs DM_MAP_KEY; fell back to CARTO for this build.';
|
||||
provider = MapTileProvider.carto;
|
||||
if (_key.isNotEmpty) provider = provider.withKey(_key);
|
||||
}
|
||||
|
||||
return DmMapConfig(
|
||||
provider: provider,
|
||||
userAgent: 'Doormile/$_appVersion ($packageName)',
|
||||
warning: warning,
|
||||
);
|
||||
}
|
||||
|
||||
String urlFor(DmMapStyle style) => provider.urlFor(style);
|
||||
|
||||
String get attribution => provider.attribution;
|
||||
String get attributionUrl => provider.attributionUrl;
|
||||
List<String> get subdomains => provider.subdomains;
|
||||
int get maxNativeZoom => provider.maxNativeZoom;
|
||||
bool get retina => provider.retina;
|
||||
|
||||
/// One line for a diagnostics screen or a log at startup.
|
||||
String get describe =>
|
||||
'${provider.name}${provider.hasKey ? ' (keyed)' : ' (anonymous)'}';
|
||||
}
|
||||
1227
lib/data/models.dart
Normal file
1227
lib/data/models.dart
Normal file
File diff suppressed because it is too large
Load Diff
148
lib/data/session_store.dart
Normal file
148
lib/data/session_store.dart
Normal file
@@ -0,0 +1,148 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
/// One signed-in session: the two tokens, when the access token dies, and who
|
||||
/// the customer is.
|
||||
@immutable
|
||||
class Session {
|
||||
const Session({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresAt,
|
||||
this.customer,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
|
||||
/// Absolute expiry, computed from the `expiresIn` the server sent.
|
||||
final DateTime expiresAt;
|
||||
|
||||
final Customer? customer;
|
||||
|
||||
/// Treated as expired a minute early, so a call started just before the
|
||||
/// boundary does not land just after it.
|
||||
bool get isExpired =>
|
||||
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1)));
|
||||
|
||||
Session copyWith({
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
DateTime? expiresAt,
|
||||
Customer? customer,
|
||||
}) => Session(
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
customer: customer ?? this.customer,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'accessToken': accessToken,
|
||||
'refreshToken': refreshToken,
|
||||
'expiresAt': expiresAt.millisecondsSinceEpoch,
|
||||
if (customer != null) 'customer': customer!.toJson(),
|
||||
};
|
||||
|
||||
static Session? fromJson(Map<String, dynamic> json) {
|
||||
final access = json['accessToken'] as String?;
|
||||
final refresh = json['refreshToken'] as String?;
|
||||
final expires = json['expiresAt'];
|
||||
if (access == null || access.isEmpty) return null;
|
||||
if (refresh == null || refresh.isEmpty) return null;
|
||||
return Session(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
expiresAt: expires is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(expires)
|
||||
: DateTime.now(),
|
||||
customer: json['customer'] is Map<String, dynamic>
|
||||
? Customer.fromJson(json['customer'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the session lives between launches.
|
||||
///
|
||||
/// The refresh token is valid for 60 days and **rotates on every use** — a
|
||||
/// replayed one revokes the whole chain server-side. So the two rules this
|
||||
/// class exists to enforce are: persist the newest token the moment it arrives,
|
||||
/// and never hand out a half-written session.
|
||||
abstract class SessionStore {
|
||||
Future<Session?> read();
|
||||
Future<void> write(Session session);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
/// Keychain on iOS, EncryptedSharedPreferences on Android.
|
||||
class SecureSessionStore implements SessionStore {
|
||||
SecureSessionStore({FlutterSecureStorage? storage})
|
||||
// Keychain on iOS by default; Android needs asking, or it falls back to a
|
||||
// plaintext preferences file — which is the one place a 60-day refresh
|
||||
// token must never sit.
|
||||
: _storage =
|
||||
storage ??
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const String _key = 'dm_cx_session_v1';
|
||||
|
||||
@override
|
||||
Future<Session?> read() async {
|
||||
try {
|
||||
final raw = await _storage.read(key: _key);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
return Session.fromJson(decoded);
|
||||
} catch (e) {
|
||||
// A session we cannot read is a session we do not have. Signing the
|
||||
// customer out is recoverable; crashing at launch is not.
|
||||
debugPrint('[SESSION] unreadable, treating as signed out: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(Session session) async {
|
||||
try {
|
||||
await _storage.write(key: _key, value: jsonEncode(session.toJson()));
|
||||
} catch (e) {
|
||||
// Losing persistence costs the customer a re-login next launch. It must
|
||||
// not cost them the sign-in they just completed.
|
||||
debugPrint('[SESSION] could not persist: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
try {
|
||||
await _storage.delete(key: _key);
|
||||
} catch (e) {
|
||||
debugPrint('[SESSION] could not clear: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory store. Tests only — a session that does not survive the process
|
||||
/// is not a session.
|
||||
class MemorySessionStore implements SessionStore {
|
||||
Session? _session;
|
||||
|
||||
@override
|
||||
Future<Session?> read() async => _session;
|
||||
|
||||
@override
|
||||
Future<void> write(Session session) async => _session = session;
|
||||
|
||||
@override
|
||||
Future<void> clear() async => _session = null;
|
||||
}
|
||||
Reference in New Issue
Block a user