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:
138
lib/ui/screens/settings/about_screen.dart
Normal file
138
lib/ui/screens/settings/about_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
lib/ui/screens/settings/notifications_screen.dart
Normal file
141
lib/ui/screens/settings/notifications_screen.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
120
lib/ui/screens/settings/payment_screen.dart
Normal file
120
lib/ui/screens/settings/payment_screen.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
195
lib/ui/screens/settings/settings_kit.dart
Normal file
195
lib/ui/screens/settings/settings_kit.dart
Normal 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;
|
||||
166
lib/ui/screens/settings/support_screen.dart
Normal file
166
lib/ui/screens/settings/support_screen.dart
Normal 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',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user