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

@@ -178,6 +178,34 @@ class AppState extends ChangeNotifier {
void _afterSignIn() {
unawaited(refreshOrders());
unawaited(detectPickupLocation());
// ── The serviceable set, before anybody asks for it ──
//
// Two things read it, and both want it already there. Home's reach line
// states how far Doormile goes and is simply absent until the cities are
// known — so warming it only when the booking sheet opens meant the line
// appeared *after* the one moment it was written to inform. And the sheet
// itself skips its loading skeleton when [cachedCities] is populated.
//
// It is small, it is the same list every customer gets, and it is the
// answer to a question asked on the first screen.
unawaited(_warmServiceArea());
}
/// Loads the serviceable cities and tells the screens they arrived.
///
/// The notify is the point. [loadCities] fills [statesCache] and
/// [districtCache] and returns — it changes no observable field, so nothing
/// rebuilds, and Home's reach line stayed absent while the data it needed sat
/// in the cache beside it. A silent warm-up is only a warm-up for whoever
/// asks next.
Future<void> _warmServiceArea() async {
try {
await loadCities();
} on ApiException catch (e) {
debugPrint('[AREA] could not prefetch the serviceable cities: $e');
return;
}
notifyListeners();
}
/// Called by the API layer when a refresh fails — the chain is dead, so the
@@ -511,8 +539,40 @@ class AppState extends ChangeNotifier {
/// Null means the signed-in customer, which is the answer nearly every time
/// — so the field on the pickup screen opens prefilled with their number and
/// this stays null until they change it.
/// Who the Miler rings at the door, when it is not the account holder.
///
/// ── What the rider actually receives ──
///
/// Nothing the app sends on `pickup` reaches them. `GET /miler/bookings`
/// returns exactly one phone field, `customerphone` — verified against
/// production and written down in the Miler app's own `stop_contact.dart` —
/// and the backend derives it from this booking's **account**. So the number
/// on the rider's screen is the number the customer signed in with, always.
///
/// There is no second contact field in the contract to put this in. It
/// travels in `remarks`, which the contract does store and the console does
/// show, so a human sees it even though the rider's call button will still
/// dial the account. Until the backend carries a real handover contact, that
/// is the honest ceiling — and the booking screen says so rather than
/// implying the rider will ring this number.
String? draftContactPhone;
/// The handover person's name, so a rider ringing an unfamiliar number knows
/// who they are asking for. A number with no name is a cold call.
String? draftContactName;
/// Records who is handing the parcel over, and tells the screens.
///
/// The two fields were being assigned directly, which is why Review showed
/// nothing after the pickup screen popped back to it: `Navigator.pop` does
/// not rebuild the route it reveals, so the contact card kept the build it
/// had from before the customer typed anything.
void setHandoverContact({String? name, String? phone}) {
draftContactName = name;
draftContactPhone = phone;
notifyListeners();
}
Future<Booking> confirmBooking() async {
final key = _bookingIdempotencyKey ??= _newIdempotencyKey();
try {
@@ -522,6 +582,7 @@ class AppState extends ChangeNotifier {
slotId: draftSlotId,
fare: draftFare,
contactPhone: draftContactPhone,
contactName: draftContactName,
idempotencyKey: key,
);
// The intent is spent. A further booking needs a new key or the server
@@ -792,11 +853,22 @@ class AppState extends ChangeNotifier {
final states = statesCache;
if (states == null) return null;
// ── Both caches hold more than the picker offers ──
//
// [statesCache] is the whole response, closed states included, and
// [loadCities] only ever fetches districts for the open ones — so walking
// every cached state looking for its districts finds a hole and concludes
// nothing is cached. That is exactly what happened: `loadCities` returned
// eleven cities and this getter returned null beside it.
//
// [districtCache] is the whole response too. The same two filters
// `loadStates` and `loadDistricts` apply have to be applied here, or this
// would offer a district the picker will not show.
final out = <CityOption>[];
for (final state in states) {
for (final state in states.where((s) => s.hasOpenDistricts)) {
final districts = districtCache[state.code];
if (districts == null) return null;
for (final district in districts) {
for (final district in districts.where((d) => d.available)) {
out.add(CityOption(state: state, district: district));
}
}
@@ -819,6 +891,41 @@ class AppState extends ChangeNotifier {
];
}
/// Starts a new draft carrying over what a previous booking already answered.
///
/// ── What is carried, and what deliberately is not ──
///
/// The **pickup** is carried. It is the same door; asking for it again is
/// asking a customer to confirm where they are standing.
///
/// The **destinations** are carried only when the caller asks — "send again"
/// from a past order means the same route, "send another from here" means
/// the same door and a new route.
///
/// The **window is never carried.** A slot fills up, and a second booking
/// silently pinned to one that is now full would be refused at confirm with
/// nothing the customer could act on. It is also the one field that is
/// genuinely a fresh decision: the first parcel going at 2pm says nothing
/// about when they want the next visit.
void startBookingFrom(
Booking previous, {
bool detailed = false,
bool keepDestinations = false,
}) {
startBooking(detailed: detailed);
draftPickup = previous.pickup;
if (keepDestinations && previous.destinations.isNotEmpty) {
draftDestinations = [
for (final group in previous.destinations.take(limits.maxDestinations))
DestinationGroup(
destination: group.destination.copy(),
packageCount: group.packageCount,
),
];
}
notifyListeners();
}
/// Picks a city on the draft's only destination — both codes at once, since
/// the contract wants `stateCode` and `districtCode` together.
void selectCity(CityOption city, {int index = 0}) {