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:
2026-09-24 15:59:06 +05:30
parent 86b6af48c2
commit 8956e818a4
42 changed files with 2059 additions and 956 deletions

View File

@@ -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),
],
);
}

View File

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