Files
doormile_customer_app/test/booking_flow_test.dart
Thiru-tenext c3e25feaea Five payload bugs, four pages behind dead rows, and one sheet
── The full-address path was reaching the Miler empty ──

`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.

Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.

The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.

── Who the Miler rings ──

One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.

Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.

── Account's rows led nowhere ──

Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.

Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.

── ONE TOUCH is one sheet ──

It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.

Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.

── Sending a second parcel ──

`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.

Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.

── Bundle ──

pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
2026-09-29 12:31:58 +05:30

967 lines
37 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
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/main.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:doormile_cx/ui/widgets/buttons.dart';
import 'package:doormile_cx/ui/widgets/map_panel.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:doormile_cx/data/dev_doormile_api.dart';
/// The app runs continuous animations (radar, live dots, map pulse, skeletons),
/// so `pumpAndSettle` would never return. Pump fixed slices instead.
Future<void> settle(WidgetTester tester, [int ms = 800]) async {
await tester.pump();
await tester.pump(Duration(milliseconds: ms));
await tester.pump(const Duration(milliseconds: 400));
}
/// Drains toast and fare-estimate timers so a test ends with nothing pending.
Future<void> drainToasts(WidgetTester tester) async {
await tester.pump(const Duration(seconds: 3));
}
/// The street line currently shown under the pickup map. Empty while the pin
/// is still being resolved, which is itself the thing worth asserting.
String pickupAddress(WidgetTester tester) {
final title = find.byKey(const ValueKey('pickupAddressTitle'));
return title.evaluate().isEmpty
? ''
: tester.widget<Text>(title).data ?? '';
}
Future<void> signIn(WidgetTester tester, {AppState? state}) async {
// A tall surface so whole screens fit without scrolling in the harness.
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(DoormileApp(initialState: state));
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
// The CTA only enables once the field rebuilds with a valid number.
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// One field, not four boxes, and it verifies as soon as the fourth digit
// lands. Entered in one go on purpose: four separate fields dropped a digit
// whenever a keystroke arrived while focus was moving between them, which is
// what typing at any normal speed does.
await tester.enterText(find.byType(TextField).first, '1111');
await settle(tester);
}
/// Moves a booking on the **server**, then lets the app read it back.
///
/// This is the only way a stage changes now: the app has no local stage
/// machine, so a test cannot step a booking without the backend doing it
/// first. Fire-and-pump rather than await — the fake answers on a delay the
/// test clock only advances while pumping.
Future<void> serverAdvance(
WidgetTester tester,
AppState state,
String reference,
JourneyStage stage,
) async {
final booking = state.byReference(reference)!;
unawaited(state.advanceStage(booking, stage));
await settle(tester, 1400);
}
/// Home ▸ the BOOK orb ▸ the destination sheet.
///
/// BOOK asks one question — where is it going — and the review screen is what
/// the answer leads to, so every booking test is [openSend] then [pickCity].
/// The sheet is left open: tests that are about the sheet itself go no
/// further.
Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('ONE TOUCH'));
await settle(tester);
}
/// A finder restricted to the open bottom sheet.
///
/// Home's live card heads with the district alone — "Chennai", not
/// "Chennai, Tamil Nadu" — so a bare `find.text('Chennai')` matches the screen
/// behind the scrim as well as the row in the sheet. Every assertion about
/// what a sheet is offering goes through this.
Finder inSheet(Finder matching) =>
find.descendant(of: find.byType(BottomSheet), matching: matching);
/// Answers both questions on the pickup sheet.
///
/// ── One sheet now, not two ──
///
/// It used to be a destination sheet that closed so a window sheet could open
/// behind it, and this helper drove both. `showPickupSheet` asks them on one
/// surface, so the second half is a step rather than a new sheet and its
/// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM".
///
/// The `takeWindow: false` branch went with the change: dismissing now drops
/// the destination too, because there is only one sheet to dismiss, so a
/// booking that reaches Review without a slot is no longer reachable from
/// Home. Nothing passed it.
Future<void> pickCity(WidgetTester tester, String name) async {
await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250);
// A `Text` inside the sheet, explicitly.
//
// `find.text` also matches `EditableText`, so once the city's name is in the
// search box the plain finder returns the box as well — and Home, behind the
// scrim, heads its live card with the district alone, so an unscoped finder
// can land on a widget the sheet is covering. Scoping to the modal route
// fixes both.
await tester.tap(
inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first,
);
await settle(tester);
// Where the server allows several destinations the row ticks rather than
// closing the sheet, so the choice has to be confirmed. Where it caps the
// booking at one there is no button and the tap already committed.
final send = find.textContaining('Send to ');
if (send.evaluate().isNotEmpty) {
await tester.tap(send.first);
await settle(tester);
}
// The same sheet, now asking when.
final confirm = find.text('Confirm pickup');
if (confirm.evaluate().isNotEmpty) {
await tester.tap(confirm.first);
await settle(tester);
}
}
/// Opens the pickup-point editor from the send screen's FROM row.
Future<void> openPickupEditor(WidgetTester tester) async {
await openSend(tester);
await pickCity(tester, 'Chennai');
await tester.tap(find.text('PICKUP'));
await settle(tester);
}
/// The fake standing in for the backend in this file.
///
/// A fresh one per test, installed as the singleton so the widgets under test
/// reach it exactly as they would reach the real API. It is the **only** source
/// of bookings here: nothing plants one in [AppState].
late DevDoormileApi api;
void main() {
setUp(() {
api = DevDoormileApi();
DoormileApi.overrideInstance(api);
// Widget tests run on a fake clock a real platform-channel reply never
// reaches, so the device GPS is replaced with a fixed fix. Without this the
// pickup screen would sit on "Getting address…" forever.
LocationService.instance = FixedLocationService();
});
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('the code is one field, typed a digit at a time',
(tester) async {
// The regression this locks: the code used to be four `TextField`s that
// passed focus along, and a keystroke arriving mid-transition was dropped.
// The customer typed four digits, three arrived, and the screen answered
// "That code did not match" — blaming them for its own race.
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// One field. Nothing to pass focus between, so nothing to drop it in.
expect(find.byType(TextField), findsOneWidget);
final field = find.byType(TextField).first;
for (final sofar in ['1', '12', '123', '1234']) {
await tester.enterText(field, sofar);
await tester.pump();
}
await settle(tester);
// All four digits arrived, so it verified and moved on.
expect(find.text('ONE TOUCH'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('signs in and lands on Home', (tester) async {
await signIn(tester);
// Home greets by name, carries the sphere, and ends on the live card.
expect(find.text('Hello, Joe'), findsOneWidget);
expect(find.text('ONE TOUCH'), findsOneWidget);
// The seeded active booking surfaces on Home. Its state is the uppercase
// micro-label beside the destination, in the chip vocabulary — "Assigned",
// not the rail's "Miler assigned".
expect(find.text('ASSIGNED'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('dragging the map re-resolves the pickup address',
(tester) async {
await signIn(tester);
await openPickupEditor(tester);
// The pin resolved to a street, not a placeholder.
expect(find.text('Getting address…'), findsNothing);
final address = pickupAddress(tester);
expect(address, isNotEmpty);
// Move the map under the fixed pin, the way a customer corrects the door.
await tester.drag(find.byType(DmMapPanel), const Offset(-90, -70));
await tester.pump();
// The address goes quiet while the new coordinates are resolved…
expect(find.text('Getting address…'), findsOneWidget);
// …and comes back as a different place.
await settle(tester, 900);
expect(find.text('Getting address…'), findsNothing);
expect(pickupAddress(tester), isNot(address));
// Which is what the editor carries back to the send screen.
final corrected = pickupAddress(tester);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
// Back on the send screen: its title is the route, not a heading.
expect(find.text('DROP'), findsOneWidget);
expect(find.text(corrected), findsOneWidget);
await drainToasts(tester);
});
testWidgets('the full form asks for the drop address; One Touch does not',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// One Touch: state, district, window, and nothing else is asked.
await openSend(tester);
await pickCity(tester, 'Chennai');
expect(state.draftDetailed, isFalse);
expect(find.text('DROP ADDRESS'), findsNothing);
expect(
tester
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
.onPressed,
isNotNull,
);
// Back to Home, and in again through the form's DROP row.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
await tester.tap(find.text('DROP'));
await settle(tester);
// ── Where and when first, the door afterwards ──
//
// The address sheet used to land between the destination and the window.
// Both paths now answer where-and-when on one sheet, and the full form's
// extra question — which door — comes after it, next to the review that
// shows it.
await pickCity(tester, 'Chennai');
expect(state.draftDetailed, isTrue);
expect(find.text('Where in Chennai?'), findsOneWidget);
// A building or a street is enough to send somebody to.
await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments');
await settle(tester, 200);
await tester.tap(find.text('Save address'));
await settle(tester);
// Then the review, which carries the address as a row.
expect(find.text('DROP ADDRESS'), findsOneWidget);
final group = state.draftDestinations.first;
expect(group.details.building, '3B Lotus Apartments');
expect(group.details.exactAddress, isNotNull);
expect(
tester
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
.onPressed,
isNotNull,
);
await drainToasts(tester);
});
testWidgets('the collect-from sheet hands off to the map and back',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// The PICKUP row of Home's pick/drop form opens the sheet.
await tester.tap(find.text('PICKUP'));
await settle(tester);
expect(find.text('Collect from'), findsOneWidget);
// "Select on map" pushes the map screen over the sheet, which stays
// mounted underneath.
await tester.tap(find.text('Select on map'));
await settle(tester);
expect(find.text('Confirm pickup point'), findsOneWidget);
expect(find.text('PICKUP FROM'), findsOneWidget);
// Backing out leaves the customer on the search they came from, not Home.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
expect(find.text('Collect from'), findsOneWidget);
// Confirming closes both and writes the pin as the pickup.
await tester.tap(find.text('Select on map'));
await settle(tester);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(find.text('Collect from'), findsNothing);
expect(state.pickup, isNotNull);
expect(state.hasPickupPoint, isTrue);
await drainToasts(tester);
});
testWidgets('GPS, map drag and search all write one pickup point',
(tester) async {
final state = AppState(
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
);
await signIn(tester, state: state);
await openPickupEditor(tester);
DmMapPanel map() => tester.widget<DmMapPanel>(find.byType(DmMapPanel));
// 1 — the device fix. Address and coordinates land together.
final located = state.pickup!;
expect(state.hasPickupPoint, isTrue);
expect(located.lat, closeTo(11.0183, 0.0001));
expect(map().lat, located.lat);
expect(pickupAddress(tester), located.title);
// 2 — dragging the map moves the same point.
await tester.drag(find.byType(DmMapPanel), const Offset(-140, -110));
await settle(tester, 900);
final dragged = state.pickup!;
expect(dragged.lat, isNot(located.lat));
expect(dragged.hasLocation, isTrue);
expect(map().lat, dragged.lat);
expect(pickupAddress(tester), dragged.title);
// 3 — a search result overwrites it, coordinates included, and the map
// follows the address rather than staying where the drag left it.
await tester.tap(find.text('Edit'));
await settle(tester);
await tester.tap(find.text('Brookefields Mall'));
await settle(tester);
final searched = state.pickup!;
expect(searched.title, 'Brookefields Mall');
expect(searched.lat, closeTo(10.9987, 0.0001));
expect(searched.lng, closeTo(76.9628, 0.0001));
expect(map().lat, searched.lat);
expect(map().lng, searched.lng);
expect(pickupAddress(tester), 'Brookefields Mall');
// …and that one point is what the booking carries forward.
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(find.text('DROP'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall');
await drainToasts(tester);
});
testWidgets('the customer is never shown raw coordinates', (tester) async {
final state = AppState(
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
);
await signIn(tester, state: state);
await openPickupEditor(tester);
// Whatever the pin resolves to, it reads as a place — never a decimal pair.
final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}');
for (final text in tester.widgetList<Text>(find.byType(Text))) {
final data = text.data;
if (data == null) continue;
expect(
coordinate.hasMatch(data),
isFalse,
reason: 'coordinates leaked into the UI: "$data"',
);
}
expect(state.pickup!.hasLocation, isTrue);
await drainToasts(tester);
});
testWidgets('the customer sees their own position as a blue dot',
(tester) async {
final state = AppState(
location: FixedLocationService(
lat: 11.0183,
lng: 76.9725,
accuracy: 22,
),
);
await signIn(tester, state: state);
await openPickupEditor(tester);
final dot = find.byKey(const ValueKey('deviceLocationDot'));
expect(dot, findsOneWidget);
expect(state.hasDeviceLocation, isTrue);
expect(state.deviceLat, closeTo(11.0183, 0.0001));
expect(state.deviceAccuracy, 22);
// Dragging moves the pickup pin. It must not move the customer: the dot
// stays where they actually are, which is the whole point of showing it.
await tester.drag(find.byType(DmMapPanel), const Offset(-150, -120));
await settle(tester, 900);
expect(dot, findsOneWidget);
expect(state.deviceLat, closeTo(11.0183, 0.0001));
expect(state.deviceLng, closeTo(76.9725, 0.0001));
expect(state.pickup!.lat, isNot(closeTo(11.0183, 0.0001)));
await drainToasts(tester);
});
testWidgets('no blue dot when the device refused a position',
(tester) async {
LocationService.instance =
FixedLocationService.denied(LocationDenial.serviceOff);
final state = AppState();
await signIn(tester, state: state);
await openPickupEditor(tester);
// A dot in the wrong place is worse than no dot.
expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing);
expect(state.hasDeviceLocation, isFalse);
await drainToasts(tester);
});
testWidgets('a refused location still lets the customer book',
(tester) async {
LocationService.instance =
FixedLocationService.denied(LocationDenial.denied);
await signIn(tester);
await openPickupEditor(tester);
// The map falls back to the service area rather than an empty ocean, and
// the refusal is explained instead of silently swallowed.
expect(
find.textContaining('Allow location access'),
findsOneWidget,
);
expect(find.text('Search'), findsOneWidget);
// Booking is not blocked by it.
expect(
tester
.widget<DmButton>(
find.widgetWithText(DmButton, 'Confirm pickup point'),
)
.onPressed,
isNotNull,
);
await drainToasts(tester);
});
testWidgets('one screen books a parcel, every optional field skipped',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
await openSend(tester);
// BOOK asks one question, and it is the only thing on screen. Scoped to
// the sheet: Home's DROP row asks the same question behind the scrim.
expect(inSheet(find.text('Where is it going?')), findsOneWidget);
expect(find.text('Flat or house number'), findsNothing);
await pickCity(tester, 'Chennai');
// The window was the outstanding row on review, and answering it is what
// enables the button.
expect(find.text('Review and book'), findsOneWidget);
// Everything else the booking needs is on one screen, and the route to
// the top of it is two rows with no labels but FROM and TO.
expect(find.text('PICKUP'), findsOneWidget);
expect(find.text('DROP'), findsOneWidget);
// Nothing is asked for here at all any more: no address fields, and no
// disclosure hiding three more. The route, the packages and the button.
expect(find.byType(TextField), findsNothing);
expect(find.text('Add landmark or recipient details'), findsNothing);
// The customer answered the window sheet on the way in, so the booking
// already carries a slot they chose.
expect(state.draftSlotId, isNotNull);
expect(find.text('Confirm Booking'), findsOneWidget);
await tester.tap(find.text('Confirm Booking'));
await settle(tester);
expect(find.text('Pickup booked'), findsOneWidget);
final booking = state.trackedBooking!;
expect(booking.destinations, hasLength(1));
expect(booking.totalPackages, 1);
expect(booking.destinations.first.trackingId, isNull);
// Skipped fields are absent, not empty strings.
expect(booking.details.exactAddress, isNull);
await drainToasts(tester);
});
testWidgets('the pickup contact defaults to the account and can be changed',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
await openPickupEditor(tester);
// The contact is folded away — the screen exists to place a pin, and the
// number is right nearly every time — so it has to be asked for.
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
// Opens on the signed-in customer's own number, without the country code,
// because the field carries its own prefix.
final contact = find.widgetWithText(TextField, '9876543210');
expect(contact, findsOneWidget);
// Left alone it stays null, so the request carries the account rather than
// a copy of it that can drift.
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(state.draftContactPhone, isNull);
// ── Changed, it is a note — not the number the Miler's button dials ──
//
// That is what this test used to claim. The rider's number is derived by
// the backend from the booking's **account**: `GET /miler/bookings` sends
// one phone field and nothing the create request carries can change it.
// The handover person travels in `remarks` instead, with a name, and both
// this screen and Review say so rather than implying otherwise.
await tester.tap(find.text('PICKUP'));
await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
// By value, not by position: the fold asks for a name first now, and
// `.first` quietly typed the phone number into it.
await tester.enterText(
find.widgetWithText(TextField, '9876543210'),
'9003144518',
);
await tester.enterText(find.byType(TextField).first, 'Meera S');
await settle(tester, 200);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(state.draftContactPhone, '+91 9003144518');
expect(state.draftContactName, 'Meera S');
// Review shows the account's number as the one that will be called, and
// the handover person beside it.
expect(find.text('YOUR MILER WILL CALL'), findsOneWidget);
// Whatever shape the account's number is in — the dev backend echoes the
// typed identifier, production sends E.164 — it is the one on the card.
expect(find.text('+91 9876543210'), findsWidgets);
expect(find.textContaining('Meera S'), findsWidgets);
await drainToasts(tester);
});
testWidgets('the package stepper is on the screen and clamps', (tester) async {
final state = AppState();
await signIn(tester, state: state);
await openSend(tester);
await pickCity(tester, 'Coimbatore');
expect(find.text('1 parcel'), findsOneWidget);
await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.plus));
await tester.pump();
expect(find.text('3 parcels'), findsOneWidget);
expect(state.draftTotalPackages, 3);
// Push past the cap on the state; the clamp is the model's, not the UI's.
state.setPackageCount(0, 999);
await tester.pump();
expect(state.draftTotalPackages, state.limits.maxPackages);
await tester.tap(find.text('Confirm Booking'));
await settle(tester);
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
await drainToasts(tester);
});
testWidgets('the window sheet changes the window', (tester) async {
final state = AppState();
await signIn(tester, state: state);
await openSend(tester);
await pickCity(tester, 'Chennai');
final first = state.draftSlotId;
// The window is a row on the review list, under the route. It carries no
// label — the clock glyph and the time are the whole row — so it is found
// by the window itself.
await tester.tap(find.textContaining('2:00 – 4:00 PM').first);
await settle(tester);
expect(find.text('Pickup window'), findsOneWidget);
// A different window, then commit it.
await tester.tap(find.text('4:00 – 6:00 PM').first);
await tester.pump();
await tester.tap(find.textContaining('Use 4:00 – 6:00 PM'));
await settle(tester);
expect(state.draftSlotId, isNot(first));
expect(find.text('Pickup window'), findsNothing);
await drainToasts(tester);
});
testWidgets('the destination step is one flat list, not two levels',
(tester) async {
await signIn(tester);
await openSend(tester);
// ── What this replaced ──
//
// The sheet used to open on states and hide every city until one was
// tapped, so the first thing a customer saw was a question about
// geography rather than an answer about service. The old test asserted
// that Chennai was *absent* from step one.
//
// Now the state is a heading and the cities are all there. The list is
// also the answer to "where do you deliver?", which is why Home's reach
// line opens this and not a second screen of its own.
expect(inSheet(find.text('TAMIL NADU')), findsOneWidget);
expect(inSheet(find.text('KERALA')), findsOneWidget);
expect(inSheet(find.text('Chennai')), findsOneWidget);
expect(inSheet(find.text('Ernakulam')), findsOneWidget);
// Districts that are not open are never offered, and neither is a state
// whose every district is closed.
expect(inSheet(find.text('Madurai')), findsNothing);
expect(inSheet(find.text('Puducherry')), findsNothing);
// And search still cuts across states: Ernakulam is in Kerala and the
// customer should not have to know that to find it.
await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets);
});
testWidgets('orders split into their own rows once collected', (tester) async {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
// Before collection: one row, owned by the pickup. The reference shares a
// line with the date and the contents now, so it is a span inside a
// paragraph rather than a Text of its own.
expect(find.textContaining('DM-482913'), findsOneWidget);
// After collection: the seeded multi-destination pickup is in transit, so
// each destination stands on its own row.
final booking = state.byReference('DM-471200')!;
expect(booking.hasOrders, isTrue);
expect(find.text('Pickup DM-471200'), findsNothing);
expect(find.text('Ernakulam'), findsOneWidget);
expect(find.text('Coimbatore'), findsOneWidget);
// Each row is identified by its OWN tracking number, not by the visit that
// produced it. "Collected on DM-471200" used to run under both rows; it
// answers a question asked about an order, not while scanning a list of
// them, so it moved to the order's own screen.
expect(find.text('Collected on DM-471200'), findsNothing);
for (final group in booking.destinations) {
expect(find.textContaining(group.trackingId!), findsOneWidget);
}
// Independently tracked.
expect(
booking.destinations.map((d) => d.trackingId).toSet().length,
booking.destinations.length,
);
// Opening one follows that order, not the whole visit: the head card's
// route ends at that destination, and the reference is its own tracking id.
await tester.tap(find.text('Ernakulam'));
await settle(tester);
expect(find.text('Ernakulam, Kerala'), findsWidgets);
expect(find.text('YOUR PACKAGES'), findsOneWidget);
expect(find.text('YOUR PACKAGES'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('the timeline is the shipment, not the pickup visit',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
// The whole shipment is on screen the moment tracking opens — five rows
// fit where the three-row summary and its "Full timeline" action used to.
expect(find.text('Full timeline'), findsNothing);
// Five shipment milestones. Everything before the order exists is one
// row — the whole pickup visit, which the live card above narrates by
// name, with a distance and an ETA.
expect(find.text('Pickup booked'), findsOneWidget);
expect(find.text('Order created'), findsOneWidget);
expect(find.text('In transit'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
expect(find.text('Delivered'), findsOneWidget);
// The visit's own stages are never rows of their own.
expect(find.text('Pickup in progress'), findsNothing);
expect(find.text('Package collected'), findsNothing);
expect(find.text('Miler arrived'), findsNothing);
// Advancing through the visit does not add a row — it moves the detail
// under the one row the whole visit occupies.
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
expect(find.text('Pickup booked'), findsOneWidget);
expect(find.textContaining('Miler on the way'), findsWidgets);
await drainToasts(tester);
});
testWidgets('packages stay together until the pickup is actually collected',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
// The head card names the state in a pill and draws the route as a rail.
// It used to be a filled crimson card headed "LIVE" — crimson is the
// action colour, and tracking is a screen you read.
expect(find.text('Miler assigned'), findsWidgets);
expect(find.text('12 Nehru Street'), findsWidgets);
expect(find.text('Cancel this pickup'), findsOneWidget);
// No per-package breakdown before collection.
expect(find.text('YOUR PACKAGES'), findsNothing);
await serverAdvance(tester, state, 'DM-482913', JourneyStage.pickedUp);
// Custody has changed hands: cancellation is no longer offered.
expect(find.text('Cancel this pickup'), findsNothing);
await serverAdvance(tester, state, 'DM-482913', JourneyStage.orderCreated);
final booking = state.byReference('DM-482913')!;
expect(booking.stage, JourneyStage.orderCreated);
expect(booking.destinations.first.trackingId, isNotNull);
await drainToasts(tester);
});
testWidgets('cancels the whole pickup from tracking', (tester) async {
final state = AppState();
await signIn(tester, state: state);
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await settle(tester);
await tester.tap(find.text('Cancel this pickup'));
await settle(tester);
expect(find.text('Cancel this pickup?'), findsOneWidget);
await tester.tap(find.text('Package not ready'));
await tester.pump();
await tester.tap(find.text('Yes, cancel pickup'));
await settle(tester);
// The head card carries the state in its pill now, not as a headline.
expect(find.textContaining('Cancelled'), findsWidgets);
final booking = state.byReference('DM-482913')!;
expect(booking.status, BookingStatus.cancelled);
await drainToasts(tester);
});
testWidgets('shows the empty service-area state', (tester) async {
api.flags.emptyServiceAreas = true;
await signIn(tester);
await openSend(tester);
expect(find.text('No cities open yet'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('shows the network error state with retry', (tester) async {
await signIn(tester);
api.flags.networkError = true;
await openSend(tester);
expect(find.text("Couldn't load this"), findsOneWidget);
api.flags.networkError = false;
await tester.tap(find.text('Retry'));
await settle(tester);
// It comes back with the list it failed to load, not a blank sheet. The
// state is a heading now — upper-cased and not a step to tap through — so
// the cities are there without a second navigation.
expect(find.text('TAMIL NADU'), findsOneWidget);
expect(find.text('Coimbatore'), findsWidgets);
expect(find.text('Chennai'), findsWidgets);
await drainToasts(tester);
});
testWidgets('a delivered order opens its receipt', (tester) async {
await signIn(tester);
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
await tester.tap(find.textContaining('Past'));
await settle(tester);
await tester.tap(find.text('Salem'));
await settle(tester);
expect(find.text('Delivered'), findsWidgets);
expect(find.text('Total paid'), findsOneWidget);
expect(find.text('₹64'), findsWidgets);
// What the Miler recorded at the door.
expect(find.text('COLLECTED AT YOUR DOOR'), findsOneWidget);
expect(find.text('Miler verified'), findsOneWidget);
// Once on the parcel block, once in the payment breakdown.
expect(find.text('1.6 kg'), findsNWidgets(2));
expect(find.text('Verified weight'), findsOneWidget);
expect(find.text('1 of 1'), findsOneWidget);
expect(find.textContaining('Weighed and photographed by'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('key screens survive large accessibility text', (tester) async {
// Any overflow throws, so simply building these screens is the assertion.
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP
expect(find.text('ONE TOUCH'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai');
// The pickup map: a fixed-height sheet over a full-bleed map, so large
// text has nowhere to grow into. Opening the contact fold is the tallest
// it ever gets.
await tester.tap(find.text('PICKUP'));
await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
await tester.tap(find.text('Confirm Booking'));
await settle(tester); // confirmation
await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking
// The live card names the stage; the rail names the shipment milestone.
expect(find.text('Pickup booked'), findsWidgets);
await drainToasts(tester);
});
testWidgets('sign up survives large accessibility text', (tester) async {
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.tap(find.text('Create account'));
await settle(tester);
// Building it is the assertion: any overflow in the hero or the form
// throws.
expect(find.text('Create your account'), findsOneWidget);
await drainToasts(tester);
});
testWidgets('orders and account survive large accessibility text',
(tester) async {
tester.platformDispatcher.textScaleFactorTestValue = 1.8;
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester);
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
// At 1.8x the three tabs are wider than the screen, so the second one has
// to be scrolled to before it can be tapped.
await tester.ensureVisible(find.textContaining('Past'));
await tester.pump();
await tester.tap(find.textContaining('Past'));
await settle(tester);
await tester.tap(find.text('Salem'));
await settle(tester); // receipt
await drainToasts(tester);
});
testWidgets('orders tabs filter by status', (tester) async {
final state = AppState();
await signIn(tester, state: state);
await tester.tap(find.bySemanticsLabel('Orders'));
await settle(tester);
// Each tab carries its own count — the figure is the rows that tab will
// actually show, which for a collected multi-destination pickup is one
// per destination. The count shares a paragraph with the label, so it is
// a span rather than a Text of its own.
expect(
find.textContaining(
'Active (${state.entriesOf(BookingStatus.active).length})',
),
findsOneWidget,
);
// The tab row scrolls — three labels plus their counts do not fit a 390pt
// phone at large text sizes.
await tester.ensureVisible(find.textContaining('Cancelled').first);
await tester.pump();
await tester.tap(find.textContaining('Cancelled').first);
await settle(tester);
expect(find.text('Erode'), findsOneWidget);
await drainToasts(tester);
});
}