── 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.
390 lines
15 KiB
Dart
390 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../data/app_config.dart';
|
|
import '../../state/app_scope.dart';
|
|
import '../../data/models.dart';
|
|
import '../tokens.dart';
|
|
import '../widgets/misc.dart';
|
|
import '../format.dart';
|
|
import '../widgets/buttons.dart';
|
|
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.
|
|
///
|
|
/// The profile folds into the brand header: the avatar takes the slot the other
|
|
/// tab roots give an action, the customer's name takes the slot the title holds
|
|
/// elsewhere, and the phone number sits in the recessed track. That removes the
|
|
/// profile *card* the screen used to open with — a card whose entire content
|
|
/// was three facts about the person already looking at the screen.
|
|
class AccountScreen extends StatelessWidget {
|
|
const AccountScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final app = AppScope.of(context);
|
|
final customer = app.customer;
|
|
|
|
// Empty rather than a stand-in name: this screen is only ever built for a
|
|
// signed-in customer, and a placeholder here would read as somebody's
|
|
// actual account.
|
|
final name = customer?.name ?? '';
|
|
final phone = customer?.phone ?? '';
|
|
|
|
// Counted off what is already loaded, so the figures move with the list
|
|
// rather than claiming a lifetime total the client cannot know.
|
|
final completed = app.orders.where(
|
|
(o) => o.status == BookingStatus.completed,
|
|
);
|
|
final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages);
|
|
final cities = {
|
|
for (final o in completed)
|
|
for (final g in o.destinations) ?g.destination.districtCode,
|
|
}.length;
|
|
|
|
return SafeArea(
|
|
bottom: false,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// No wordmark bar, and no search or bell on it. Home and Orders lost
|
|
// the same one: a root that opens on the customer's own face does not
|
|
// need to be told which app it is, and the two glyphs beside it went
|
|
// to screens that have their own way in.
|
|
// ── The profile, centred ──
|
|
//
|
|
// It was a 34pt name left-aligned with the number under it, which is
|
|
// the shape a *list* screen takes. The reference puts the person at
|
|
// the top of their own account: a large avatar with an edit badge, the
|
|
// name, the number, and the account's standing — and that is what
|
|
// makes Account read as somewhere you are rather than as a menu.
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
DmSpace.pad,
|
|
26,
|
|
DmSpace.pad,
|
|
18,
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
DmAvatar(initials: initialsOf(name), size: 86),
|
|
Positioned(
|
|
right: -2,
|
|
bottom: -2,
|
|
child: Container(
|
|
width: 30,
|
|
height: 30,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.surface,
|
|
shape: BoxShape.circle,
|
|
boxShadow: DmShadow.card,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.pencil,
|
|
size: 14,
|
|
color: DmColors.ink2,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
Text(
|
|
name.isEmpty ? 'Your account' : name,
|
|
style: DmText.headerTitle.copyWith(fontSize: 24),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
if (phone.isNotEmpty) ...[
|
|
const SizedBox(height: 7),
|
|
Text(
|
|
phone,
|
|
style: DmText.monoSm.copyWith(
|
|
fontSize: 13,
|
|
color: DmColors.ink3,
|
|
),
|
|
),
|
|
],
|
|
// ── Two figures, both counted here ──
|
|
//
|
|
// The reference puts "24 all-time parcels" and "100% carbon
|
|
// offset" side by side under the name. The first is real — it
|
|
// is the orders already in memory — so it is shown. The second
|
|
// is not: nothing in this backend measures an offset, and a
|
|
// sustainability claim is the last thing an app should invent.
|
|
// Delivered parcels and cities reached are what the data
|
|
// actually supports.
|
|
if (delivered > 0) ...[
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _Stat(
|
|
icon: LucideIcons.package,
|
|
label: 'DELIVERED',
|
|
value: '$delivered',
|
|
note: delivered == 1 ? 'parcel' : 'parcels',
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _Stat(
|
|
icon: LucideIcons.mapPin,
|
|
label: 'CITIES',
|
|
value: '$cities',
|
|
note: cities == 1 ? 'destination' : 'destinations',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
// Clears the floating tab bar, which the shell stacks over the
|
|
// body rather than laying out beneath it.
|
|
padding: EdgeInsets.fromLTRB(
|
|
DmSpace.pad,
|
|
4,
|
|
DmSpace.pad,
|
|
dmTabBarClear(context),
|
|
),
|
|
children: [
|
|
const DmMicroHead('Preferences', top: 4, brand: false),
|
|
DmRowGroup(
|
|
children: [
|
|
DmRow(
|
|
icon: LucideIcons.mapPin,
|
|
label: 'Saved addresses',
|
|
note: 'Pickup and drop locations',
|
|
// No value. It was the pickup address, which is a street
|
|
// name and rendered as "38 Mettupal…" — a truncation
|
|
// nobody can read anything out of, in the slot meant for a
|
|
// word.
|
|
showChevron: true,
|
|
onTap: () async {
|
|
final place = await showPlaceSearchSheet(context);
|
|
if (place != null && context.mounted) {
|
|
app.setPickup(place);
|
|
DmToast.show(context, 'Pickup location updated');
|
|
}
|
|
},
|
|
),
|
|
// ── 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',
|
|
// 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()),
|
|
),
|
|
DmRow(
|
|
icon: LucideIcons.wallet,
|
|
label: 'Payment methods',
|
|
note: 'Pay after the doorstep weigh',
|
|
showChevron: true,
|
|
onTap: () =>
|
|
pushSettings(context, const PaymentScreen()),
|
|
),
|
|
],
|
|
),
|
|
// ── A second group, because the first was two things ──
|
|
//
|
|
// "Preferences" held three settings and one destination, and
|
|
// the destination — help — was the row a customer in trouble
|
|
// scans for. The two policy rows every app is expected to carry
|
|
// had nowhere to go at all, so they were simply missing.
|
|
//
|
|
// Split, each head says what its rows are: things you change,
|
|
// and things you read.
|
|
const DmMicroHead('About', brand: false),
|
|
DmRowGroup(
|
|
children: [
|
|
DmRow(
|
|
icon: LucideIcons.circleHelp,
|
|
label: 'Help and support',
|
|
note: 'Talk to the courier desk',
|
|
showChevron: true,
|
|
onTap: () =>
|
|
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: () => pushSettings(
|
|
context,
|
|
const AboutScreen(title: 'Terms and policies'),
|
|
),
|
|
),
|
|
DmRow(
|
|
icon: LucideIcons.info,
|
|
label: 'About Doormile',
|
|
note: 'Version and licences',
|
|
showChevron: true,
|
|
onTap: () => pushSettings(context, const AboutScreen()),
|
|
),
|
|
],
|
|
),
|
|
// The environment line is not a control — it is what a bug
|
|
// report needs in order to be actionable. Always shown when the
|
|
// session came from a dev token rather than a sign-in.
|
|
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
|
|
const DmMicroHead('Build', brand: false),
|
|
// Not a DmRow: that lays a short label against a short value
|
|
// on one line, and this value is a URL plus every flag the
|
|
// build carries. It overflowed by 59px the moment a bypass was
|
|
// on — which is exactly when someone needs to read it.
|
|
//
|
|
// Not a card either. It is a line of mono on the page, like
|
|
// every other thing on this screen.
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2, bottom: 4),
|
|
child: Text(
|
|
AppConfig.describe,
|
|
style: DmText.mono.copyWith(
|
|
height: 1.5,
|
|
color: DmColors.ink3,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
DmButton(
|
|
label: 'Log Out',
|
|
icon: LucideIcons.logOut,
|
|
iconLeading: true,
|
|
kind: DmButtonKind.ghost,
|
|
danger: true,
|
|
onPressed: () {
|
|
app.signOut();
|
|
Navigator.of(context).pushAndRemoveUntil(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => const LoginScreen(),
|
|
),
|
|
(route) => false,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text.rich(
|
|
TextSpan(
|
|
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
|
children: [
|
|
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
|
TextSpan(
|
|
text: 'v${AppConfig.appVersion}',
|
|
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
|
),
|
|
],
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One counted figure under the profile.
|
|
///
|
|
/// A glyph in the corner, a micro-label, the number at display size and the
|
|
/// unit under it — the shape a dashboard tile takes when it is one number
|
|
/// rather than a chart.
|
|
class _Stat extends StatelessWidget {
|
|
const _Stat({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.value,
|
|
required this.note,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final String note;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DmCard(
|
|
children: [
|
|
DmCardCell(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 14, 15),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: DmText.eyebrow,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(icon, size: 15, color: DmColors.ink4),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
value,
|
|
style: DmText.headerTitle.copyWith(fontSize: 28),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
note,
|
|
style: DmText.small.copyWith(
|
|
fontSize: 12.5,
|
|
color: DmColors.ink3,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|