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
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user