Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
957 lines
36 KiB
Dart
957 lines
36 KiB
Dart
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 the destination sheet, then the window sheet behind it.
|
||
///
|
||
/// The destination is reached by searching rather than by opening its state:
|
||
/// search cuts across every state from the first step, and it is the path a
|
||
/// customer who already knows where they are sending actually takes.
|
||
///
|
||
/// [takeWindow] false dismisses the window sheet rather than answering it,
|
||
/// which leaves the booking without a slot and Confirm disabled — the review
|
||
/// screen then carries "Choose a window" as its outstanding row.
|
||
Future<void> pickCity(
|
||
WidgetTester tester,
|
||
String name, {
|
||
bool takeWindow = true,
|
||
}) 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 window sheet follows the destination on its own.
|
||
final use = find.textContaining('Use ');
|
||
if (takeWindow && use.evaluate().isNotEmpty) {
|
||
await tester.tap(use.first);
|
||
await settle(tester);
|
||
} else if (!takeWindow) {
|
||
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
|
||
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('ORIGIN'));
|
||
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('DESTINATION'), 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);
|
||
|
||
// Destination first, then the door — the address sheet comes on its own.
|
||
await tester.enterText(find.byType(TextField).first, 'Chennai');
|
||
await settle(tester, 250);
|
||
await tester.tap(
|
||
inSheet(
|
||
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
|
||
).first,
|
||
);
|
||
await settle(tester);
|
||
final send = find.textContaining('Send to ');
|
||
if (send.evaluate().isNotEmpty) {
|
||
await tester.tap(send.first);
|
||
await settle(tester);
|
||
}
|
||
|
||
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 window, then the review — which carries the address as a row.
|
||
await tester.tap(find.textContaining('Use ').first);
|
||
await settle(tester);
|
||
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('DESTINATION'), 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('ORIGIN'), findsOneWidget);
|
||
expect(find.text('DESTINATION'), 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 the number the Miler will ring at the door.
|
||
await tester.tap(find.text('ORIGIN'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Someone else is handing it over?'));
|
||
await settle(tester);
|
||
await tester.enterText(find.byType(TextField).first, '9003144518');
|
||
await settle(tester, 200);
|
||
await tester.tap(find.text('Confirm pickup point'));
|
||
await settle(tester);
|
||
expect(state.draftContactPhone, '+91 9003144518');
|
||
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 sheet browses state then district',
|
||
(tester) async {
|
||
await signIn(tester);
|
||
await openSend(tester);
|
||
|
||
// Step one is the states, not sixty district names.
|
||
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
|
||
expect(inSheet(find.text('Kerala')), findsOneWidget);
|
||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||
|
||
// Step two is that state's districts.
|
||
await tester.tap(find.text('Tamil Nadu'));
|
||
await settle(tester);
|
||
expect(inSheet(find.text('Chennai')), findsOneWidget);
|
||
expect(inSheet(find.text('Coimbatore')), findsWidgets);
|
||
|
||
// 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);
|
||
|
||
// Back out, then let search cut across states: Ernakulam is in Kerala and
|
||
// the customer should not have to know that to find it.
|
||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||
await settle(tester);
|
||
expect(inSheet(find.text('Chennai')), findsNothing);
|
||
|
||
await pickCity(tester, 'Ernakulam');
|
||
expect(find.textContaining('Ernakulam'), findsWidgets);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
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('Booking created'), 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('Booking created'), 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 on the step it failed on: the states, not a blank sheet.
|
||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||
await tester.tap(find.text('Tamil Nadu'));
|
||
await settle(tester);
|
||
expect(find.text('Coimbatore'), 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('ORIGIN'));
|
||
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('Booking created'), 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);
|
||
});
|
||
}
|