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:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

476
lib/data/api_client.dart Normal file
View 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
View 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
View 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)' : ''}';
}

View 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
View 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 {}
}

View 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');
}
}

View 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
View 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

File diff suppressed because it is too large Load Diff

148
lib/data/session_store.dart Normal file
View 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;
}