One Touch, and a pickup map worth the name
Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
@@ -81,23 +81,15 @@ Future<void> serverAdvance(
|
||||
|
||||
/// Home ▸ the BOOK orb ▸ the destination sheet.
|
||||
///
|
||||
/// 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].
|
||||
/// BOOK asks one question — where is it going — and the review screen is what
|
||||
/// the answer leads to, so every booking test is [openSend] then [pickCity].
|
||||
/// The sheet is left open: tests that are about the sheet itself go no
|
||||
/// further.
|
||||
Future<void> openSend(WidgetTester tester) async {
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A finder restricted to the open bottom sheet.
|
||||
///
|
||||
/// Home's live card heads with the district alone — "Chennai", not
|
||||
@@ -107,6 +99,15 @@ Future<void> openSend(WidgetTester tester) async {
|
||||
Finder inSheet(Finder matching) =>
|
||||
find.descendant(of: find.byType(BottomSheet), matching: matching);
|
||||
|
||||
/// Answers the destination sheet, then the window sheet behind it.
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
/// [takeWindow] false dismisses the window sheet rather than answering it,
|
||||
/// which leaves the booking without a slot and Confirm disabled — the review
|
||||
/// screen then carries "Choose a window" as its outstanding row.
|
||||
Future<void> pickCity(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
@@ -118,16 +119,11 @@ Future<void> pickCity(
|
||||
//
|
||||
// `find.text` also matches `EditableText`, so once the city's name is in the
|
||||
// search box the plain finder returns the box as well — and Home, behind the
|
||||
// scrim, now heads its live card with the district alone, so an unscoped
|
||||
// finder can land on a widget the sheet is covering. Scoping to the modal
|
||||
// route fixes both.
|
||||
// scrim, heads its live card with the district alone, so an unscoped finder
|
||||
// can land on a widget the sheet is covering. Scoping to the modal route
|
||||
// fixes both.
|
||||
await tester.tap(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byWidgetPredicate((w) => w is Text && w.data == name),
|
||||
)
|
||||
.first,
|
||||
inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first,
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
@@ -140,6 +136,7 @@ Future<void> pickCity(
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
// The window sheet follows the destination on its own.
|
||||
final use = find.textContaining('Use ');
|
||||
if (takeWindow && use.evaluate().isNotEmpty) {
|
||||
await tester.tap(use.first);
|
||||
@@ -209,7 +206,7 @@ void main() {
|
||||
await settle(tester);
|
||||
|
||||
// All four digits arrived, so it verified and moved on.
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -218,7 +215,7 @@ void main() {
|
||||
|
||||
// Home greets by name, carries the sphere, and ends on the live card.
|
||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||||
// not the rail's "Miler assigned".
|
||||
@@ -250,7 +247,7 @@ void main() {
|
||||
|
||||
// Which is what the editor carries back to the send screen.
|
||||
final corrected = pickupAddress(tester);
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
// Back on the send screen: its title is the route, not a heading.
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
@@ -258,6 +255,103 @@ void main() {
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the full form asks for the drop address; One Touch does not',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
// One Touch: state, district, window, and nothing else is asked.
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Chennai');
|
||||
expect(state.draftDetailed, isFalse);
|
||||
expect(find.text('DROP ADDRESS'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
// Back to Home, and in again through the form's DROP row.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('DROP'));
|
||||
await settle(tester);
|
||||
|
||||
// Destination first, then the door — the address sheet comes on its own.
|
||||
await tester.enterText(find.byType(TextField).first, 'Chennai');
|
||||
await settle(tester, 250);
|
||||
await tester.tap(
|
||||
inSheet(
|
||||
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
|
||||
).first,
|
||||
);
|
||||
await settle(tester);
|
||||
final send = find.textContaining('Send to ');
|
||||
if (send.evaluate().isNotEmpty) {
|
||||
await tester.tap(send.first);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
expect(state.draftDetailed, isTrue);
|
||||
expect(find.text('Where in Chennai?'), findsOneWidget);
|
||||
|
||||
// A building or a street is enough to send somebody to.
|
||||
await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Save address'));
|
||||
await settle(tester);
|
||||
|
||||
// Then the window, then the review — which carries the address as a row.
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
await settle(tester);
|
||||
expect(find.text('DROP ADDRESS'), findsOneWidget);
|
||||
|
||||
final group = state.draftDestinations.first;
|
||||
expect(group.details.building, '3B Lotus Apartments');
|
||||
expect(group.details.exactAddress, isNotNull);
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the collect-from sheet hands off to the map and back',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
// The PICKUP row of Home's pick/drop form opens the sheet.
|
||||
await tester.tap(find.text('PICKUP'));
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsOneWidget);
|
||||
|
||||
// "Select on map" pushes the map screen over the sheet, which stays
|
||||
// mounted underneath.
|
||||
await tester.tap(find.text('Select on map'));
|
||||
await settle(tester);
|
||||
expect(find.text('Confirm pickup point'), findsOneWidget);
|
||||
expect(find.text('PICKUP FROM'), findsOneWidget);
|
||||
|
||||
// Backing out leaves the customer on the search they came from, not Home.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsOneWidget);
|
||||
|
||||
// Confirming closes both and writes the pin as the pickup.
|
||||
await tester.tap(find.text('Select on map'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsNothing);
|
||||
expect(state.pickup, isNotNull);
|
||||
expect(state.hasPickupPoint, isTrue);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('GPS, map drag and search all write one pickup point',
|
||||
(tester) async {
|
||||
final state = AppState(
|
||||
@@ -286,7 +380,7 @@ void main() {
|
||||
|
||||
// 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 tester.tap(find.text('Edit'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Brookefields Mall'));
|
||||
await settle(tester);
|
||||
@@ -300,7 +394,7 @@ void main() {
|
||||
expect(pickupAddress(tester), 'Brookefields Mall');
|
||||
|
||||
// …and that one point is what the booking carries forward.
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
expect(state.draftPickup!.lat, searched.lat);
|
||||
@@ -396,7 +490,7 @@ void main() {
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(
|
||||
find.widgetWithText(DmButton, 'Use this address'),
|
||||
find.widgetWithText(DmButton, 'Confirm pickup point'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
@@ -410,12 +504,17 @@ void main() {
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openSend(tester);
|
||||
// BOOK asks one question, and it is the only thing on screen.
|
||||
expect(find.text('Where is it going?'), findsOneWidget);
|
||||
// BOOK asks one question, and it is the only thing on screen. Scoped to
|
||||
// the sheet: Home's DROP row asks the same question behind the scrim.
|
||||
expect(inSheet(find.text('Where is it going?')), findsOneWidget);
|
||||
expect(find.text('Flat or house number'), findsNothing);
|
||||
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
// The window was the outstanding row on review, and answering it is what
|
||||
// enables the button.
|
||||
expect(find.text('Review and book'), findsOneWidget);
|
||||
|
||||
// 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('ORIGIN'), findsOneWidget);
|
||||
@@ -449,6 +548,11 @@ void main() {
|
||||
await signIn(tester, state: state);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
// The contact is folded away — the screen exists to place a pin, and the
|
||||
// number is right nearly every time — so it has to be asked for.
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
|
||||
// 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');
|
||||
@@ -456,16 +560,18 @@ void main() {
|
||||
|
||||
// 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 tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, isNull);
|
||||
|
||||
// Changed, it is the number the Miler will ring at the door.
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, '9003144518');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, '+91 9003144518');
|
||||
await drainToasts(tester);
|
||||
@@ -694,9 +800,6 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows the empty service-area state', (tester) async {
|
||||
// Set before signing in. Home's quick-dispatch row calls `loadCities()`
|
||||
// as soon as it builds, which warms `statesCache` — good for the sheet,
|
||||
// which now opens instantly, and fatal to a flag flipped afterwards.
|
||||
api.flags.emptyServiceAreas = true;
|
||||
await signIn(tester);
|
||||
|
||||
@@ -757,11 +860,21 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester); // login + OTP
|
||||
expect(find.text('BOOK NOW'), findsOneWidget); // home
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget); // home
|
||||
|
||||
await openSend(tester); // the whole booking, one screen
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
// The pickup map: a fixed-height sheet over a full-bleed map, so large
|
||||
// text has nowhere to grow into. Opening the contact fold is the tallest
|
||||
// it ever gets.
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await settle(tester); // confirmation
|
||||
await tester.tap(find.text('Track pickup'));
|
||||
|
||||
@@ -182,8 +182,8 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking: state, district, window, then one screen.
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
// Booking: BOOK asks where, then the review screen asks the rest.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-destination-states');
|
||||
|
||||
@@ -191,10 +191,14 @@ void main() {
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07b-destination-districts');
|
||||
|
||||
// The live card, by its reference. Home's quick-dispatch chips are
|
||||
// named after cities too, so a bare 'Chennai' finder now hits a
|
||||
// shortcut that starts a new booking instead.
|
||||
await tester.tap(find.text('DM-482913'));
|
||||
await tester.tap(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.text('Chennai'),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await _settle(tester);
|
||||
// The dev backend allows several destinations, so the row ticked rather
|
||||
// than closing the sheet — the choice is confirmed.
|
||||
|
||||
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |