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
This commit is contained in:
2026-09-24 15:59:06 +05:30
parent 86b6af48c2
commit 8956e818a4
42 changed files with 2059 additions and 956 deletions

View File

@@ -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'));

View File

@@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB