── 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.
158 lines
5.8 KiB
Dart
158 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:doormile_cx/data/dev_doormile_api.dart';
|
|
import 'package:doormile_cx/data/doormile_api.dart';
|
|
import 'package:doormile_cx/data/models.dart';
|
|
import 'package:doormile_cx/state/app_scope.dart';
|
|
import 'package:doormile_cx/state/app_state.dart';
|
|
import 'package:doormile_cx/ui/screens/booking/send_screen.dart';
|
|
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// SENDING A SECOND PARCEL
|
|
///
|
|
/// One booking carries one destination while the server caps it there, so a
|
|
/// customer with two parcels for two places books twice. What that used to
|
|
/// cost them was the whole flow twice — confirm, walk back to Home, re-answer
|
|
/// the door they had not moved from, the window, everything.
|
|
///
|
|
/// [AppState.startBookingFrom] is what makes the second one short. These pin
|
|
/// what it carries and, more importantly, what it refuses to.
|
|
/// ─────────────────────────────────────────────────────────────────────────
|
|
void main() {
|
|
late AppState app;
|
|
late Booking previous;
|
|
|
|
setUp(() async {
|
|
DoormileApi.overrideInstance(DevDoormileApi());
|
|
app = AppState()
|
|
..customer = const Customer(
|
|
id: 'c1',
|
|
name: 'Joe Oommen',
|
|
phone: '+91 98765 43210',
|
|
email: 'joe@example.com',
|
|
);
|
|
await app.refreshOrders();
|
|
previous = app.orders.first;
|
|
});
|
|
|
|
tearDown(() => DoormileApi.overrideInstance(null));
|
|
|
|
group('what a repeat carries', () {
|
|
test('the door, always', () {
|
|
app.startBookingFrom(previous);
|
|
expect(app.draftPickup?.title, previous.pickup.title);
|
|
expect(app.draftPickup?.lat, previous.pickup.lat);
|
|
expect(app.draftPickup?.lng, previous.pickup.lng);
|
|
});
|
|
|
|
test('the destination only when asked', () {
|
|
// "Send another from here" — same door, new route.
|
|
app.startBookingFrom(previous);
|
|
expect(
|
|
app.draftDestinations.first.destination.districtCode,
|
|
isNull,
|
|
reason: 'the customer is choosing somewhere new',
|
|
);
|
|
|
|
// "Send another like this" — same door, same route.
|
|
app.startBookingFrom(previous, keepDestinations: true);
|
|
expect(
|
|
app.draftDestinations.first.destination.districtCode,
|
|
previous.destinations.first.destination.districtCode,
|
|
);
|
|
expect(
|
|
app.draftDestinations.first.packageCount,
|
|
previous.destinations.first.packageCount,
|
|
);
|
|
});
|
|
|
|
test('never the window', () {
|
|
// ── The one field a repeat must not assume ──
|
|
//
|
|
// A slot fills up. Pinning the second booking to the first one's window
|
|
// would be refused at confirm with nothing the customer could act on —
|
|
// and it is a genuinely fresh decision anyway: the first parcel going at
|
|
// 2pm says nothing about when they want the next visit.
|
|
expect(previous.slotId, isNotEmpty);
|
|
app.startBookingFrom(previous, keepDestinations: true);
|
|
expect(app.draftSlotId, isNull);
|
|
});
|
|
|
|
test('a copy, not the booking itself', () {
|
|
app.startBookingFrom(previous, keepDestinations: true);
|
|
app.draftDestinations.first.packageCount = 9;
|
|
|
|
expect(
|
|
previous.destinations.first.packageCount,
|
|
isNot(9),
|
|
reason: 'editing the draft must not rewrite the order it came from',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('the cap is obeyed', () {
|
|
test('a repeat never carries more destinations than the server allows', () {
|
|
final many = Booking(
|
|
reference: 'DM-999999',
|
|
pickup: previous.pickup,
|
|
destinations: [
|
|
for (var i = 0; i < 5; i++)
|
|
DestinationGroup(
|
|
destination: Destination(
|
|
stateCode: 'TN',
|
|
districtCode: 'TN-$i',
|
|
stateName: 'Tamil Nadu',
|
|
districtName: 'District $i',
|
|
),
|
|
),
|
|
],
|
|
slotId: previous.slotId,
|
|
createdAt: DateTime.now(),
|
|
);
|
|
|
|
app.startBookingFrom(many, keepDestinations: true);
|
|
expect(
|
|
app.draftDestinations.length,
|
|
lessThanOrEqualTo(app.limits.maxDestinations),
|
|
);
|
|
});
|
|
});
|
|
|
|
|
|
group('the one-destination cap explains itself', () {
|
|
/// Review with a draft on it, at whatever cap the server is advertising.
|
|
Future<void> pumpReview(WidgetTester tester, {required int cap}) async {
|
|
tester.view.physicalSize = const Size(1230, 9000);
|
|
tester.view.devicePixelRatio = 3.0;
|
|
addTearDown(tester.view.reset);
|
|
|
|
app.limits = BookingLimits(maxPackages: 20, maxDestinations: cap);
|
|
app.startBookingFrom(previous, keepDestinations: true);
|
|
|
|
await tester.pumpWidget(
|
|
AppScope(state: app, child: const MaterialApp(home: SendScreen())),
|
|
);
|
|
for (var i = 0; i < 6; i++) {
|
|
await tester.pump(const Duration(milliseconds: 120));
|
|
}
|
|
}
|
|
|
|
testWidgets('says so when the server allows one', (tester) async {
|
|
// ── Why this needs a test at all ──
|
|
//
|
|
// The dev backend advertises five, so this line never renders in the
|
|
// screenshots or anywhere else a person would look. Production
|
|
// advertises one. A conditional nobody exercises is a conditional that
|
|
// quietly never fires.
|
|
await pumpReview(tester, cap: 1);
|
|
expect(find.textContaining('One destination per pickup'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('stays quiet when it allows more', (tester) async {
|
|
await pumpReview(tester, cap: 5);
|
|
expect(find.textContaining('One destination per pickup'), findsNothing);
|
|
});
|
|
});
|
|
}
|