Picks up where 0d66627 left off. Three things.
SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.
Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.
`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.
BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".
Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.
Together those put the window, the package count and the CTA above the fold.
THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:
* `useDevData` is false in a release whatever the defines say;
* `describe` leads with DEV DATA (offline) and shows "no network" rather than
a host the build never contacts.
It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.
flutter run --dart-define=DM_MOCK=true
86 tests green, analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
714 lines
25 KiB
Dart
714 lines
25 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:doormile_cx/ui/widgets/chrome.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));
|
||
await settle(tester);
|
||
|
||
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);
|
||
|
||
// The OTP screen verifies as soon as the fourth digit lands.
|
||
final boxes = find.byType(TextField);
|
||
for (var i = 0; i < 4; i++) {
|
||
await tester.enterText(boxes.at(i), '1');
|
||
await tester.pump();
|
||
}
|
||
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 ▸ Start booking ▸ the send screen.
|
||
///
|
||
/// One screen now: the pickup point is already known, so there is no step
|
||
/// between the CTA and the form.
|
||
Future<void> openSend(WidgetTester tester) async {
|
||
await tester.tap(find.text('Start booking'));
|
||
await settle(tester);
|
||
}
|
||
|
||
/// Opens the pickup-point editor from the send screen's "Change".
|
||
Future<void> openPickupEditor(WidgetTester tester) async {
|
||
await openSend(tester);
|
||
await tester.tap(find.text('Change').first);
|
||
await settle(tester);
|
||
}
|
||
|
||
/// The city strip scrolls horizontally; drag later cities into view first.
|
||
Future<void> pickCity(WidgetTester tester, String name) async {
|
||
final width = tester.view.physicalSize.width / tester.view.devicePixelRatio;
|
||
final strip = find.descendant(
|
||
of: find.byKey(const ValueKey('cityStrip')),
|
||
matching: find.byType(Scrollable),
|
||
);
|
||
|
||
for (var i = 0; i < 8; i++) {
|
||
final finder = find.text(name);
|
||
if (finder.evaluate().isNotEmpty) {
|
||
final rect = tester.getRect(finder.first);
|
||
if (rect.left >= 0 && rect.right <= width) break;
|
||
}
|
||
await tester.drag(strip, const Offset(-160, 0));
|
||
await tester.pump();
|
||
}
|
||
await tester.tap(find.text(name).first);
|
||
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('signs in and lands on Home', (tester) async {
|
||
await signIn(tester);
|
||
|
||
expect(find.text('Send a parcel'), findsOneWidget);
|
||
expect(find.text('Start booking'), findsOneWidget);
|
||
// The seeded active booking surfaces on Home.
|
||
expect(find.text('Miler 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('Use this address'));
|
||
await settle(tester);
|
||
expect(find.text('Send a parcel'), findsOneWidget);
|
||
expect(find.text(corrected), findsOneWidget);
|
||
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('Change'));
|
||
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('Use this address'));
|
||
await settle(tester);
|
||
expect(find.text('Send a parcel'), 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, 'Use this address'),
|
||
)
|
||
.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);
|
||
// Everything the booking needs is on this one screen.
|
||
expect(find.text('Send a parcel'), findsOneWidget);
|
||
expect(find.text('SENDING TO'), findsOneWidget);
|
||
expect(find.text('STREET ADDRESS'), findsOneWidget);
|
||
// RECIPIENT is no longer a head of its own. Landmark and the two recipient
|
||
// fields are optional, and drawn at full weight they put six rows of
|
||
// "you may skip this" between the address and the button — so they fold
|
||
// behind one row, and this is the closed state the screen opens in.
|
||
expect(find.text('Add landmark or recipient details'), findsOneWidget);
|
||
expect(find.text('Landmark'), findsNothing);
|
||
|
||
// Nothing to price until somewhere to send it.
|
||
expect(find.text('Pick a city to see the price'), findsOneWidget);
|
||
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
// Picking the city also takes the first window the backend still has, so
|
||
// the common path never opens the sheet.
|
||
expect(state.draftSlotId, isNotNull);
|
||
expect(find.text('Book pickup'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Book pickup'));
|
||
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 address and recipient reach the booking', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openSend(tester);
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
await tester.enterText(
|
||
find.widgetWithText(TextField, '12B, Nehru Apartments'),
|
||
'12B, Nehru Apartments',
|
||
);
|
||
await tester.enterText(
|
||
find.widgetWithText(TextField, 'Anna Nagar West, 3rd Street'),
|
||
'Anna Nagar West',
|
||
);
|
||
// The recipient fields are optional and now fold behind one row, so a
|
||
// customer who wants them opens that first. This tap is the flow, not a
|
||
// workaround for it.
|
||
await tester.tap(find.text('Add landmark or recipient details'));
|
||
await settle(tester, 200);
|
||
|
||
await tester.enterText(find.widgetWithText(TextField, 'Meera Anand'), 'Meera Anand');
|
||
await tester.enterText(find.widgetWithText(TextField, '90031 44518'), '9003144518');
|
||
await settle(tester, 200);
|
||
|
||
await tester.tap(find.text('Book pickup'));
|
||
await settle(tester);
|
||
|
||
final details = state.trackedBooking!.destinations.first.details;
|
||
expect(details.building, '12B, Nehru Apartments');
|
||
expect(details.street, 'Anna Nagar West');
|
||
expect(details.recipientName, 'Meera Anand');
|
||
// The country code is added once, here, rather than being typed.
|
||
expect(details.recipientPhone, '+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 package'), 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 packages'), 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('Book pickup'));
|
||
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;
|
||
|
||
await tester.tap(find.text('Change').last);
|
||
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 city strip offers serviceable cities only', (tester) async {
|
||
await signIn(tester);
|
||
await openSend(tester);
|
||
|
||
// One flat strip: the state step is gone, so the cities of every open
|
||
// state stand side by side.
|
||
expect(find.text('Chennai'), findsOneWidget);
|
||
expect(find.text('Coimbatore'), findsWidgets);
|
||
await pickCity(tester, 'Ernakulam');
|
||
expect(find.text('Ernakulam'), findsWidgets);
|
||
|
||
// Places that are not open are never offered — not as disabled cards, and
|
||
// not as a state whose every district is closed.
|
||
expect(find.text('Madurai'), findsNothing);
|
||
expect(find.text('Puducherry'), findsNothing);
|
||
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.text('Orders'));
|
||
await settle(tester);
|
||
|
||
// Before collection: one row, owned by the pickup.
|
||
expect(find.text('DM-482913'), findsOneWidget);
|
||
|
||
// After collection: the seeded multi-destination pickup is in transit, so
|
||
// each destination stands on its own with a quiet line back to the visit.
|
||
final booking = state.byReference('DM-471200')!;
|
||
expect(booking.hasOrders, isTrue);
|
||
expect(find.text('Pickup DM-471200'), findsNothing);
|
||
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
|
||
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
|
||
expect(
|
||
find.text('Collected on DM-471200'),
|
||
findsNWidgets(booking.destinations.length),
|
||
);
|
||
// Independently tracked.
|
||
expect(
|
||
booking.destinations.map((d) => d.trackingId).toSet().length,
|
||
booking.destinations.length,
|
||
);
|
||
|
||
// Opening one follows that order, not the whole visit.
|
||
await tester.tap(find.text('Ernakulam, Kerala'));
|
||
await settle(tester);
|
||
expect(find.text('To Ernakulam, Kerala'), findsOneWidget);
|
||
expect(find.text('YOUR PACKAGES'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the customer timeline groups the operational states',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await tester.tap(find.text('Miler assigned'));
|
||
await settle(tester);
|
||
|
||
// Tracking opens on the three most recent updates; the rest of the journey
|
||
// is one tap away.
|
||
await tester.tap(find.text('All 7 stages'));
|
||
await settle(tester);
|
||
|
||
// Seven customer milestones, not nine backend states.
|
||
expect(find.text('Pickup booked'), findsWidgets);
|
||
expect(find.text('Pickup in progress'), findsOneWidget);
|
||
expect(find.text('Package collected'), findsOneWidget);
|
||
// Operational states are never timeline rows of their own.
|
||
expect(find.text('Miler arrived'), findsNothing);
|
||
expect(find.text('Order created'), findsNothing);
|
||
|
||
// Advancing to "on the way" keeps the milestone and moves the detail. It
|
||
// reads twice: once on the timeline, and once as the caption on the live
|
||
// card's own progress rail.
|
||
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
|
||
expect(find.text('Pickup in progress'), findsNWidgets(2));
|
||
expect(find.textContaining('Miler on the way'), findsWidgets);
|
||
// The pill speaks customer language, not the operational stage.
|
||
expect(find.text('Order created'), findsNothing);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('packages stay together until the pickup is actually collected',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await tester.tap(find.text('Miler assigned'));
|
||
await settle(tester);
|
||
|
||
// The live card, not an eyebrow: a pickup in flight is the screen's one
|
||
// crimson surface.
|
||
expect(find.text('LIVE'), findsOneWidget);
|
||
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);
|
||
|
||
await tester.tap(find.text('Miler assigned'));
|
||
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);
|
||
|
||
expect(find.text('Booking cancelled'), findsOneWidget);
|
||
final booking = state.byReference('DM-482913')!;
|
||
expect(booking.status, BookingStatus.cancelled);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('shows the empty service-area state', (tester) async {
|
||
await signIn(tester);
|
||
api.flags.emptyServiceAreas = true;
|
||
|
||
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);
|
||
// The strip comes back with the cities, not with a state to pick first.
|
||
expect(find.text('Coimbatore'), findsWidgets);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('a delivered order opens its receipt', (tester) async {
|
||
await signIn(tester);
|
||
|
||
await tester.tap(find.text('Orders'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Completed'));
|
||
await settle(tester);
|
||
|
||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||
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('Start booking'), findsOneWidget); // home
|
||
|
||
await openSend(tester); // the whole booking, one screen
|
||
await pickCity(tester, 'Chennai');
|
||
|
||
await tester.tap(find.text('Book pickup'));
|
||
await settle(tester); // confirmation
|
||
await tester.tap(find.text('Track pickup'));
|
||
await settle(tester); // tracking
|
||
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());
|
||
await settle(tester);
|
||
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.text('Orders'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Completed'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Salem, Tamil Nadu'));
|
||
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.text('Orders'));
|
||
await settle(tester);
|
||
// Each tab carries its own count, inside the header's segmented control —
|
||
// and the figure is the rows that tab will actually show, which for a
|
||
// collected multi-destination pickup is one per destination.
|
||
expect(
|
||
find.descendant(
|
||
of: find.byType(DmSegmented),
|
||
matching: find.text('${state.entriesOf(BookingStatus.active).length}'),
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
|
||
await tester.tap(find.text('Cancelled'));
|
||
await settle(tester);
|
||
expect(find.text('Erode, Tamil Nadu'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
}
|