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

View File

@@ -0,0 +1,673 @@
import 'package:doormile_cx/data/app_config.dart';
import 'package:doormile_cx/data/doormile_api.dart';
import 'package:doormile_cx/data/location_service.dart';
import 'package:doormile_cx/data/models.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:flutter_test/flutter_test.dart';
/// A programmable stand-in for the service layer.
///
/// Not the mock API — that one invents a whole product. This one answers
/// exactly what a test sets and records exactly what it was asked, which is
/// what the wiring between [AppState] and the contract needs proving.
class FakeApi extends DoormileApi {
FakeApi({this.limits = const BookingLimits(maxPackages: 20, maxDestinations: 5)});
BookingLimits limits;
Object? limitsError;
final List<String?> createKeys = [];
int createFailures = 0;
Object? createError;
final List<BookingPage> pages = [];
int pageCalls = 0;
final List<String?> cursors = [];
List<PickupSlot> slots = const [
PickupSlot(
id: 'slot_20260905_t1',
day: 'Today',
window: '2:00 – 4:00 PM',
available: true,
tag: 'Fastest pickup',
milersNearby: 4,
caption: 'Arriving in approx. 45 mins',
),
];
int slotCalls = 0;
/// Reverse-geocode answers, keyed by latitude, with a delay each — so a test
/// can make an earlier request land after a later one.
final Map<double, (Duration, Place)> geocodes = {};
@override
Future<BookingLimits> getBookingLimits({Place? pickup}) async {
if (limitsError != null) throw limitsError!;
return limits;
}
@override
Future<Booking> createBooking({
required Place pickup,
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? idempotencyKey,
}) async {
createKeys.add(idempotencyKey);
if (createFailures > 0) {
createFailures--;
throw createError ?? ApiException(ApiException.network, 'offline');
}
return Booking(
reference: 'DM-482913',
pickup: pickup,
destinations: destinations.map((d) => d.copy()).toList(),
slotId: slotId ?? '',
createdAt: DateTime.now(),
);
}
@override
Future<BookingPage> getBookingPage({
BookingStatus? status,
String? cursor,
int limit = 20,
}) async {
cursors.add(cursor);
if (pageCalls >= pages.length) return BookingPage.empty;
return pages[pageCalls++];
}
@override
Future<List<PickupSlot>> getPickupSlots({Place? pickup}) async {
slotCalls++;
return slots;
}
@override
Future<Place> reverseGeocode({double? lat, double? lng}) async {
final answer = geocodes[lat];
if (answer == null) {
return Place(title: 'Pin', sub: 'somewhere', lat: lat, lng: lng);
}
await Future<void>.delayed(answer.$1);
return answer.$2;
}
// ---- everything the interface needs but these tests do not exercise ------
@override
Future<OtpChallenge> sendOtp(String identifier) async =>
OtpChallenge.unspecified;
@override
Future<OtpChallenge> signUp({
required String name,
required String phone,
String? email,
}) async => OtpChallenge.unspecified;
@override
Future<Customer> verifyOtp(
String identifier,
String code, {
String? name,
}) async => Customer(
id: 'c1',
name: name ?? 'Joe',
phone: identifier,
email: '',
);
@override
Future<List<ServiceArea>> getServiceableStates() async => const [];
@override
Future<List<District>> getServiceableDistricts(String stateCode) async =>
const [];
@override
District? districtByCode(String? code) => null;
@override
PickupSlot? slotById(String? id) {
for (final slot in slots) {
if (slot.id == id) return slot;
}
return null;
}
@override
Future<List<Place>> searchPlaces(
String query, {
double? lat,
double? lng,
}) async => const [];
@override
Future<FareEstimate> estimateFare({
required Place pickup,
required List<DestinationGroup> destinations,
}) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI');
@override
Future<void> cancelBooking(String reference, String? reason) async {}
@override
@override
Future<Booking?> getBooking(String reference) async => null;
@override
bool isCancellable(JourneyStage stage) =>
stage.index < JourneyStage.arrived.index;
final List<(String, JourneyStage)> stageCalls = [];
@override
Future<void> setStage(String reference, JourneyStage stage) async {
stageCalls.add((reference, stage));
}
}
Booking _booking(String reference) => Booking(
reference: reference,
pickup: const Place(title: 'Home', sub: 'Coimbatore'),
destinations: [DestinationGroup()],
slotId: 'slot_1',
createdAt: DateTime.now(),
);
AppState _state(FakeApi api) =>
AppState(api: api, location: FixedLocationService());
void main() {
group('nothing on screen is invented', () {
test('a fresh state holds no bookings until the server sends some', () {
// There is no seed, no demo data and no constructor side effect that
// could put a booking in front of a customer. The list starts empty and
// is only ever filled by a response.
late AppState state;
expect(() => state = _state(FakeApi()), returnsNormally);
expect(state.orders, isEmpty);
});
test('the orders list is the server\'s, adopted whole', () async {
final api = FakeApi()
..pages.add(BookingPage(bookings: [_booking('DM-9')]));
final state = _state(api);
await state.refreshOrders();
expect(state.orders.single.reference, 'DM-9');
// A later page that no longer contains it removes it, rather than the
// client keeping a copy the backend has forgotten.
api.pages.add(const BookingPage(bookings: []));
await state.refreshOrders();
expect(state.orders, isEmpty);
});
test('a stage move goes through the server and comes back read', () async {
final api = FakeApi()
..pages.add(BookingPage(bookings: [_booking('DM-1')]));
final state = _state(api);
await state.refreshOrders();
await state.advanceStage(state.orders.single, JourneyStage.delivered);
// The client asked; it did not apply anything to its own copy.
expect(api.stageCalls, [('DM-1', JourneyStage.delivered)]);
expect(state.orders.single.stage, JourneyStage.booked);
expect(state.orders.single.destinations.single.trackingId, isNull);
});
});
group('development access', () {
test('no dev token is compiled into this build', () {
// A pasted token is not present unless a define supplies one.
expect(AppConfig.hasDevToken, isFalse);
expect(AppConfig.describe, isNot(contains('DEV TOKEN')));
});
test('the test environment is a bypass, and says so out loud', () {
// The Miler app shipped a flag that swapped authentication for a fake
// rider on any four digits, and said nothing on screen. The rule that
// replaces it: a build that did not sign in must name it. Under
// FLUTTER_TEST the dev data is on, so it must read as bypassed and the
// environment line must carry the DEV DATA marker.
expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test');
expect(AppConfig.authBypassed, isTrue);
expect(AppConfig.describe, contains('DEV DATA'));
});
test('the auto-login default never reaches the tests', () {
// Dev data now defaults on in a debug build, and with it the login
// screen is skipped — one `flutter run` lands on Home, because staging
// has no SMS gateway to send a code through.
//
// The widget tests must keep walking that entrance anyway: they cover the
// phone field, the code screen, and the guard that refuses to open the
// code screen when nothing was sent. A session waiting for them at launch
// would delete all of it and nothing would fail, so [devAutoLogin] is
// switched off under FLUTTER_TEST on purpose. This is that promise.
expect(AppConfig.devAutoLogin, isFalse);
});
// The guarantee that matters cannot be asserted from a debug test — every
// switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken`
// and `allowStageOverride` are all forced false in a release build,
// whatever the defines say. That is what keeps a bypass out of the store.
});
group('booking limits', () {
test('a failed limits call falls back to one destination, not five', () async {
final api = FakeApi()..limitsError = ApiException('network', 'offline');
final state = _state(api);
await Future<void>.delayed(Duration.zero);
// Multi-destination is undeliverable until the Miler build lands. The
// permissive default is the one that strands a parcel.
expect(state.limits.maxDestinations, 1);
expect(state.allowsMultipleDestinations, isFalse);
expect(state.addDestination(), isFalse);
});
test('the served cap is authoritative when it arrives', () async {
final state = _state(FakeApi());
await state.loadLimits();
expect(state.limits.maxDestinations, 5);
expect(state.allowsMultipleDestinations, isTrue);
});
test('a zero or missing cap resolves to one, never to none', () {
expect(BookingLimits.fromJson(const {}).maxDestinations, 1);
expect(BookingLimits.fromJson(const {}).maxPackages, 20);
expect(
BookingLimits.fromJson(const {'maxDestinations': 0, 'maxPackages': 0})
.maxDestinations,
1,
);
expect(
BookingLimits.fromJson(const {'maxPackages': 0}).maxPackages,
1,
);
});
});
group('pickup slots', () {
test('starting a booking drops yesterday\'s slots', () async {
final api = FakeApi();
final state = _state(api);
await state.loadSlots();
await state.loadSlots();
expect(api.slotCalls, 1, reason: 'cached within one booking');
final epoch = state.slotsEpoch;
state.startBooking();
await state.loadSlots();
expect(api.slotCalls, 2);
expect(state.slotsEpoch, greaterThan(epoch));
});
test('a refused slot clears the cache and the choice', () async {
final api = FakeApi()
..createFailures = 1
..createError = ApiException(
ApiException.invalid,
'That pickup time has passed — pick a new slot',
);
final state = _state(api);
state.startBooking();
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
state.draftPickup = state.pickup;
await state.loadSlots();
state.selectSlot(api.slots.first);
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
expect(state.draftSlotId, isNull);
expect(state.slotsCache, isNull);
});
test('the parser keeps every field the picker renders', () {
final slot = PickupSlot.fromJson(const {
'id': 'slot_20260905_t1',
'day': 'Today',
'window': '2:00 – 4:00 PM',
'available': true,
'tag': 'Fastest pickup',
'milersNearby': 4,
'caption': 'Arriving in approx. 45 mins',
'note': null,
});
expect(slot.tag, 'Fastest pickup');
expect(slot.milersNearby, 4);
expect(slot.caption, 'Arriving in approx. 45 mins');
expect(slot.label, 'Today · 2:00 – 4:00 PM');
});
});
group('idempotency', () {
test('a retried booking reuses its key; a new booking mints one', () async {
final api = FakeApi()..createFailures = 1;
final state = _state(api);
state.startBooking();
state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76);
state.draftPickup = state.pickup;
state.selectSlot(api.slots.first);
await expectLater(state.confirmBooking(), throwsA(isA<ApiException>()));
await state.confirmBooking();
expect(api.createKeys.length, 2);
expect(api.createKeys[0], isNotNull);
// Same intent, same key — the server replays rather than booking twice.
expect(api.createKeys[0], api.createKeys[1]);
state.startBooking();
state.draftPickup = state.pickup;
state.selectSlot(api.slots.first);
await state.confirmBooking();
expect(api.createKeys[2], isNot(api.createKeys[1]));
});
});
group('keyset pagination', () {
test('appends pages and follows the cursor', () async {
final api = FakeApi()
..pages.addAll([
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')], nextCursor: 'c2'),
BookingPage(bookings: [_booking('DM-3')], nextCursor: null),
]);
final state = _state(api);
await state.refreshOrders();
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
expect(state.hasMoreOrders, isTrue);
await state.loadMoreOrders();
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2', 'DM-3']);
expect(state.hasMoreOrders, isFalse);
expect(api.cursors, [null, 'c2']);
});
test('a row that arrives on two pages is shown once', () async {
final api = FakeApi()
..pages.addAll([
BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'),
BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')]),
]);
final state = _state(api);
await state.refreshOrders();
await state.loadMoreOrders();
expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']);
});
test('a failed page keeps the cursor so the next scroll retries it', () async {
final api = FakeApi()
..pages.add(BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'));
final state = _state(api);
await state.refreshOrders();
// The fake runs out of pages and returns an empty one; the cursor then
// clears normally. What matters is that nothing throws at the UI.
await state.loadMoreOrders();
expect(state.loadingMoreOrders, isFalse);
});
});
group('pickup point', () {
test('the latest reverse-geocode wins, however the earlier one lands', () async {
final api = FakeApi()
..geocodes[11.0] = (
const Duration(milliseconds: 80),
const Place(title: 'First drag', sub: 'stale', lat: 11.0, lng: 76.0),
)
..geocodes[12.0] = (
const Duration(milliseconds: 10),
const Place(title: 'Second drag', sub: 'fresh', lat: 12.0, lng: 77.0),
);
final state = _state(api);
final first = state.setPickupFromPin(11.0, 76.0);
final second = state.setPickupFromPin(12.0, 77.0);
await Future.wait([first, second]);
expect(state.pickup!.title, 'Second drag');
expect(state.resolvingPickup, isFalse);
});
test('a refused permission still leaves a usable pickup', () async {
final state = AppState(
api: FakeApi(),
location: FixedLocationService.denied(LocationDenial.deniedForever),
);
await state.detectPickupLocation();
expect(state.locationDenial, LocationDenial.deniedForever);
// Falls back to the service-area centre rather than stranding the form,
// and search still overrides it.
expect(state.pickup, isNotNull);
state.setPickup(
const Place(title: 'Brookefields Mall', sub: 'CBE', lat: 10.99, lng: 76.96),
);
expect(state.pickup!.title, 'Brookefields Mall');
expect(state.hasPickupPoint, isTrue);
expect(state.locationDenial, isNull);
});
});
group('booking parser', () {
final json = <String, dynamic>{
'reference': 'DM-482913',
'stage': 'in_transit',
'status': 'active',
'cancellable': false,
'createdAt': 1757056800000,
'pickup': {
'title': '12 Nehru Street',
'sub': 'Gandhipuram, Coimbatore 641012',
'lat': 11.0183,
'lng': 76.9725,
},
'slotId': 'slot_t_1',
'destinations': [
{
'stateCode': 'TN',
'stateName': 'Tamil Nadu',
'districtCode': 'TN-MAA',
'districtName': 'Chennai',
'packageCount': 2,
'district': {
'code': 'TN-MAA',
'name': 'Chennai',
'available': true,
'hub': 'Chennai Guindy Hub',
'promise': 'Next-day delivery',
'lat': 13.08,
'lng': 80.27,
},
'details': {
'street': '12th Main',
'recipientName': 'Meera S',
'recipientPhone': '+919884412210',
},
'trackingId': 'DMX10482913',
'stage': 'in_transit',
'verification': {
'weightKg': 2.8,
'photos': ['https://cdn.doormile.com/pv/abc.jpg'],
'capturedAt': 1757060400000,
'capturedBy': 'Arun Kumar',
},
},
],
'miler': {
'name': 'Arun Kumar',
'vehicle': 'TN 37 BX 4412',
'phone': '+919000011223',
'rating': 4.9,
'trips': 1240,
'vehicleType': 'E-Scooter',
},
'deliveryAgent': null,
'milerDistanceKm': null,
'milerEtaMinutes': null,
'milersInZone': 3,
'routeKm': 6.4,
'expectedDelivery': 'Thu, 12 Sep',
'fare': {
'min': 49,
'max': 64,
'paymentMethod': 'UPI · Cash at doorstep',
'parcel': 'Standard box (up to 3 kg)',
},
'amountPaid': 64,
'deliveredAt': null,
'cancelReason': null,
'history': [
{'stage': 'booked', 'at': 1757056800000},
{'stage': 'assigned', 'at': 1757057400000},
{'stage': 'in_transit', 'at': 1757062200000},
],
};
test('reads the whole canonical object, not a subset', () {
final booking = Booking.fromJson(json);
expect(booking.reference, 'DM-482913');
expect(booking.stage, JourneyStage.inTransit);
expect(booking.status, BookingStatus.active);
expect(booking.cancellable, isFalse);
expect(booking.createdAt.millisecondsSinceEpoch, 1757056800000);
expect(booking.pickup.title, '12 Nehru Street');
expect(booking.slotId, 'slot_t_1');
expect(booking.routeKm, 6.4);
expect(booking.milersInZone, 3);
expect(booking.expectedDelivery, 'Thu, 12 Sep');
expect(booking.amountPaid, 64);
expect(booking.deliveredAt, isNull);
expect(booking.miler!.name, 'Arun Kumar');
expect(booking.miler!.trips, 1240);
expect(booking.deliveryAgent, isNull);
expect(booking.fare!.range, '₹49 – ₹64');
expect(booking.history.length, 3);
expect(booking.history.first.stage, JourneyStage.booked);
expect(booking.timeOf(JourneyStage.assigned), isNotNull);
final group = booking.destinations.single;
expect(group.destination.label, 'Chennai, Tamil Nadu');
expect(group.packageCount, 2);
expect(group.trackingId, 'DMX10482913');
expect(group.stage, JourneyStage.inTransit);
expect(group.details.recipientName, 'Meera S');
expect(group.district!.hub, 'Chennai Guindy Hub');
expect(group.hub, isNotNull, reason: 'district coordinates draw the route');
expect(group.verification!.weightLabel, '2.8 kg');
expect(group.verification!.photos.single, endsWith('abc.jpg'));
});
test('before order_created there is no order to track', () {
final pending = Map<String, dynamic>.from(json)
..['stage'] = 'assigned'
..['amountPaid'] = null
..['destinations'] = [
{
'stateCode': 'TN',
'stateName': 'Tamil Nadu',
'districtCode': 'TN-MAA',
'districtName': 'Chennai',
'packageCount': 1,
},
];
final booking = Booking.fromJson(pending);
final group = booking.destinations.single;
expect(group.trackingId, isNull);
expect(group.stage, isNull);
expect(group.verification, isNull);
expect(booking.amountPaid, isNull);
expect(booking.hasOrders, isFalse);
});
test('an unknown stage key falls back to booked', () {
final unknown = Map<String, dynamic>.from(json)
..['stage'] = 'delivery_failed';
expect(Booking.fromJson(unknown).stage, JourneyStage.booked);
expect(JourneyStage.fromKey(null), JourneyStage.booked);
expect(JourneyStage.fromKey('delivered'), JourneyStage.delivered);
});
test('a cancelled booking parses even when the server thins it out', () {
final booking = Booking.fromJson(const {
'reference': 'DM-449220',
'status': 'cancelled',
'stage': 'booked',
'cancelReason': 'Package not ready',
});
expect(booking.status, BookingStatus.cancelled);
expect(booking.isCancelled, isTrue);
expect(booking.cancelReason, 'Package not ready');
expect(booking.destinations, hasLength(1));
expect(booking.slotId, '');
});
test('timestamps are epoch millis, and nothing ambiguous is guessed', () {
expect(
epochMillis(1757056800000)!.millisecondsSinceEpoch,
1757056800000,
);
expect(epochMillis('1757056800000')!.millisecondsSinceEpoch, 1757056800000);
expect(epochMillis('2026-09-05T14:30:00+05:30'), isNotNull);
expect(epochMillis(null), isNull);
// A naive local string is ambiguous by 5h30m here. Refusing it is how the
// tracking screen avoids dating a parcel to yesterday.
expect(epochMillis('2026-09-05 14:30:00'), isNull);
});
test('a destination round-trips into the create request', () {
final group = DestinationGroup(
destination: Destination(stateCode: 'TN', districtCode: 'TN-MAA'),
packageCount: 3,
);
expect(group.toBookingJson(), {
'stateCode': 'TN',
'districtCode': 'TN-MAA',
'packageCount': 3,
});
// The contract carries the recipient flat on the destination, beside the
// codes, and spells a dropped pin `latitude`/`longitude`.
group.details.update(recipientName: 'Meera S', building: '12/A');
group.details.pin = const MapPin(13.085, 80.21);
expect(group.toBookingJson(), {
'stateCode': 'TN',
'districtCode': 'TN-MAA',
'packageCount': 3,
'recipientName': 'Meera S',
'building': '12/A',
'latitude': 13.085,
'longitude': 80.21,
});
});
test('a PATCH sends nulls, because null is how a field is cleared', () {
final details = DeliveryDetails(street: '12th Main');
final patch = details.toPatchJson();
expect(patch['street'], '12th Main');
expect(patch.containsKey('landmark'), isTrue);
expect(patch['landmark'], isNull);
expect(patch.containsKey('latitude'), isTrue);
});
});
}