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:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user