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:
@@ -8,8 +8,10 @@ import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../../widgets/route_rail.dart';
|
||||
import '../sheets/pickup_sheet.dart';
|
||||
import '../tracking_screen.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'send_screen.dart';
|
||||
|
||||
/// Booking confirmed.
|
||||
///
|
||||
@@ -66,6 +68,30 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Books again from the same door.
|
||||
///
|
||||
/// `pushReplacement`, not `push`: the confirmation for the booking just made
|
||||
/// has served its purpose, and leaving it under the new one would put a
|
||||
/// stale reference behind the customer's back button.
|
||||
Future<void> _sendAnother(BuildContext context) async {
|
||||
final app = AppScope.read(context);
|
||||
final previous = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
||||
if (previous == null) return;
|
||||
|
||||
final asked = await showPickupSheet(context);
|
||||
if (asked == null || asked.places.isEmpty || !context.mounted) return;
|
||||
|
||||
app.startBookingFrom(previous);
|
||||
app.setDestinations(asked.places);
|
||||
// After the reset, for the same reason Home does it after `startBooking`.
|
||||
app.selectSlot(asked.slot);
|
||||
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).pushReplacement(
|
||||
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
@@ -228,6 +254,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
|
||||
),
|
||||
),
|
||||
// ── The second parcel, without the walk back ──
|
||||
//
|
||||
// A customer with two parcels for two places had to book the first,
|
||||
// come back to Home, and start again — the pickup, the window and
|
||||
// the whole flow, re-answered at a door they had not moved from.
|
||||
//
|
||||
// One booking still means one destination while the server caps it
|
||||
// there (see [BookingLimits]), so this is the shortest honest route
|
||||
// to a second: the door is carried, the questions are the two that
|
||||
// genuinely changed, and Review replaces this screen rather than
|
||||
// stacking on it.
|
||||
DmButton(
|
||||
label: 'Send another from here',
|
||||
icon: LucideIcons.plus,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
onPressed: () => _sendAnother(context),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Back to home',
|
||||
kind: DmButtonKind.ghost,
|
||||
|
||||
@@ -51,6 +51,11 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// explain in a remarks box nobody reads.
|
||||
final _contact = TextEditingController();
|
||||
|
||||
/// Who to ask for. A rider dialling an unfamiliar number with no name has to
|
||||
/// open with "is this the Doormile pickup?", which is how a collection turns
|
||||
/// into a wrong number.
|
||||
final _contactName = TextEditingController();
|
||||
|
||||
/// The contact field is folded away until asked for. On a screen whose job
|
||||
/// is a pin, a phone number is the second question.
|
||||
bool _contactOpen = false;
|
||||
@@ -60,6 +65,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
super.initState();
|
||||
final app = AppScope.read(context);
|
||||
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
|
||||
_contactName.text = app.draftContactName ?? '';
|
||||
_contactOpen = app.draftContactPhone != null;
|
||||
|
||||
// Arriving without a pin — ask the device where we are.
|
||||
@@ -75,6 +81,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
_contact.dispose();
|
||||
_contactName.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -89,10 +96,13 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// the account rather than a copy of it that can drift.
|
||||
void _commitContact(AppState app) {
|
||||
final typed = _contact.text.trim();
|
||||
app.draftContactPhone =
|
||||
typed.isEmpty || typed == _digitsOf(app.customer?.phone)
|
||||
? null
|
||||
: '+91 $typed';
|
||||
final own = typed.isEmpty || typed == _digitsOf(app.customer?.phone);
|
||||
final who = _contactName.text.trim();
|
||||
app.setHandoverContact(
|
||||
phone: own ? null : '+91 $typed',
|
||||
// The name is only meaningful beside somebody else's number.
|
||||
name: own || who.isEmpty ? null : who,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _search(AppState app) async {
|
||||
@@ -185,6 +195,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
resolving: resolving,
|
||||
denial: app.locationDenial,
|
||||
contact: _contact,
|
||||
contactName: _contactName,
|
||||
contactOpen: _contactOpen,
|
||||
onToggleContact: () =>
|
||||
setState(() => _contactOpen = !_contactOpen),
|
||||
@@ -214,7 +225,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
double _sheetHeight(BuildContext context, AppState app) {
|
||||
final scale = MediaQuery.textScalerOf(context).scale(1);
|
||||
return (app.locationDenial != null ? 268.0 : 202.0) * scale +
|
||||
(_contactOpen ? 86 : 0) +
|
||||
(_contactOpen ? 214 : 0) +
|
||||
MediaQuery.paddingOf(context).bottom;
|
||||
}
|
||||
}
|
||||
@@ -328,6 +339,7 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
required this.resolving,
|
||||
required this.denial,
|
||||
required this.contact,
|
||||
required this.contactName,
|
||||
required this.contactOpen,
|
||||
required this.onToggleContact,
|
||||
required this.onEdit,
|
||||
@@ -340,6 +352,7 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
final bool resolving;
|
||||
final LocationDenial? denial;
|
||||
final TextEditingController contact;
|
||||
final TextEditingController contactName;
|
||||
final bool contactOpen;
|
||||
final VoidCallback onToggleContact;
|
||||
final VoidCallback onEdit;
|
||||
@@ -435,15 +448,44 @@ class _ConfirmSheet extends StatelessWidget {
|
||||
child: contactOpen
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: contactName,
|
||||
keyboardType: TextInputType.name,
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmTextField(
|
||||
label: 'Their number',
|
||||
controller: contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ── Said here, not discovered later ──
|
||||
//
|
||||
// The rider's call button dials the account, and
|
||||
// nothing this app sends can change that. Better
|
||||
// to say so beside the field than to let somebody
|
||||
// hand their parcel to a neighbour believing the
|
||||
// Miler has the neighbour's number.
|
||||
Text(
|
||||
'We pass this to your Miler as a note. Their '
|
||||
'call button still dials your own number.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
|
||||
@@ -168,6 +168,51 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── Why there is no "add another destination" here ──
|
||||
//
|
||||
// A pickup can carry several destinations — the contract is "one
|
||||
// booking → 1..N destinations → 1..N orders" and this screen renders
|
||||
// DROP 1 / DROP 2 when it has them. It is the *server* that caps it,
|
||||
// through `GET /customer/config/booking-limits`, because a pickup
|
||||
// that fans out into several orders is undeliverable until the Miler
|
||||
// build keys its work on `consignmentid`.
|
||||
//
|
||||
// Without a word the screen just has no way to add a second place,
|
||||
// which reads as a form that is missing something. One line says it
|
||||
// is a limit rather than an omission, and says it where a customer
|
||||
// with a second parcel goes looking.
|
||||
if (!app.allowsMultipleDestinations) ...[
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 1),
|
||||
child: Icon(
|
||||
LucideIcons.info,
|
||||
size: 13,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'One destination per pickup for now. Book this, then '
|
||||
'“Send another from here” — the Miler makes one trip '
|
||||
'either way.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
// BOOK asks where it is going; the window is asked here. Unset it
|
||||
// takes the brand, so the one row still to be filled is the one the
|
||||
@@ -189,6 +234,36 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// ── Who the Miler will ring ──
|
||||
//
|
||||
// Every failed collection starts the same way: the rider arrives,
|
||||
// calls, and nobody picks up. So the number is on the screen the
|
||||
// customer confirms on, not buried in a map fold — and it is the
|
||||
// real one.
|
||||
//
|
||||
// It is the **account's** number, and it is not editable here for a
|
||||
// good reason: that is the only number the rider gets. The backend
|
||||
// derives it from the booking's customer and `GET /miler/bookings`
|
||||
// carries exactly one phone field. A picker that let the customer
|
||||
// change it on this screen would be a control over something the app
|
||||
// does not control.
|
||||
//
|
||||
// A different handover person is offered on the pickup map instead,
|
||||
// and shown here as a second line, worded so nobody expects the
|
||||
// rider's call button to dial it.
|
||||
_ContactCard(
|
||||
name: app.customer?.name ?? '',
|
||||
phone: app.customer?.phone ?? '',
|
||||
handoverName: app.draftContactName,
|
||||
handoverPhone: app.draftContactPhone,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
bookingRoute<Place?>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── Only the long way asks for this ──
|
||||
//
|
||||
// One Touch books on a state, a district and a window; the door is
|
||||
@@ -454,6 +529,103 @@ class _RouteHead extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The pickup contact, as the rider will see it.
|
||||
class _ContactCard extends StatelessWidget {
|
||||
const _ContactCard({
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.handoverName,
|
||||
required this.handoverPhone,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? handoverName;
|
||||
final String? handoverPhone;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final handover = (handoverPhone ?? '').trim();
|
||||
final who = (handoverName ?? '').trim();
|
||||
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 14),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
LucideIcons.phone,
|
||||
size: 18,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('YOUR MILER WILL CALL', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
phone.isEmpty ? 'Your account number' : phone,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (name.isNotEmpty)
|
||||
Text(
|
||||
name,
|
||||
style: DmText.small.copyWith(color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (handover.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
who.isEmpty
|
||||
? '$handover is handing it over. We will pass '
|
||||
'this on — your Miler still calls the '
|
||||
'number above.'
|
||||
: '$who ($handover) is handing it over. We will '
|
||||
'pass this on — your Miler still calls the '
|
||||
'number above.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
height: 1.45,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stop extends StatelessWidget {
|
||||
const _Stop({
|
||||
required this.label,
|
||||
|
||||
Reference in New Issue
Block a user