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

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