Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
800 lines
28 KiB
Dart
800 lines
28 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: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 ▸ BOOK ▸ confirm the pin ▸ destination step.
|
||
Future<void> openDestination(WidgetTester tester) async {
|
||
await tester.tap(find.text('Start booking'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester);
|
||
}
|
||
|
||
/// State chips scroll horizontally; drag later ones fully into view first.
|
||
Future<void> tapState(WidgetTester tester, String name) async {
|
||
final width = tester.view.physicalSize.width / tester.view.devicePixelRatio;
|
||
final chips = find
|
||
.ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable))
|
||
.first;
|
||
|
||
for (var i = 0; i < 8; i++) {
|
||
final finder = find.text(name);
|
||
if (finder.evaluate().isNotEmpty) {
|
||
final rect = tester.getRect(finder);
|
||
if (rect.left >= 0 && rect.right <= width) break;
|
||
}
|
||
await tester.drag(chips, const Offset(-160, 0));
|
||
await tester.pump();
|
||
}
|
||
await tester.tap(find.text(name));
|
||
await settle(tester);
|
||
}
|
||
|
||
Future<void> pickPlace(WidgetTester tester, String state, String district) async {
|
||
await tapState(tester, state);
|
||
await tester.tap(find.text(district));
|
||
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 tester.tap(find.text('Start booking'));
|
||
await settle(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 Next now carries forward.
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester);
|
||
expect(find.text('Where are you sending it?'), 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 tester.tap(find.text('Start booking'));
|
||
await settle(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('Next'));
|
||
await settle(tester);
|
||
expect(find.text('Where are you sending it?'), 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 tester.tap(find.text('Start booking'));
|
||
await settle(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 tester.tap(find.text('Start booking'));
|
||
await settle(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 tester.tap(find.text('Start booking'));
|
||
await settle(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 tester.tap(find.text('Start booking'));
|
||
await settle(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, 'Next')).onPressed,
|
||
isNotNull,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('one destination, one package, every optional field skipped',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openDestination(tester);
|
||
expect(find.text('Where are you sending it?'), findsOneWidget);
|
||
|
||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||
|
||
// Settles into one card carrying the place and nothing else demanded.
|
||
//
|
||
// The package stepper and the optional delivery-details row were removed:
|
||
// one package to one door is the whole of the common path, so the step now
|
||
// asks for the destination and stops. `packageCount` still defaults to 1 in
|
||
// the model, which the read-only screens go on showing.
|
||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||
expect(find.text('How many packages?'), findsNothing);
|
||
expect(find.text('Add exact delivery details'), findsNothing);
|
||
expect(find.text('Send to another place'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester);
|
||
expect(find.text('When should we come?'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||
await tester.pump();
|
||
await tester.tap(find.text('Review booking'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('Review pickup details'), findsOneWidget);
|
||
expect(find.text('SENDING TO'), findsOneWidget);
|
||
expect(find.text('1 package'), findsOneWidget);
|
||
// Optional omissions are stated calmly, never as an error.
|
||
expect(
|
||
find.text('Not added — the Miler can confirm this at pickup.'),
|
||
findsOneWidget,
|
||
);
|
||
// A single destination shows no multi-destination summary.
|
||
expect(find.textContaining('destinations'), findsNothing);
|
||
|
||
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);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
// ── These three used to drive the package stepper ──
|
||
//
|
||
// The stepper is gone from the destination step: one package to one door is
|
||
// the whole common path, so the screen no longer asks. The *logic* it drove
|
||
// is untouched — `setPackageCount` still clamps to `limits.maxPackages`, the
|
||
// totals still roll up, and the booking payload still carries them — so the
|
||
// counts are set on the state here and the assertions that matter (totals,
|
||
// the cap, what review and the booking show) are kept exactly as they were.
|
||
testWidgets('one destination, four packages', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openDestination(tester);
|
||
await pickPlace(tester, 'Tamil Nadu', 'Coimbatore');
|
||
|
||
state.setPackageCount(0, 4);
|
||
await tester.pump();
|
||
expect(state.draftTotalPackages, 4);
|
||
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||
await tester.pump();
|
||
await tester.tap(find.text('Review booking'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('4 packages'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('three destinations with mixed counts total correctly on review',
|
||
(tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openDestination(tester);
|
||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||
state.setPackageCount(0, 2);
|
||
await tester.pump();
|
||
|
||
await tester.tap(find.text('Send to another place'));
|
||
await settle(tester);
|
||
await pickPlace(tester, 'Kerala', 'Ernakulam');
|
||
|
||
await tester.tap(find.text('Send to another place'));
|
||
await settle(tester);
|
||
await pickPlace(tester, 'Karnataka', 'Bengaluru Urban');
|
||
state.setPackageCount(2, 3);
|
||
await tester.pump();
|
||
|
||
expect(state.draftDestinations, hasLength(3));
|
||
expect(state.draftTotalPackages, 6);
|
||
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester);
|
||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||
await tester.pump();
|
||
await tester.tap(find.text('Review booking'));
|
||
await settle(tester);
|
||
|
||
expect(find.text('6 packages · 3 destinations'), findsOneWidget);
|
||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
|
||
expect(find.text('Bengaluru Urban, Karnataka'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Book pickup'));
|
||
await settle(tester);
|
||
final booking = state.trackedBooking!;
|
||
expect(booking.destinations, hasLength(3));
|
||
expect(booking.totalPackages, 6);
|
||
expect(booking.packageSummary, '6 packages · 3 destinations');
|
||
// Before collection it is one pickup, however many places it serves.
|
||
expect(
|
||
state
|
||
.entriesOf(BookingStatus.active)
|
||
.where((e) => e.booking.reference == booking.reference)
|
||
.length,
|
||
1,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the package cap holds', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openDestination(tester);
|
||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||
|
||
// Push well past the cap; `setPackageCount` must clamp.
|
||
state.setPackageCount(0, 999);
|
||
await tester.pump();
|
||
|
||
expect(state.draftTotalPackages, state.limits.maxPackages);
|
||
// A full pickup cannot take another place either.
|
||
expect(state.canAddDestination, isFalse);
|
||
expect(
|
||
find.text('Up to ${state.limits.maxPackages} packages in one pickup.'),
|
||
findsOneWidget,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the destination cap holds', (tester) async {
|
||
final state = AppState();
|
||
await signIn(tester, state: state);
|
||
|
||
await openDestination(tester);
|
||
|
||
const places = [
|
||
('Tamil Nadu', 'Chennai'),
|
||
('Kerala', 'Ernakulam'),
|
||
('Karnataka', 'Mysuru'),
|
||
('Tamil Nadu', 'Erode'),
|
||
('Kerala', 'Thrissur'),
|
||
];
|
||
|
||
for (var i = 0; i < places.length; i++) {
|
||
if (i > 0) {
|
||
await tester.tap(find.text('Send to another place'));
|
||
await settle(tester);
|
||
}
|
||
await pickPlace(tester, places[i].$1, places[i].$2);
|
||
}
|
||
|
||
expect(state.draftDestinations, hasLength(state.limits.maxDestinations));
|
||
expect(state.canAddDestination, isFalse);
|
||
expect(find.text('Send to another place'), findsNothing);
|
||
expect(
|
||
find.text(
|
||
'Up to ${state.limits.maxDestinations} places in one pickup. '
|
||
'Book another pickup for more.',
|
||
),
|
||
findsOneWidget,
|
||
);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('the picker offers serviceable districts only', (tester) async {
|
||
await signIn(tester);
|
||
await openDestination(tester);
|
||
|
||
// Nothing until a state is picked.
|
||
expect(find.text('Chennai'), findsNothing);
|
||
|
||
await tapState(tester, 'Tamil Nadu');
|
||
expect(find.text('Chennai'), findsOneWidget);
|
||
expect(find.text('Coimbatore'), findsOneWidget);
|
||
expect(find.text('Ernakulam'), findsNothing);
|
||
|
||
// Unsupported places are never disabled tiles — they are named once,
|
||
// quietly, and only because they are genuinely coming.
|
||
expect(find.text('Madurai'), findsNothing);
|
||
expect(find.text('Coming soon: Madurai'), findsOneWidget);
|
||
|
||
await tapState(tester, 'Kerala');
|
||
expect(find.text('Ernakulam'), findsOneWidget);
|
||
expect(find.text('Palakkad'), findsOneWidget);
|
||
expect(find.text('Chennai'), findsNothing);
|
||
expect(find.text('Coming soon: Kozhikode'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('a state with nothing open is not offered at all', (tester) async {
|
||
await signIn(tester);
|
||
await openDestination(tester);
|
||
|
||
// Puducherry is in the backend response but has no open district, so the
|
||
// customer never has to read past it.
|
||
expect(find.text('Puducherry'), findsNothing);
|
||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||
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 openDestination(tester);
|
||
|
||
expect(find.text('No service areas available'), findsOneWidget);
|
||
await drainToasts(tester);
|
||
});
|
||
|
||
testWidgets('shows the network error state with retry', (tester) async {
|
||
await signIn(tester);
|
||
api.flags.networkError = true;
|
||
|
||
await openDestination(tester);
|
||
|
||
expect(find.text("Couldn't load this"), findsOneWidget);
|
||
|
||
api.flags.networkError = false;
|
||
await tester.tap(find.text('Retry'));
|
||
await settle(tester);
|
||
expect(find.text('Tamil Nadu'), findsOneWidget);
|
||
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 openDestination(tester); // pickup point + destination
|
||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
|
||
|
||
await tester.tap(find.text('Next'));
|
||
await settle(tester); // pickup time
|
||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||
await tester.pump();
|
||
await tester.tap(find.text('Review booking'));
|
||
await settle(tester); // review
|
||
expect(find.text('Review pickup details'), findsOneWidget);
|
||
|
||
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);
|
||
});
|
||
}
|