Redesign: Poppins, a two-step destination, and a splash that says what the app does

The effort pass, end to end. Every screen was run through one test — if I
remove this sentence, does the customer make a worse decision? — and the parts
that failed it are gone.

The flow

  Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked

BOOK opens a sheet, not a form. The destination is browsed state-then-district
because a flat list of every serviceable district survives twelve and not
sixty, and search cuts across states because somebody who knows they are
sending to Chennai should not have to know which state it is in. Districts
multi-select, but only where the server allows it: BookingLimits advertises
maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet
that ignored that would sell a booking the network cannot complete.

The pickup window is now a step the customer answers rather than a slot chosen
for them. A pickup window is a promise about somebody's afternoon.

What the screens stopped saying

Home lost the orb caption for returning customers and a four-cell live card.
Send lost the city strip, both address fields, the optional disclosure and
three sentences about charging — the route, the packages and the button are
what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone
count, a "Step 2 of 7" and a sentence describing the screen you were looking
at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed
evening handover".

Type

Poppins, which has no variable release — four static cuts, and the sans styles
set fontWeight alone because fontVariations on a static font is ignored in
silence. Every weight dropped a step and the tracking went deeper: Poppins is
built on near-circles and carries more ink than the humanist faces before it.

Objects

One lit sphere on Home, and the primary button now takes its gradient and rim
because a committing action that is not lit like the hero reads as a different
material. The tracking rail's connector is crimson as far as the parcel has
come, so the line is the progress bar. Confirmation is a white tick on green:
crimson is this app's action colour and that screen has nothing left to do.

Bugs found on the way

The OTP screen dropped digits. Four fields passing focus along lose a keystroke
that arrives mid-transition, so "1234" became "124" and the screen answered
"That code did not match" — blaming the customer for its own race. One field
now, four boxes that only draw.

Nothing ever asked for the customer's location: detectPickupLocation was the
OTP screen's job, so a restored session or an auto-login never triggered the
permission prompt and the pickup map had nothing to centre on.

The launcher icon and both splash screens pointed at a house drawn as two
vector paths — a placeholder that shipped.

The splash clock started when the widget was built rather than when it was
visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash.
It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a
binding that never reports one cannot strand the app.

Also: design/screens/ holds all 19 screens under readable names, tool/ has the
scripts that refresh them and rebrand the Lottie, and DESIGN.md is current.

flutter analyze clean. 88 tests, 1 skipped.

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-22 17:45:54 +05:30
parent 8207e27a97
commit 06fa6b797a
107 changed files with 4597 additions and 2071 deletions

View File

@@ -53,6 +53,7 @@ class FakeApi extends DoormileApi {
required List<DestinationGroup> destinations,
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? idempotencyKey,
}) async {
createKeys.add(idempotencyKey);

View File

@@ -44,7 +44,10 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
addTearDown(tester.view.reset);
await tester.pumpWidget(DoormileApp(initialState: state));
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
// The CTA only enables once the field rebuilds with a valid number.
@@ -52,12 +55,11 @@ Future<void> signIn(WidgetTester tester, {AppState? state}) async {
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();
}
// One field, not four boxes, and it verifies as soon as the fourth digit
// lands. Entered in one go on purpose: four separate fields dropped a digit
// whenever a keystroke arrived while focus was moving between them, which is
// what typing at any normal speed does.
await tester.enterText(find.byType(TextField).first, '1111');
await settle(tester);
}
@@ -78,40 +80,64 @@ Future<void> serverAdvance(
await settle(tester, 1400);
}
/// Home ▸ Start booking ▸ the send screen.
/// Home ▸ the BOOK orb ▸ the destination sheet.
///
/// One screen now: the pickup point is already known, so there is no step
/// between the CTA and the form.
/// BOOK no longer opens the form. It opens one question — where is it going —
/// and the form is what the answer leads to, so every booking test is
/// [openSend] followed by [pickCity].
Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('Start booking'));
await tester.tap(find.text('BOOK'));
await settle(tester);
}
/// Opens the pickup-point editor from the send screen's "Change".
/// Answers the destination sheet, then the window sheet behind it.
///
/// Two sheets, because the customer now chooses their own pickup window
/// instead of being handed the first one silently. [takeWindow] false leaves
/// the booking without a slot, which is what dismissing the sheet does.
///
/// The destination is reached by searching rather than by opening its state:
/// search cuts across every state from the first step, and it is the path a
/// customer who already knows where they are sending actually takes.
Future<void> pickCity(
WidgetTester tester,
String name, {
bool takeWindow = true,
}) async {
await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250);
// A `Text`, explicitly: `find.text` also matches `EditableText`, so once the
// city's name is in the search box the plain finder returns two widgets and
// `.first` is the box itself. Tapping that only refocuses it.
await tester.tap(
find.byWidgetPredicate((w) => w is Text && w.data == name).first,
);
await settle(tester);
// Where the server allows several destinations the row ticks rather than
// closing the sheet, so the choice has to be confirmed. Where it caps the
// booking at one there is no button and the tap already committed.
final send = find.textContaining('Send to ');
if (send.evaluate().isNotEmpty) {
await tester.tap(send.first);
await settle(tester);
}
final use = find.textContaining('Use ');
if (takeWindow && use.evaluate().isNotEmpty) {
await tester.tap(use.first);
await settle(tester);
} else if (!takeWindow) {
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
await settle(tester);
}
}
/// Opens the pickup-point editor from the send screen's FROM row.
Future<void> openPickupEditor(WidgetTester tester) async {
await openSend(tester);
await 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 pickCity(tester, 'Chennai');
await tester.tap(find.text('FROM'));
await settle(tester);
}
@@ -135,11 +161,46 @@ void main() {
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('the code is one field, typed a digit at a time',
(tester) async {
// The regression this locks: the code used to be four `TextField`s that
// passed focus along, and a keystroke arriving mid-transition was dropped.
// The customer typed four digits, three arrived, and the screen answered
// "That code did not match" — blaming them for its own race.
tester.view.physicalSize = const Size(1230, 9000);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);
await tester.tap(find.text('Continue'));
await settle(tester);
// One field. Nothing to pass focus between, so nothing to drop it in.
expect(find.byType(TextField), findsOneWidget);
final field = find.byType(TextField).first;
for (final sofar in ['1', '12', '123', '1234']) {
await tester.enterText(field, sofar);
await tester.pump();
}
await settle(tester);
// All four digits arrived, so it verified and moved on.
expect(find.text('BOOK'), findsOneWidget);
await drainToasts(tester);
});
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);
expect(find.text('BOOK'), findsOneWidget);
// The seeded active booking surfaces on Home.
expect(find.text('Miler assigned'), findsOneWidget);
await drainToasts(tester);
@@ -171,7 +232,8 @@ void main() {
final corrected = pickupAddress(tester);
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(find.text('Send a parcel'), findsOneWidget);
// Back on the send screen: its title is the route, not a heading.
expect(find.text('TO'), findsOneWidget);
expect(find.text(corrected), findsOneWidget);
await drainToasts(tester);
});
@@ -220,7 +282,7 @@ void main() {
// …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(find.text('TO'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat);
expect(state.draftPickup!.lng, searched.lng);
expect(state.draftPickup!.title, 'Brookefields Mall');
@@ -328,24 +390,23 @@ void main() {
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);
// BOOK asks one question, and it is the only thing on screen.
expect(find.text('Where is it going?'), findsOneWidget);
expect(find.text('Flat or house number'), findsNothing);
await pickCity(tester, 'Chennai');
// Picking the city also takes the first window the backend still has, so
// the common path never opens the sheet.
// Everything else the booking needs is on one screen, and the route to
// the top of it is two rows with no labels but FROM and TO.
expect(find.text('FROM'), findsOneWidget);
expect(find.text('TO'), findsOneWidget);
// Nothing is asked for here at all any more: no address fields, and no
// disclosure hiding three more. The route, the packages and the button.
expect(find.byType(TextField), findsNothing);
expect(find.text('Add landmark or recipient details'), findsNothing);
// The customer answered the window sheet on the way in, so the booking
// already carries a slot they chose.
expect(state.draftSlotId, isNotNull);
expect(find.text('Book pickup'), findsOneWidget);
@@ -362,40 +423,31 @@ void main() {
await drainToasts(tester);
});
testWidgets('the address and recipient reach the booking', (tester) async {
testWidgets('the pickup contact defaults to the account and can be changed',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
await openPickupEditor(tester);
await openSend(tester);
await pickCity(tester, 'Chennai');
// Opens on the signed-in customer's own number, without the country code,
// because the field carries its own prefix.
final contact = find.widgetWithText(TextField, '9876543210');
expect(contact, findsOneWidget);
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'));
// Left alone it stays null, so the request carries the account rather than
// a copy of it that can drift.
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(state.draftContactPhone, isNull);
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');
// Changed, it is the number the Miler will ring at the door.
await tester.tap(find.text('FROM'));
await settle(tester);
await tester.enterText(find.byType(TextField).first, '9003144518');
await settle(tester, 200);
await tester.tap(find.text('Use this address'));
await settle(tester);
expect(state.draftContactPhone, '+91 9003144518');
await drainToasts(tester);
});
@@ -433,7 +485,9 @@ void main() {
await pickCity(tester, 'Chennai');
final first = state.draftSlotId;
await tester.tap(find.text('Change').last);
// The window lives in the footer now, beside the price it is quoted
// against, and the whole left column opens the sheet.
await tester.tap(find.text('PICKUP'));
await settle(tester);
expect(find.text('Pickup window'), findsOneWidget);
@@ -448,21 +502,35 @@ void main() {
await drainToasts(tester);
});
testWidgets('the city strip offers serviceable cities only', (tester) async {
testWidgets('the destination sheet browses state then district',
(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.
// Step one is the states, not sixty district names.
expect(find.text('Tamil Nadu'), findsOneWidget);
expect(find.text('Kerala'), findsOneWidget);
expect(find.text('Chennai'), findsNothing);
// Step two is that state's districts.
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
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.
// Districts that are not open are never offered, and neither is a state
// whose every district is closed.
expect(find.text('Madurai'), findsNothing);
expect(find.text('Puducherry'), findsNothing);
// Back out, then let search cut across states: Ernakulam is in Kerala and
// the customer should not have to know that to find it.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
expect(find.text('Chennai'), findsNothing);
await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets);
await drainToasts(tester);
});
@@ -477,16 +545,21 @@ void main() {
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.
// each destination stands on its own row.
final booking = state.byReference('DM-471200')!;
expect(booking.hasOrders, isTrue);
expect(find.text('Pickup DM-471200'), findsNothing);
expect(find.text('Ernakulam, Kerala'), findsOneWidget);
expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget);
expect(
find.text('Collected on DM-471200'),
findsNWidgets(booking.destinations.length),
);
// Each row is identified by its OWN tracking number, not by the visit that
// produced it. "Collected on DM-471200" used to run under both rows; it
// answers a question asked about an order, not while scanning a list of
// them, so it moved to the order's own screen.
expect(find.text('Collected on DM-471200'), findsNothing);
for (final group in booking.destinations) {
expect(find.text(group.trackingId!), findsOneWidget);
}
// Independently tracked.
expect(
booking.destinations.map((d) => d.trackingId).toSet().length,
@@ -501,7 +574,7 @@ void main() {
await drainToasts(tester);
});
testWidgets('the customer timeline groups the operational states',
testWidgets('the timeline is the shipment, not the pickup visit',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
@@ -509,27 +582,29 @@ void main() {
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);
// The whole shipment is on screen the moment tracking opens — five rows
// fit where the three-row summary and its "Full timeline" action used to.
expect(find.text('Full timeline'), findsNothing);
// 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.
// Five shipment milestones. Everything before the order exists is one
// row — the whole pickup visit, which the live card above narrates by
// name, with a distance and an ETA.
expect(find.text('Booking created'), findsOneWidget);
expect(find.text('Order created'), findsOneWidget);
expect(find.text('In transit'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
expect(find.text('Delivered'), findsOneWidget);
// The visit's own stages are never rows of their own.
expect(find.text('Pickup in progress'), findsNothing);
expect(find.text('Package collected'), findsNothing);
expect(find.text('Miler arrived'), findsNothing);
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.
// Advancing through the visit does not add a row — it moves the detail
// under the one row the whole visit occupies.
await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay);
expect(find.text('Pickup in progress'), findsNWidgets(2));
expect(find.text('Booking created'), findsOneWidget);
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);
});
@@ -602,7 +677,10 @@ void main() {
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.
// It comes back on the step it failed on: the states, not a blank sheet.
expect(find.text('Tamil Nadu'), findsOneWidget);
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
expect(find.text('Coimbatore'), findsWidgets);
await drainToasts(tester);
});
@@ -639,7 +717,7 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP
expect(find.text('Start booking'), findsOneWidget); // home
expect(find.text('BOOK'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai');
@@ -648,7 +726,8 @@ void main() {
await settle(tester); // confirmation
await tester.tap(find.text('Track pickup'));
await settle(tester); // tracking
expect(find.text('Pickup booked'), findsWidgets);
// The live card names the stage; the rail names the shipment milestone.
expect(find.text('Booking created'), findsWidgets);
await drainToasts(tester);
});
@@ -662,13 +741,16 @@ void main() {
addTearDown(tester.view.reset);
await tester.pumpWidget(const DoormileApp());
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.tap(find.text('Create account'));
await settle(tester);
// Building it is the assertion: any overflow in the hero or the form
// throws.
expect(find.text('Create your account.'), findsOneWidget);
expect(find.text('Create your account'), findsOneWidget);
await drainToasts(tester);
});

View File

@@ -28,7 +28,12 @@ import 'package:flutter_test/flutter_test.dart';
/// everything in Ahem's black boxes and the pictures are worthless.
Future<void> _loadFonts() async {
for (final (family, asset) in const [
('Manrope', 'assets/fonts/Manrope-VF.ttf'),
// Every cut, because Poppins is static: a FontLoader given only one file
// renders w800 headings by smearing the one weight it has.
('Poppins', 'assets/fonts/Poppins-Medium.ttf'),
('Poppins', 'assets/fonts/Poppins-SemiBold.ttf'),
('Poppins', 'assets/fonts/Poppins-Bold.ttf'),
('Poppins', 'assets/fonts/Poppins-ExtraBold.ttf'),
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
// The icon font too, or every glyph in the picture is a hollow box.
('packages/lucide_icons_flutter/Lucide',
@@ -39,10 +44,20 @@ Future<void> _loadFonts() async {
}
}
/// Runs the clock forward until a screen has stopped moving.
///
/// Four pumps, not two. An entrance animation is started by a zero-delay
/// `Future.delayed` inside `flutter_animate`, and under the test binding's fake
/// clock that timer only fires on the pump *after* the widget was built — so
/// the controller begins life part-way through the first elapse and a two-pump
/// settle photographs the screen mid-fade. Everything here is under a second,
/// so the extra frames cost nothing and the pictures are of settled screens.
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));
for (var i = 0; i < 4; i++) {
await tester.pump(const Duration(milliseconds: 400));
}
}
Future<void> _shot(WidgetTester tester, String name) async {
@@ -78,18 +93,39 @@ void main() {
final state = AppState();
await tester.pumpWidget(DoormileApp(initialState: state));
await _settle(tester);
// Image.asset resolves off the frame loop, so in a test it never paints
// unless it is decoded first — without this the wordmark is a blank gap in
// every picture of the entrance.
// unless it is decoded first — without this the mark and the wordmark are
// blank gaps in every picture of the entrance.
await tester.runAsync(() async {
await precacheImage(
const AssetImage('assets/images/doormile-logo.png'),
tester.element(find.byType(MaterialApp)),
);
for (final asset in const [
'assets/images/doormile-icon.png',
'assets/images/doormile-logo.png',
]) {
await precacheImage(
AssetImage(asset),
tester.element(find.byType(MaterialApp)),
);
}
});
await _settle(tester);
// Both beats, with raw pumps: `_settle` always runs four extra 400ms
// frames at the end, which is 2.5s — past the moment the splash hands
// over, so it would photograph the login screen instead of either one.
// The clock does not start until the engine says it has rasterized a
// frame and the composition has parsed, which under the test binding is
// about 1.2s. Both shots are taken relative to that, not to pumpWidget.
await tester.pump();
await tester.pump(const Duration(milliseconds: 2000));
await _shot(tester, '00-splash-animation');
// Past the handover, plus a frame for the switch to settle.
await tester.pump(const Duration(milliseconds: 1200));
await tester.pump(const Duration(milliseconds: 400));
await _shot(tester, '00b-splash-mark');
// The splash holds for its own minimum — and the waits in front of it — before handing over.
await _settle(tester, 4400);
await _shot(tester, '01-sign-in');
await tester.tap(find.text('Create account'));
@@ -104,14 +140,18 @@ void main() {
await _settle(tester);
await _shot(tester, '02-verify');
final boxes = find.byType(TextField);
for (var i = 0; i < 4; i++) {
await tester.enterText(boxes.at(i), '1');
await tester.pump();
}
await tester.enterText(find.byType(TextField).first, '1111');
await _settle(tester);
await _shot(tester, '03-home');
// The pickup address is a live value in the header, so the search that
// changes it is reachable from Home rather than only from the booking.
await tester.tap(find.text('38 Mettupalayam Road'));
await _settle(tester);
await _shot(tester, '03b-pickup-search');
await tester.tapAt(const Offset(200, 40)); // through the scrim
await _settle(tester);
await tester.tap(find.text('Orders'));
await _settle(tester);
await _shot(tester, '04-orders');
@@ -125,33 +165,50 @@ void main() {
await tester.tap(find.text('Miler assigned'));
await _settle(tester);
await _shot(tester, '06-tracking');
await tester.dragUntilVisible(
find.text('Cancel this pickup'),
find.byType(Scrollable).first,
const Offset(0, -200),
);
await tester.tap(find.text('Cancel this pickup'));
await _settle(tester);
await _shot(tester, '06b-cancel-sheet');
await tester.tapAt(const Offset(200, 40));
await _settle(tester);
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
// Booking: one screen, plus the two escape hatches.
await tester.tap(find.text('Start booking'));
// Booking: state, district, window, then one screen.
await tester.tap(find.text('BOOK'));
await _settle(tester);
await _shot(tester, '07-send-empty');
await _shot(tester, '07-destination-states');
await tester.tap(find.text('Tamil Nadu'));
await _settle(tester);
await _shot(tester, '07b-destination-districts');
await tester.tap(find.text('Chennai').first);
await _settle(tester);
await _shot(tester, '08-send-ready');
// 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);
// The dev backend allows several destinations, so the row ticked rather
// than closing the sheet — the choice is confirmed.
await tester.tap(find.textContaining('Send to ').first);
await _settle(tester);
// The window sheet follows the destination on its own: the customer
// chooses their slot, it is never chosen for them.
await _shot(tester, '09-window-sheet');
await tester.tap(find.textContaining('Use ').first);
await _settle(tester);
await _shot(tester, '08-send');
// The map editor behind the FROM row: the one screen in the flow whose
// subject is a picture.
await tester.tap(find.text('FROM'));
await _settle(tester);
await _shot(tester, '10-pickup-map');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
await tester.tap(find.text('Book pickup'));
await _settle(tester);
@@ -164,6 +221,7 @@ void main() {
await _settle(tester);
await tester.tap(find.text('Completed'));
await _settle(tester);
await _shot(tester, '04b-orders-completed');
await tester.tap(find.text('Salem, Tamil Nadu'));
await _settle(tester);
await _shot(tester, '12-receipt');

View File

@@ -47,7 +47,10 @@ Future<void> _tapContinue(WidgetTester tester, AppState state) async {
addTearDown(tester.view.reset);
await tester.pumpWidget(DoormileApp(initialState: state));
await settle(tester);
// The splash holds for its own minimum — and the waits in front of it — before handing over, so
// the entrance is waited out once, here, rather than by lengthening every
// settle in the suite.
await settle(tester, 4400);
await tester.enterText(find.byType(TextField).first, '9876543210');
await settle(tester, 200);

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

BIN
test/snapshots/08-send.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 177 KiB

55
test/top_bar_test.dart Normal file
View File

@@ -0,0 +1,55 @@
import 'package:doormile_cx/ui/tokens.dart';
import 'package:doormile_cx/ui/widgets/chrome.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
/// The top bar's one piece of behaviour.
///
/// It reads the [ScrollNotificationObserver] that [Scaffold] installs, which is
/// a framework detail rather than something this app owns — so it is worth a
/// test. If a Flutter upgrade moves that observer, the bar silently goes back
/// to being a flat slab and nothing else in the suite would notice.
void main() {
Color? barColour(WidgetTester tester) {
final box = tester.widget<AnimatedContainer>(
find
.descendant(
of: find.byType(DmTopBar),
matching: find.byType(AnimatedContainer),
)
.first,
);
return (box.decoration as BoxDecoration?)?.color;
}
testWidgets('the bar lifts off the canvas once content is under it',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Probe'),
body: ListView(
children: [
for (var i = 0; i < 40; i++) const SizedBox(height: 40),
],
),
),
),
);
await tester.pumpAndSettle();
// At rest it is the page: no line, no edge, nothing to notice.
expect(barColour(tester), DmColors.canvas);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(barColour(tester), DmColors.surface);
// And back again, so the top of a page is never wearing a line for
// content that has scrolled away.
await tester.drag(find.byType(ListView), const Offset(0, 600));
await tester.pumpAndSettle();
expect(barColour(tester), DmColors.canvas);
});
}