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

@@ -276,6 +276,37 @@ class AppConfig {
static String get clientHeader => 'doormile-cx/$appVersion';
// ------------------------------------------------------------------- contact
/// ── Empty on purpose ──
///
/// Account's support and policy rows used to fire a toast reading "Opening
/// doormile.com…" and open nothing. Replacing a fake toast with a fake phone
/// number is not an improvement, and a support line that rings nowhere is
/// worse than no support line — so these default to empty and every block
/// that needs one is simply absent until it is filled in.
///
/// Pass them at build time, the same way [appVersion] is passed:
///
/// --dart-define=DM_SUPPORT_PHONE=+911234567890
/// --dart-define=DM_SUPPORT_EMAIL=help@doormile.com
/// --dart-define=DM_TERMS_URL=https://doormile.com/terms
/// --dart-define=DM_PRIVACY_URL=https://doormile.com/privacy
/// --dart-define=DM_SITE_URL=https://doormile.com
static const String supportPhone = String.fromEnvironment(
'DM_SUPPORT_PHONE',
);
static const String supportEmail = String.fromEnvironment(
'DM_SUPPORT_EMAIL',
);
static const String termsUrl = String.fromEnvironment('DM_TERMS_URL');
static const String privacyUrl = String.fromEnvironment('DM_PRIVACY_URL');
static const String siteUrl = String.fromEnvironment('DM_SITE_URL');
/// True when there is at least one way for a customer to reach a person.
static bool get hasSupportContact =>
supportPhone.isNotEmpty || supportEmail.isNotEmpty;
static String get platformHeader {
if (kIsWeb) return 'web';
try {

View File

@@ -443,6 +443,7 @@ class DevDoormileApi extends DoormileApi {
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? contactName,
String? idempotencyKey,
}) => _respond(() {
if (destinations.isEmpty ||

View File

@@ -134,6 +134,7 @@ abstract class DoormileApi {
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? contactName,
String? idempotencyKey,
});

View File

@@ -401,50 +401,53 @@ class LiveDoormileApi extends DoormileApi {
required String? slotId,
FareEstimate? fare,
String? contactPhone,
String? contactName,
String? idempotencyKey,
}) async {
if (slotId == null || slotId.isEmpty) {
throw ApiException(ApiException.invalid, 'Pick a pickup slot');
}
// [fare] is deliberately not sent. The contract's create request has no
// field for what the customer was quoted, and an undocumented extra on the
// one call that must not be rejected is not worth the audit trail. The
// server prices the booking itself; the shown band lives in the estimate
// call's own log.
// ── The pickup carries no contact, because nothing reads one ──
//
// Who the Miler asks for at the door. The contract asks for it on the
// pickup, and the signed-in customer is the only answer this app has.
final customer = (await client.currentSession())?.customer;
// This used to send `contactName` and `contactPhone` on the pickup object.
// The create contract's pickup is `{title, sub, lat, lng}` and nothing
// else, and extra keys are dropped without a word — so those two were
// written, accepted, and discarded on every booking.
//
// The rider's number comes from the **account**: `GET /miler/bookings`
// returns one phone field, `customerphone`, derived by the backend from
// this booking's customer. A second number cannot reach them through this
// call at all, so the app stops pretending it can and puts the handover
// person in `remarks`, which is stored and shown.
final response = await client.post(
'/bookings',
idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(),
body: {
'slotId': slotId,
'pickup': pickup.toBookingJson(
contactName: customer?.name,
// The customer's own number unless they said somebody else is
// handing the parcel over. Same field either way — the contract has
// always carried it; it simply had one possible source.
contactPhone: contactPhone ?? customer?.phone,
),
'pickup': pickup.toJson(),
'destinations': [for (final d in destinations) d.toBookingJson()],
// The delivery instructions the customer typed per destination. The
// contract carries one `remarks` line for the whole visit, which is
// what the Miler reads, so several are joined rather than dropped.
'remarks': ?_remarksFrom(destinations),
// The visit's one free-text line. Only the handover person goes here
// now — per-destination instructions have their own field inside
// `details` and were being duplicated into this one.
'remarks': ?_handoverNote(name: contactName, phone: contactPhone),
},
);
return _bookingFrom(response.map);
}
static String? _remarksFrom(List<DestinationGroup> destinations) {
final lines = [
for (final d in destinations)
if (d.details.instructions?.trim().isNotEmpty ?? false)
d.details.instructions!.trim(),
];
return lines.isEmpty ? null : lines.join(' · ');
/// The one place a different handover person can be recorded.
///
/// There is no contact field on the create request — the rider's number is
/// derived by the backend from the booking's account — so this goes in the
/// visit's `remarks`, which is stored and shown. Labelled, so whoever reads
/// it knows it is a person to ring and not a note about the parcel.
static String? _handoverNote({String? name, String? phone}) {
final number = phone?.trim() ?? '';
if (number.isEmpty) return null;
final who = name?.trim() ?? '';
return who.isEmpty
? 'Handover contact: $number'
: 'Handover contact: $who, $number';
}
@override

View File

@@ -480,14 +480,6 @@ class Place {
/// Carries who the Miler asks for at the door. That is the signed-in
/// customer unless the caller names somebody else, and it is sent rather
/// than left to the server to look up, because the contract asks for it.
Map<String, dynamic> toBookingJson({
String? contactName,
String? contactPhone,
}) => {
...toJson(),
'contactName': ?contactName,
'contactPhone': ?contactPhone,
};
}
/// The only required destination information: a serviceable state + district.
@@ -567,7 +559,12 @@ class MapPin {
return MapPin(lat, lng);
}
Map<String, dynamic> toJson() => {'latitude': lat, 'longitude': lng};
/// `{lat, lng}` — the contract's spelling, and the one that has already
/// cost this app a production outage once: `latitude`/`longitude` on a
/// pickup made the server see a booking with no coordinates and answer
/// **422 unserviceable** for months. The destination pin was left on the old
/// spelling when the pickup was fixed.
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lng};
}
/// Everything here is optional at booking time. The Miler fills the gaps
@@ -671,38 +668,49 @@ class DeliveryDetails {
instructions == null &&
pin == null;
/// The recipient and address fields exactly as a booking's destination
/// carries them.
/// The contents of a destination's `details` object on booking create.
///
/// [instructions] is deliberately absent: the contract has no per-destination
/// note, it has one `remarks` line for the whole visit, and that is where
/// [LiveDoormileApi] sends it. Repeating it here would put the same sentence
/// on the wire twice under a key the server does not read.
/// ── This used to be spread flat onto the destination ──
///
/// It read well and it was silently discarded. The create contract nests
/// these under `details`, and extra keys on a destination are dropped
/// without an error — so every building number, street, landmark, recipient
/// and pin a customer typed on the full-address path went to the server,
/// was accepted with a 201, and never reached the Miler.
///
/// [instructions] belongs here too. It was being folded into the visit's one
/// `remarks` line on the belief that the contract had no per-destination
/// note. It has one.
Map<String, dynamic> toJson() => {
'recipientName': ?recipientName,
'recipientPhone': ?recipientPhone,
'building': ?building,
'street': ?street,
'landmark': ?landmark,
'latitude': ?pin?.lat,
'longitude': ?pin?.lng,
'instructions': ?instructions,
'pin': ?pin?.toJson(),
};
/// `PATCH /customer/bookings/{reference}/destinations/{index}`.
///
/// Flat and in the destination's own vocabulary, like every other place the
/// contract carries a recipient. A `null` **clears** the field rather than
/// being omitted, so every key is sent whether or not it has a value —
/// otherwise a customer could add a landmark but never remove one.
/// The body *is* the details object, so this one is flat by design — unlike
/// create, where it nests.
///
/// ── Empty string clears; null does not ──
///
/// This sent `null` to clear a field, and said so in a comment. The server
/// writes only non-nil values, so a `null` means "leave it alone" — which
/// made removing a landmark or an instruction impossible. Every key is still
/// sent, but an unset field goes as `""`, and an unset pin as `{0,0}`, which
/// is what the contract documents as clearing them.
Map<String, dynamic> toPatchJson() => {
'recipientName': recipientName,
'recipientPhone': recipientPhone,
'building': building,
'street': street,
'landmark': landmark,
'instructions': instructions,
'latitude': pin?.lat,
'longitude': pin?.lng,
'recipientName': recipientName ?? '',
'recipientPhone': recipientPhone ?? '',
'building': building ?? '',
'street': street ?? '',
'landmark': landmark ?? '',
'instructions': instructions ?? '',
'pin': pin?.toJson() ?? const {'lat': 0, 'lng': 0},
};
}
@@ -963,12 +971,16 @@ class DestinationGroup {
/// `latitude`/`longitude`. Anything the customer left blank is omitted —
/// that is the "Not added" state the Miler completes at the door, and it is
/// not the same as sending an empty string.
Map<String, dynamic> toBookingJson() => {
...destination.toJson(),
'packageCount': packageCount,
...details.toJson(),
'codAmount': ?codAmount,
};
Map<String, dynamic> toBookingJson() {
final detail = {...details.toJson(), 'codAmount': ?codAmount};
return {
...destination.toJson(),
'packageCount': packageCount,
// Omitted rather than sent empty: One Touch fills none of this in, and
// `details: {}` is a key that says nothing.
if (detail.isNotEmpty) 'details': detail,
};
}
}
/// Indicative price for a booking, confirmed at pickup once the Miler weighs

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}) {

View File

@@ -12,6 +12,11 @@ import '../widgets/cards.dart';
import '../widgets/feedback.dart';
import '../widgets/inputs.dart';
import 'auth/login_screen.dart';
import 'settings/about_screen.dart';
import 'settings/notifications_screen.dart';
import 'settings/payment_screen.dart';
import 'settings/settings_kit.dart';
import 'settings/support_screen.dart';
import 'sheets/place_search_sheet.dart';
/// Account — profile, preferences, and the build's environment line.
@@ -181,17 +186,30 @@ class AccountScreen extends StatelessWidget {
}
},
),
const DmRow(
// ── Both of these were chevrons pointing at nothing ──
//
// No `onTap` at all: the rows rendered the affordance for
// opening a page and then swallowed the tap. A customer
// cannot tell that from a page that is slow, so they press
// it again.
DmRow(
icon: LucideIcons.bell,
label: 'Notifications',
note: 'Real-time SMS and push',
// Was "Real-time SMS and push". Push is not wired in —
// `registerPushToken` says so itself — so the note was
// promising a channel the app does not have.
note: 'What you get told, and when',
showChevron: true,
onTap: () =>
pushSettings(context, const NotificationsScreen()),
),
const DmRow(
DmRow(
icon: LucideIcons.wallet,
label: 'Payment methods',
note: 'UPI, on delivery',
note: 'Pay after the doorstep weigh',
showChevron: true,
onTap: () =>
pushSettings(context, const PaymentScreen()),
),
],
),
@@ -213,23 +231,31 @@ class AccountScreen extends StatelessWidget {
note: 'Talk to the courier desk',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Support is on the way'),
pushSettings(context, const SupportScreen()),
),
// ── One screen, two doors ──
//
// Both rows land on [AboutScreen] under their own title.
// The policies are three links and the build is three
// facts; splitting them gives two screens that each look
// unfinished, and a customer looking for "the legal bit"
// finds it either way.
DmRow(
icon: LucideIcons.fileText,
label: 'Terms and policies',
note: 'What Doormile covers',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Opening doormile.com…'),
onTap: () => pushSettings(
context,
const AboutScreen(title: 'Terms and policies'),
),
),
DmRow(
icon: LucideIcons.info,
label: 'About Doormile',
note: 'Version and licences',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Opening doormile.com…'),
onTap: () => pushSettings(context, const AboutScreen()),
),
],
),

View File

@@ -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,

View File

@@ -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),

View File

@@ -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,

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 ──

View File

@@ -8,13 +8,17 @@ import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/milestones.dart';
import '../widgets/misc.dart';
import '../widgets/pieces.dart';
import '../widgets/route_rail.dart';
import '../widgets/states.dart';
import '../widgets/summary.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'settings/settings_kit.dart';
import 'settings/support_screen.dart';
import 'sheets/window_sheet.dart';
/// A finished order, in full: where it went, what happened when, and exactly
/// what it cost.
@@ -23,6 +27,26 @@ class OrderDetailsScreen extends StatelessWidget {
final String reference;
/// Repeats a booking: same door, same destination, a new window.
Future<void> _sendAgain(BuildContext context, Booking booking) async {
final app = AppScope.read(context);
app.startBookingFrom(booking, keepDestinations: true);
final slot = await showWindowSheet(context);
if (slot == null || !context.mounted) {
// Nothing is left half-started: a draft with a door and a destination
// and no window would sit behind Home's form looking like a booking in
// progress that the customer never began.
app.startBooking();
return;
}
if (!context.mounted) return;
await Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
@@ -336,13 +360,22 @@ class OrderDetailsScreen extends StatelessWidget {
bottomNavigationBar: DmFooter(
edge: true,
children: [
// ── The same parcel again, as one question ──
//
// People send to the same places repeatedly, and the whole of a
// repeat is already on this screen: the door it left from and where
// it went. Only the window is a fresh decision, so only the window
// is asked.
DmButton(
label: 'Send another like this',
icon: LucideIcons.repeat,
iconLeading: true,
onPressed: () => _sendAgain(context, booking),
),
DmButton(
label: 'Need help with this order?',
kind: DmButtonKind.outline,
onPressed: () => DmToast.show(
context,
'Support chat is not available yet',
),
onPressed: () => pushSettings(context, const SupportScreen()),
),
],
),

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/app_config.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/inputs.dart';
import 'settings_kit.dart';
/// About, and the policies — the two rows that used to toast "Opening
/// doormile.com…" and open nothing.
///
/// ── What is real here and what is a build flag ──
///
/// The version, the platform and the licences are real: the first two come
/// from [AppConfig], and the licence list is Flutter's own `showLicensePage`,
/// which enumerates every package actually linked into this binary. Nothing
/// about them can go stale.
///
/// The three links cannot be real without somebody confirming them. There is
/// no terms URL in this repository — `doormile.com` appears only as the API
/// host — and guessing `/terms` off it is the same class of mistake as
/// inventing a support number. They come from `--dart-define` (DM_TERMS_URL,
/// DM_PRIVACY_URL, DM_SITE_URL) and each row is simply absent until its URL is
/// set, so the screen can never point at a 404.
class AboutScreen extends StatelessWidget {
const AboutScreen({super.key, this.title = 'About Doormile'});
/// Both Account rows land here. "Terms and policies" opens it scrolled to
/// the same content under its own name rather than on a second screen
/// holding two links — see the note in `account_screen.dart`.
final String title;
bool get _hasLinks =>
AppConfig.termsUrl.isNotEmpty ||
AppConfig.privacyUrl.isNotEmpty ||
AppConfig.siteUrl.isNotEmpty;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: DmTopBar(title: title),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
children: [
SettingsNote(
icon: LucideIcons.package,
title: 'Doormile Customer',
body:
'Book a Miler to collect a parcel from your door, and watch it '
'the whole way. Version ${AppConfig.appVersion} on '
'${AppConfig.platformHeader}.',
),
if (_hasLinks) ...[
const SizedBox(height: 18),
const DmMicroHead('Policies', brand: false),
DmRowGroup(
children: [
if (AppConfig.termsUrl.isNotEmpty)
DmRow(
icon: LucideIcons.fileText,
label: 'Terms of service',
note: 'What Doormile covers, and what it does not',
trailing: const Icon(
externalIcon,
size: 16,
color: DmColors.ink4,
),
onTap: () =>
openLink(context, Uri.parse(AppConfig.termsUrl)),
),
if (AppConfig.privacyUrl.isNotEmpty)
DmRow(
icon: LucideIcons.lock,
label: 'Privacy policy',
note: 'What is collected, and why',
trailing: const Icon(
externalIcon,
size: 16,
color: DmColors.ink4,
),
onTap: () =>
openLink(context, Uri.parse(AppConfig.privacyUrl)),
),
if (AppConfig.siteUrl.isNotEmpty)
DmRow(
icon: LucideIcons.globe,
label: 'doormile.com',
note: 'The website',
trailing: const Icon(
externalIcon,
size: 16,
color: DmColors.ink4,
),
onTap: () =>
openLink(context, Uri.parse(AppConfig.siteUrl)),
),
],
),
],
const SizedBox(height: 18),
const DmMicroHead('Build', brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.tag,
label: 'Version',
value: AppConfig.appVersion,
),
DmRow(
icon: LucideIcons.smartphone,
label: 'Platform',
value: AppConfig.platformHeader,
),
DmRow(
icon: LucideIcons.scale,
label: 'Open-source licences',
note: 'Every package linked into this build',
showChevron: true,
// Flutter's own page, which reads the licence registry rather
// than a list somebody has to remember to update.
onTap: () => showLicensePage(
context: context,
applicationName: 'Doormile Customer',
applicationVersion: AppConfig.appVersion,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import 'settings_kit.dart';
/// What Doormile tells you, and where it appears.
///
/// ── Why there is not a single toggle on this screen ──
///
/// The obvious build for a row labelled "Notifications" is a list of switches:
/// push on, SMS on, promotions off. Every one of them would be a lie here.
/// There is no notification-preference endpoint in the customer contract, and
/// no push SDK is wired into the app at all — `AppState.registerPushToken`
/// exists as a seam and its own comment says nothing calls it, because there is
/// no token to hand over.
///
/// A switch that changes nothing is worse than no switch: it is a control the
/// customer will trust, turn off, and then be annoyed by. So this screen
/// answers the question the row actually asks — *how will I know what is
/// happening to my parcel?* — and answers it with what is true today.
///
/// When push is wired in, the "not yet" card is the thing to delete and the
/// toggles belong under it.
class NotificationsScreen extends StatelessWidget {
const NotificationsScreen({super.key});
/// The five moments the app has a state for. These are
/// `CustomerMilestone`'s own stages, in order — not a list written here that
/// could drift from the rail a customer is watching on the tracking screen.
static const _moments = [
(
icon: LucideIcons.calendarCheck,
title: 'Pickup booked',
body: 'Your visit is arranged and a Miler is being assigned.',
),
(
icon: LucideIcons.packageCheck,
title: 'Order created',
body: 'The parcel has been weighed and has a tracking number.',
),
(
icon: LucideIcons.truck,
title: 'In transit',
body: 'It has left your city and is on its way.',
),
(
icon: LucideIcons.mapPin,
title: 'Out for delivery',
body: 'It is with a Miler at the other end, going to the door.',
),
(
icon: LucideIcons.circleCheck,
title: 'Delivered',
body: 'Handed over. The receipt is on the order.',
),
];
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final phone = app.customer?.phone ?? '';
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Notifications'),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
children: [
const SettingsNote(
icon: LucideIcons.bellRing,
title: 'Push is not switched on yet',
body:
'Until it is, an update appears in the app rather than on your '
'lock screen. The live card at the top of Home shows anything '
'currently moving, and Orders holds the full history.',
),
const SizedBox(height: 18),
const DmMicroHead('What you get told', brand: false),
DmCard(
children: [
for (var i = 0; i < _moments.length; i++)
DmCardCell(
padding: EdgeInsets.fromLTRB(16, i == 0 ? 14 : 12, 16, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 1),
child: Icon(
_moments[i].icon,
size: 17,
color: DmColors.brand,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_moments[i].title,
style: DmText.cardTitle.copyWith(fontSize: 15),
),
const SizedBox(height: 2),
Text(
_moments[i].body,
style: DmText.small.copyWith(
color: DmColors.ink3,
height: 1.45,
),
),
],
),
),
],
),
),
],
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 18),
const DmMicroHead('Sent to', brand: false),
SettingsNote(
icon: LucideIcons.smartphone,
title: phone,
body:
'The number on this account. Changing it means signing in '
'with the new one.',
),
],
],
),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import 'settings_kit.dart';
/// How paying works, which is the question "Payment methods" is really asking.
///
/// ── Why this does not manage anything ──
///
/// A row called "Payment methods" in most apps opens a list of saved cards with
/// an *Add* button. There is nothing to list here and nothing to add: Doormile
/// takes no money at booking time and stores no instrument. `paymentMethod`
/// arrives from `estimateFare` as a string the server decides per booking —
/// "UPI · Cash at doorstep" — and the app's own job is to print it.
///
/// So a management screen would be a screen of empty state forever. What a
/// customer wants from this row is the thing the model makes surprising: *when
/// do I pay, and how much, given nobody has weighed anything yet.* That is
/// answerable, and it is what this says.
class PaymentScreen extends StatelessWidget {
const PaymentScreen({super.key});
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
// The most recent method the server actually quoted, rather than a list
// written here. Absent until a booking has been priced, which is honest:
// before that the app genuinely does not know.
String? quoted;
for (final booking in app.orders) {
final method = booking.fare?.paymentMethod;
if (method != null && method.trim().isNotEmpty) {
quoted = method.trim();
break;
}
}
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Payment'),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
children: [
const SettingsNote(
icon: LucideIcons.shieldCheck,
title: 'Nothing is charged when you book',
body:
'No card is saved and no money moves until your parcel has '
'been weighed at your door. Cancelling before a Miler collects '
'costs nothing.',
),
const SizedBox(height: 18),
const DmMicroHead('How it works', brand: false),
DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
children: const [
SettingsStep(
index: 1,
title: 'You book',
body:
'Review shows an estimate as a range, because the '
'price depends on a weight nobody has taken yet.',
),
SettingsStep(
index: 2,
title: 'The Miler weighs it',
body:
'At your door, in front of you. That is the moment '
'the estimate becomes a price.',
),
SettingsStep(
index: 3,
title: 'You pay',
body:
'By the method confirmed on Review before you '
'booked. It is on the receipt afterwards.',
last: true,
),
],
),
),
],
),
if (quoted != null) ...[
const SizedBox(height: 18),
const DmMicroHead('Accepted on your bookings', brand: false),
SettingsNote(
icon: LucideIcons.wallet,
title: quoted,
body:
'What the server quoted for your most recent booking. It is '
'confirmed per booking on the Review screen, so check there '
'if it matters for a particular parcel.',
),
],
const SizedBox(height: 18),
const DmMicroHead('Where to find a receipt', brand: false),
const SettingsNote(
icon: LucideIcons.receipt,
title: 'On the order itself',
body:
'Open Orders, choose a delivered parcel, and the receipt shows '
'the weight it was charged on, the amount and the method.',
),
],
),
);
}
}

View File

@@ -0,0 +1,195 @@
/// Shared parts of the four screens behind Account's rows.
///
/// ── What those rows used to do ──
///
/// Nothing, or a toast. `Notifications` and `Payment methods` had no `onTap` at
/// all — a chevron pointing at a page that did not exist. `Help and support`
/// answered with "Support is on the way", and both policy rows with "Opening
/// doormile.com…", which opened nothing. Five rows that looked like doors.
///
/// ── The rule these pages are written to ──
///
/// Say only what is true of this app today. There is no notification-preference
/// endpoint, no stored payment method and no push SDK wired in, so none of
/// these pages pretends to manage any of that. What they do instead is answer
/// the question the row's label asks — *how do I get told?*, *how do I pay?* —
/// which is what a customer opening them actually wants, and which happens to
/// be answerable honestly.
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../booking/booking_routes.dart';
/// Opens one of these pages, with the app's own transition.
Future<void> pushSettings(BuildContext context, Widget page) {
return Navigator.of(context).push(
DmPageRoute<void>(builder: (_) => page),
);
}
/// A block of explanatory text under a heading, as a card.
class SettingsNote extends StatelessWidget {
const SettingsNote({
super.key,
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 17, color: DmColors.brand),
const SizedBox(width: 9),
Expanded(
child: Text(title, style: DmText.cardTitle),
),
],
),
const SizedBox(height: 8),
Text(
body,
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.5),
),
],
),
),
],
);
}
}
/// A numbered step in a sequence the customer will actually see happen.
class SettingsStep extends StatelessWidget {
const SettingsStep({
super.key,
required this.index,
required this.title,
required this.body,
this.last = false,
});
final int index;
final String title;
final String body;
final bool last;
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 26,
child: Column(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brandSoft,
shape: BoxShape.circle,
),
child: Text(
'$index',
style: DmText.tiny.copyWith(
color: DmColors.brand,
fontWeight: FontWeight.w700,
),
),
),
// The thread belongs to the step above the join, so the last
// one ends clean.
if (!last)
const Expanded(
child: VerticalDivider(
width: 1,
thickness: 1,
color: DmColors.border,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 1, bottom: last ? 0 : 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: DmText.cardTitle.copyWith(fontSize: 15)),
const SizedBox(height: 3),
Text(
body,
style: DmText.small.copyWith(
color: DmColors.ink3,
height: 1.45,
),
),
],
),
),
),
],
),
);
}
}
/// Opens a URL, and says so when it cannot.
///
/// ── Why the failure is reported ──
///
/// `launchUrl` returns false when nothing on the device can handle the scheme —
/// no mail client, no dialler, no browser. Ignoring that return is how the old
/// rows behaved: the tap appeared to work and nothing happened. A customer who
/// taps a phone number and sees no dialler needs to be told, because the next
/// thing they will do is assume the app is broken.
Future<void> openLink(BuildContext context, Uri uri, {String? failure}) async {
var opened = false;
try {
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
opened = false;
}
if (opened || !context.mounted) return;
DmToast.show(context, failure ?? "Couldn't open that on this device");
}
/// Copies text and confirms it, because a copy with no feedback is a tap that
/// did nothing as far as the customer can tell.
Future<void> copyText(
BuildContext context,
String text, {
required String confirmation,
}) async {
await Clipboard.setData(ClipboardData(text: text));
if (context.mounted) DmToast.show(context, confirmation);
}
/// The glyph for a row that leaves the app.
const IconData externalIcon = LucideIcons.externalLink;

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/app_config.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/inputs.dart';
import 'settings_kit.dart';
/// Help — what the customer can do themselves, and how to reach a person.
///
/// ── The contact block is missing on purpose ──
///
/// This row used to answer with a toast reading "Support is on the way", which
/// was a sentence about a thing that was not happening. The obvious fix is a
/// phone number — except there is no Doormile support number anywhere in this
/// repository, and inventing one replaces a fake toast with a line that rings
/// nowhere, which is worse: a customer with a problem would sit listening to it
/// fail.
///
/// So the contact block renders only when [AppConfig.hasSupportContact] is
/// true, and that comes from `--dart-define`. Fill in DM_SUPPORT_PHONE or
/// DM_SUPPORT_EMAIL and the block appears, wired to the dialler and the mail
/// client. Until then this screen does the part it genuinely can: point at the
/// things in the app that solve the four problems people actually write in
/// about.
class SupportScreen extends StatelessWidget {
const SupportScreen({super.key});
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Help and support'),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 8, DmSpace.pad, 32),
children: [
const DmMicroHead('Do it yourself', top: 0, brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.mapPinned,
label: 'Where is my parcel?',
note: 'Open it in Orders for a live rail and the Miler',
showChevron: true,
onTap: () => Navigator.of(context).pop(),
),
DmRow(
icon: LucideIcons.phoneCall,
label: 'Reach the Miler collecting it',
note: 'Their number is on the tracking screen while active',
showChevron: true,
onTap: () => Navigator.of(context).pop(),
),
DmRow(
icon: LucideIcons.circleX,
label: 'Cancel a pickup',
note: 'Free until a Miler has collected it',
showChevron: true,
onTap: () => Navigator.of(context).pop(),
),
DmRow(
icon: LucideIcons.mapPin,
label: 'Wrong pickup address',
note: 'Change it on Home before the Miler arrives',
showChevron: true,
onTap: () => Navigator.of(context).pop(),
),
],
),
if (AppConfig.hasSupportContact) ...[
const SizedBox(height: 18),
const DmMicroHead('Talk to us', brand: false),
DmRowGroup(
children: [
if (AppConfig.supportPhone.isNotEmpty)
DmRow(
icon: LucideIcons.phone,
label: 'Call the courier desk',
note: AppConfig.supportPhone,
showChevron: true,
onTap: () => openLink(
context,
Uri(scheme: 'tel', path: AppConfig.supportPhone),
failure: 'No dialler on this device',
),
),
if (AppConfig.supportEmail.isNotEmpty)
DmRow(
icon: LucideIcons.mail,
label: 'Email us',
note: AppConfig.supportEmail,
showChevron: true,
onTap: () => openLink(
context,
Uri(
scheme: 'mailto',
path: AppConfig.supportEmail,
// Prefilled, because the first thing any reply asks for
// is the reference and the build.
query: Uri.encodeFull(
'subject=Doormile help'
'&body=\n\n---\nApp ${AppConfig.appVersion}'
'\nAccount ${app.customer?.phone ?? 'signed out'}',
),
),
failure: 'No mail app on this device',
),
),
],
),
],
const SizedBox(height: 18),
const DmMicroHead('If you report a problem', brand: false),
DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Send these with it',
style: DmText.cardTitle,
),
const SizedBox(height: 8),
Text(
'A report with the build and the account on it can be '
'chased. One without them usually cannot.',
style: DmText.small.copyWith(
color: DmColors.ink3,
height: 1.5,
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: DmChipButton(
label: 'Copy details',
icon: LucideIcons.copy,
onPressed: () => copyText(
context,
'Doormile Customer ${AppConfig.appVersion}\n'
'Platform ${AppConfig.platformHeader}\n'
'Account ${app.customer?.phone ?? 'signed out'}',
confirmation: 'Details copied',
),
),
),
],
),
),
],
),
],
),
);
}
}

View File

@@ -46,24 +46,43 @@ import '../../widgets/states.dart';
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
return showDmSheet<List<CityOption>>(
context: context,
builder: (context) => const _DestinationSheet(),
builder: (context) => DestinationPicker(
onPicked: (cities) => Navigator.of(context).pop(cities),
),
);
}
class _DestinationSheet extends StatefulWidget {
const _DestinationSheet();
/// The *where* half, without a sheet around it.
///
/// ── Why this is a widget and not a sheet ──
///
/// It is used twice: on its own behind Review's "Change", and as the first
/// step of [showPickupSheet], where it shares one surface with the pickup
/// window rather than closing so a second sheet can open over it. Extracting
/// the body is what lets the merged flow reuse this list instead of owning a
/// second copy of it.
class DestinationPicker extends StatefulWidget {
const DestinationPicker({
super.key,
required this.onPicked,
this.showHeader = true,
});
/// Called with the chosen cities. The caller decides what that means — pop a
/// sheet, or move to the next step of one.
final ValueChanged<List<CityOption>> onPicked;
/// False when the host is drawing the title and the close itself.
final bool showHeader;
@override
State<_DestinationSheet> createState() => _DestinationSheetState();
State<DestinationPicker> createState() => _DestinationPickerState();
}
class _DestinationSheetState extends State<_DestinationSheet> {
class _DestinationPickerState extends State<DestinationPicker> {
final _query = TextEditingController();
String _q = '';
/// The state being browsed, or null while the states themselves are.
ServiceArea? _openState;
/// Chosen districts, by district code, in the order they were picked.
final _chosen = <String, CityOption>{};
@@ -73,27 +92,10 @@ class _DestinationSheetState extends State<_DestinationSheet> {
super.dispose();
}
void _open(ServiceArea area) {
HapticFeedback.selectionClick();
setState(() {
_openState = area;
_query.clear();
_q = '';
});
}
void _back() {
setState(() {
_openState = null;
_query.clear();
_q = '';
});
}
void _tap(CityOption city, {required bool multi, required int cap}) {
HapticFeedback.selectionClick();
if (!multi) {
Navigator.of(context).pop([city]);
widget.onPicked([city]);
return;
}
final code = city.district.code;
@@ -126,7 +128,6 @@ class _DestinationSheetState extends State<_DestinationSheet> {
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final browsing = _openState;
final cap = app.limits.maxDestinations;
final multi = app.allowsMultipleDestinations;
@@ -134,66 +135,44 @@ class _DestinationSheetState extends State<_DestinationSheet> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back inside the sheet, not out of it: the customer is one level
// into a question they are still answering, and dismissing the
// whole thing to change their mind about a state would throw away
// both the step they got right and everything they have ticked.
if (browsing != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
if (widget.showHeader)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DmSheetHeader(
title: 'Where is it going?',
// ── One level, and the subtitle says what the list is ──
//
// This used to open on states and make the customer pick one
// to see any city, so the subtitle had to explain that tapping
// a row opened it rather than chose it — a sentence that only
// existed because the shape needed defending.
//
// The list is now every city Doormile serves, headed by state.
// That is also the answer to "where do you deliver?", which is
// the question a customer opening this actually has, and it
// was previously two taps down.
subtitle: multi
? 'Every city we serve. Choose one or more.'
: 'Every city we serve',
),
),
Expanded(
child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?',
// ── A line of orientation ──
//
// The sheet asked a question and then showed a list, and a
// customer who has never used it cannot tell whether tapping
// a state chooses it or opens it. One sentence says which,
// and at the district step says how many they may pick.
subtitle: browsing == null
? 'Pick a state to see the cities it serves'
: (app.allowsMultipleDestinations
? 'Choose one or more districts'
: 'Choose a district'),
// A close, because a sheet that can only be dismissed by
// dragging is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
),
// A close, because a sheet that can only be dismissed by dragging
// is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
],
),
],
),
DmSearchField(
controller: _query,
hint: browsing == null
? 'Search for a city'
: 'Search in ${browsing.name}',
hint: 'Search for a city',
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: 6),
@@ -215,30 +194,45 @@ class _DestinationSheetState extends State<_DestinationSheet> {
// Sized against the screen rather than fixed in points, and floored
// above the skeleton's own height so it can never be the thing that
// decides how tall this is.
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
// smallest box the clamp above can produce. Four did not.
skeletonRows: 3,
// Skips the skeleton entirely when the cities are already known,
// which after the first open they always are. A shimmer that
// appears and vanishes inside one frame is noise.
initialItems: app.cachedCities,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check "
'back shortly.',
builder: (context, cities) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
// ── Flexible around the fixed box, not instead of it ──
//
// The box is what stops the sheet resizing as its content loads. The
// `Flexible` is what stops it overflowing when something *else* on the
// sheet grows — the multi-select confirm button appearing, a large
// text scale, a two-line header — on a sheet already at the modal's
// 86% cap. A strip of recent destinations sat here briefly and
// overflowed it by twenty points, which is how the need was found.
//
// Loose fit: the box asks for its height and gets it whenever there is
// room, and gives way rather than overflowing when there is not. Fixed
// in the common case, never the thing that breaks the layout.
Flexible(
child: SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44)
.clamp(240.0, 420.0),
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
// smallest box the clamp above can produce. Four did not.
skeletonRows: 3,
// Skips the skeleton entirely when the cities are already known,
// which after the first open they always are. A shimmer that
// appears and vanishes inside one frame is noise.
initialItems: app.cachedCities,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check "
'back shortly.',
builder: (context, cities) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
),
),
),
),
@@ -250,21 +244,31 @@ class _DestinationSheetState extends State<_DestinationSheet> {
? 'Send to ${_chosen.values.first.district.name}'
: 'Send to ${_chosen.length} places',
icon: LucideIcons.arrowRight,
onPressed: () =>
Navigator.of(context).pop(_chosen.values.toList()),
onPressed: () => widget.onPicked(_chosen.values.toList()),
),
],
],
);
}
/// Every serviceable city, headed by its state.
///
/// ── One level, not two ──
///
/// This used to be a list of states that had to be opened to reach a city.
/// Two levels of navigation, a back button inside the sheet, and — by the
/// file's own admission — a search box that already cut across every state
/// from the first step, which is the path anybody who knew their destination
/// actually took. The browse existed for the customer who did *not* know,
/// and that customer is better served by seeing the whole list.
///
/// The state is a heading now. It still groups, it still carries its mark,
/// and it is no longer somewhere you have to go.
List<Widget> _body(
List<CityOption> cities, {
required bool multi,
required int cap,
}) {
final browsing = _openState;
Widget district(CityOption city) => _DistrictRow(
city: city,
multi: multi,
@@ -272,13 +276,8 @@ class _DestinationSheetState extends State<_DestinationSheet> {
onTap: () => _tap(city, multi: multi, cap: cap),
);
// Searching from the top level goes straight to districts, across every
// state. Searching inside one stays inside it.
if (_q.isNotEmpty) {
final pool = browsing == null
? cities
: cities.where((c) => c.state.code == browsing.code);
final matches = pool
final matches = cities
.where((c) => c.label.toLowerCase().contains(_q))
.toList();
if (matches.isEmpty) {
@@ -293,77 +292,50 @@ class _DestinationSheetState extends State<_DestinationSheet> {
return [for (final city in matches) district(city)];
}
if (browsing != null) {
return [
for (final city in cities.where((c) => c.state.code == browsing.code))
district(city),
];
final out = <Widget>[];
for (final area in _statesOf(cities)) {
final inArea = cities.where((c) => c.state.code == area.code).toList();
out.add(_StateHead(area: area, first: out.isEmpty));
out.addAll(inArea.map(district));
}
return [
for (final area in _statesOf(cities))
_StateRow(
area: area,
// The count is the reason to open it, and it is free: the list it
// came from is the list the next step shows.
count: cities.where((c) => c.state.code == area.code).length,
// How many of this state's districts are already in, so a customer
// who has ticked two in Tamil Nadu can see that from the top.
chosen: cities
.where(
(c) =>
c.state.code == area.code &&
_chosen.containsKey(c.district.code),
)
.length,
onTap: () => _open(area),
),
];
return out;
}
}
/// One state: its mark, its name, and how many cities are open inside it.
class _StateRow extends StatelessWidget {
const _StateRow({
required this.area,
required this.count,
required this.chosen,
required this.onTap,
});
/// A state's name over the cities inside it. A heading, not a row: nothing
/// happens when it is tapped, because there is nowhere left to go.
class _StateHead extends StatelessWidget {
const _StateHead({required this.area, required this.first});
final ServiceArea area;
final int count;
final int chosen;
final VoidCallback onTap;
final bool first;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
// The mark, with nothing behind it. It sat in a 38pt washed-crimson
// tile, so a list of six states was six pink squares in a column — six
// containers and six spots of brand to hold six glyphs that read
// perfectly well on the sheet's own surface.
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
title: area.name,
subtitle:
'$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
// The number of districts already chosen in this state. It was a filled
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
trailing: chosen == 0
? null
: Text(
'$chosen',
style: DmText.cardTitle.copyWith(color: DmColors.brand),
return Padding(
padding: EdgeInsets.fromLTRB(2, first ? 6 : 18, 2, 8),
child: Row(
children: [
Icon(stateMark(area.code), size: 15, color: DmColors.ink4),
const SizedBox(width: 8),
Text(area.name.toUpperCase(), style: DmText.eyebrow),
if (area.transitTag != null) ...[
const SizedBox(width: 8),
Expanded(
child: Text(
area.transitTag!,
style: DmText.tiny.copyWith(color: DmColors.ink4),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
);
}
}
/// One district. No mark — the name and the promise are the whole row, and a
/// glyph beside every one of them would be decoration competing with the only
/// two things on it that differ.
class _DistrictRow extends StatelessWidget {
const _DistrictRow({
required this.city,
@@ -419,13 +391,15 @@ class _Check extends StatelessWidget {
}
}
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
/// A city: a target, its name, its transit promise, and a tick.
///
/// It carried a `leading` slot for the state rows' marks. There are no state
/// rows any more — the state is a heading — so the slot went with them.
class _Row extends StatelessWidget {
const _Row({
required this.onTap,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.selected = false,
});
@@ -433,7 +407,6 @@ class _Row extends StatelessWidget {
final VoidCallback onTap;
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final bool selected;
@@ -468,7 +441,6 @@ class _Row extends StatelessWidget {
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 14)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import 'destination_sheet.dart';
import 'window_sheet.dart';
/// What ONE TOUCH asks: where, and when — on one surface.
///
/// ── What this replaced ──
///
/// Two sheets in sequence. The destination sheet rose, the customer answered
/// it, it fell, and the window sheet rose behind it. The file that drove it
/// hoped this would read "as one surface changing its question rather than as
/// a sheet failing and another arriving", and it did not: two present
/// animations and a dismiss between them is three pieces of motion for two
/// questions, and the customer sees a sheet go away before they have finished.
///
/// One sheet now, two steps inside it. The surface never leaves, it does not
/// change height — see the fixed box in [DestinationPicker] — and stepping back
/// is an arrow rather than a dismissal that would throw away the answer.
///
/// ── Why the destination is still asked at all ──
///
/// It is fair to ask why: a Miler rides to the customer's door, and nothing
/// about that trip depends on where the parcel is going afterwards. The
/// destination earns its place for three reasons that are not about the trip:
///
/// * It is the only thing that can price the job. `estimateFare` takes
/// `stateCode`/`districtCode`; without them Review has no figure on it at
/// all, not even a range.
/// * It is the serviceability gate. The expensive thing here is a wasted
/// rider trip, and finding out at the door that the parcel cannot be
/// carried costs the whole visit.
/// * Orders are minted per destination when the Miler completes pickup, so
/// the booking has to know how many there are.
///
/// What was wrong was the ceremony, not the question. Two levels of browsing
/// became one list, the list is now also the answer to "where do you deliver?",
/// places sent to before are one tap, and both questions share a surface.
class PickupRequest {
const PickupRequest({required this.places, required this.slot});
final List<CityOption> places;
final PickupSlot slot;
}
/// Asks both questions and returns both answers, or null if dismissed.
Future<PickupRequest?> showPickupSheet(BuildContext context) {
return showDmSheet<PickupRequest>(
context: context,
builder: (context) => const _PickupSheet(),
);
}
class _PickupSheet extends StatefulWidget {
const _PickupSheet();
@override
State<_PickupSheet> createState() => _PickupSheetState();
}
class _PickupSheetState extends State<_PickupSheet> {
List<CityOption>? _places;
bool get _onWhen => _places != null;
void _back() {
HapticFeedback.selectionClick();
setState(() => _places = null);
}
@override
Widget build(BuildContext context) {
final places = _places;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back to the first question, not out of the sheet. Dismissing to
// change a destination would throw away the step already answered.
if (_onWhen)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
),
),
Expanded(
child: DmSheetHeader(
title: _onWhen ? 'When should we come?' : 'Where is it going?',
// On the second step the subtitle is the answer to the first,
// so the customer can see what they are booking against
// without going back for it.
subtitle: _onWhen
? _summarise(places!)
: 'Every city we serve',
),
),
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
],
),
// ── Flexible, because a cross-fade is briefly both steps ──
//
// `AnimatedSwitcher` stacks the outgoing child under the incoming one
// and takes the height of the taller, so for the length of the fade
// this is as tall as *where* plus nothing to give. Unconstrained that
// is eleven points past the sheet's cap and a rendering assertion on a
// frame no screenshot catches — it only ever exists mid-animation.
//
// Constrained, the overshoot clips for those few frames, which is
// invisible under a cross-fade, and each step still gets the height it
// asks for once it is the only one there.
Flexible(
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onWhen
? Padding(
key: const ValueKey('when'),
padding: const EdgeInsets.only(top: 6),
child: SizedBox(
height: _stepHeight(context),
child: WindowPicker(
confirmLabel: 'Confirm pickup',
onPicked: (slot) => Navigator.of(context).pop(
PickupRequest(places: places!, slot: slot),
),
),
),
)
: DestinationPicker(
key: const ValueKey('where'),
showHeader: false,
onPicked: (chosen) => setState(() => _places = chosen),
),
),
),
],
);
}
/// The same box [DestinationPicker] gives its list, so the two steps are the
/// same height and the sheet does not jump when it changes its question.
static double _stepHeight(BuildContext context) =>
(MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0);
static String _summarise(List<CityOption> places) {
if (places.length == 1) return 'Going to ${places.first.label}';
return 'Going to ${places.length} places';
}
}

View File

@@ -28,21 +28,11 @@ Future<PickupSlot?> showWindowSheet(BuildContext context) {
);
}
class _WindowSheet extends StatefulWidget {
class _WindowSheet extends StatelessWidget {
const _WindowSheet();
@override
State<_WindowSheet> createState() => _WindowSheetState();
}
class _WindowSheetState extends State<_WindowSheet> {
String? _day;
PickupSlot? _picked;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -58,8 +48,52 @@ class _WindowSheetState extends State<_WindowSheet> {
),
],
),
Flexible(
child: DmAsyncList<PickupSlot>(
// The same box the pickup sheet gives its steps, so this sheet is one
// height whether it is loading, listing or empty — and so `Expanded`
// inside the picker has something to divide.
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
child: WindowPicker(
onPicked: (slot) => Navigator.of(context).pop(slot),
),
),
],
);
}
}
/// The *when* half, without a sheet around it.
///
/// ── Why this is a widget and not a sheet ──
///
/// It is used twice: on its own behind Review's "Change", and as the second
/// step of [showPickupSheet], where it shares one surface with the destination
/// rather than arriving as a second sheet over the first. Extracting the body
/// is what lets the merged flow reuse this instead of owning a second copy of
/// day chips and slot rows that would drift.
class WindowPicker extends StatefulWidget {
const WindowPicker({super.key, required this.onPicked, this.confirmLabel});
/// Called with the chosen slot. The caller decides what that means — pop a
/// sheet, or move to the next step of one.
final ValueChanged<PickupSlot> onPicked;
/// Overrides the button's text. Null uses "Use 2:00 – 4:00 PM".
final String? confirmLabel;
@override
State<WindowPicker> createState() => _WindowPickerState();
}
class _WindowPickerState extends State<WindowPicker> {
String? _day;
PickupSlot? _picked;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return DmAsyncList<PickupSlot>(
reloadToken: app.slotsEpoch,
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
emptyIcon: LucideIcons.clock,
@@ -81,64 +115,75 @@ class _WindowSheetState extends State<_WindowSheet> {
_slotById(slots, app.draftSlotId) ??
shown.where((s) => s.available).firstOrNull;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
// The segments sit in a groove, which is what makes the
// chosen one read as raised rather than as merely white.
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
// ── Days and the button are pinned; only the slots scroll ──
//
// This was one scroll view holding all three. At a large text
// scale the slot rows grow until the button is below the fold,
// and the primary action on a sheet must never be something you
// have to find. The day switcher is a control too, so it stays
// put as well.
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
// The segments sit in a groove, which is what makes the
// chosen one read as raised rather than as merely white.
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
],
],
),
),
const SizedBox(height: 16),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
for (final slot in shown) ...[
_SlotRow(
slot: slot,
selected: selected?.id == slot.id,
onTap: () => setState(() => _picked = slot),
),
const SizedBox(height: 10),
],
],
),
),
const SizedBox(height: 16),
for (final slot in shown) ...[
_SlotRow(
slot: slot,
selected: selected?.id == slot.id,
onTap: () => setState(() => _picked = slot),
),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
DmButton(
),
const SizedBox(height: 6),
DmButton(
label: selected == null
? 'Choose a window'
: 'Use ${selected.window}',
: (widget.confirmLabel ?? 'Use ${selected.window}'),
onPressed: selected == null
? null
: () {
app.selectSlot(selected);
Navigator.of(context).pop(selected);
widget.onPicked(selected);
},
),
const SizedBox(height: 4),
],
),
const SizedBox(height: 4),
],
);
},
),
),
],
);
}