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

@@ -14,10 +14,9 @@ import '../widgets/book_orb.dart';
import '../widgets/misc.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'sheets/destination_sheet.dart';
import 'sheets/drop_address_sheet.dart';
import 'sheets/pickup_sheet.dart';
import 'sheets/place_search_sheet.dart';
import 'sheets/window_sheet.dart';
import 'tracking_screen.dart';
/// Home — two questions, and nothing else on the screen.
@@ -53,28 +52,37 @@ class _HomeScreenState extends State<HomeScreen> {
/// BOOK. The one action on this screen, and the whole flow's front door.
///
/// ── Where, then when, then the review ──
/// ── One sheet, then the review ──
///
/// Two sheets in sequence: states → districts, then the pickup window. The
/// second rises while the first is still falling, so the handover reads as
/// one surface changing its question rather than as a sheet failing and
/// another arriving.
/// It was two sheets in sequence — destination, then window — with a dismiss
/// between them. [showPickupSheet] asks both on one surface that never
/// leaves, which is what the old comment here hoped the sequence would look
/// like and it never did.
///
/// Dismissing the window sheet is allowed. The review screen carries the
/// window as its own row — brand-coloured while it is unanswered — and the
/// button stays disabled until there is one, so backing out of the second
/// sheet costs a tap rather than the booking.
/// The full form asks for doors afterwards rather than in the middle: the
/// two questions every booking needs are answered first and together, and
/// the addresses — which only the detailed path wants — come last, next to
/// the review that shows them.
Future<void> _book({bool detailed = false}) async {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
setState(() => _handingOver = true);
try {
final places = await showDestinationSheet(context);
if (places == null || places.isEmpty || !mounted) return;
final asked = await showPickupSheet(context);
if (asked == null || asked.places.isEmpty || !mounted) return;
app.startBooking(detailed: detailed);
app.setDestinations(places);
app.setDestinations(asked.places);
// ── The slot is re-selected, not assumed ──
//
// [WindowPicker] writes it as the customer taps, which is right when it
// is opened on its own from Review. Opened as the second step of the
// pickup sheet it writes it *before* the draft exists, and
// `startBooking` clears `draftSlotId` — so the booking arrived at Review
// with no window and a disabled button. Writing it after the reset is
// the only ordering that holds for both callers.
app.selectSlot(asked.slot);
// The full form asks for the door before it asks for the window, and it
// asks once per destination: a visit that fans out to three places is
@@ -93,9 +101,6 @@ class _HomeScreenState extends State<HomeScreen> {
}
if (!mounted) return;
await showWindowSheet(context);
if (!mounted) return;
await navigator.push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
@@ -292,6 +297,25 @@ class _HomeScreenState extends State<HomeScreen> {
duration: 620.ms,
curve: Curves.easeOutBack,
),
// ── Where we deliver, said before anybody
// commits ──
//
// A customer cannot tell from a sphere whether
// Doormile goes where their parcel needs to go,
// and the only place that answered it was two
// taps inside the booking flow. One line says
// it, and tapping it opens the same list the
// booking uses — the answer itself rather than a
// second screen repeating it.
//
// Inside the scroll column, not pinned under it.
// Pinned, it took 32 points Home did not have
// and overflowed the fold by 11. In here its
// room is reserved out of the sphere's glow by
// [_captionRoom], which is the mechanism that
// already stops the caption falling off — so the
// sphere gives way and nothing is clipped.
_ReachLine(onTap: () => _book()),
],
),
),
@@ -318,7 +342,7 @@ class _HomeScreenState extends State<HomeScreen> {
// nothing between them the sentence read as the card's heading.
// It costs the sphere 14 points of glow and nothing else — the
// field is whatever is left over now. See [DmBookOrb.field].
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 10, DmSpace.pad, 0),
child: _PickDropForm(
pickup: app.pickup?.title,
onPickup: () async {
@@ -337,9 +361,20 @@ class _HomeScreenState extends State<HomeScreen> {
);
}
/// The line under the sphere, plus its gap. Reserved out of the sphere's
/// field rather than laid out after it and hoped for.
static const _captionRoom = 28.0;
/// What sits under the sphere inside the scroll column — its caption, and
/// the reach line — plus their gaps.
///
/// Reserved out of the sphere's field rather than laid out after it and
/// hoped for. Anything added below the sphere has to be counted here or it
/// will be the thing that falls off the bottom, silently, which has now
/// happened often enough to be the rule rather than the exception.
///
/// Reserved whether or not the reach line renders. When it does not — the
/// first seconds of a cold start, before the serviceable cities land — the
/// sphere simply has room to spare, which nobody can see. A field that grew
/// the moment the cities arrived would be a sphere that resized itself on
/// screen for no reason the customer could name.
static const _captionRoom = 28.0 + _ReachLine.height;
static String _firstName(String? name) {
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
@@ -367,6 +402,86 @@ class _HomeScreenState extends State<HomeScreen> {
}
}
/// "Delivering to 11 cities across 3 states" — the reach, in one line.
///
/// ── Why this is not a page ──
///
/// The question it answers is binary: *do you go where I need?* A customer who
/// gets "yes" needs nothing more, and one who gets "no" needs the list, which
/// is one tap away and is the same list the booking step uses. A screen of its
/// own would be a second copy of that list to keep in step.
///
/// Counted from the cache rather than fetched: the numbers are the serviceable
/// set the destination step already loaded, so this cannot disagree with what
/// the customer sees when they open it.
class _ReachLine extends StatelessWidget {
const _ReachLine({required this.onTap});
/// Fixed, and the same number [_HomeScreenState._captionRoom] reserves.
///
/// A line whose height depended on its own padding and text metrics was a
/// number I had to estimate twice and got wrong both times — the fold
/// overflowed by eleven points, which is a precise amount of nothing to
/// debug. Declared once, consumed once.
static const double height = 40;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cities = AppScope.of(context).cachedCities;
if (cities == null || cities.isEmpty) return const SizedBox.shrink();
final states = <String>{for (final c in cities) c.state.code}.length;
final label =
'Delivering to ${cities.length} '
'${cities.length == 1 ? 'city' : 'cities'}'
'${states > 1 ? ' across $states states' : ''}';
return SizedBox(
height: height,
child: Center(
child: InkResponse(
onTap: onTap,
radius: 24,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
// ── The label gives way; the row does not push ──
//
// A `Row` of `min` size takes its intrinsic width and overflows
// whatever it is in when that is wider — and this sentence gets
// longer as Doormile opens more states, and longer again at a
// large text scale. It is one quiet line: it may ellipsize, but it
// must never be the thing that breaks the fold.
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.mapPinned,
size: 13,
color: DmColors.ink4,
),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
),
),
],
),
),
),
),
);
}
}
/// The live booking, at the head of Home.
///
/// ── Smaller, and without the courier ──