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
This commit is contained in:
2026-09-21 13:10:28 +05:30
parent 0d66627c3c
commit 8207e27a97
37 changed files with 1382 additions and 1566 deletions

View File

@@ -1,89 +1,114 @@
import 'package:doormile_cx/data/app_config.dart';
import 'package:doormile_cx/data/location_service.dart';
import 'package:doormile_cx/main.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
/// Drives the real app on the simulator all the way through a booking, then
/// holds on the confirmation so it can be photographed from outside.
/// Proves that a build carrying a real token reaches a real, authenticated
/// booking screen — on a device, against the real backend.
///
/// Run against the dev data, which is where any 4-digit code signs in and the
/// serviceability / slots / booking all exist without a server:
/// ── Why this file shrank ──
///
/// It used to walk the whole booking: sign in with `9876543210` and four 1s,
/// then pickup → destination → window → review → confirmation. Both halves of
/// that stopped being true.
///
/// * **The sign-in** only ever worked under the offline `DM_MOCK` build,
/// deleted on 15 Sep 2026 after it cost two rounds of hunting for bookings
/// in the admin console that had never left the phone. There is no longer a
/// four-digit code that signs anybody in.
/// * **The flow** was rebuilt into ONE screen plus two sheets. There is no
/// "Where are you sending it?" page, no "When should we come?" page and no
/// review page left to walk; the city is a horizontal strip and the window
/// is a modal sheet.
///
/// So every finder in it referred to something that no longer exists, and it
/// could not pass. What replaces it is deliberately narrower than what it
/// claimed to do, and everything in it has been checked against the current
/// screens rather than remembered.
///
/// **What this asserts:** the app adopts a server-issued token through the same
/// session store a normal login writes to, asks the backend who that is, opens
/// the real Home, and reaches a booking screen whose empty-draft state is
/// rendered from live serviceability. Every call in that chain is a real call.
///
/// **What it does not assert:** the booking itself. Completing it needs a city
/// tapped out of a server-loaded strip and a window chosen from a sheet, and a
/// test that guesses at those without ever having run is worth less than an
/// honest gap. Do that part by hand on the device — the button tells you where
/// you are, in this order:
///
/// "Pick a city to see the price" → "Choose a pickup window" → "Book pickup"
///
/// Run it:
///
/// flutter test integration_test/book_one_test.dart \
/// -d <simulator> --dart-define=DM_MOCK=true
/// -d <device> \
/// --dart-define=DM_DEV_TOKEN=eyJ...
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('books a pickup to Chennai and lands on the confirmation',
testWidgets('a real token opens a real, authenticated booking screen',
(tester) async {
// Deterministic pin, so the pickup step never stops on an OS location
// dialog and resolves the same way every run.
// ── Skip rather than fail confusingly ──
//
// With no token the app correctly opens the login screen, and every finder
// below would fail on a missing widget — which reads as "the app is broken"
// when the truth is "this run was given no identity". Say the missing thing
// by name, once.
if (!AppConfig.authBypassed) {
markTestSkipped(
'No DM_DEV_TOKEN (or DM_LOGIN_AS) — this test runs against the real '
'backend and needs a real session. Pass '
'--dart-define=DM_DEV_TOKEN=<server-issued token>.',
);
return;
}
// Deterministic pin, so the pickup row never stops on an OS location dialog
// and resolves the same way every run.
LocationService.instance = FixedLocationService();
await tester.pumpWidget(const DoormileApp());
await tester.pumpAndSettle(const Duration(milliseconds: 400));
// The app runs continuous animations, so pumpAndSettle would spin forever.
// Pump fixed slices instead.
// The app runs continuous animations, so pumpAndSettle never returns. Pump
// fixed slices instead.
Future<void> hold([int ms = 900]) async {
await tester.pump();
await tester.pump(Duration(milliseconds: ms));
await tester.pump(const Duration(milliseconds: 400));
}
// ---- sign in: 9876543210 + any 4-digit code ----------------------------
await tester.enterText(find.byType(TextField).first, '9876543210');
await hold(300);
await tester.tap(find.text('Continue'));
await hold();
// Longer than the other holds on purpose: this is two network calls — the
// token is adopted into the session store, then `GET /customer/auth/me` is
// asked who it belongs to — and against a real server they are not instant.
await hold(4000);
final boxes = find.byType(TextField);
for (var i = 0; i < 4; i++) {
await tester.enterText(boxes.at(i), '1');
await tester.pump();
}
await hold(1200);
expect(find.text('Where should we pick up?'), findsOneWidget);
debugPrint('DRIVER: signed in, on Home');
expect(
find.text('Where should we pick up?'),
findsOneWidget,
reason: 'the app did not reach Home. A token that is expired or revoked '
'lands on the login screen by design — that is the session being '
'refused, not a bug in the app.',
);
debugPrint('DRIVER: real session restored from DM_DEV_TOKEN, on Home');
// ---- BOOK → confirm the pin -----------------------------------
await tester.tap(find.text('BOOK'));
await hold(1200);
await tester.tap(find.text('Next'));
await hold();
expect(find.text('Where are you sending it?'), findsOneWidget);
await hold(2500);
// ---- destination: Tamil Nadu ▸ Chennai ---------------------------------
await tester.tap(find.text('Tamil Nadu'));
await hold();
await tester.tap(find.text('Chennai'));
await hold();
expect(find.text('Chennai, Tamil Nadu'), findsOneWidget);
await tester.tap(find.text('Next'));
await hold();
expect(find.text('Send a parcel'), findsOneWidget);
// ---- pickup window -----------------------------------------------------
expect(find.text('When should we come?'), findsOneWidget);
await tester.tap(find.text('2:00 – 4:00 PM').first);
await hold();
await tester.tap(find.text('Review booking'));
await hold(1200);
// ---- review → book -----------------------------------------------------
expect(find.text('Review pickup details'), findsOneWidget);
debugPrint('DRIVER: on review');
await tester.tap(find.text('Book Pickup'));
await hold(1600);
// ---- confirmation ------------------------------------------------------
expect(find.text('Pickup booked'), findsOneWidget);
debugPrint('DRIVER: BOOKED — holding on confirmation');
// Hold so the confirmation can be photographed from outside the test.
for (var i = 0; i < 50; i++) {
await tester.pump(const Duration(milliseconds: 500));
}
// The empty-draft state, in the button's own words. Reaching this proves
// the screen built against live serviceability rather than a fixture: the
// city strip is an async list off the server, and this is the label the
// screen shows while nothing in it is selected.
expect(
find.text('Pick a city to see the price'),
findsOneWidget,
reason: 'the booking screen did not reach its empty-draft state',
);
debugPrint('DRIVER: on the booking screen, awaiting a city — '
'finish by hand from here');
});
}