Booking and sign-in flow, and the offline build back under guard
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
@@ -222,6 +222,45 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('which backend this build believes it is', () {
|
||||
// `DM_ENV` used to default to `staging` for EVERY build, release included,
|
||||
// while `_stagingBase` is `_prodBase`. So a release APK built without
|
||||
// `--dart-define=DM_ENV=prod` talked to production and reported
|
||||
// `isProd == false` — and `isProd` is what the non-network guards read: the
|
||||
// Account screen's Build panel, `allowStageOverride`, `setStage`'s refusal,
|
||||
// and the environment name on every bug report.
|
||||
|
||||
test('an unflagged debug build is staging, not production', () {
|
||||
// This test run carries no DM_ENV, and under test kReleaseMode is false.
|
||||
expect(AppConfig.environment, DoormileEnvironment.staging);
|
||||
expect(AppConfig.isProd, isFalse);
|
||||
});
|
||||
|
||||
test('staging and prod resolve to the same host today', () {
|
||||
// The backend has not named a staging host, so the environment decides
|
||||
// what the build CALLS ITSELF, not who it talks to. Worth pinning: the
|
||||
// day a real staging host appears, this test failing is the reminder that
|
||||
// the default now changes where traffic goes.
|
||||
expect(AppConfig.baseUrl, 'https://api.doormile.com/api/v1');
|
||||
});
|
||||
|
||||
test('the environment is named on every build description', () {
|
||||
// What a bug report carries. A production session that called itself
|
||||
// "staging" is how a real incident gets triaged against the wrong server.
|
||||
expect(AppConfig.describe, contains(AppConfig.environment.name));
|
||||
});
|
||||
|
||||
// NOT ASSERTABLE FROM HERE, and stated rather than faked: the release
|
||||
// branch of `environment` is `kReleaseMode ? prod : staging`, and
|
||||
// kReleaseMode is a compile-time const that is false under `flutter test`.
|
||||
// There is no way to exercise the release arm from a debug test run, so
|
||||
// this note is the coverage. Verify it with:
|
||||
//
|
||||
// flutter build apk --release → Account shows no Build panel
|
||||
// flutter build apk --release --dart-define=DM_ENV=staging
|
||||
// → Build panel returns
|
||||
});
|
||||
|
||||
group('development access', () {
|
||||
test('no dev token is compiled into this build', () {
|
||||
// A pasted token is not present unless a define supplies one.
|
||||
@@ -229,33 +268,45 @@ void main() {
|
||||
expect(AppConfig.describe, isNot(contains('DEV TOKEN')));
|
||||
});
|
||||
|
||||
test('the test environment is a bypass, and says so out loud', () {
|
||||
// The Miler app shipped a flag that swapped authentication for a fake
|
||||
// rider on any four digits, and said nothing on screen. The rule that
|
||||
// replaces it: a build that did not sign in must name it. Under
|
||||
// FLUTTER_TEST the dev data is on, so it must read as bypassed and the
|
||||
// environment line must carry the DEV DATA marker.
|
||||
expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test');
|
||||
expect(AppConfig.authBypassed, isTrue);
|
||||
expect(AppConfig.describe, contains('DEV DATA'));
|
||||
test('an offline build says so, every time', () {
|
||||
// The offline build is back, and this is the guarantee that makes it
|
||||
// safe to have. It was deleted once because a build answering from the
|
||||
// fake looked exactly like a build answering from the server — which
|
||||
// cost two rounds of hunting for bookings in the admin console that had
|
||||
// never left the phone.
|
||||
//
|
||||
// A test run IS an offline build, so this asserts the announcement in
|
||||
// the one place it can be asserted at all.
|
||||
expect(AppConfig.useDevData, isTrue);
|
||||
expect(AppConfig.describe, contains('DEV DATA (offline)'));
|
||||
expect(
|
||||
AppConfig.describe,
|
||||
isNot(contains(AppConfig.baseUrl)),
|
||||
reason: 'naming a host a build never contacts is the confusion this '
|
||||
'whole guard exists to prevent',
|
||||
);
|
||||
});
|
||||
|
||||
test('the auto-login default never reaches the tests', () {
|
||||
// Dev data now defaults on in a debug build, and with it the login
|
||||
// screen is skipped — one `flutter run` lands on Home, because staging
|
||||
// has no SMS gateway to send a code through.
|
||||
//
|
||||
// The widget tests must keep walking that entrance anyway: they cover the
|
||||
// phone field, the code screen, and the guard that refuses to open the
|
||||
// code screen when nothing was sent. A session waiting for them at launch
|
||||
// would delete all of it and nothing would fail, so [devAutoLogin] is
|
||||
// switched off under FLUTTER_TEST on purpose. This is that promise.
|
||||
expect(AppConfig.devAutoLogin, isFalse);
|
||||
test('no dev token and no auto-login are compiled in', () {
|
||||
// The two ways to skip the login SCREEN without faking the login. Both
|
||||
// hold a server-issued token; neither define is passed to this run.
|
||||
expect(AppConfig.hasAutoLogin, isFalse);
|
||||
expect(AppConfig.hasDevToken, isFalse);
|
||||
});
|
||||
|
||||
test('the auto-login flag can never fire under test', () {
|
||||
// The widget tests drive the real entrance — phone, code, and the guard
|
||||
// that refuses to open the code screen when no code was sent. A session
|
||||
// waiting for them at launch would delete that coverage and nothing
|
||||
// would fail, so the flag is inert under FLUTTER_TEST whatever the
|
||||
// defines say.
|
||||
expect(AppConfig.isTest, isTrue);
|
||||
expect(AppConfig.hasAutoLogin, isFalse);
|
||||
});
|
||||
|
||||
// The guarantee that matters cannot be asserted from a debug test — every
|
||||
// switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken`
|
||||
// and `allowStageOverride` are all forced false in a release build,
|
||||
// switch is `!kReleaseMode`, so `autoLoginIdentifier`, `hasDevToken` and
|
||||
// `allowStageOverride` are all forced empty/false in a release build,
|
||||
// whatever the defines say. That is what keeps a bypass out of the store.
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
@@ -77,37 +78,40 @@ Future<void> serverAdvance(
|
||||
await settle(tester, 1400);
|
||||
}
|
||||
|
||||
/// Home ▸ BOOK ▸ confirm the pin ▸ destination step.
|
||||
Future<void> openDestination(WidgetTester tester) async {
|
||||
/// 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);
|
||||
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 {
|
||||
/// 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 chips = find
|
||||
.ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable))
|
||||
.first;
|
||||
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);
|
||||
final rect = tester.getRect(finder.first);
|
||||
if (rect.left >= 0 && rect.right <= width) break;
|
||||
}
|
||||
await tester.drag(chips, const Offset(-160, 0));
|
||||
await tester.drag(strip, 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 tester.tap(find.text(name).first);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
@@ -144,8 +148,7 @@ void main() {
|
||||
testWidgets('dragging the map re-resolves the pickup address',
|
||||
(tester) async {
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
// The pin resolved to a street, not a placeholder.
|
||||
expect(find.text('Getting address…'), findsNothing);
|
||||
@@ -164,10 +167,12 @@ void main() {
|
||||
expect(find.text('Getting address…'), findsNothing);
|
||||
expect(pickupAddress(tester), isNot(address));
|
||||
|
||||
// Which is what Next now carries forward.
|
||||
await tester.tap(find.text('Next'));
|
||||
// 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('Where are you sending it?'), findsOneWidget);
|
||||
expect(find.text('Send a parcel'), findsOneWidget);
|
||||
expect(find.text(corrected), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -177,8 +182,7 @@ void main() {
|
||||
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
DmMapPanel map() => tester.widget<DmMapPanel>(find.byType(DmMapPanel));
|
||||
|
||||
@@ -214,9 +218,9 @@ void main() {
|
||||
expect(pickupAddress(tester), 'Brookefields Mall');
|
||||
|
||||
// …and that one point is what the booking carries forward.
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await settle(tester);
|
||||
expect(find.text('Where are you sending it?'), findsOneWidget);
|
||||
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');
|
||||
@@ -228,8 +232,7 @@ void main() {
|
||||
location: FixedLocationService(lat: 11.0183, lng: 76.9725),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
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,}');
|
||||
@@ -256,8 +259,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
final dot = find.byKey(const ValueKey('deviceLocationDot'));
|
||||
expect(dot, findsOneWidget);
|
||||
@@ -284,8 +286,7 @@ void main() {
|
||||
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(tester);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
// A dot in the wrong place is worse than no dot.
|
||||
expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing);
|
||||
@@ -299,8 +300,7 @@ void main() {
|
||||
FixedLocationService.denied(LocationDenial.denied);
|
||||
|
||||
await signIn(tester);
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await settle(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.
|
||||
@@ -312,52 +312,42 @@ void main() {
|
||||
|
||||
// Booking is not blocked by it.
|
||||
expect(
|
||||
tester.widget<DmButton>(find.widgetWithText(DmButton, 'Next')).onPressed,
|
||||
tester
|
||||
.widget<DmButton>(
|
||||
find.widgetWithText(DmButton, 'Use this address'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('one destination, one package, every optional field skipped',
|
||||
testWidgets('one screen books a parcel, 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);
|
||||
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('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);
|
||||
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);
|
||||
@@ -367,180 +357,112 @@ void main() {
|
||||
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);
|
||||
});
|
||||
|
||||
// ── 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 {
|
||||
testWidgets('the address and recipient reach the booking', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Coimbatore');
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
state.setPackageCount(0, 4);
|
||||
await tester.pump();
|
||||
expect(state.draftTotalPackages, 4);
|
||||
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.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.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 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,
|
||||
);
|
||||
|
||||
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 cap holds', (tester) async {
|
||||
testWidgets('the package stepper is on the screen and clamps', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await pickPlace(tester, 'Tamil Nadu', 'Chennai');
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Coimbatore');
|
||||
|
||||
// Push well past the cap; `setPackageCount` must clamp.
|
||||
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);
|
||||
// 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 tester.tap(find.text('Book pickup'));
|
||||
await settle(tester);
|
||||
expect(state.trackedBooking!.totalPackages, state.limits.maxPackages);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the destination cap holds', (tester) async {
|
||||
testWidgets('the window sheet changes the window', (tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openDestination(tester);
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Chennai');
|
||||
final first = state.draftSlotId;
|
||||
|
||||
const places = [
|
||||
('Tamil Nadu', 'Chennai'),
|
||||
('Kerala', 'Ernakulam'),
|
||||
('Karnataka', 'Mysuru'),
|
||||
('Tamil Nadu', 'Erode'),
|
||||
('Kerala', 'Thrissur'),
|
||||
];
|
||||
await tester.tap(find.text('Change').last);
|
||||
await settle(tester);
|
||||
expect(find.text('Pickup window'), findsOneWidget);
|
||||
|
||||
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);
|
||||
}
|
||||
// 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.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,
|
||||
);
|
||||
expect(state.draftSlotId, isNot(first));
|
||||
expect(find.text('Pickup window'), findsNothing);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the picker offers serviceable districts only', (tester) async {
|
||||
testWidgets('the city strip offers serviceable cities only', (tester) async {
|
||||
await signIn(tester);
|
||||
await openDestination(tester);
|
||||
await openSend(tester);
|
||||
|
||||
// Nothing until a state is picked.
|
||||
expect(find.text('Chennai'), findsNothing);
|
||||
|
||||
await tapState(tester, 'Tamil Nadu');
|
||||
// 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'), findsOneWidget);
|
||||
expect(find.text('Ernakulam'), findsNothing);
|
||||
expect(find.text('Coimbatore'), findsWidgets);
|
||||
await pickCity(tester, 'Ernakulam');
|
||||
expect(find.text('Ernakulam'), findsWidgets);
|
||||
|
||||
// Unsupported places are never disabled tiles — they are named once,
|
||||
// quietly, and only because they are genuinely coming.
|
||||
// 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('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);
|
||||
});
|
||||
|
||||
@@ -663,9 +585,9 @@ void main() {
|
||||
await signIn(tester);
|
||||
api.flags.emptyServiceAreas = true;
|
||||
|
||||
await openDestination(tester);
|
||||
await openSend(tester);
|
||||
|
||||
expect(find.text('No service areas available'), findsOneWidget);
|
||||
expect(find.text('No cities open yet'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -673,14 +595,15 @@ void main() {
|
||||
await signIn(tester);
|
||||
api.flags.networkError = true;
|
||||
|
||||
await openDestination(tester);
|
||||
await openSend(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);
|
||||
// The strip comes back with the cities, not with a state to pick first.
|
||||
expect(find.text('Coimbatore'), findsWidgets);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -718,17 +641,8 @@ void main() {
|
||||
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 openSend(tester); // the whole booking, one screen
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await settle(tester); // confirmation
|
||||
|
||||
@@ -128,27 +128,30 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking flow.
|
||||
// Booking: one screen, plus the two escape hatches.
|
||||
await tester.tap(find.text('Start booking'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-pickup-point');
|
||||
await tester.tap(find.text('Next'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Tamil Nadu'));
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Chennai'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '08-destination');
|
||||
await _shot(tester, '07-send-empty');
|
||||
|
||||
await tester.tap(find.text('Next'));
|
||||
await tester.tap(find.text('Chennai').first);
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('2:00 – 4:00 PM').first);
|
||||
await _settle(tester);
|
||||
await _shot(tester, '09-slot');
|
||||
await _shot(tester, '08-send-ready');
|
||||
|
||||
await tester.tap(find.text('Review booking'));
|
||||
// The window row is below the fold on a phone, and a ListView does not
|
||||
// build what it has not reached.
|
||||
// The window already carries the first available slot, so scroll to the
|
||||
// package row beneath it — a string that is there either way.
|
||||
await tester.dragUntilVisible(
|
||||
find.text('1 package'),
|
||||
find.byType(Scrollable).first,
|
||||
const Offset(0, -220),
|
||||
);
|
||||
await _settle(tester);
|
||||
await tester.tap(find.text('Change').last);
|
||||
await _settle(tester);
|
||||
await _shot(tester, '09-window-sheet');
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
await _settle(tester);
|
||||
await _shot(tester, '10-review');
|
||||
|
||||
await tester.tap(find.text('Book pickup'));
|
||||
await _settle(tester);
|
||||
|
||||
@@ -66,9 +66,12 @@ void main() {
|
||||
reason: 'no code was delivered, so there is nothing to verify',
|
||||
);
|
||||
expect(
|
||||
// The entry screen's own headline, which now sits on the brand field
|
||||
// rather than as a heading inside the sheet.
|
||||
find.text('Book a pickup from your door.'),
|
||||
// The entry screen's heading. It used to be the promise on the crimson
|
||||
// ("Book a pickup from your door."); the screen was cut back to an
|
||||
// instruction, a field and a button, so the heading is now the
|
||||
// instruction. What this asserts is unchanged: the customer is still on
|
||||
// the sign-in screen and can try again.
|
||||
find.text('Enter your mobile number'),
|
||||
findsOneWidget,
|
||||
reason: 'the customer stays where they can try again',
|
||||
);
|
||||
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 136 KiB |
BIN
test/snapshots/07-send-empty.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 100 KiB |
BIN
test/snapshots/08-send-ready.png
Normal file
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 204 KiB |
BIN
test/snapshots/09-window-sheet.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 176 KiB |