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

24
lib/state/app_scope.dart Normal file
View File

@@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import 'app_state.dart';
/// Makes [AppState] available to the widget tree and rebuilds dependents when
/// it changes. Deliberately dependency-free — no external state package.
class AppScope extends InheritedNotifier<AppState> {
const AppScope({super.key, required AppState state, required super.child})
: super(notifier: state);
/// Subscribes the calling widget to changes.
static AppState of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope.of() called outside an AppScope');
return scope!.notifier!;
}
/// Reads without subscribing — for callbacks and one-shot lookups.
static AppState read(BuildContext context) {
final scope = context.getInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope.read() called outside an AppScope');
return scope!.notifier!;
}
}

728
lib/state/app_state.dart Normal file
View File

@@ -0,0 +1,728 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../data/app_config.dart';
import '../data/doormile_api.dart';
import '../data/location_service.dart';
import '../data/models.dart';
/// Single source of truth for the customer app.
///
/// Screens read from here and call these methods; nothing mutates a [Booking]
/// directly.
///
/// **Every booking on screen came from the server.** This class has no way to
/// invent one and no way to move one along: a stage changes when the backend
/// says it changed and [refreshBooking] reads it back.
class AppState extends ChangeNotifier {
AppState({DoormileApi? api, LocationService? location})
: api = api ?? DoormileApi.instance,
location = location ?? LocationService.instance {
this.api.onSessionLost = onSessionLost;
loadLimits();
}
final DoormileApi api;
final LocationService location;
// ------------------------------------------------------------- session
Customer? customer;
/// **The authoritative pickup point.** Display address *and* the coordinates
/// the Miler is sent to, in one object.
///
/// All three ways of choosing a pickup — the device fix, a map drag, and a
/// search result — write this single field, so nothing downstream has to
/// reconcile "the address" against "the pin".
Place? pickup;
/// True once we have both an address and coordinates to send a Miler to.
/// The booking step gates on this rather than on the address alone.
bool get hasPickupPoint => pickup?.hasLocation ?? false;
/// True while a pin is being turned into an address. The pickup card shows a
/// placeholder rather than a stale street name.
bool resolvingPickup = false;
/// Why we could not read the device location, if we could not. Null when the
/// pin came from GPS or from the customer.
LocationDenial? locationDenial;
/// Where the **customer** is, as opposed to where the pickup pin is.
///
/// Kept separate on purpose: the pin moves when they drag the map, this does
/// not. It is what the blue dot renders at, and it survives a drag so they
/// can always see how far they have moved the pin from themselves.
double? deviceLat;
double? deviceLng;
/// Horizontal accuracy in metres, when the platform reported it — the halo
/// around the dot.
double? deviceAccuracy;
bool get hasDeviceLocation => deviceLat != null && deviceLng != null;
/// Guards against an earlier reverse-geocode landing after a later drag.
int _pinSeq = 0;
/// Where the map should open: the current pin, else the service-area default.
double get mapLat => pickup?.lat ?? LocationService.fallbackLat;
double get mapLng => pickup?.lng ?? LocationService.fallbackLng;
bool get isSignedIn => customer != null;
/// True until the stored session has been looked for. The app shows its
/// launch state rather than the sign-in screen while this is set, so a
/// returning customer never sees a login form flash past.
bool restoringSession = true;
void signIn(Customer c) {
customer = c;
notifyListeners();
unawaited(refreshOrders());
}
/// Looks for a persisted session at launch.
///
/// Never throws: being offline at launch is not being signed out, and the
/// stored customer stands until a call actually comes back 401.
Future<void> restoreSession() async {
try {
final restored = await api.restoreSession();
if (restored != null) {
customer = restored;
unawaited(refreshOrders());
} else if (AppConfig.hasAutoLogin) {
await _autoSignIn();
} else if (AppConfig.devAutoLogin) {
// Dev builds only, and only when asked with DM_DEV_LOGIN: open straight
// into the app without the login screen. Guarded in [AppConfig] so no
// define can do this in a release. Without the flag, a dev build still
// lands on login, where any 4-digit code signs in.
debugPrint('[AUTH] DM_DEV_LOGIN — opening without the login screen');
signIn(
const Customer(
id: 'cust_dev',
name: 'Dev Customer',
phone: '+91 98765 43210',
email: 'dev@doormile.com',
),
);
}
} catch (e) {
// Same rule as the limits call: this decides which screen opens, so it
// fails to "signed out", never to a crash.
debugPrint('[SESSION] restore failed: $e');
} finally {
restoringSession = false;
notifyListeners();
}
}
/// Signs in for real, without the login screen — `DM_LOGIN_AS`.
///
/// This is a genuine sign-in, not a bypass: it runs the same two calls the
/// login and code screens run, and what it ends up holding is a token the
/// server issued. That is the whole point of it existing next to
/// [AppConfig.useDevData] — a build that skips the entrance still has to end
/// up as a real customer, or every booking it makes is a fake one.
///
/// Failure is not fatal and never throws: the app simply lands on the login
/// screen, which is exactly where someone whose code was refused should be.
Future<void> _autoSignIn() async {
final identifier = AppConfig.autoLoginIdentifier;
try {
// The request may come back `sent: false` on the resend cooldown; that
// is fine, the code already issued is still the one Redis is holding.
await api.sendOtp(identifier);
final verified = await api.verifyOtp(identifier, AppConfig.autoLoginCode);
customer = verified;
unawaited(refreshOrders());
debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier');
} catch (e) {
debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e');
}
}
/// Called by the API layer when a refresh fails — the chain is dead, so the
/// customer is signed out wherever they happen to be standing.
void onSessionLost() {
if (customer == null) return;
debugPrint('[SESSION] lost, signing out');
signOut();
}
Future<void> signOut() async {
customer = null;
orders.clear();
pickup = null;
deviceLat = null;
deviceLng = null;
deviceAccuracy = null;
locationDenial = null;
ordersCursor = null;
clearCaches();
notifyListeners();
try {
await api.signOut();
} on ApiException catch (e) {
// Local sign-out has already happened and must stand.
debugPrint('[SESSION] sign-out call failed: $e');
}
}
/// Reads the device location and turns it into an address.
///
/// Never throws and never leaves the customer stuck: a refused permission
/// falls back to the service-area centre with [locationDenial] set, so the
/// screen can explain it and offer search instead.
Future<void> detectPickupLocation() async {
final seq = ++_pinSeq;
resolvingPickup = true;
notifyListeners();
final fix = await location.current();
if (seq != _pinSeq) return;
locationDenial = fix.denial;
if (fix.hasFix) {
deviceLat = fix.lat;
deviceLng = fix.lng;
deviceAccuracy = fix.accuracy;
}
final lat = fix.lat ?? pickup?.lat ?? LocationService.fallbackLat;
final lng = fix.lng ?? pickup?.lng ?? LocationService.fallbackLng;
await _resolvePin(lat, lng, seq);
}
/// Called when the map settles under the pin. The newest drag always wins.
Future<void> setPickupFromPin(double lat, double lng) async {
final seq = ++_pinSeq;
resolvingPickup = true;
notifyListeners();
await _resolvePin(lat, lng, seq);
}
Future<void> _resolvePin(double lat, double lng, int seq) async {
Place resolved;
try {
resolved = await api.reverseGeocode(lat: lat, lng: lng);
} on ApiException {
resolved = Place(
title: 'Pinned location',
sub: 'Address unavailable — the Miler will confirm it at pickup',
lat: lat,
lng: lng,
);
}
if (seq != _pinSeq) return;
pickup = resolved;
resolvingPickup = false;
notifyListeners();
}
/// Adopts a place chosen from search. Cancels any in-flight pin resolution,
/// so the newest customer intent always wins — same rule as a drag.
void setPickup(Place place) {
_pinSeq++;
// A search result without coordinates cannot be a pin. The backend
// contract requires them (§6.2); if one arrives without, keep the address
// and hold the previous pin rather than dropping the customer somewhere
// else on the map.
assert(
place.hasLocation,
'searchPlaces returned a Place with no coordinates: ${place.title}',
);
pickup = place.hasLocation
? place
: Place(
title: place.title,
sub: place.sub,
lat: pickup?.lat,
lng: pickup?.lng,
);
resolvingPickup = false;
locationDenial = null;
notifyListeners();
}
/// Opens OS settings after a permanent refusal.
Future<void> openLocationSettings() => location.openSettings();
// ------------------------------------------------- service delegation
//
// Screens call these, never `DoormileApi.instance`. That keeps the injected
// instance authoritative and means no widget can tell whether an answer came
// from the backend.
Future<OtpChallenge> sendOtp(String identifier) => api.sendOtp(identifier);
Future<OtpChallenge> signUp({
required String name,
required String phone,
String? email,
}) => api.signUp(name: name, phone: phone, email: email);
/// Verifies the code and adopts the session in one step, so no screen has to
/// remember to call [signIn] afterwards.
Future<Customer> verifyOtp(
String identifier,
String code, {
String? name,
}) async {
final verified = await api.verifyOtp(identifier, code, name: name);
signIn(verified);
return verified;
}
/// Biased towards the pickup point the customer is working with, so a search
/// for a street name answers with the one in their city first.
Future<List<Place>> searchPlaces(String query) {
final near = draftPickup ?? pickup;
return api.searchPlaces(query, lat: near?.lat, lng: near?.lng);
}
/// Backend policy on cancellation; the UI only asks.
bool isCancellable(JourneyStage stage) => api.isCancellable(stage);
/// The window a booking was placed in.
///
/// Served from whatever [loadSlots] last returned, which is why the index is
/// never cleared: a booking made an hour ago must still be able to name its
/// window after the slot list has rolled over.
PickupSlot? slotById(String? id) =>
id == null ? null : _slotIndex[id] ?? api.slotById(id);
final Map<String, PickupSlot> _slotIndex = {};
// --------------------------------------------------------------- limits
/// Caps on one pickup. Backend-owned; the UI never hardcodes them.
BookingLimits limits = const BookingLimits();
/// Caps are allowed to vary by city, so the pickup point goes with the ask.
Future<void> loadLimits() async {
try {
limits = await api.getBookingLimits(pickup: pickup);
notifyListeners();
} catch (e) {
// The safe cap is already in place; a failed fetch must not block
// booking. Catching everything, not just ApiException: this runs from
// the constructor, and a malformed response is not worth a dead app.
debugPrint('[LIMITS] keeping defaults: $e');
}
}
// -------------------------------------------------------- booking draft
/// The in-progress pickup. One destination is the common case; the customer
/// can add more, all collected in the same visit.
Place? draftPickup;
List<DestinationGroup> draftDestinations = [DestinationGroup()];
String? draftSlotId;
FareEstimate? draftFare;
int get draftTotalPackages =>
draftDestinations.fold(0, (sum, d) => sum + d.packageCount);
bool get canAddDestination =>
draftDestinations.length < limits.maxDestinations &&
draftTotalPackages < limits.maxPackages &&
draftDestinations.every((d) => d.isComplete);
bool canAddPackageTo(int index) => draftTotalPackages < limits.maxPackages;
/// Every destination has a serviceable state and district.
bool get draftReady =>
draftDestinations.isNotEmpty &&
draftDestinations.every((d) => d.isComplete);
/// One key per booking *intent*, held across every retry of it.
///
/// A retried `POST /customer/bookings` carrying the same key replays the
/// original response instead of creating a second pickup; a new key is a new
/// booking. So it is minted once here, when the customer starts filling the
/// form, and cleared only when a booking actually lands.
String? _bookingIdempotencyKey;
void startBooking() {
draftPickup = pickup;
draftDestinations = [DestinationGroup()];
draftSlotId = null;
draftFare = null;
districtCache.clear();
// Slots carry their own date. A session left open across midnight would
// otherwise offer yesterday's windows, and booking one is refused — so the
// list is re-read for every booking rather than kept for the session.
_invalidateSlots();
_bookingIdempotencyKey = null;
notifyListeners();
}
/// Whether "Send to another place" is offered at all.
///
/// Multi-destination is server-gated while the Miler app still keys its work
/// on a booking-level id: a fanned-out pickup would give the rider one stop
/// for three parcels. The backend advertises the cap, the client obeys it,
/// and a cap of 1 hides the control rather than showing a dead one.
bool get allowsMultipleDestinations => limits.allowsMultipleDestinations;
void selectStateFor(int index, ServiceArea area) {
draftDestinations[index].destination
..stateCode = area.code
..stateName = area.name
..districtCode = null
..districtName = null;
draftDestinations[index].district = null;
draftFare = null;
notifyListeners();
}
void selectDistrictFor(int index, District district) {
draftDestinations[index]
..destination.districtCode = district.code
..destination.districtName = district.name
..district = district;
notifyListeners();
_loadFare();
}
void setPackageCount(int index, int count) {
final group = draftDestinations[index];
final others = draftTotalPackages - group.packageCount;
group.packageCount = count.clamp(1, limits.maxPackages - others);
notifyListeners();
_loadFare();
}
/// Returns false when the caps say no, so the caller can explain why.
bool addDestination() {
if (!canAddDestination) return false;
draftDestinations.add(DestinationGroup());
notifyListeners();
return true;
}
void removeDestination(int index) {
if (draftDestinations.length <= 1) return;
draftDestinations.removeAt(index);
notifyListeners();
_loadFare();
}
/// Republish after something mutated the draft in place (the details sheet).
void touch() => notifyListeners();
void selectSlot(PickupSlot slot) {
draftSlotId = slot.id;
notifyListeners();
}
void setDraftPin(int index, MapPin? pin) {
draftDestinations[index].details.pin = pin;
notifyListeners();
}
/// Priced as soon as the route is known, so Review has it ready.
Future<void> _loadFare() async {
final from = draftPickup ?? pickup;
if (from == null || !draftReady) return;
try {
draftFare = await api.estimateFare(
pickup: from,
destinations: draftDestinations,
);
notifyListeners();
} on ApiException {
// A missing estimate is not worth blocking the booking over.
}
}
Future<Booking> confirmBooking() async {
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
try {
final booking = await api.createBooking(
pickup: draftPickup ?? pickup!,
destinations: draftDestinations,
slotId: draftSlotId,
fare: draftFare,
idempotencyKey: key,
);
// The intent is spent. A further booking needs a new key or the server
// would replay this one.
_bookingIdempotencyKey = null;
orders.insert(0, booking);
activeReference = booking.reference;
notifyListeners();
return booking;
} on ApiException catch (e) {
// The slot went stale — filled up, or its window passed while the form
// was open. Both recover by re-reading the list, so drop the cached one
// here and let the screen send them back to slot selection.
if (e.needsFreshSlots) {
_invalidateSlots();
draftSlotId = null;
notifyListeners();
}
rethrow;
}
}
String _newIdempotencyKey() {
final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
final salt = Object().hashCode.toRadixString(36);
return '$now-$salt';
}
// -------------------------------------------------------------- orders
final List<Booking> orders = [];
String? activeReference;
BookingStatus ordersTab = BookingStatus.active;
Booking? byReference(String? reference) {
if (reference == null) return null;
for (final o in orders) {
if (o.reference == reference) return o;
}
return null;
}
Booking? get trackedBooking => byReference(activeReference);
List<Booking> get activeBookings =>
orders.where((o) => o.status == BookingStatus.active).toList();
List<Booking> ofStatus(BookingStatus status) =>
orders.where((o) => o.status == status).toList();
/// Orders as the customer owns them: one row per pickup before collection,
/// one row per destination once the backend has created those orders.
List<OrderEntry> entriesOf(BookingStatus status) => [
for (final booking in ofStatus(status))
if (!booking.hasOrders)
OrderEntry(booking)
else
for (var i = 0; i < booking.destinations.length; i++)
OrderEntry(booking, i),
];
List<OrderEntry> get recentEntries => [
for (final booking in orders.where((o) => o.status != BookingStatus.active))
if (!booking.hasOrders)
OrderEntry(booking)
else
for (var i = 0; i < booking.destinations.length; i++)
OrderEntry(booking, i),
].take(3).toList();
/// Everything no longer running, newest first — Home's "Recent".
List<Booking> get recent =>
orders.where((o) => o.status != BookingStatus.active).take(3).toList();
/// Keyset cursor for the next page of orders; null when the last page has
/// been read or none has been read yet.
String? ordersCursor;
/// True while [loadMoreOrders] is in flight, so a scroll cannot start two.
bool loadingMoreOrders = false;
bool get hasMoreOrders => ordersCursor != null && ordersCursor!.isNotEmpty;
/// Pull-to-refresh on Home and Orders — reads the first page again.
///
/// The list is the server's: whatever comes back replaces what is on screen,
/// including an empty page, which is a real answer and not a failure.
Future<void> refreshOrders() async {
try {
final page = await api.getBookingPage();
// An empty first page is a real answer: this customer has no bookings.
// The list is the server's, so it is adopted whole rather than merged.
orders
..clear()
..addAll(page.bookings);
ordersCursor = page.nextCursor;
} on ApiException {
// A failed refresh leaves the last good list on screen.
}
notifyListeners();
}
/// Appends the next keyset page. Keyset rather than offset, so a booking
/// arriving at the top mid-scroll cannot push a row into a second page and
/// show it twice.
Future<void> loadMoreOrders() async {
if (!hasMoreOrders || loadingMoreOrders) return;
loadingMoreOrders = true;
notifyListeners();
try {
final page = await api.getBookingPage(cursor: ordersCursor);
final seen = orders.map((o) => o.reference).toSet();
orders.addAll(page.bookings.where((b) => !seen.contains(b.reference)));
ordersCursor = page.nextCursor;
} on ApiException {
// Keep the rows already on screen; the cursor stays put so the next
// scroll retries the same page rather than skipping it.
} finally {
loadingMoreOrders = false;
notifyListeners();
}
}
void openTracking(String reference) {
activeReference = reference;
notifyListeners();
unawaited(refreshBooking(reference));
}
/// Re-reads one booking. Null means the server answered `304` — nothing
/// moved — so the copy already on screen stands.
Future<void> refreshBooking(String reference) async {
try {
final fresh = await api.getBooking(reference);
if (fresh == null) return;
final at = orders.indexWhere((o) => o.reference == reference);
if (at == -1) {
orders.insert(0, fresh);
} else {
orders[at] = fresh;
}
notifyListeners();
} on ApiException {
// Keep showing what we have.
}
}
void setOrdersTab(BookingStatus status) {
ordersTab = status;
notifyListeners();
}
/// Sends the optional address and recipient for an existing booking.
///
/// Accepted until the parcels are collected; after that the server answers
/// `409` and the Miler's record at the door is the one that counts.
Future<void> saveDestinationDetails(Booking booking, int index) async {
if (index < 0 || index >= booking.destinations.length) return;
final group = booking.destinations[index];
await api.updateDestinationDetails(
booking.reference,
// The server's own position for this stop, which is what the route
// addresses. The list position is only a fallback for a payload that
// did not send one.
group.index ?? index,
group.details,
);
notifyListeners();
}
// ---------------------------------------------------------------- push
/// Registers this device for milestone notifications.
///
/// Nothing calls this yet: no push SDK is wired in, so the app has no token
/// to hand over. The endpoint is reached through here so that adding one is
/// a two-line change rather than a new path through the state layer.
Future<void> registerPushToken(String token) async {
try {
await api.registerDevice(token);
} on ApiException catch (e) {
debugPrint('[PUSH] could not register device: $e');
}
}
Future<void> unregisterPushToken(String token) async {
try {
await api.unregisterDevice(token);
} on ApiException catch (e) {
debugPrint('[PUSH] could not unregister device: $e');
}
}
// ------------------------------------------------- stage transitions
/// What the tracking screen's QA stepper calls.
///
/// It asks the server's own helper to move the booking, then re-reads it —
/// so what appears on screen is the stage the **server** recorded, never one
/// the client applied to its own copy.
Future<void> advanceStage(Booking booking, JourneyStage target) async {
try {
await api.setStage(booking.reference, target);
await refreshBooking(booking.reference);
} on ApiException catch (e) {
debugPrint('[OPS] stage override refused: $e');
}
}
Future<void> cancelBooking(Booking booking, String? reason) async {
await api.cancelBooking(booking.reference, reason);
booking
..status = BookingStatus.cancelled
..cancelReason = reason
..cancellable = false;
notifyListeners();
}
// ------------------------------------------------- serviceability cache
List<ServiceArea>? statesCache;
final Map<String, List<District>> districtCache = {};
List<PickupSlot>? slotsCache;
/// Only states we can actually serve reach the picker. A state with nothing
/// open is not an option the customer should have to read past.
Future<List<ServiceArea>> loadStates({bool refresh = false}) async {
if (refresh) statesCache = null;
final all = statesCache ??= await api.getServiceableStates();
return all.where((s) => s.hasOpenDistricts).toList();
}
/// Districts the picker offers: serviceable ones only. The full response is
/// cached so [upcomingDistricts] can name what is opening without putting
/// disabled tiles in front of anyone.
Future<List<District>> loadDistricts(
String stateCode, {
bool refresh = false,
}) async {
if (refresh) districtCache.remove(stateCode);
final all = districtCache[stateCode] ??= await api.getServiceableDistricts(
stateCode,
);
return all.where((d) => d.available).toList();
}
/// Names of districts in this state that are not open yet, for a quiet
/// "Coming soon" line. Empty until the districts have been fetched.
List<String> upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const [])
.where((d) => !d.available)
.map((d) => d.name)
.toList();
/// Bumped whenever [slotsCache] is dropped, so a slot list already on screen
/// re-reads instead of showing what it fetched the first time.
int slotsEpoch = 0;
void _invalidateSlots() {
slotsCache = null;
slotsEpoch++;
}
Future<List<PickupSlot>> loadSlots({bool refresh = false}) async {
if (refresh) slotsCache = null;
final slots = slotsCache ??= await api.getPickupSlots(pickup: draftPickup);
for (final slot in slots) {
_slotIndex[slot.id] = slot;
}
return slots;
}
void clearCaches() {
statesCache = null;
districtCache.clear();
_invalidateSlots();
}
}