Five payload bugs, four pages behind dead rows, and one sheet

── The full-address path was reaching the Miler empty ──

`DestinationGroup.toBookingJson` spread its details FLAT across the
destination. The contract nests them under `details{}`, and a destination
carrying keys the server does not recognise is accepted without a word — so
every building number, street, landmark, recipient name, recipient phone and
pin a customer typed was written, answered 201, and thrown away. The Miler
arrived with a district.

Four more on the same call. The destination pin spelled `latitude`/`longitude`
— the same spelling that answered 422 unserviceable for months on the pickup
before it was fixed there and missed here. A PATCH that sent `null` to clear a
field, with a comment saying so, when the server writes only non-nil values, so
a landmark could be added and never removed. Per-destination `instructions`
folded into the visit's one `remarks` line on the belief the contract had no
per-destination note; it has one. And `contactName`/`contactPhone` on the
pickup object, which the create contract has no room for and drops.

The fix ships unverified, deliberately. If `details{}` is also the wrong shape
the fields drop exactly as they do today — it cannot be worse, and holding it
costs every full-address booking in the meantime. docs/BACKEND_CHANGES.md asks
for the confirmation; tool/verify_booking.sh runs it in one command.

── Who the Miler rings ──

One number reaches the rider and it is the account's: `GET /miler/bookings`
returns a single `customerphone`, verified against production and written down
in the rider app's own stop_contact.dart. So "Someone else is handing it over?"
was collecting a number that reached nobody.

Review now shows the number that will actually be dialled, and the handover
person travels in `remarks` with a name, labelled for whoever reads it. Both
screens say plainly that the rider's call button still dials the account —
better than letting somebody hand their parcel to a neighbour believing
otherwise.

── Account's rows led nowhere ──

Two had no `onTap` at all — a chevron pointing at a page that did not exist —
and three answered with a toast. Five rows making a promise, one keeping it.

Notifications, Payment, Help and About are real screens now, written to one
rule: say only what is true of this app today. There is no notification
endpoint, no stored payment instrument and no push SDK wired in, so none of
them pretends to manage any of that. Support shows no contact block at all
rather than a number that rings nowhere — AppConfig carries the fields empty
until somebody fills them in.

── ONE TOUCH is one sheet ──

It was two in sequence with a dismissal between them, and the destination step
made you open a state to see any city — two levels of navigation for something
its own search already flattened. One flat list headed by state, which is also
the answer to "where do you deliver?", and one surface that changes its
question instead of closing so another can open.

Home says the reach in a line, and it needed two fixes to appear at all:
`cachedCities` walked closed states looking for districts that are only fetched
for open ones, and `loadCities` filled two caches while notifying nobody.

── Sending a second parcel ──

`maxDestinations` is 1 in production, so two parcels for two places means
booking twice — and that cost the whole flow twice, re-answering a door the
customer had not moved from. `startBookingFrom` carries the door, carries the
destination only when asked, and never carries the window: a slot fills up, and
a second booking pinned to one that is now full is refused at confirm with
nothing the customer can act on.

Review also says why there is no "add another destination", so a cap reads as a
limit rather than a missing button.

── Bundle ──

pubspec named its images one by one. Declaring `assets/images/` as a folder
shipped a 974 KB launcher-icon master to every customer for a file no code
opens.
This commit is contained in:
2026-09-29 12:31:58 +05:30
parent 8427824951
commit c3e25feaea
59 changed files with 3013 additions and 443 deletions

View File

@@ -99,20 +99,20 @@ 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.
/// Answers both questions on the pickup sheet.
///
/// 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.
/// ── One sheet now, not two ──
///
/// [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, {
bool takeWindow = true,
}) async {
/// It used to be a destination sheet that closed so a window sheet could open
/// behind it, and this helper drove both. `showPickupSheet` asks them on one
/// surface, so the second half is a step rather than a new sheet and its
/// button reads "Confirm pickup" rather than "Use 2:00 – 4:00 PM".
///
/// The `takeWindow: false` branch went with the change: dismissing now drops
/// the destination too, because there is only one sheet to dismiss, so a
/// booking that reaches Review without a slot is no longer reachable from
/// Home. Nothing passed it.
Future<void> pickCity(WidgetTester tester, String name) async {
await tester.enterText(find.byType(TextField).first, name);
await settle(tester, 250);
// A `Text` inside the sheet, explicitly.
@@ -136,13 +136,10 @@ 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);
await settle(tester);
} else if (!takeWindow) {
await tester.tapAt(const Offset(200, 40)); // dismiss through the scrim
// The same sheet, now asking when.
final confirm = find.text('Confirm pickup');
if (confirm.evaluate().isNotEmpty) {
await tester.tap(confirm.first);
await settle(tester);
}
}
@@ -278,20 +275,13 @@ void main() {
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);
}
// ── Where and when first, the door afterwards ──
//
// The address sheet used to land between the destination and the window.
// Both paths now answer where-and-when on one sheet, and the full form's
// extra question — which door — comes after it, next to the review that
// shows it.
await pickCity(tester, 'Chennai');
expect(state.draftDetailed, isTrue);
expect(find.text('Where in Chennai?'), findsOneWidget);
@@ -302,9 +292,7 @@ void main() {
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);
// Then the review, which carries the address as a row.
expect(find.text('DROP ADDRESS'), findsOneWidget);
final group = state.draftDestinations.first;
@@ -564,16 +552,38 @@ void main() {
await settle(tester);
expect(state.draftContactPhone, isNull);
// Changed, it is the number the Miler will ring at the door.
// ── Changed, it is a note — not the number the Miler's button dials ──
//
// That is what this test used to claim. The rider's number is derived by
// the backend from the booking's **account**: `GET /miler/bookings` sends
// one phone field and nothing the create request carries can change it.
// The handover person travels in `remarks` instead, with a name, and both
// this screen and Review say so rather than implying otherwise.
await tester.tap(find.text('PICKUP'));
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');
// By value, not by position: the fold asks for a name first now, and
// `.first` quietly typed the phone number into it.
await tester.enterText(
find.widgetWithText(TextField, '9876543210'),
'9003144518',
);
await tester.enterText(find.byType(TextField).first, 'Meera S');
await settle(tester, 200);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(state.draftContactPhone, '+91 9003144518');
expect(state.draftContactName, 'Meera S');
// Review shows the account's number as the one that will be called, and
// the handover person beside it.
expect(find.text('YOUR MILER WILL CALL'), findsOneWidget);
// Whatever shape the account's number is in — the dev backend echoes the
// typed identifier, production sends E.164 — it is the one on the card.
expect(find.text('+91 9876543210'), findsWidgets);
expect(find.textContaining('Meera S'), findsWidgets);
await drainToasts(tester);
});
@@ -629,36 +639,35 @@ void main() {
await drainToasts(tester);
});
testWidgets('the destination sheet browses state then district',
testWidgets('the destination step is one flat list, not two levels',
(tester) async {
await signIn(tester);
await openSend(tester);
// Step one is the states, not sixty district names.
expect(inSheet(find.text('Tamil Nadu')), findsOneWidget);
expect(inSheet(find.text('Kerala')), findsOneWidget);
expect(inSheet(find.text('Chennai')), findsNothing);
// Step two is that state's districts.
await tester.tap(find.text('Tamil Nadu'));
await settle(tester);
// ── What this replaced ──
//
// The sheet used to open on states and hide every city until one was
// tapped, so the first thing a customer saw was a question about
// geography rather than an answer about service. The old test asserted
// that Chennai was *absent* from step one.
//
// Now the state is a heading and the cities are all there. The list is
// also the answer to "where do you deliver?", which is why Home's reach
// line opens this and not a second screen of its own.
expect(inSheet(find.text('TAMIL NADU')), findsOneWidget);
expect(inSheet(find.text('KERALA')), findsOneWidget);
expect(inSheet(find.text('Chennai')), findsOneWidget);
expect(inSheet(find.text('Coimbatore')), findsWidgets);
expect(inSheet(find.text('Ernakulam')), findsOneWidget);
// Districts that are not open are never offered, and neither is a state
// whose every district is closed.
expect(inSheet(find.text('Madurai')), findsNothing);
expect(inSheet(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(inSheet(find.text('Chennai')), findsNothing);
// And search still cuts across states: Ernakulam is in Kerala and the
// customer should not have to know that to find it.
await pickCity(tester, 'Ernakulam');
expect(find.textContaining('Ernakulam'), findsWidgets);
await drainToasts(tester);
});
testWidgets('orders split into their own rows once collected', (tester) async {
@@ -820,11 +829,12 @@ void main() {
api.flags.networkError = false;
await tester.tap(find.text('Retry'));
await settle(tester);
// 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);
// It comes back with the list it failed to load, not a blank sheet. The
// state is a heading now — upper-cased and not a step to tap through — so
// the cities are there without a second navigation.
expect(find.text('TAMIL NADU'), findsOneWidget);
expect(find.text('Coimbatore'), findsWidgets);
expect(find.text('Chennai'), findsWidgets);
await drainToasts(tester);
});