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

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