One Touch, and a pickup map worth the name
Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |
@@ -394,7 +394,26 @@ class AppState extends ChangeNotifier {
|
||||
/// form, and cleared only when a booking actually lands.
|
||||
String? _bookingIdempotencyKey;
|
||||
|
||||
void startBooking() {
|
||||
/// Whether this booking asks the customer for the drop address.
|
||||
///
|
||||
/// ── Two ways to book the same parcel ──
|
||||
///
|
||||
/// **One Touch** asks three things: the state, the district and a pickup
|
||||
/// window. Nothing else — no door number, no recipient, no weight. Those are
|
||||
/// filled in by the Miler standing at the address with the parcel in their
|
||||
/// hand, which is the only moment anybody actually knows them.
|
||||
///
|
||||
/// **The full form** is for the customer who already knows the door and
|
||||
/// would rather type it than have it asked for later. It is the same
|
||||
/// booking with `details` populated on the wire.
|
||||
///
|
||||
/// The contract has always taken both: [DestinationGroup.toBookingJson]
|
||||
/// omits whatever is blank, and an omitted field is precisely the "not added
|
||||
/// yet" state the Miler completes. This flag only decides what the app asks.
|
||||
bool draftDetailed = false;
|
||||
|
||||
void startBooking({bool detailed = false}) {
|
||||
draftDetailed = detailed;
|
||||
draftPickup = pickup;
|
||||
draftDestinations = [DestinationGroup()];
|
||||
draftSlotId = null;
|
||||
|
||||
@@ -7,10 +7,8 @@ import '../../data/models.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../format.dart';
|
||||
import 'shell_screen.dart';
|
||||
import '../widgets/buttons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import 'auth/login_screen.dart';
|
||||
@@ -39,245 +37,261 @@ class AccountScreen extends StatelessWidget {
|
||||
|
||||
// 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 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,
|
||||
for (final g in o.destinations) ?g.destination.districtCode,
|
||||
}.length;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => ShellScreen.of(context)?.goToTab(1),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
// ── 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, 6, 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(
|
||||
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: [
|
||||
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',
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
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, 74 + MediaQuery.paddingOf(context).bottom),
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
note: 'UPI, on delivery',
|
||||
showChevron: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
// ── 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: () => DmToast.show(context, 'Support is on the way'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Terms and policies',
|
||||
note: 'What Doormile covers',
|
||||
showChevron: true,
|
||||
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.info,
|
||||
label: 'About Doormile',
|
||||
note: 'Version and licences',
|
||||
showChevron: true,
|
||||
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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,
|
||||
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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
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,
|
||||
74 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const DmMicroHead('Preferences', top: 4, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
||||
TextSpan(
|
||||
text: 'v${AppConfig.appVersion}',
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
note: 'UPI, on delivery',
|
||||
showChevron: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
// ── 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: () =>
|
||||
DmToast.show(context, 'Support is on the way'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Terms and policies',
|
||||
note: 'What Doormile covers',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.info,
|
||||
label: 'About Doormile',
|
||||
note: 'Version and licences',
|
||||
showChevron: true,
|
||||
onTap: () =>
|
||||
DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,17 +7,29 @@ import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/map_panel.dart';
|
||||
import '../sheets/place_search_sheet.dart';
|
||||
|
||||
/// Step 1 — where we collect from.
|
||||
///
|
||||
/// One card: a real map on top with the pin locked to its centre, and the
|
||||
/// address it resolves to in the box directly underneath. Drag the map, the
|
||||
/// address follows. Nothing is typed unless the customer wants to.
|
||||
/// ── A map screen, not a screen with a map on it ──
|
||||
///
|
||||
/// This was a title, a caption, and a bordered card holding a map, an address
|
||||
/// strip and a phone field, on a canvas, under an app bar. Five surfaces for
|
||||
/// one question, and the map — the only thing on the screen the customer can
|
||||
/// actually *use* — got whatever height was left over.
|
||||
///
|
||||
/// It is the shape every ride app converged on now, and they converged on it
|
||||
/// because it is the shape of the task: the map fills the screen, the pin is
|
||||
/// nailed to the centre, and the world moves under it. Nothing else can be
|
||||
/// dragged, so nothing else competes for the gesture.
|
||||
///
|
||||
/// Three things float over it. A search field at the top, because typing is
|
||||
/// the right answer when you are not standing where the parcel is. A locate
|
||||
/// button, because the device usually knows. And a sheet at the foot carrying
|
||||
/// what the pin resolved to, who the Miler should ask for, and the button that
|
||||
/// agrees to it.
|
||||
class PickupLocationScreen extends StatefulWidget {
|
||||
const PickupLocationScreen({super.key});
|
||||
|
||||
@@ -39,11 +51,16 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// explain in a remarks box nobody reads.
|
||||
final _contact = 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;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final app = AppScope.read(context);
|
||||
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
|
||||
_contactOpen = app.draftContactPhone != null;
|
||||
|
||||
// Arriving without a pin — ask the device where we are.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -79,7 +96,9 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
}
|
||||
|
||||
Future<void> _search(AppState app) async {
|
||||
final picked = await showPlaceSearchSheet(context);
|
||||
// No map row inside the sheet this screen opens: it would push a second
|
||||
// copy of this screen over the first.
|
||||
final picked = await showPlaceSearchSheet(context, allowMap: false);
|
||||
if (picked == null || !mounted) return;
|
||||
app.setPickup(picked);
|
||||
setState(() => _pinMoving = false);
|
||||
@@ -91,232 +110,430 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
final app = AppScope.of(context);
|
||||
final place = app.pickup;
|
||||
final resolving = app.resolvingPickup || _pinMoving;
|
||||
final top = MediaQuery.paddingOf(context).top;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Pickup point'),
|
||||
body: Column(
|
||||
// No app bar. The back button floats on the map with everything else,
|
||||
// because a bar would cut a strip off the one thing worth seeing.
|
||||
body: Stack(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
|
||||
child: _Intro(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// The map and the address it resolves to are one object, so they get
|
||||
// one card. Not a ListView: the map must win vertical drags.
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
14,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DmMapPanel(
|
||||
lat: app.mapLat,
|
||||
lng: app.mapLng,
|
||||
deviceLat: app.deviceLat,
|
||||
deviceLng: app.deviceLng,
|
||||
deviceAccuracy: app.deviceAccuracy,
|
||||
loading: resolving,
|
||||
borderRadius: BorderRadius.zero,
|
||||
onLocate: () {
|
||||
setState(() => _pinMoving = false);
|
||||
app.detectPickupLocation();
|
||||
},
|
||||
onPinMoveStart: () {
|
||||
if (!_pinMoving) setState(() => _pinMoving = true);
|
||||
},
|
||||
onPinMoved: (lat, lng) {
|
||||
setState(() => _pinMoving = false);
|
||||
app.setPickupFromPin(lat, lng);
|
||||
},
|
||||
),
|
||||
),
|
||||
_AddressBox(
|
||||
title: place?.title,
|
||||
sub: place?.sub,
|
||||
resolving: resolving,
|
||||
onChange: () => _search(app),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
16,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
child: DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: _contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// ── The map, edge to edge ──
|
||||
Positioned.fill(
|
||||
child: DmMapPanel(
|
||||
lat: app.mapLat,
|
||||
lng: app.mapLng,
|
||||
deviceLat: app.deviceLat,
|
||||
deviceLng: app.deviceLng,
|
||||
deviceAccuracy: app.deviceAccuracy,
|
||||
loading: resolving,
|
||||
borderRadius: BorderRadius.zero,
|
||||
onPinMoveStart: () {
|
||||
if (!_pinMoving) setState(() => _pinMoving = true);
|
||||
},
|
||||
onPinMoved: (lat, lng) {
|
||||
setState(() => _pinMoving = false);
|
||||
app.setPickupFromPin(lat, lng);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (app.locationDenial != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
12,
|
||||
),
|
||||
child: _LocationNotice(
|
||||
denial: app.locationDenial!,
|
||||
onSettings: app.openLocationSettings,
|
||||
onSearch: () => _search(app),
|
||||
),
|
||||
// ── Back, and the way to type instead ──
|
||||
Positioned(
|
||||
left: DmSpace.pad,
|
||||
right: DmSpace.pad,
|
||||
top: top + 10,
|
||||
child: Row(
|
||||
children: [
|
||||
_FloatingCircle(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
tooltip: 'Back',
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _SearchBar(onTap: () => _search(app))),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── The device knows, usually ──
|
||||
//
|
||||
// It sits just above the sheet rather than in the map's own corner:
|
||||
// the sheet is a fixed height, so the button can be placed against
|
||||
// it and stay off the attribution line.
|
||||
Positioned(
|
||||
right: DmSpace.pad,
|
||||
bottom: _sheetHeight(context, app) + 14,
|
||||
child: _FloatingCircle(
|
||||
icon: LucideIcons.locateFixed,
|
||||
tooltip: 'Use my current location',
|
||||
onPressed: () {
|
||||
setState(() => _pinMoving = false);
|
||||
app.detectPickupLocation();
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ── What the pin resolved to, and the button that agrees ──
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _ConfirmSheet(
|
||||
title: place?.title,
|
||||
sub: place?.sub,
|
||||
resolving: resolving,
|
||||
denial: app.locationDenial,
|
||||
contact: _contact,
|
||||
contactOpen: _contactOpen,
|
||||
onToggleContact: () =>
|
||||
setState(() => _contactOpen = !_contactOpen),
|
||||
onEdit: () => _search(app),
|
||||
onSettings: app.openLocationSettings,
|
||||
onConfirm: !app.hasPickupPoint || resolving
|
||||
? null
|
||||
: () {
|
||||
app.draftPickup = place;
|
||||
_commitContact(app);
|
||||
// Popped *with* the place, so the search sheet that
|
||||
// pushed this screen can close carrying the pin back to
|
||||
// whoever opened it. Review ignores the result and reads
|
||||
// `draftPickup`, which this has already written.
|
||||
Navigator.of(context).pop(place);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
DmButton(
|
||||
label: 'Use this address',
|
||||
icon: LucideIcons.check,
|
||||
// Gated on the pin, not just the address: a booking without
|
||||
// coordinates is one the Miler cannot be routed to.
|
||||
onPressed: !app.hasPickupPoint || resolving
|
||||
? null
|
||||
: () {
|
||||
app.draftPickup = place;
|
||||
_commitContact(app);
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Roughly how tall the sheet is, so the locate button can sit on top of it.
|
||||
/// It only has to be close: the button is placed against the sheet, not
|
||||
/// aligned to a hairline in it.
|
||||
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) +
|
||||
MediaQuery.paddingOf(context).bottom;
|
||||
}
|
||||
}
|
||||
|
||||
class _Intro extends StatelessWidget {
|
||||
const _Intro();
|
||||
/// A white disc that floats over the map — back, and locate.
|
||||
class _FloatingCircle extends StatelessWidget {
|
||||
const _FloatingCircle({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Where should we pick up?', style: DmText.title),
|
||||
const SizedBox(height: 8),
|
||||
Text('Drag the map to move the pin.', style: DmText.lede),
|
||||
],
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: DmShadow.cardRaised,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: DmColors.ink),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The address under the map. It is the same card, so a hairline separates
|
||||
/// them rather than a gap.
|
||||
class _AddressBox extends StatelessWidget {
|
||||
const _AddressBox({
|
||||
/// "Search for a building or street" — the way in for somebody who is not
|
||||
/// standing at the door.
|
||||
///
|
||||
/// A field that opens a sheet rather than a field you type into: the search
|
||||
/// itself has a list under it and belongs on a surface of its own. This is the
|
||||
/// affordance, and it is shaped like a field because that is what the gesture
|
||||
/// expects.
|
||||
class _SearchBar extends StatelessWidget {
|
||||
const _SearchBar({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Search for an address',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.cardRaised,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.search, size: 17, color: DmColors.ink3),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Search for a building or street',
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The sheet the map runs under.
|
||||
///
|
||||
/// ── Everything the screen asks, in one surface ──
|
||||
///
|
||||
/// The address the pin resolved to, who the Miler should ask for, and the
|
||||
/// button. It is a fixed panel rather than a draggable sheet on purpose: a
|
||||
/// sheet that can be dragged competes with a map that must be, and there is
|
||||
/// nothing below the fold worth reaching for.
|
||||
///
|
||||
/// The contact is folded away behind a line of text. It is right nearly every
|
||||
/// time — it opens on the account's own number — and the one screen that
|
||||
/// exists to place a pin should not open with a keyboard-bearing field on it.
|
||||
class _ConfirmSheet extends StatelessWidget {
|
||||
const _ConfirmSheet({
|
||||
required this.title,
|
||||
required this.sub,
|
||||
required this.resolving,
|
||||
required this.onChange,
|
||||
required this.denial,
|
||||
required this.contact,
|
||||
required this.contactOpen,
|
||||
required this.onToggleContact,
|
||||
required this.onEdit,
|
||||
required this.onSettings,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String? sub;
|
||||
final bool resolving;
|
||||
final VoidCallback onChange;
|
||||
final LocationDenial? denial;
|
||||
final TextEditingController contact;
|
||||
final bool contactOpen;
|
||||
final VoidCallback onToggleContact;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onSettings;
|
||||
|
||||
/// Null while there is no pin, or while the address is still coming back.
|
||||
final VoidCallback? onConfirm;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 10, 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
border: Border(top: BorderSide(color: DmColors.border)),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(DmRadius.header),
|
||||
),
|
||||
boxShadow: DmShadow.sheet,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// The resolved address is the map's answer, so it takes the wash: it
|
||||
// is brand-owned context, not a field waiting to be filled.
|
||||
const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
child: resolving
|
||||
? const _ResolvingLines(key: ValueKey('resolving'))
|
||||
: Column(
|
||||
key: ValueKey('$title|$sub'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
18,
|
||||
DmSpace.pad,
|
||||
14,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('PICKUP FROM', style: DmText.eyebrow),
|
||||
const SizedBox(height: 10),
|
||||
_Resolved(
|
||||
title: title,
|
||||
sub: sub,
|
||||
resolving: resolving,
|
||||
onEdit: onEdit,
|
||||
),
|
||||
if (denial != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
_LocationNotice(
|
||||
denial: denial!,
|
||||
onSettings: onSettings,
|
||||
onSearch: onEdit,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
// The second question, folded.
|
||||
//
|
||||
// Its own row rather than a `DmTextAction`, which centres its
|
||||
// label — a centred line under a left-aligned address reads as a
|
||||
// caption for the sheet rather than as a control in it.
|
||||
Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onToggleContact,
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
title ?? 'Locating you…',
|
||||
key: const ValueKey('pickupAddressTitle'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle,
|
||||
Icon(
|
||||
contactOpen
|
||||
? LucideIcons.chevronUp
|
||||
: LucideIcons.userRoundPlus,
|
||||
size: 16,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
sub ?? 'Please wait a moment',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.small.copyWith(color: DmColors.brandPress),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
contactOpen
|
||||
? 'Hide contact number'
|
||||
: 'Someone else is handing it over?',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 13.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
alignment: Alignment.topCenter,
|
||||
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,
|
||||
),
|
||||
)
|
||||
: const SizedBox(width: double.infinity),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DmButton(
|
||||
label: 'Confirm pickup point',
|
||||
icon: LucideIcons.check,
|
||||
// Gated on the pin, not just the address: a booking without
|
||||
// coordinates is one the Miler cannot be routed to.
|
||||
onPressed: onConfirm,
|
||||
),
|
||||
],
|
||||
),
|
||||
DmTextAction(label: 'Change', onPressed: onChange),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the address box shows while the pin is moving: the shape of an
|
||||
/// address, so the card never collapses or jumps.
|
||||
class _ResolvingLines extends StatelessWidget {
|
||||
const _ResolvingLines({super.key});
|
||||
/// What the pin is sitting on, and the way to change it by typing.
|
||||
class _Resolved extends StatelessWidget {
|
||||
const _Resolved({
|
||||
required this.title,
|
||||
required this.sub,
|
||||
required this.resolving,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final String? sub;
|
||||
final bool resolving;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
if (resolving) {
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
strokeCap: StrokeCap.round,
|
||||
valueColor: AlwaysStoppedAnimation(DmColors.brand),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Getting address…',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 17,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Getting address…',
|
||||
style: DmText.cardTitle.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 148,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brandLine,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title ?? 'Move the map to set the pin',
|
||||
// Keyed, because the tests read the resolved address off this
|
||||
// exact widget to prove GPS, a map drag and a search all write
|
||||
// the same pickup point.
|
||||
key: const ValueKey('pickupAddressTitle'),
|
||||
style: DmText.cardTitle.copyWith(fontSize: 17.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (sub != null && sub!.isNotEmpty)
|
||||
Text(
|
||||
sub!,
|
||||
style: DmText.small.copyWith(color: DmColors.ink3),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
DmTextAction(label: 'Edit', minHeight: 24, onPressed: onEdit),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/pieces.dart';
|
||||
import '../sheets/destination_sheet.dart';
|
||||
import '../sheets/drop_address_sheet.dart';
|
||||
import '../sheets/window_sheet.dart';
|
||||
import 'booking_routes.dart';
|
||||
import 'confirmed_screen.dart';
|
||||
@@ -111,7 +112,10 @@ class _SendScreenState extends State<SendScreen> {
|
||||
final fare = app.draftFare;
|
||||
|
||||
final ready =
|
||||
city.districtCode != null && app.draftSlotId != null && app.draftReady;
|
||||
city.districtCode != null &&
|
||||
app.draftSlotId != null &&
|
||||
app.draftReady &&
|
||||
(!app.draftDetailed || group.details.exactAddress != null);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
@@ -125,8 +129,9 @@ class _SendScreenState extends State<SendScreen> {
|
||||
// arrived here from Home's sphere has answered two questions and
|
||||
// wants to know this is the last one. Three dots said the same thing
|
||||
// and made you count them.
|
||||
const _StepHead(step: 3, of: 3),
|
||||
const SizedBox(height: 14),
|
||||
// Two steps, not three. BOOK asks when, this screen asks the rest.
|
||||
const _StepHead(step: 2, of: 2, title: 'Review and book'),
|
||||
const SizedBox(height: 16),
|
||||
// The route, as one card with the two ends and a thread between
|
||||
// them. It was four ruled rows of equal weight, so where it comes
|
||||
// from, where it goes, when and how many all read as the same kind
|
||||
@@ -141,8 +146,12 @@ class _SendScreenState extends State<SendScreen> {
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
fromNote: pickup?.sub,
|
||||
to: [for (final g in app.draftDestinations) g.destination],
|
||||
// Typed, because the screen pops with the chosen place so
|
||||
// the search sheet can carry it back. Review does not need
|
||||
// the result — it reads `draftPickup` — but a `Route<void>`
|
||||
// asserts when something is popped through it.
|
||||
onTapFrom: () => Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
bookingRoute<Place?>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
@@ -160,12 +169,17 @@ class _SendScreenState extends State<SendScreen> {
|
||||
],
|
||||
),
|
||||
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
|
||||
// eye lands on — the same treatment the destination row gets when
|
||||
// it is the one outstanding.
|
||||
_DetailCard(
|
||||
icon: LucideIcons.calendar,
|
||||
label: 'PICKUP WINDOW',
|
||||
value: slot == null
|
||||
? 'Choose a window'
|
||||
: '${slot.day}, ${slot.window}',
|
||||
unset: slot == null,
|
||||
onTap: () => showWindowSheet(context),
|
||||
trailing: slot == null
|
||||
? null
|
||||
@@ -175,6 +189,31 @@ class _SendScreenState extends State<SendScreen> {
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
// ── Only the long way asks for this ──
|
||||
//
|
||||
// One Touch books on a state, a district and a window; the door is
|
||||
// filled in by the Miler who is standing at it. A customer who chose
|
||||
// the full form is choosing to save them that step, so the row is
|
||||
// here, it is outstanding until it is answered, and the button waits
|
||||
// for it.
|
||||
if (app.draftDetailed) ...[
|
||||
const SizedBox(height: 10),
|
||||
_DetailCard(
|
||||
icon: LucideIcons.house,
|
||||
label: 'DROP ADDRESS',
|
||||
value: group.details.exactAddress ?? 'Add the full address',
|
||||
note: group.details.recipientName,
|
||||
unset: group.details.exactAddress == null,
|
||||
onTap: () async {
|
||||
final saved = await showDropAddressSheet(
|
||||
context,
|
||||
details: group.details,
|
||||
district: group.destination.shortLabel,
|
||||
);
|
||||
if (saved == true && context.mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
_DetailCard(
|
||||
icon: LucideIcons.package,
|
||||
@@ -234,11 +273,17 @@ class _SendScreenState extends State<SendScreen> {
|
||||
child: Text('ESTIMATED', style: DmText.eyebrow),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
fare?.range ?? '—',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 30),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// Stretched. The footer centres what it cannot stretch, so a short
|
||||
// value — the em dash before a fare has been quoted — drifted to
|
||||
// the middle while its own label stayed at the margin.
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
fare?.range ?? '—',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 30),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
@@ -285,40 +330,55 @@ class _SendScreenState extends State<SendScreen> {
|
||||
/// read rather than parsed, and it leaves the screen's own title to the thing
|
||||
/// the customer is about to press.
|
||||
class _StepHead extends StatelessWidget {
|
||||
const _StepHead({required this.step, required this.of});
|
||||
const _StepHead({
|
||||
required this.step,
|
||||
required this.of,
|
||||
required this.title,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final int of;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
header: true,
|
||||
label: 'Step $step of $of',
|
||||
label: '$title. Step $step of $of',
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.ink,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$step',
|
||||
style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Step $step of $of',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.ink,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$step',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 11.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Step $step of $of',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(title, style: DmText.display),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -365,8 +425,14 @@ class _RouteHead extends StatelessWidget {
|
||||
for (var i = 0; i < to.length; i++)
|
||||
_Stop(
|
||||
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
|
||||
value: to[i].shortLabel,
|
||||
note: to[i].stateName,
|
||||
// BOOK no longer asks where it is going, so this row is where the
|
||||
// question lands. Unset it reads as an invitation rather than as
|
||||
// the em dash `shortLabel` falls back to.
|
||||
value: to[i].districtCode == null
|
||||
? 'Choose a destination'
|
||||
: to[i].shortLabel,
|
||||
unset: to[i].districtCode == null,
|
||||
note: to[i].districtCode == null ? null : to[i].stateName,
|
||||
onTap: onTapTo,
|
||||
origin: false,
|
||||
last: i == to.length - 1,
|
||||
@@ -384,6 +450,7 @@ class _Stop extends StatelessWidget {
|
||||
required this.origin,
|
||||
required this.last,
|
||||
this.note,
|
||||
this.unset = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@@ -393,11 +460,15 @@ class _Stop extends StatelessWidget {
|
||||
final bool origin;
|
||||
final bool last;
|
||||
|
||||
/// Nothing chosen yet — the node hollows out and the value takes the brand,
|
||||
/// so the one row still to be filled is the one the eye lands on.
|
||||
final bool unset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$label $value',
|
||||
label: '$label $value. Change',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
@@ -416,8 +487,13 @@ class _Stop extends StatelessWidget {
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: origin ? DmColors.ink : DmColors.brand,
|
||||
color: unset
|
||||
? Colors.transparent
|
||||
: (origin ? DmColors.ink : DmColors.brand),
|
||||
shape: BoxShape.circle,
|
||||
border: unset
|
||||
? Border.all(color: DmColors.brand, width: 2)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (!last)
|
||||
@@ -434,16 +510,45 @@ class _Stop extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16),
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 2, last ? 4 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
// ── The label, and the way to change it ──
|
||||
//
|
||||
// Both stops were tappable and neither said so. The
|
||||
// thread and the two places read as a printed summary,
|
||||
// which is exactly what a review screen looks like when
|
||||
// it is *not* editable — so the one thing the screen is
|
||||
// for was invisible.
|
||||
//
|
||||
// "Change" against the label, rather than a chevron at
|
||||
// the end of the row: a chevron on a row inside a card
|
||||
// means "opens a screen", and these open a sheet over
|
||||
// this one.
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(label, style: DmText.eyebrow),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Change',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 16.5),
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 16.5,
|
||||
color: unset ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -479,6 +584,7 @@ class _DetailCard extends StatelessWidget {
|
||||
this.note,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
this.unset = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -488,6 +594,10 @@ class _DetailCard extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
/// Nothing chosen yet — the value takes the brand and the glyph well warms
|
||||
/// with it, so an unanswered row is visibly the next thing to do.
|
||||
final bool unset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
@@ -502,10 +612,14 @@ class _DetailCard extends StatelessWidget {
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
color: unset ? DmColors.brandSoft : DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: DmColors.ink2),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: unset ? DmColors.brand : DmColors.ink2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
@@ -517,7 +631,10 @@ class _DetailCard extends StatelessWidget {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 15.5,
|
||||
color: unset ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ 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/place_search_sheet.dart';
|
||||
import 'sheets/window_sheet.dart';
|
||||
import 'tracking_screen.dart';
|
||||
@@ -52,19 +53,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
|
||||
/// BOOK. The one action on this screen, and the whole flow's front door.
|
||||
///
|
||||
/// Two questions are asked here, as sheets over Home, before any form:
|
||||
/// ── Where, then when, then the review ──
|
||||
///
|
||||
/// where is it going? → when shall we collect? → the details
|
||||
/// 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.
|
||||
///
|
||||
/// Both used to be answered somewhere else. The destination was a strip of
|
||||
/// cards halfway down the form; the window was picked *for* the customer,
|
||||
/// silently, the moment they chose a city — they arrived at a booking with a
|
||||
/// time on it that they had never agreed to. A pickup window is a promise
|
||||
/// about somebody's afternoon, and it is not ours to assume.
|
||||
///
|
||||
/// Dismissing the window sheet is allowed: the send screen carries "Choose a
|
||||
/// window" in the footer and its button stays disabled until there is one.
|
||||
Future<void> _book() async {
|
||||
/// 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.
|
||||
Future<void> _book({bool detailed = false}) async {
|
||||
final app = AppScope.read(context);
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
@@ -73,9 +73,26 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
final places = await showDestinationSheet(context);
|
||||
if (places == null || places.isEmpty || !mounted) return;
|
||||
|
||||
app.startBooking();
|
||||
app.startBooking(detailed: detailed);
|
||||
app.setDestinations(places);
|
||||
|
||||
// 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
|
||||
// three addresses, and "for all parcels" is the whole point of choosing
|
||||
// this path over One Touch.
|
||||
if (detailed) {
|
||||
for (final group in app.draftDestinations) {
|
||||
if (!mounted) return;
|
||||
final saved = await showDropAddressSheet(
|
||||
context,
|
||||
details: group.details,
|
||||
district: group.destination.shortLabel,
|
||||
);
|
||||
if (saved != true) break;
|
||||
}
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
await showWindowSheet(context);
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -95,7 +112,11 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// The sphere's own field, floored so a very short window leaves it a
|
||||
// sphere rather than a slot. It is a *minimum*: the scroll view hands it
|
||||
// whatever is left once the card below has taken what it needs.
|
||||
final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38);
|
||||
// Lower than it was. The sphere gained a second control under it and the
|
||||
// live card was sitting a third of a screen below both; the field only has
|
||||
// to be big enough for the sphere to float in, not to push everything else
|
||||
// off the fold.
|
||||
final orbHeight = math.max(260.0, MediaQuery.sizeOf(context).height * 0.30);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
@@ -114,7 +135,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
// this is the top of the screen, so it takes the inset the bar used
|
||||
// to hold.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 26, DmSpace.pad, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -155,17 +176,29 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PickupLine(
|
||||
title: app.pickup?.title ?? 'Set a pickup address',
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
if (place != null && context.mounted) app.setPickup(place);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── What is running comes first ──
|
||||
//
|
||||
// The live card sat under the sphere, at the foot of the screen. A
|
||||
// customer with a parcel in the network opens this app to check on
|
||||
// it, not to send another one — so it is the first thing under their
|
||||
// own name, and the sphere is what they scroll past it to.
|
||||
if (active != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
14,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
child: _LiveCard(booking: active),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 380.ms, delay: 160.ms)
|
||||
.slideY(begin: -0.08, curve: DmMotion.ease),
|
||||
Expanded(
|
||||
child: AnimatedScale(
|
||||
scale: _handingOver ? 0.96 : 1,
|
||||
@@ -201,7 +234,30 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
DmBookOrb(onTap: _book, held: _handingOver)
|
||||
// ── What each way asks for ──
|
||||
//
|
||||
// Two ways to book the same parcel is not
|
||||
// something a customer can infer from a sphere
|
||||
// and a form. One line each, in place: this one
|
||||
// under the sphere, the other on the DROP row.
|
||||
// Said side by side in a paragraph they would be
|
||||
// a comparison nobody reads; said where the
|
||||
// choice is made they are two captions.
|
||||
DmBookOrb(
|
||||
onTap: _book,
|
||||
held: _handingOver,
|
||||
// Short enough to sit on one line, and
|
||||
// written to be read against the DROP
|
||||
// row's note rather than on its own:
|
||||
//
|
||||
// Just a city and a time
|
||||
// Enter the full address yourself
|
||||
//
|
||||
// Two captions, same length, opposite
|
||||
// answers. A paragraph comparing them
|
||||
// would say more and be read by nobody.
|
||||
caption: 'Just a city and a time',
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 420.ms)
|
||||
.scale(
|
||||
@@ -210,18 +266,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
duration: 620.ms,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// The promise, as a chip. It was a sentence under
|
||||
// the sphere on first run and nothing afterwards;
|
||||
// a returning customer is the one who most wants
|
||||
// to know a Miler is three minutes away.
|
||||
const _PromiseChip(
|
||||
icon: LucideIcons.zap,
|
||||
label: '3 min pickup',
|
||||
).animate().fadeIn(
|
||||
duration: 380.ms,
|
||||
delay: 220.ms,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -232,29 +276,29 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── The foot: what is running, and nothing else ──
|
||||
// ── Everything under the sphere is pinned ──
|
||||
//
|
||||
// A "quick dispatch" row of shortcut cities lived here and was
|
||||
// removed. It was a second way to start the one thing this screen
|
||||
// starts, and it cost the sphere the space that makes it read as an
|
||||
// object floating in a page rather than as a button in a stack.
|
||||
// The scroll view centres the sphere in whatever height is left, and
|
||||
// it cannot know about anything laid out after it — so anything laid
|
||||
// out inside that column pushed the thing before it under the fold.
|
||||
// The form sits outside it, and the sphere floats in what remains.
|
||||
//
|
||||
// The card sits outside the scroll view, so the sphere is centred in
|
||||
// what is actually left rather than in a viewport pretending the
|
||||
// card is not there.
|
||||
if (active != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
14,
|
||||
DmSpace.pad,
|
||||
6,
|
||||
),
|
||||
child: _LiveCard(booking: active),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 380.ms, delay: 160.ms)
|
||||
.slideY(begin: 0.12, curve: DmMotion.ease),
|
||||
// A "3 min pickup" chip used to sit here. It was riding the sphere's
|
||||
// lower arc — the field is centred on the disc, not on the glow
|
||||
// round it, so a chip pinned directly under the column overlapped
|
||||
// the shadow the sphere throws.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
|
||||
child: _PickDropForm(
|
||||
pickup: app.pickup?.title,
|
||||
onPickup: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
if (place != null && context.mounted) app.setPickup(place);
|
||||
},
|
||||
onDrop: () => _book(detailed: true),
|
||||
).animate().fadeIn(duration: 380.ms, delay: 300.ms),
|
||||
),
|
||||
|
||||
// Clears the floating tab bar, which the shell stacks over the body.
|
||||
//
|
||||
// Measured, not guessed. A fixed 74 was right on the 844pt golden
|
||||
@@ -273,22 +317,18 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live booking, at the foot of Home.
|
||||
/// The live booking, at the head of Home.
|
||||
///
|
||||
/// ── The loader went, and the card grew ──
|
||||
/// ── Smaller, and without the courier ──
|
||||
///
|
||||
/// It carried a thin filled line under the destination — a progress bar across
|
||||
/// the five customer milestones. It was the one fact that cannot be written as
|
||||
/// a word, and it still read as a *loader*: a bar that fills is the shape an
|
||||
/// app uses to say "wait", and a parcel two days into a journey is not
|
||||
/// something the customer is waiting through. The rail on tracking says the
|
||||
/// same thing with five labelled nodes and no ambiguity.
|
||||
/// It carried a rule and a second row naming the Miler and their plate. Both
|
||||
/// are real and neither belongs here: this card sits above the sphere now, so
|
||||
/// every point it takes is a point the one action on the screen gives up — and
|
||||
/// the Miler's name is on the screen this card opens, beside the button that
|
||||
/// rings them, which is where it is any use.
|
||||
///
|
||||
/// What the space bought is size. This is the only thing on Home besides the
|
||||
/// sphere and it was a 90pt strip — a chip, a place and a courier compressed
|
||||
/// into three tight lines. The destination is set at display weight with its
|
||||
/// promise under it now, a rule separates the journey from the person carrying
|
||||
/// it, and the arrow is a target rather than a glyph.
|
||||
/// What is left is what a glance asks: what is happening, where, and when.
|
||||
/// Three lines and an arrow.
|
||||
class _LiveCard extends StatelessWidget {
|
||||
const _LiveCard({required this.booking});
|
||||
|
||||
@@ -298,7 +338,6 @@ class _LiveCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final slot = app.slotById(booking.slotId);
|
||||
final miler = booking.miler ?? booking.deliveryAgent;
|
||||
|
||||
// The district alone. "Chennai, Tamil Nadu" shares its row with the state
|
||||
// and the reference, and on a 402pt device it rendered "Chenna…" — the
|
||||
@@ -334,20 +373,20 @@ class _LiveCard extends StatelessWidget {
|
||||
},
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 17, 18, 18),
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const DmLiveDot(size: 7),
|
||||
const SizedBox(width: 9),
|
||||
const DmLiveDot(size: 6),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.stage.chipLabel.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 0.9,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
@@ -359,7 +398,10 @@ class _LiveCard extends StatelessWidget {
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.displayId,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -367,7 +409,7 @@ class _LiveCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -377,15 +419,14 @@ class _LiveCard extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
place,
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
style: DmText.headerTitle.copyWith(fontSize: 21),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
promise,
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 14.5,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 13,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
@@ -395,12 +436,9 @@ class _LiveCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// A disc, not a bare chevron. It is the only affordance on
|
||||
// the card and it sits against 26pt of type, where a 17pt
|
||||
// glyph on its own disappears.
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
@@ -408,47 +446,12 @@ class _LiveCard extends StatelessWidget {
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.arrowRight,
|
||||
size: 19,
|
||||
size: 17,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (miler != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.hairline,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
DmAvatar(initials: miler.initials, size: 38),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
miler.name,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
miler.vehicle,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -457,103 +460,178 @@ class _LiveCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Miler is coming to, under the greeting.
|
||||
/// Pickup over drop, on one card — the shape every ride app asks a journey in.
|
||||
///
|
||||
/// ── Box → line → pill → line → row → pill ──
|
||||
/// ── Why the app has two ways to start and only one of them is a sphere ──
|
||||
///
|
||||
/// It has been most shapes. The one it has landed on is the reference's: a
|
||||
/// small bordered pill holding a brand pin, the address, and a chevron-down.
|
||||
/// One Touch is the product: a state, a district, a window, and the Miler
|
||||
/// writes down the door when they get there. It is a sphere because it is one
|
||||
/// gesture, and a gesture cannot be a form.
|
||||
///
|
||||
/// The pill is right *here* and was wrong before because the screen changed
|
||||
/// around it. On a page whose only other objects were a name and a sphere it
|
||||
/// was a container built to hold one line of text. On a page that now opens
|
||||
/// with a wordmark bar and ends with a floating one, it is the middle term —
|
||||
/// and the chevron-down is the honest glyph, because what it opens is a
|
||||
/// chooser, not another screen.
|
||||
class _PickupLine extends StatelessWidget {
|
||||
const _PickupLine({required this.title, required this.onTap});
|
||||
/// This is the same booking for a customer who already knows the door. Both
|
||||
/// ends are here because a journey has two, and seeing them together is what
|
||||
/// makes it obvious that one is already answered — the pickup carries the
|
||||
/// address the app detected, so the only row with a question on it is the
|
||||
/// drop.
|
||||
///
|
||||
/// It replaced a line of text reading "Enter the full address instead", which
|
||||
/// said what would happen and showed none of it. A form that is already half
|
||||
/// filled in is a better argument for itself than a sentence about forms.
|
||||
class _PickDropForm extends StatelessWidget {
|
||||
const _PickDropForm({
|
||||
required this.pickup,
|
||||
required this.onPickup,
|
||||
required this.onDrop,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? pickup;
|
||||
final VoidCallback onPickup;
|
||||
final VoidCallback onDrop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 14, 6),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_End(
|
||||
label: 'PICKUP',
|
||||
value: pickup ?? 'Set a pickup address',
|
||||
unset: pickup == null,
|
||||
origin: true,
|
||||
last: false,
|
||||
onTap: onPickup,
|
||||
),
|
||||
_End(
|
||||
label: 'DROP',
|
||||
value: 'Where is it going?',
|
||||
note: 'Enter the full address yourself',
|
||||
unset: true,
|
||||
origin: false,
|
||||
last: true,
|
||||
onTap: onDrop,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One end of the journey: a node on a thread, a micro-label, and the answer
|
||||
/// or the question.
|
||||
class _End extends StatelessWidget {
|
||||
const _End({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.unset,
|
||||
required this.origin,
|
||||
required this.last,
|
||||
required this.onTap,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
/// What choosing this row means. Only the drop carries one — the pickup is
|
||||
/// already answered, and a caption under an answer is noise.
|
||||
final String? note;
|
||||
final bool unset;
|
||||
final bool origin;
|
||||
final bool last;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Pickup from $title. Change',
|
||||
label: '$label $value',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300),
|
||||
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
border: Border.all(color: DmColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// The node and the thread it hangs from. The thread belongs to
|
||||
// the stop above the join, so the last one ends clean.
|
||||
SizedBox(
|
||||
width: 20,
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 17),
|
||||
Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: unset && !origin
|
||||
? Colors.transparent
|
||||
: (origin ? DmColors.ink : DmColors.brand),
|
||||
shape: BoxShape.circle,
|
||||
border: unset && !origin
|
||||
? Border.all(color: DmColors.brand, width: 2)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (!last)
|
||||
const Expanded(
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 10, 0, last ? 12 : 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 15.5,
|
||||
color: unset ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (note != null)
|
||||
Text(
|
||||
note!,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 15,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The promise under the sphere: a glyph, a figure, a tint.
|
||||
///
|
||||
/// It was a full sentence — "A Miler collects from your door." — shown on the
|
||||
/// first run only. That is the app explaining itself to somebody who has
|
||||
/// already pressed nothing; what a customer actually wants before they commit
|
||||
/// is how long they will wait, and that is worth saying every time.
|
||||
class _PromiseChip extends StatelessWidget {
|
||||
const _PromiseChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 7, 13, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: DmColors.brand),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
label,
|
||||
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
@@ -7,6 +8,7 @@ import '../tokens.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'order_details_screen.dart';
|
||||
import 'tracking_screen.dart';
|
||||
@@ -81,11 +83,10 @@ class OrderCard extends StatelessWidget {
|
||||
_ => (dateLabel(booking.createdAt), false),
|
||||
};
|
||||
|
||||
// Second line, right: what is in it. The weight appears only once the
|
||||
// Miler has actually weighed it at the door.
|
||||
// The weight of *these* parcels, and only once a Miler has actually
|
||||
// weighed them. `totalWeightLabel` is the whole visit's, which on one
|
||||
// destination's row is somebody else's parcel.
|
||||
// What is in it, for the foot. The weight of *these* parcels, and only
|
||||
// once a Miler has actually weighed them at the door —
|
||||
// `totalWeightLabel` is the whole visit's, which on one destination's row
|
||||
// is somebody else's parcel.
|
||||
final weight = entry.isPickup
|
||||
? booking.totalWeightLabel
|
||||
: group!.verification?.weightLabel;
|
||||
@@ -94,13 +95,17 @@ class OrderCard extends StatelessWidget {
|
||||
?weight,
|
||||
].join(' · ');
|
||||
|
||||
// The foot: who has it, or when it was promised.
|
||||
// The foot says who has it and what is in it. The state is the chip at
|
||||
// the top of the card, so naming it here again was the card saying one
|
||||
// thing twice while the contents had nowhere to go.
|
||||
final miler = booking.miler ?? booking.deliveryAgent;
|
||||
final footNote = miler != null
|
||||
? '${miler.name} · ${stage.chipLabel}'
|
||||
: (booking.status == BookingStatus.completed
|
||||
? 'Delivered${booking.amountPaid == null ? '' : ' · ₹${booking.amountPaid}'}'
|
||||
: stage.milestoneLabel);
|
||||
final footNote = [
|
||||
if (miler != null) miler.name,
|
||||
contents,
|
||||
if (booking.status == BookingStatus.completed &&
|
||||
booking.amountPaid != null)
|
||||
'₹${booking.amountPaid}',
|
||||
].join(' · ');
|
||||
|
||||
void open() {
|
||||
app.openTracking(booking.reference);
|
||||
@@ -125,45 +130,29 @@ class OrderCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── Two on the top line, not three ──
|
||||
//
|
||||
// The reference, the state and the time shared this row, and a
|
||||
// `Row` of flexible children splits the width by flex rather
|
||||
// than by need — so a reference wanting 120pt of a 322pt card
|
||||
// was handed 107 and rendered "#DM-482…". Truncating the one
|
||||
// string on the card that exists to be quoted, and which now
|
||||
// copies when pressed, is the one thing this row must not do.
|
||||
//
|
||||
// The time moved down beside the destination, where there was
|
||||
// already space to its right.
|
||||
Row(
|
||||
children: [
|
||||
Flexible(child: _RefChip(reference: reference)),
|
||||
Flexible(flex: 3, child: _RefChip(reference: reference)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: DmStatusChip(
|
||||
label: label,
|
||||
tone: tone,
|
||||
quiet: !live,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (urgent) ...[
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
timing,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: urgent ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
@@ -184,15 +173,32 @@ class OrderCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
contents,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (urgent) ...[
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
timing,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: urgent
|
||||
? DmColors.brand
|
||||
: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -240,11 +246,21 @@ class OrderCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference, in a quiet well.
|
||||
/// The reference, in a quiet well — and the only thing on the card you can
|
||||
/// take away with you.
|
||||
///
|
||||
/// It reads as an identifier rather than as a sentence — which is what it is,
|
||||
/// and the reason it can sit at 11.5pt beside a 26pt place name without
|
||||
/// disappearing.
|
||||
/// ── Why it copies ──
|
||||
///
|
||||
/// A reference exists to be quoted: into a chat with support, into a message
|
||||
/// to whoever is receiving the parcel, into a form somewhere else. Every one
|
||||
/// of those ends with a customer reading eight characters off a screen and
|
||||
/// typing them somewhere, which is the one interaction a phone is worst at and
|
||||
/// a clipboard is perfect at.
|
||||
///
|
||||
/// It has its own tap, so it wins the hit test over the card underneath: a
|
||||
/// press on the chip copies, a press anywhere else opens the order. That is
|
||||
/// the whole reason it is set in a well rather than as bare type — the well is
|
||||
/// what says the chip is its own object.
|
||||
class _RefChip extends StatelessWidget {
|
||||
const _RefChip({required this.reference});
|
||||
|
||||
@@ -252,17 +268,42 @@ class _RefChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.chip),
|
||||
),
|
||||
child: Text(
|
||||
reference.startsWith('#') ? reference : '#$reference',
|
||||
style: DmText.monoSm.copyWith(fontSize: 11, color: DmColors.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
final shown = reference.startsWith('#') ? reference : '#$reference';
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Copy order ID $reference',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(text: reference));
|
||||
await HapticFeedback.selectionClick();
|
||||
if (context.mounted) DmToast.show(context, 'Order ID copied');
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 6, 9, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.chip),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
shown,
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
const Icon(LucideIcons.copy, size: 12, color: DmColors.ink3),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/inputs.dart';
|
||||
import '../widgets/feedback.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/states.dart';
|
||||
import 'order_row.dart';
|
||||
|
||||
@@ -61,100 +59,117 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
final app = AppScope.of(context);
|
||||
final q = _query.text.trim();
|
||||
final all = app.entriesOf(app.ordersTab);
|
||||
final rows = [for (final e in all) if (_matches(e, q)) e];
|
||||
final rows = [
|
||||
for (final e in all)
|
||||
if (_matches(e, q)) e,
|
||||
];
|
||||
final index = _tabs.indexOf(app.ordersTab);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => DmToast.show(context, 'Search is coming soon'),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 14),
|
||||
// The filter gets its own row.
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// No wordmark bar, and no search or bell on it.
|
||||
//
|
||||
// Beside the title it had a third of the width for three segments,
|
||||
// and rendered "Act… Pa… Ca…" — the reference fits its control on
|
||||
// the title line because it carries two segments, not three. A row
|
||||
// is cheaper than an unreadable control.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Orders',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Filter(
|
||||
labels: const ['Active', 'Past', 'Cancelled'],
|
||||
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
|
||||
index: index < 0 ? 0 : index,
|
||||
onChanged: (i) => app.setOrdersTab(_tabs[i]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── A search box, because the list is the archive ──
|
||||
//
|
||||
// Orders is the only screen that grows without bound: every
|
||||
// parcel a customer has ever sent ends up here. Scrolling is
|
||||
// fine at four and useless at forty, and the two things they
|
||||
// have to go on are the reference somebody quoted at them and
|
||||
// the city they sent it to. Both are matched.
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: 'Search by order ID or city',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: app.refreshOrders,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// The last card clears the floating tab bar, which the shell
|
||||
// stacks over the body rather than laying out beneath it.
|
||||
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
|
||||
itemCount: rows.isEmpty ? 1 : rows.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) => rows.isEmpty
|
||||
? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
|
||||
: OrderCard(entry: rows[i]),
|
||||
// The bar was carrying the app's name over a screen already titled
|
||||
// "Orders", and the search glyph beside it duplicated the field this
|
||||
// screen now has of its own. Home lost the same bar for the same
|
||||
// reason: a root that opens on a title does not need to be told which
|
||||
// app it is.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
26,
|
||||
DmSpace.pad,
|
||||
14,
|
||||
),
|
||||
// The filter gets its own row.
|
||||
//
|
||||
// Beside the title it had a third of the width for three segments,
|
||||
// and rendered "Act… Pa… Ca…" — the reference fits its control on
|
||||
// the title line because it carries two segments, not three. A row
|
||||
// is cheaper than an unreadable control.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Orders',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Filter(
|
||||
labels: const ['Active', 'Past', 'Cancelled'],
|
||||
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
|
||||
index: index < 0 ? 0 : index,
|
||||
onChanged: (i) => app.setOrdersTab(_tabs[i]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── A search box, because the list is the archive ──
|
||||
//
|
||||
// Orders is the only screen that grows without bound: every
|
||||
// parcel a customer has ever sent ends up here. Scrolling is
|
||||
// fine at four and useless at forty, and the two things they
|
||||
// have to go on are the reference somebody quoted at them and
|
||||
// the city they sent it to. Both are matched.
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: 'Search by order ID or city',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: app.refreshOrders,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
// The last card clears the floating tab bar, which the shell
|
||||
// stacks over the body rather than laying out beneath it.
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
74 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
itemCount: rows.isEmpty ? 1 : rows.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) => rows.isEmpty
|
||||
? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
|
||||
: OrderCard(entry: rows[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing matched what they typed — which is a different problem from
|
||||
/// having no orders, and says so.
|
||||
Widget _noMatch(String query) => DmEmptyState(
|
||||
icon: LucideIcons.search,
|
||||
title: 'No orders match "$query"',
|
||||
message: 'Try an order ID, or the city it was going to.',
|
||||
);
|
||||
icon: LucideIcons.search,
|
||||
title: 'No orders match "$query"',
|
||||
message: 'Try an order ID, or the city it was going to.',
|
||||
);
|
||||
|
||||
Widget _empty(BookingStatus tab) {
|
||||
final (title, message) = switch (tab) {
|
||||
BookingStatus.active => (
|
||||
'No active pickups',
|
||||
'Book a pickup and it will show up here.',
|
||||
),
|
||||
'No active pickups',
|
||||
'Book a pickup and it will show up here.',
|
||||
),
|
||||
BookingStatus.completed => (
|
||||
'Nothing completed yet',
|
||||
'Delivered orders will appear here.',
|
||||
),
|
||||
'Nothing completed yet',
|
||||
'Delivered orders will appear here.',
|
||||
),
|
||||
BookingStatus.cancelled => (
|
||||
'No cancelled bookings',
|
||||
'Anything you cancel will appear here.',
|
||||
),
|
||||
'No cancelled bookings',
|
||||
'Anything you cancel will appear here.',
|
||||
),
|
||||
};
|
||||
return DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
|
||||
164
lib/ui/screens/sheets/drop_address_sheet.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.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 '../../widgets/inputs.dart';
|
||||
|
||||
/// The full drop address, for a booking made the long way.
|
||||
///
|
||||
/// ── Why this is a sheet and not a screen ──
|
||||
///
|
||||
/// Every field on it is optional to the contract and every one of them is
|
||||
/// something the Miler can fill in at the door. That is the whole argument for
|
||||
/// One Touch. A customer who chooses the full form is choosing to save the
|
||||
/// Miler that step, so the form has to be quick enough to be worth choosing:
|
||||
/// one surface, five fields, one button.
|
||||
///
|
||||
/// Nothing here is validated beyond being non-empty. A street name the
|
||||
/// backend has never heard of is still better than no street name — it is
|
||||
/// read by a person, not matched against a table.
|
||||
Future<bool?> showDropAddressSheet(
|
||||
BuildContext context, {
|
||||
required DeliveryDetails details,
|
||||
required String district,
|
||||
}) {
|
||||
return showDmSheet<bool>(
|
||||
context: context,
|
||||
builder: (context) => _DropAddressBody(details: details, district: district),
|
||||
);
|
||||
}
|
||||
|
||||
class _DropAddressBody extends StatefulWidget {
|
||||
const _DropAddressBody({required this.details, required this.district});
|
||||
|
||||
final DeliveryDetails details;
|
||||
final String district;
|
||||
|
||||
@override
|
||||
State<_DropAddressBody> createState() => _DropAddressBodyState();
|
||||
}
|
||||
|
||||
class _DropAddressBodyState extends State<_DropAddressBody> {
|
||||
late final _building = TextEditingController(text: widget.details.building);
|
||||
late final _street = TextEditingController(text: widget.details.street);
|
||||
late final _landmark = TextEditingController(text: widget.details.landmark);
|
||||
late final _name = TextEditingController(text: widget.details.recipientName);
|
||||
late final _phone = TextEditingController(
|
||||
text: _digitsOf(widget.details.recipientPhone),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// The button turns on the moment there is enough to find a door, so the
|
||||
// two fields that decide it are listened to rather than read on submit.
|
||||
_building.addListener(_refresh);
|
||||
_street.addListener(_refresh);
|
||||
}
|
||||
|
||||
void _refresh() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_building.removeListener(_refresh);
|
||||
_street.removeListener(_refresh);
|
||||
_building.dispose();
|
||||
_street.dispose();
|
||||
_landmark.dispose();
|
||||
_name.dispose();
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The ten digits, whatever shape the number arrived in — the field carries
|
||||
/// its own `+91`.
|
||||
static String _digitsOf(String? phone) {
|
||||
final digits = (phone ?? '').replaceAll(RegExp(r'\D'), '');
|
||||
return digits.length <= 10 ? digits : digits.substring(digits.length - 10);
|
||||
}
|
||||
|
||||
/// Enough to send a Miler to. A building or a street will do — between them
|
||||
/// they are what a person needs to find a door, and the rest is help.
|
||||
bool get _enough =>
|
||||
_building.text.trim().isNotEmpty || _street.text.trim().isNotEmpty;
|
||||
|
||||
void _save() {
|
||||
final phone = _phone.text.trim();
|
||||
widget.details.update(
|
||||
building: _building.text,
|
||||
street: _street.text,
|
||||
landmark: _landmark.text,
|
||||
recipientName: _name.text,
|
||||
recipientPhone: phone.isEmpty ? null : '+91 $phone',
|
||||
instructions: widget.details.instructions,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmSheetHeader(
|
||||
title: 'Where in ${widget.district}?',
|
||||
subtitle: 'The door the Miler is delivering to',
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmTextField(
|
||||
label: 'Flat, house or building',
|
||||
controller: _building,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Street and area',
|
||||
controller: _street,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Landmark',
|
||||
hint: 'Optional',
|
||||
controller: _landmark,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('WHO RECEIVES IT', style: DmText.eyebrow),
|
||||
const SizedBox(height: 10),
|
||||
DmTextField(
|
||||
label: 'Name',
|
||||
hint: 'Optional',
|
||||
controller: _name,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Phone',
|
||||
hint: 'Optional',
|
||||
controller: _phone,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Save address',
|
||||
icon: _enough ? LucideIcons.check : null,
|
||||
onPressed: _enough ? _save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,32 @@ import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/states.dart';
|
||||
import '../booking/pickup_location_screen.dart';
|
||||
|
||||
/// Alias used from Review, where the sheet edits the pickup point in place.
|
||||
Future<Place?> showPlaceSearchSheetForReview(BuildContext context) =>
|
||||
showPlaceSearchSheet(context);
|
||||
|
||||
/// Pickup-location picker. Returns the chosen [Place], or null if dismissed.
|
||||
Future<Place?> showPlaceSearchSheet(BuildContext context) {
|
||||
///
|
||||
/// [allowMap] offers "Select on map", which pushes the map screen over this
|
||||
/// sheet and closes it carrying whatever pin comes back. It is off when the
|
||||
/// map screen is the one asking — a row that opens the screen you are already
|
||||
/// on is a loop with a label.
|
||||
Future<Place?> showPlaceSearchSheet(
|
||||
BuildContext context, {
|
||||
bool allowMap = true,
|
||||
}) {
|
||||
return showDmSheet<Place>(
|
||||
context: context,
|
||||
builder: (context) => const _PlaceSearchBody(),
|
||||
builder: (context) => _PlaceSearchBody(allowMap: allowMap),
|
||||
);
|
||||
}
|
||||
|
||||
class _PlaceSearchBody extends StatefulWidget {
|
||||
const _PlaceSearchBody();
|
||||
const _PlaceSearchBody({required this.allowMap});
|
||||
|
||||
final bool allowMap;
|
||||
|
||||
@override
|
||||
State<_PlaceSearchBody> createState() => _PlaceSearchBodyState();
|
||||
@@ -44,6 +55,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Pushes the map *over* this sheet rather than replacing it.
|
||||
///
|
||||
/// The sheet stays mounted underneath, so a customer who opens the map and
|
||||
/// changes their mind lands back on the search they came from instead of on
|
||||
/// Home. It closes only when the map hands a pin back.
|
||||
Future<void> _openMap() async {
|
||||
final place = await Navigator.of(context, rootNavigator: true).push<Place>(
|
||||
MaterialPageRoute(builder: (_) => const PickupLocationScreen()),
|
||||
);
|
||||
if (place == null || !mounted) return;
|
||||
Navigator.of(context).pop(place);
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 220), () {
|
||||
@@ -57,12 +81,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const DmSheetHeader(title: 'Collect from'),
|
||||
const DmSheetHeader(
|
||||
title: 'Collect from',
|
||||
subtitle: 'Search for it, or drop a pin where you are standing',
|
||||
),
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: 'Search street, area or landmark',
|
||||
onChanged: _onChanged,
|
||||
),
|
||||
if (widget.allowMap) ...[
|
||||
const SizedBox(height: 12),
|
||||
_MapRow(onTap: _openMap),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
@@ -146,3 +177,81 @@ class _PlaceRow extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "Select on map" — the way in for somebody who cannot name where they are.
|
||||
///
|
||||
/// It sits above the results rather than among them because it is not a
|
||||
/// result: search answers "which of these", and this answers "none of these,
|
||||
/// let me point at it". A pin on a map is the only honest way to give a Miler
|
||||
/// a gate, a side entrance or a building with no number.
|
||||
class _MapRow extends StatelessWidget {
|
||||
const _MapRow({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 60),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.mapPinned,
|
||||
size: 18,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Select on map',
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
),
|
||||
Text(
|
||||
'Drop a pin at your exact door',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ class _TabBar extends StatelessWidget {
|
||||
(label: 'Account', icon: LucideIcons.userRound),
|
||||
];
|
||||
|
||||
static const double _slot = 62;
|
||||
static const double _height = 58;
|
||||
static const double _slot = 74;
|
||||
static const double _height = 62;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -161,30 +161,30 @@ class _TabButtonState extends State<_TabButton> {
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedScale(
|
||||
scale: _down ? 0.9 : 1,
|
||||
scale: _down ? 0.88 : 1,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: AnimatedScale(
|
||||
// A shade of overshoot as the tint arrives under it, so the
|
||||
// two read as one gesture rather than as a glyph that
|
||||
// recolours when a rectangle happens to appear behind it.
|
||||
scale: selected ? 1.06 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
// ── Colour alone ──
|
||||
//
|
||||
// The glyph sat in a `brandSoft` rounded square, which made the
|
||||
// selected tab the only filled object in a bar whose whole job is
|
||||
// to recede. The reference marks it with the brand and nothing
|
||||
// else, and with three glyphs in a white pill that is unambiguous
|
||||
// — there is only one coloured thing on the bar.
|
||||
//
|
||||
// It arrives with a shade of overshoot, so the tab reacts to the
|
||||
// press rather than recolouring after it.
|
||||
child: AnimatedScale(
|
||||
scale: selected ? 1.08 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 21,
|
||||
size: 23,
|
||||
color: selected ? DmColors.brand : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
@@ -252,17 +253,17 @@ class _TrackCard extends StatelessWidget {
|
||||
final group = focused ?? booking.destinations.first;
|
||||
|
||||
// What the customer can act on: when, before the handover; where, after.
|
||||
final when = switch (true) {
|
||||
_ when cancelled => 'You were not charged',
|
||||
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
|
||||
'Arriving in about ${booking.milerEtaMinutes} min',
|
||||
_ when stage == JourneyStage.arrived => 'At your door now',
|
||||
_ when stage.index <= JourneyStage.assigned.index && slot != null =>
|
||||
'Today · ${slot!.window}',
|
||||
_ when booking.expectedDelivery != null =>
|
||||
'Expected ${booking.expectedDelivery}',
|
||||
_ => null,
|
||||
};
|
||||
// ── One line only, and only when it is not a time ──
|
||||
//
|
||||
// This carried "Today · 2:00 – 4:00 PM" under the headline while the strip
|
||||
// below it carried "WINDOW · 2:00 – 4:00 PM" — the same promise twice,
|
||||
// eighty points apart. Every timing this used to show has a home of its
|
||||
// own now: the ETA is the pill beside the headline, the window and the
|
||||
// expected date are cells in the strip.
|
||||
//
|
||||
// What is left is the one thing that is not a time and has nowhere else
|
||||
// to go.
|
||||
final when = cancelled ? 'You were not charged' : null;
|
||||
|
||||
final eta = booking.milerEtaMinutes;
|
||||
final reference = focused?.trackingId ?? booking.displayId;
|
||||
@@ -270,22 +271,30 @@ class _TrackCard extends StatelessWidget {
|
||||
// Only figures the backend actually sent. A strip with one cell in it is
|
||||
// a fact centred in a grey band, which is worse than the fact on its own
|
||||
// — so it is drawn at two or more.
|
||||
// The parcel is collected once it is past the handover, and after that
|
||||
// the window it was collected in is history — what the customer wants is
|
||||
// when it lands.
|
||||
final collected = stage.index >= JourneyStage.pickedUp.index;
|
||||
|
||||
final figures = <Widget>[
|
||||
if (booking.fare?.routeKm != null)
|
||||
_Figure(
|
||||
label: 'Distance',
|
||||
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
|
||||
),
|
||||
_Figure(
|
||||
label: booking.totalPackages == 1 ? 'Package' : 'Packages',
|
||||
value: '${booking.totalPackages}',
|
||||
),
|
||||
// The timing, in the one place it appears.
|
||||
if (collected && booking.expectedDelivery != null)
|
||||
_Figure(label: 'Expected', value: booking.expectedDelivery!)
|
||||
else if (!collected && slot != null)
|
||||
_Figure(label: 'Window', value: slot!.window),
|
||||
if (booking.totalWeightLabel != null)
|
||||
_Figure(label: 'Weight', value: booking.totalWeightLabel!)
|
||||
else if (booking.fare?.routeKm != null)
|
||||
_Figure(
|
||||
label: 'Distance',
|
||||
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
|
||||
)
|
||||
else if (booking.miler?.vehicleType != null)
|
||||
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!)
|
||||
else if (slot != null)
|
||||
_Figure(label: 'Window', value: slot!.window),
|
||||
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
@@ -293,20 +302,12 @@ class _TrackCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
DmStatusChip(
|
||||
label: reference.startsWith('#') ? reference : '#$reference',
|
||||
tone: cancelled ? DmColors.ink4 : DmColors.brand,
|
||||
quiet: cancelled,
|
||||
),
|
||||
// No priority chip. The reference carries "EXPRESS PRIORITY"
|
||||
// beside the reference; this backend has no express tier, and a
|
||||
// badge that every booking wears is decoration.
|
||||
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
// The reference used to float here as a small crimson chip above
|
||||
// the headline — an identifier introducing a state, which is the
|
||||
// wrong way round and left it as the smallest thing on the screen
|
||||
// in the loudest position. It is the last row of the shipment card
|
||||
// now, where it identifies the record it belongs to and can be
|
||||
// copied.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -351,20 +352,49 @@ class _TrackCard extends StatelessWidget {
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
// ── Pickup → drop ──
|
||||
//
|
||||
// "ORIGIN" and "DESTINATION" are the words a logistics system
|
||||
// uses about a consignment. The customer's parcel is collected
|
||||
// from somewhere and dropped somewhere, and those are the two
|
||||
// words they already use for it.
|
||||
//
|
||||
// The arrow between them is the point: two labelled columns
|
||||
// are a table, and a table does not say which way the parcel
|
||||
// is going. It sits in a well of its own so it reads as a
|
||||
// joint rather than as a stray glyph.
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Place(
|
||||
label: 'ORIGIN',
|
||||
label: 'PICKUP',
|
||||
value: booking.pickup.title,
|
||||
note: booking.pickup.sub,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const SizedBox(width: 12),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.arrowRight,
|
||||
size: 14,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _Place(
|
||||
label: 'DESTINATION',
|
||||
label: 'DROP',
|
||||
value: group.destination.shortLabel,
|
||||
note: group.destination.stateName,
|
||||
end: true,
|
||||
@@ -373,13 +403,29 @@ class _TrackCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 13, 12, 13),
|
||||
child: _Reference(
|
||||
reference: reference,
|
||||
cancelled: cancelled,
|
||||
),
|
||||
),
|
||||
if (figures.length > 1)
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(4, 12, 4, 13),
|
||||
// `groove`, not `surfaceAlt`: the latter is within a point
|
||||
// of the canvas, so the strip read as spilling out of the
|
||||
// bottom of the card rather than as a band inside it.
|
||||
background: DmColors.groove,
|
||||
padding: const EdgeInsets.fromLTRB(4, 13, 4, 14),
|
||||
// ── Red glass, not grey ──
|
||||
//
|
||||
// It was `groove`, a warm grey chosen so the band read as
|
||||
// *inside* the card rather than spilling out of it. That
|
||||
// worked and said nothing: a neutral strip under a white
|
||||
// card is a strip. Eight percent of the brand is a tint the
|
||||
// white above it can still be read against, and it ties the
|
||||
// figures to the shipment rather than to the furniture.
|
||||
//
|
||||
// The type stays ink. Crimson on a crimson wash is the one
|
||||
// combination this design has never allowed, and the figures
|
||||
// are facts — they are not the live thing on the screen.
|
||||
background: DmColors.brand.withValues(alpha: 0.08),
|
||||
child: Row(children: figures),
|
||||
),
|
||||
],
|
||||
@@ -454,14 +500,23 @@ class _Figure extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(fontSize: 9.5),
|
||||
// A step darker than the eyebrow's usual grey. On the tint the
|
||||
// default `ink3` went soft, and a label nobody can read is a row
|
||||
// of numbers with no names.
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 9.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -634,8 +689,15 @@ class _ReachState extends State<_Reach> {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
// ── Call is green ──
|
||||
//
|
||||
// It was the brand, which made a phone call the same colour as
|
||||
// BOOK, the primary button and every live mark in the app. Green
|
||||
// is what a call button is in every phone anybody owns, and it
|
||||
// buys back a crimson on a screen that already spends one on the
|
||||
// current milestone and one on Cancel.
|
||||
decoration: BoxDecoration(
|
||||
color: widget.filled ? DmColors.brand : DmColors.surfaceAlt,
|
||||
color: widget.filled ? DmColors.ok : DmColors.surfaceAlt,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
@@ -880,3 +942,61 @@ class _Credentials extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The booking's own number, at the foot of the shipment card.
|
||||
///
|
||||
/// ── Why it copies, and why it is here ──
|
||||
///
|
||||
/// A reference exists to be quoted — into a chat with support, into a message
|
||||
/// to whoever is receiving the parcel. Every one of those ends with somebody
|
||||
/// reading eight characters off a screen and typing them somewhere else, which
|
||||
/// is the one interaction a phone is worst at. Orders learned this first; this
|
||||
/// is the same chip in the same colours.
|
||||
///
|
||||
/// It sits under the route and the figures because that is what it identifies.
|
||||
/// Above the headline it was an identifier introducing a state, which reads
|
||||
/// backwards: you learn *what is happening* first and reach for the number
|
||||
/// only when you need to tell somebody about it.
|
||||
class _Reference extends StatelessWidget {
|
||||
const _Reference({required this.reference, required this.cancelled});
|
||||
|
||||
final String reference;
|
||||
final bool cancelled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = reference.startsWith('#') ? reference : '#$reference';
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Copy order ID $reference',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
await Clipboard.setData(ClipboardData(text: reference));
|
||||
await HapticFeedback.selectionClick();
|
||||
if (context.mounted) DmToast.show(context, 'Order ID copied');
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Text('ORDER ID', style: DmText.eyebrow),
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
shown,
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 13,
|
||||
color: cancelled ? DmColors.ink3 : DmColors.ink,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
const Icon(LucideIcons.copy, size: 14, color: DmColors.ink3),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,8 +60,7 @@ class DmBookOrb extends StatefulWidget {
|
||||
const DmBookOrb({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.label = 'BOOK NOW',
|
||||
this.strapline = 'INSTANT PARCEL',
|
||||
this.label = 'ONE TOUCH',
|
||||
this.caption,
|
||||
this.held = false,
|
||||
});
|
||||
@@ -81,9 +80,6 @@ class DmBookOrb extends StatefulWidget {
|
||||
/// second use of this widget elsewhere is not forced to repeat itself.
|
||||
final String? caption;
|
||||
|
||||
/// The small line inside the sphere, under the label.
|
||||
final String strapline;
|
||||
|
||||
@override
|
||||
State<DmBookOrb> createState() => _DmBookOrbState();
|
||||
}
|
||||
@@ -104,12 +100,33 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
duration: const Duration(milliseconds: 4000),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
/// The press turning the sphere inside out.
|
||||
///
|
||||
/// White floods from the centre to the rim and the word flips to crimson —
|
||||
/// the inverse of the resting state, which is what makes it read as the
|
||||
/// button having been *pressed* rather than as a new object appearing. It
|
||||
/// holds while the sheet it opened is up and drains back on the way out.
|
||||
late final AnimationController _reveal = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 420),
|
||||
reverseDuration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
bool _down = false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant DmBookOrb old) {
|
||||
super.didUpdateWidget(old);
|
||||
// Held while the destination sheet is up; released when the flow returns
|
||||
// or is dismissed, and the sphere fills back in.
|
||||
if (old.held && !widget.held) _reveal.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_radar.dispose();
|
||||
_breath.dispose();
|
||||
_reveal.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -121,19 +138,23 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: widget.caption == null
|
||||
? '${widget.label}. ${widget.strapline}'
|
||||
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
|
||||
? widget.label
|
||||
: '${widget.label}. ${widget.caption}',
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTapCancel: () {
|
||||
setState(() => _down = false);
|
||||
_reveal.reverse();
|
||||
},
|
||||
onTap: () {
|
||||
setState(() => _down = false);
|
||||
// The press is a commitment — it opens the booking flow — and a
|
||||
// sphere gives no edge feedback the way a bordered button does.
|
||||
HapticFeedback.mediumImpact();
|
||||
_reveal.forward();
|
||||
widget.onTap();
|
||||
},
|
||||
child: SizedBox(
|
||||
@@ -154,20 +175,32 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── In, then out ──
|
||||
//
|
||||
// Down is quick and flat: 140ms of `easeOutCubic`, so the
|
||||
// finger feels the give rather than watching it happen. Up
|
||||
// is slower and overshoots — `easeOutBack` carries the scale
|
||||
// past 1 before it settles — which is the small outward
|
||||
// bounce a physical button gives back when you let go.
|
||||
AnimatedScale(
|
||||
scale: _down || widget.held ? 0.94 : 1,
|
||||
// Just past the press, so the finger feels the give rather
|
||||
// than watching it happen.
|
||||
duration: const Duration(milliseconds: 140),
|
||||
curve: Curves.easeOutCubic,
|
||||
scale: _down || widget.held ? 0.93 : 1,
|
||||
duration: Duration(
|
||||
milliseconds: _down || widget.held ? 140 : 420,
|
||||
),
|
||||
curve: _down || widget.held
|
||||
? Curves.easeOutCubic
|
||||
: Curves.easeOutBack,
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 1.0, end: 1.012).animate(
|
||||
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
|
||||
),
|
||||
child: _Sphere(
|
||||
size: disc,
|
||||
label: widget.label,
|
||||
strapline: widget.strapline,
|
||||
child: AnimatedBuilder(
|
||||
animation: _reveal,
|
||||
builder: (_, _) => _Sphere(
|
||||
size: disc,
|
||||
label: widget.label,
|
||||
reveal: Curves.easeOutCubic.transform(_reveal.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -195,12 +228,15 @@ class _Sphere extends StatelessWidget {
|
||||
const _Sphere({
|
||||
required this.size,
|
||||
required this.label,
|
||||
required this.strapline,
|
||||
required this.reveal,
|
||||
});
|
||||
|
||||
final double size;
|
||||
final String label;
|
||||
final String strapline;
|
||||
|
||||
/// 0 at rest, 1 fully pressed. Drives the white circle that opens from the
|
||||
/// centre and the crimson word laid over it.
|
||||
final double reveal;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -273,57 +309,113 @@ class _Sphere extends StatelessWidget {
|
||||
//
|
||||
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
|
||||
// which filled the sphere edge to edge and read as a warning label.
|
||||
// The reference sets a glyph, a sentence-case line and a small
|
||||
// strapline — the same three parts every physical button that has
|
||||
// ever been designed well carries, and the air round them is what
|
||||
// makes the circle read as an object rather than as a word in a
|
||||
// red disc.
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.navigation,
|
||||
size: 21,
|
||||
color: Colors.white,
|
||||
// A glyph over a single line, with air round it, is what makes the
|
||||
// circle read as an object rather than as a word in a red disc.
|
||||
//
|
||||
// "INSTANT PARCEL" sat under it and is gone: on a screen whose only
|
||||
// other text is the customer's own name and their address, a
|
||||
// strapline is the app talking about itself.
|
||||
_face(Colors.white, shadowed: true),
|
||||
|
||||
// ── The press, turning it inside out ──
|
||||
//
|
||||
// A white disc opens from the centre to the rim, clipped to the
|
||||
// sphere, with the word re-drawn crimson on top of it. Two complete
|
||||
// faces rather than an interpolated colour: the word has to invert
|
||||
// *as the white passes under it*, and only a clip can do that — a
|
||||
// tween would fade the whole label at once while half of it is still
|
||||
// sitting on red.
|
||||
if (reveal > 0)
|
||||
Positioned.fill(
|
||||
child: ClipPath(
|
||||
clipper: _Reveal(reveal),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// White, with a crimson hairline round it. Without the
|
||||
// ring the pressed state is a white disc on a near-white
|
||||
// page — the shape the whole screen is built around
|
||||
// dissolves at exactly the moment it is being pressed.
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.white,
|
||||
border: Border.all(
|
||||
color: DmColors.brandLine,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_face(DmColors.brand),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 23,
|
||||
height: 1.05,
|
||||
letterSpacing: -0.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontVariations: const [FontVariation('wght', 700)],
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// One face, in whichever ink the layer under it calls for.
|
||||
///
|
||||
/// The drop shadow belongs to the white word only — it is there to lift type
|
||||
/// off a saturated gradient, and under crimson on white it would read as
|
||||
/// grime.
|
||||
Widget _face(Color ink, {bool shadowed = false}) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.navigation, size: 21, color: ink),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 23,
|
||||
height: 1.05,
|
||||
letterSpacing: -0.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontVariations: const [FontVariation('wght', 700)],
|
||||
color: ink,
|
||||
shadows: shadowed
|
||||
? const [
|
||||
Shadow(
|
||||
color: Color(0x4D5C0802),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
strapline,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.7,
|
||||
color: const Color(0xB3FFFFFF),
|
||||
),
|
||||
),
|
||||
],
|
||||
]
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle growing from the centre of the sphere to its rim.
|
||||
class _Reveal extends CustomClipper<Path> {
|
||||
const _Reveal(this.t);
|
||||
|
||||
final double t;
|
||||
|
||||
@override
|
||||
Path getClip(Size size) => Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: Offset(size.width / 2, size.height / 2),
|
||||
// A shade past the rim at full extent, so the last frame is a clean
|
||||
// white disc rather than a hairline of crimson round the edge.
|
||||
radius: size.width / 2 * t * 1.02,
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
bool shouldReclip(_Reveal old) => old.t != t;
|
||||
}
|
||||
|
||||
/// A rim that changes with the light: white along the top arc, shadow along
|
||||
/// the bottom, nothing at the sides.
|
||||
class _RimPainter extends CustomPainter {
|
||||
|
||||
@@ -81,23 +81,15 @@ Future<void> serverAdvance(
|
||||
|
||||
/// Home ▸ the BOOK orb ▸ the destination sheet.
|
||||
///
|
||||
/// BOOK no longer opens the form. It opens one question — where is it going —
|
||||
/// and the form is what the answer leads to, so every booking test is
|
||||
/// [openSend] followed by [pickCity].
|
||||
/// BOOK asks one question — where is it going — and the review screen is what
|
||||
/// the answer leads to, so every booking test is [openSend] then [pickCity].
|
||||
/// The sheet is left open: tests that are about the sheet itself go no
|
||||
/// further.
|
||||
Future<void> openSend(WidgetTester tester) async {
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
/// Answers the destination sheet, then the window sheet behind it.
|
||||
///
|
||||
/// Two sheets, because the customer now chooses their own pickup window
|
||||
/// instead of being handed the first one silently. [takeWindow] false leaves
|
||||
/// the booking without a slot, which is what dismissing the sheet does.
|
||||
///
|
||||
/// The destination is reached by searching rather than by opening its state:
|
||||
/// search cuts across every state from the first step, and it is the path a
|
||||
/// customer who already knows where they are sending actually takes.
|
||||
/// A finder restricted to the open bottom sheet.
|
||||
///
|
||||
/// Home's live card heads with the district alone — "Chennai", not
|
||||
@@ -107,6 +99,15 @@ Future<void> openSend(WidgetTester tester) async {
|
||||
Finder inSheet(Finder matching) =>
|
||||
find.descendant(of: find.byType(BottomSheet), matching: matching);
|
||||
|
||||
/// Answers the destination sheet, then the window sheet behind it.
|
||||
///
|
||||
/// The destination is reached by searching rather than by opening its state:
|
||||
/// search cuts across every state from the first step, and it is the path a
|
||||
/// customer who already knows where they are sending actually takes.
|
||||
///
|
||||
/// [takeWindow] false dismisses the window sheet rather than answering it,
|
||||
/// which leaves the booking without a slot and Confirm disabled — the review
|
||||
/// screen then carries "Choose a window" as its outstanding row.
|
||||
Future<void> pickCity(
|
||||
WidgetTester tester,
|
||||
String name, {
|
||||
@@ -118,16 +119,11 @@ Future<void> pickCity(
|
||||
//
|
||||
// `find.text` also matches `EditableText`, so once the city's name is in the
|
||||
// search box the plain finder returns the box as well — and Home, behind the
|
||||
// scrim, now heads its live card with the district alone, so an unscoped
|
||||
// finder can land on a widget the sheet is covering. Scoping to the modal
|
||||
// route fixes both.
|
||||
// scrim, heads its live card with the district alone, so an unscoped finder
|
||||
// can land on a widget the sheet is covering. Scoping to the modal route
|
||||
// fixes both.
|
||||
await tester.tap(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.byWidgetPredicate((w) => w is Text && w.data == name),
|
||||
)
|
||||
.first,
|
||||
inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first,
|
||||
);
|
||||
await settle(tester);
|
||||
|
||||
@@ -140,6 +136,7 @@ Future<void> pickCity(
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
// The window sheet follows the destination on its own.
|
||||
final use = find.textContaining('Use ');
|
||||
if (takeWindow && use.evaluate().isNotEmpty) {
|
||||
await tester.tap(use.first);
|
||||
@@ -209,7 +206,7 @@ void main() {
|
||||
await settle(tester);
|
||||
|
||||
// All four digits arrived, so it verified and moved on.
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
@@ -218,7 +215,7 @@ void main() {
|
||||
|
||||
// Home greets by name, carries the sphere, and ends on the live card.
|
||||
expect(find.text('Hello, Joe'), findsOneWidget);
|
||||
expect(find.text('BOOK NOW'), findsOneWidget);
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget);
|
||||
// The seeded active booking surfaces on Home. Its state is the uppercase
|
||||
// micro-label beside the destination, in the chip vocabulary — "Assigned",
|
||||
// not the rail's "Miler assigned".
|
||||
@@ -250,7 +247,7 @@ void main() {
|
||||
|
||||
// Which is what the editor carries back to the send screen.
|
||||
final corrected = pickupAddress(tester);
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
// Back on the send screen: its title is the route, not a heading.
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
@@ -258,6 +255,103 @@ void main() {
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the full form asks for the drop address; One Touch does not',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
// One Touch: state, district, window, and nothing else is asked.
|
||||
await openSend(tester);
|
||||
await pickCity(tester, 'Chennai');
|
||||
expect(state.draftDetailed, isFalse);
|
||||
expect(find.text('DROP ADDRESS'), findsNothing);
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
// Back to Home, and in again through the form's DROP row.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('DROP'));
|
||||
await settle(tester);
|
||||
|
||||
// Destination first, then the door — the address sheet comes on its own.
|
||||
await tester.enterText(find.byType(TextField).first, 'Chennai');
|
||||
await settle(tester, 250);
|
||||
await tester.tap(
|
||||
inSheet(
|
||||
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
|
||||
).first,
|
||||
);
|
||||
await settle(tester);
|
||||
final send = find.textContaining('Send to ');
|
||||
if (send.evaluate().isNotEmpty) {
|
||||
await tester.tap(send.first);
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
expect(state.draftDetailed, isTrue);
|
||||
expect(find.text('Where in Chennai?'), findsOneWidget);
|
||||
|
||||
// A building or a street is enough to send somebody to.
|
||||
await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Save address'));
|
||||
await settle(tester);
|
||||
|
||||
// Then the window, then the review — which carries the address as a row.
|
||||
await tester.tap(find.textContaining('Use ').first);
|
||||
await settle(tester);
|
||||
expect(find.text('DROP ADDRESS'), findsOneWidget);
|
||||
|
||||
final group = state.draftDestinations.first;
|
||||
expect(group.details.building, '3B Lotus Apartments');
|
||||
expect(group.details.exactAddress, isNotNull);
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('the collect-from sheet hands off to the map and back',
|
||||
(tester) async {
|
||||
final state = AppState();
|
||||
await signIn(tester, state: state);
|
||||
|
||||
// The PICKUP row of Home's pick/drop form opens the sheet.
|
||||
await tester.tap(find.text('PICKUP'));
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsOneWidget);
|
||||
|
||||
// "Select on map" pushes the map screen over the sheet, which stays
|
||||
// mounted underneath.
|
||||
await tester.tap(find.text('Select on map'));
|
||||
await settle(tester);
|
||||
expect(find.text('Confirm pickup point'), findsOneWidget);
|
||||
expect(find.text('PICKUP FROM'), findsOneWidget);
|
||||
|
||||
// Backing out leaves the customer on the search they came from, not Home.
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsOneWidget);
|
||||
|
||||
// Confirming closes both and writes the pin as the pickup.
|
||||
await tester.tap(find.text('Select on map'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(find.text('Collect from'), findsNothing);
|
||||
expect(state.pickup, isNotNull);
|
||||
expect(state.hasPickupPoint, isTrue);
|
||||
await drainToasts(tester);
|
||||
});
|
||||
|
||||
testWidgets('GPS, map drag and search all write one pickup point',
|
||||
(tester) async {
|
||||
final state = AppState(
|
||||
@@ -286,7 +380,7 @@ void main() {
|
||||
|
||||
// 3 — a search result overwrites it, coordinates included, and the map
|
||||
// follows the address rather than staying where the drag left it.
|
||||
await tester.tap(find.text('Change'));
|
||||
await tester.tap(find.text('Edit'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Brookefields Mall'));
|
||||
await settle(tester);
|
||||
@@ -300,7 +394,7 @@ void main() {
|
||||
expect(pickupAddress(tester), 'Brookefields Mall');
|
||||
|
||||
// …and that one point is what the booking carries forward.
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(find.text('DESTINATION'), findsOneWidget);
|
||||
expect(state.draftPickup!.lat, searched.lat);
|
||||
@@ -396,7 +490,7 @@ void main() {
|
||||
expect(
|
||||
tester
|
||||
.widget<DmButton>(
|
||||
find.widgetWithText(DmButton, 'Use this address'),
|
||||
find.widgetWithText(DmButton, 'Confirm pickup point'),
|
||||
)
|
||||
.onPressed,
|
||||
isNotNull,
|
||||
@@ -410,12 +504,17 @@ void main() {
|
||||
await signIn(tester, state: state);
|
||||
|
||||
await openSend(tester);
|
||||
// BOOK asks one question, and it is the only thing on screen.
|
||||
expect(find.text('Where is it going?'), findsOneWidget);
|
||||
// BOOK asks one question, and it is the only thing on screen. Scoped to
|
||||
// the sheet: Home's DROP row asks the same question behind the scrim.
|
||||
expect(inSheet(find.text('Where is it going?')), findsOneWidget);
|
||||
expect(find.text('Flat or house number'), findsNothing);
|
||||
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
// The window was the outstanding row on review, and answering it is what
|
||||
// enables the button.
|
||||
expect(find.text('Review and book'), findsOneWidget);
|
||||
|
||||
// Everything else the booking needs is on one screen, and the route to
|
||||
// the top of it is two rows with no labels but FROM and TO.
|
||||
expect(find.text('ORIGIN'), findsOneWidget);
|
||||
@@ -449,6 +548,11 @@ void main() {
|
||||
await signIn(tester, state: state);
|
||||
await openPickupEditor(tester);
|
||||
|
||||
// The contact is folded away — the screen exists to place a pin, and the
|
||||
// number is right nearly every time — so it has to be asked for.
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
|
||||
// Opens on the signed-in customer's own number, without the country code,
|
||||
// because the field carries its own prefix.
|
||||
final contact = find.widgetWithText(TextField, '9876543210');
|
||||
@@ -456,16 +560,18 @@ void main() {
|
||||
|
||||
// Left alone it stays null, so the request carries the account rather than
|
||||
// a copy of it that can drift.
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, isNull);
|
||||
|
||||
// Changed, it is the number the Miler will ring at the door.
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.enterText(find.byType(TextField).first, '9003144518');
|
||||
await settle(tester, 200);
|
||||
await tester.tap(find.text('Use this address'));
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
expect(state.draftContactPhone, '+91 9003144518');
|
||||
await drainToasts(tester);
|
||||
@@ -694,9 +800,6 @@ void main() {
|
||||
});
|
||||
|
||||
testWidgets('shows the empty service-area state', (tester) async {
|
||||
// Set before signing in. Home's quick-dispatch row calls `loadCities()`
|
||||
// as soon as it builds, which warms `statesCache` — good for the sheet,
|
||||
// which now opens instantly, and fatal to a flag flipped afterwards.
|
||||
api.flags.emptyServiceAreas = true;
|
||||
await signIn(tester);
|
||||
|
||||
@@ -757,11 +860,21 @@ void main() {
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
|
||||
await signIn(tester); // login + OTP
|
||||
expect(find.text('BOOK NOW'), findsOneWidget); // home
|
||||
expect(find.text('ONE TOUCH'), findsOneWidget); // home
|
||||
|
||||
await openSend(tester); // the whole booking, one screen
|
||||
await pickCity(tester, 'Chennai');
|
||||
|
||||
// The pickup map: a fixed-height sheet over a full-bleed map, so large
|
||||
// text has nowhere to grow into. Opening the contact fold is the tallest
|
||||
// it ever gets.
|
||||
await tester.tap(find.text('ORIGIN'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Someone else is handing it over?'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('Confirm pickup point'));
|
||||
await settle(tester);
|
||||
|
||||
await tester.tap(find.text('Confirm Booking'));
|
||||
await settle(tester); // confirmation
|
||||
await tester.tap(find.text('Track pickup'));
|
||||
|
||||
@@ -182,8 +182,8 @@ void main() {
|
||||
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
|
||||
await _settle(tester);
|
||||
|
||||
// Booking: state, district, window, then one screen.
|
||||
await tester.tap(find.text('BOOK NOW'));
|
||||
// Booking: BOOK asks where, then the review screen asks the rest.
|
||||
await tester.tap(find.text('ONE TOUCH'));
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07-destination-states');
|
||||
|
||||
@@ -191,10 +191,14 @@ void main() {
|
||||
await _settle(tester);
|
||||
await _shot(tester, '07b-destination-districts');
|
||||
|
||||
// The live card, by its reference. Home's quick-dispatch chips are
|
||||
// named after cities too, so a bare 'Chennai' finder now hits a
|
||||
// shortcut that starts a new booking instead.
|
||||
await tester.tap(find.text('DM-482913'));
|
||||
await tester.tap(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(BottomSheet),
|
||||
matching: find.text('Chennai'),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
await _settle(tester);
|
||||
// The dev backend allows several destinations, so the row ticked rather
|
||||
// than closing the sheet — the choice is confirmed.
|
||||
|
||||
|
Before Width: | Height: | Size: 400 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 403 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 183 KiB |