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
1003 lines
36 KiB
Dart
1003 lines
36 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
|
||
import '../../data/models.dart';
|
||
import '../../data/app_config.dart';
|
||
import '../../state/app_scope.dart';
|
||
import '../../state/app_state.dart';
|
||
import '../tokens.dart';
|
||
import '../widgets/buttons.dart';
|
||
import '../widgets/cards.dart';
|
||
import '../widgets/chrome.dart';
|
||
import '../widgets/feedback.dart';
|
||
import '../widgets/milestones.dart';
|
||
import '../widgets/misc.dart';
|
||
import '../widgets/states.dart';
|
||
import 'booking/booking_routes.dart';
|
||
import 'booking/send_screen.dart';
|
||
import 'sheets/cancel_sheet.dart';
|
||
|
||
/// Live tracking.
|
||
///
|
||
/// One screen for the whole journey. While a Miler is being matched it shows a
|
||
/// radar; once one is assigned it shows their live position, who they are, and
|
||
/// how to reach them; after pickup it follows the order to the door.
|
||
/// Cancellation is offered only while the backend still allows it.
|
||
class TrackingScreen extends StatelessWidget {
|
||
const TrackingScreen({super.key, this.focusedDestination});
|
||
|
||
/// When set, this screen follows one destination's own order rather than the
|
||
/// pickup as a whole. Only meaningful once the orders exist.
|
||
final int? focusedDestination;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final app = AppScope.of(context);
|
||
final booking = app.trackedBooking;
|
||
|
||
if (booking == null) {
|
||
return Scaffold(
|
||
backgroundColor: DmColors.canvas,
|
||
appBar: const DmTopBar(title: 'Tracking'),
|
||
body: const DmEmptyState(
|
||
icon: LucideIcons.package,
|
||
title: 'Nothing to track',
|
||
message: 'Book a pickup to get started.',
|
||
),
|
||
);
|
||
}
|
||
|
||
final cancelled = booking.isCancelled;
|
||
final slot = app.slotById(booking.slotId);
|
||
|
||
// A focused destination follows its own journey; the pickup follows its own.
|
||
final focused =
|
||
(focusedDestination != null &&
|
||
focusedDestination! < booking.destinations.length &&
|
||
booking.destinations[focusedDestination!].trackingId != null)
|
||
? booking.destinations[focusedDestination!]
|
||
: null;
|
||
final stage = focused?.stage ?? booking.stage;
|
||
|
||
final milerEnRoute =
|
||
!cancelled &&
|
||
booking.miler != null &&
|
||
stage.index >= JourneyStage.assigned.index &&
|
||
stage.index <= JourneyStage.pickedUp.index;
|
||
|
||
return Scaffold(
|
||
backgroundColor: DmColors.canvas,
|
||
appBar: DmTopBar(
|
||
title: cancelled ? 'Booking' : 'Live Order Tracking',
|
||
// No reference here. The head card carries it at 26pt, which is what
|
||
// a customer reads out over a phone, and the same string twice on one
|
||
// screen is one of them doing nothing.
|
||
),
|
||
body: RefreshIndicator(
|
||
color: DmColors.brand,
|
||
backgroundColor: DmColors.surface,
|
||
onRefresh: () => app.refreshBooking(booking.reference),
|
||
child: ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.only(bottom: 24),
|
||
children: [
|
||
_TrackCard(booking: booking, slot: slot, focused: focused),
|
||
|
||
// ── No map, and no radar ──
|
||
//
|
||
// The radar was a 200pt card with a pulsing sweep and a bike glyph
|
||
// at its centre, beside a banner counting "verified Milers active
|
||
// in your zone" — animation standing in for an answer.
|
||
//
|
||
// The map went for a harder reason. It was 178pt of basemap, two
|
||
// labels and a pin, on a screen whose question is "where is my
|
||
// parcel" and whose honest answer is a milestone. The pin was the
|
||
// *pickup address* — a place the customer chose and already knows
|
||
// — not the Miler, who only has a position for the twenty minutes
|
||
// between assignment and the door. A picture that cannot show the
|
||
// thing it implies invites somebody to stare at a stationary dot
|
||
// and conclude nothing is happening.
|
||
//
|
||
// The live position, when there is one, is a sentence on the
|
||
// Miler's own card.
|
||
if (!cancelled)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
DmSpace.pad,
|
||
0,
|
||
DmSpace.pad,
|
||
10,
|
||
),
|
||
child: _Journey(booking: booking),
|
||
),
|
||
|
||
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
DmSpace.pad,
|
||
0,
|
||
DmSpace.pad,
|
||
10,
|
||
),
|
||
child: _YourPackages(booking: booking, focused: focused),
|
||
),
|
||
|
||
// `_RouteOutline` was here: the same pickup, destination and
|
||
// window the head card now draws as a rail, repeated at the foot
|
||
// of the screen. It existed when the head was a crimson headline
|
||
// that carried no route at all.
|
||
|
||
// ── Who is carrying it comes last ──
|
||
//
|
||
// The Miler's card sat directly under the head, between the
|
||
// reference and the journey, which put a person between the
|
||
// customer and the answer they opened the screen for. It is real
|
||
// and it is reachable — a name, a plate, Call and Message — but it
|
||
// is *who*, and the screen's question is *where*. The journey has
|
||
// the middle of the page now and the person is at the foot of it,
|
||
// one thumb away.
|
||
if (milerEnRoute)
|
||
_MilerCard(person: booking.miler!),
|
||
if (!cancelled &&
|
||
stage == JourneyStage.outForDelivery &&
|
||
booking.deliveryAgent != null)
|
||
_MilerCard(person: booking.deliveryAgent!),
|
||
|
||
// Stands in for backend push updates. Real stage changes arrive
|
||
// from `GET /customer/bookings/{reference}`, so this exists only
|
||
// where there is no server to send them.
|
||
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
|
||
],
|
||
),
|
||
),
|
||
// No bed under the actions. `edge: true` painted a white bar with a
|
||
// hairline across the bottom, which on this canvas reads as a second
|
||
// surface holding two buttons — and the buttons already have edges.
|
||
bottomNavigationBar: DmFooter(
|
||
children: _footerActions(context, app, booking),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// How far along the route the Miler is drawn, by stage.
|
||
|
||
/// The footer is a hierarchy, not a single button: the helpful action is the
|
||
/// filled one and cancelling is a text button under it. Cancel used to be a
|
||
/// full-width outlined button in the danger colour — the most prominent
|
||
/// control on a screen whose job is to reassure someone that their parcel is
|
||
/// fine.
|
||
List<Widget> _footerActions(
|
||
BuildContext context,
|
||
AppState app,
|
||
Booking booking,
|
||
) {
|
||
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
|
||
return [
|
||
DmButton(
|
||
label: 'Book another pickup',
|
||
icon: LucideIcons.arrowRight,
|
||
onPressed: () {
|
||
app.startBooking();
|
||
Navigator.of(context).pushAndRemoveUntil(
|
||
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
||
(route) => route.isFirst,
|
||
);
|
||
},
|
||
),
|
||
];
|
||
}
|
||
|
||
return [
|
||
// Filled, not outlined. Sharing a live link is the only thing a
|
||
// customer can *do* from this screen while the parcel is moving, and an
|
||
// outlined button on a page of white cards was the quietest object on
|
||
// it. Cancelling stays a text action, which is the right weight for
|
||
// something you should not reach for by accident.
|
||
DmButton(
|
||
label: 'Share live tracking',
|
||
icon: LucideIcons.share2,
|
||
iconLeading: true,
|
||
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
|
||
),
|
||
// Backend policy decides whether cancelling is still possible.
|
||
if (app.isCancellable(booking.stage))
|
||
DmButton(
|
||
label: 'Cancel this pickup',
|
||
kind: DmButtonKind.ghost,
|
||
danger: true,
|
||
onPressed: () async {
|
||
final cancelled = await showCancelSheet(context, booking);
|
||
if (cancelled == true && context.mounted) {
|
||
DmToast.show(context, 'Pickup cancelled');
|
||
}
|
||
},
|
||
)
|
||
else
|
||
DmButton(
|
||
label: 'Need help with this order?',
|
||
kind: DmButtonKind.ghost,
|
||
onPressed: () =>
|
||
DmToast.show(context, 'Support chat is not available yet'),
|
||
),
|
||
];
|
||
}
|
||
}
|
||
|
||
/// The state, the route, and the shipment's own figures.
|
||
///
|
||
/// ── Card → bare type → this ──
|
||
///
|
||
/// The bordered card repeated the milestone the journey below it already
|
||
/// listed. Stripping it to type fixed that and left the top of the screen with
|
||
/// nothing on it but two words and two places.
|
||
///
|
||
/// The reference's answer is better than both: the state stays the hero, but
|
||
/// it earns a row of its own with the reference and a priority mark beside it,
|
||
/// the promise sits under it, and the route becomes a card carrying the two
|
||
/// ends *and* the three figures a customer checks while waiting — how far,
|
||
/// what is carrying it, how heavy.
|
||
class _TrackCard extends StatelessWidget {
|
||
const _TrackCard({required this.booking, required this.slot, this.focused});
|
||
|
||
final Booking booking;
|
||
final PickupSlot? slot;
|
||
|
||
/// Set when following one destination's own order.
|
||
final DestinationGroup? focused;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final cancelled = booking.isCancelled;
|
||
final stage = focused?.stage ?? booking.stage;
|
||
final group = focused ?? booking.destinations.first;
|
||
|
||
// What the customer can act on: when, before the handover; where, after.
|
||
// ── 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;
|
||
|
||
// 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>[
|
||
_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!),
|
||
];
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 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: [
|
||
Expanded(
|
||
child: Text(
|
||
cancelled ? 'Cancelled' : stage.milestoneLabel,
|
||
style: DmText.headerTitle.copyWith(
|
||
fontSize: 29,
|
||
color: cancelled ? DmColors.ink3 : DmColors.ink,
|
||
),
|
||
),
|
||
),
|
||
if (eta != null && eta > 0 && !cancelled) ...[
|
||
const SizedBox(width: 12),
|
||
Container(
|
||
padding: const EdgeInsets.fromLTRB(11, 7, 11, 8),
|
||
decoration: BoxDecoration(
|
||
color: DmColors.brand,
|
||
borderRadius: DmRadius.all(DmRadius.chip),
|
||
),
|
||
child: Text(
|
||
'ETA $eta mins',
|
||
style: DmText.label.copyWith(
|
||
fontSize: 12.5,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
if (when != null) ...[
|
||
const SizedBox(height: 5),
|
||
Text(
|
||
when,
|
||
style: DmText.body.copyWith(fontSize: 14.5, color: DmColors.ink3),
|
||
),
|
||
],
|
||
const SizedBox(height: 16),
|
||
DmCard(
|
||
raised: !cancelled,
|
||
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: 'PICKUP',
|
||
value: booking.pickup.title,
|
||
note: booking.pickup.sub,
|
||
),
|
||
),
|
||
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: 'DROP',
|
||
value: group.destination.shortLabel,
|
||
note: group.destination.stateName,
|
||
end: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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, 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),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A labelled place. Two of these are the route.
|
||
class _Place extends StatelessWidget {
|
||
const _Place({
|
||
required this.label,
|
||
required this.value,
|
||
this.note,
|
||
this.end = false,
|
||
});
|
||
|
||
final String label;
|
||
final String value;
|
||
final String? note;
|
||
|
||
/// The destination, which aligns to the card's right edge so the two ends
|
||
/// read as two ends rather than as two columns of a table.
|
||
final bool end;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment:
|
||
end ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(label, style: DmText.eyebrow),
|
||
const SizedBox(height: 5),
|
||
Text(
|
||
value,
|
||
textAlign: end ? TextAlign.right : TextAlign.left,
|
||
style: DmText.cardTitle.copyWith(fontSize: 17),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (note != null && note!.isNotEmpty)
|
||
Text(
|
||
note!,
|
||
textAlign: end ? TextAlign.right : TextAlign.left,
|
||
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
|
||
// Two lines. A pickup note is a locality and a pincode, and at one
|
||
// line it rendered "Gandhipuram, Coimbat…" — which is the half of
|
||
// it a customer already knows.
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// One of the three figures in the strip under the route.
|
||
class _Figure extends StatelessWidget {
|
||
const _Figure({required this.label, required this.value});
|
||
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Expanded(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
label.toUpperCase(),
|
||
// 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,
|
||
color: DmColors.ink,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The journey. All of it, always.
|
||
///
|
||
/// It used to show the three most recent milestones behind a "Full timeline"
|
||
/// action, which was the right compression for seven and is the wrong one for
|
||
/// five: the whole shipment now fits in the space the summary occupied, and a
|
||
/// customer who opens tracking to ask "where is my parcel" should not have to
|
||
/// tap to see the answer's context.
|
||
class _Journey extends StatelessWidget {
|
||
const _Journey({required this.booking});
|
||
|
||
final Booking booking;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DmMilestones(
|
||
booking: booking,
|
||
title: 'Trip milestones',
|
||
showStep: true,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Who is collecting, and how to reach them.
|
||
///
|
||
/// ── Card → bare row → card ──
|
||
///
|
||
/// The first card was a bordered box with an avatar, a name, a plate tag and
|
||
/// a two-cell footer holding a filled crimson Call and an outlined Message —
|
||
/// six objects to say "Arun is coming, here is his number". Stripping it to a
|
||
/// row under a hairline fixed the clutter and lost the person: the one human
|
||
/// being in the whole flow became the quietest thing on the screen.
|
||
///
|
||
/// It is a lifted card again, with the contents the flat pass arrived at. The
|
||
/// avatar keeps its crimson disc — this is the one place in the app standing
|
||
/// in for a person, which is the job the solid tone was written for — and the
|
||
/// two actions are a filled Call beside an outlined Message, because a phone
|
||
/// number is what this card is *for*.
|
||
///
|
||
/// "Your Miler" / "Delivery Miler" stayed gone. The two are shown at mutually
|
||
/// exclusive stages, so the label distinguished between things that are never
|
||
/// on the screen together.
|
||
class _MilerCard extends StatelessWidget {
|
||
const _MilerCard({required this.person});
|
||
|
||
final Person person;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10),
|
||
child: DmCard(
|
||
raised: true,
|
||
children: [
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||
child: Row(
|
||
children: [
|
||
DmAvatar(initials: person.initials, size: 46),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
person.name,
|
||
style: DmText.cardTitle.copyWith(fontSize: 16.5),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
const SizedBox(height: 3),
|
||
// ── Reputation, then the plate ──
|
||
//
|
||
// The reference puts "★ 4.9 · 1,240 deliveries" under
|
||
// the name, which is the line that makes a stranger at
|
||
// your door a person somebody has vouched for. It is
|
||
// shown only when the backend actually sent a rating or
|
||
// a count — a default here would be an invented
|
||
// reputation on a real person — and the plate takes the
|
||
// line whenever it did not.
|
||
if (person.rating != null || person.trips != null)
|
||
_Credentials(person: person)
|
||
else
|
||
Text(
|
||
person.vehicle,
|
||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
_Reach(
|
||
icon: LucideIcons.phone,
|
||
tooltip: 'Call ${person.name}',
|
||
filled: true,
|
||
onPressed: () =>
|
||
DmToast.show(context, 'Calling ${person.name}…'),
|
||
),
|
||
const SizedBox(width: 9),
|
||
_Reach(
|
||
icon: LucideIcons.messageSquare,
|
||
tooltip: 'Message ${person.name}',
|
||
onPressed: () =>
|
||
DmToast.show(context, 'Chat is not available yet'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Call and Message, as two discs.
|
||
///
|
||
/// They were full-width buttons in a second cell, which gave a phone call the
|
||
/// same footprint as the screen's primary action. A round target beside the
|
||
/// name is the shape every app that has ever put a courier on a screen
|
||
/// arrived at, and it costs a third of the height.
|
||
class _Reach extends StatefulWidget {
|
||
const _Reach({
|
||
required this.icon,
|
||
required this.tooltip,
|
||
required this.onPressed,
|
||
this.filled = false,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final VoidCallback onPressed;
|
||
final bool filled;
|
||
|
||
@override
|
||
State<_Reach> createState() => _ReachState();
|
||
}
|
||
|
||
class _ReachState extends State<_Reach> {
|
||
bool _down = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Tooltip(
|
||
message: widget.tooltip,
|
||
child: Semantics(
|
||
button: true,
|
||
label: widget.tooltip,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTapDown: (_) => setState(() => _down = true),
|
||
onTapUp: (_) => setState(() => _down = false),
|
||
onTapCancel: () => setState(() => _down = false),
|
||
onTap: widget.onPressed,
|
||
child: AnimatedScale(
|
||
scale: _down ? 0.92 : 1,
|
||
duration: DmMotion.fast,
|
||
curve: DmMotion.ease,
|
||
child: Container(
|
||
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.ok : DmColors.surfaceAlt,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
widget.icon,
|
||
size: 19,
|
||
color: widget.filled ? Colors.white : DmColors.ink,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The individual orders a completed pickup produced.
|
||
///
|
||
/// Deliberately absent until the backend has actually created them: before
|
||
/// collection the customer has one scheduled visit, not several shipments.
|
||
class _YourPackages extends StatelessWidget {
|
||
const _YourPackages({required this.booking, this.focused});
|
||
|
||
final Booking booking;
|
||
|
||
/// The one being followed, so the list can mark it.
|
||
final DestinationGroup? focused;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DmCard(
|
||
children: [
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'YOUR PACKAGES',
|
||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||
),
|
||
),
|
||
Flexible(
|
||
child: Text(
|
||
focused == null
|
||
? 'Collected in one visit'
|
||
: 'Pickup ${booking.reference}',
|
||
textAlign: TextAlign.right,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: DmText.tiny,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
for (final group in booking.destinations)
|
||
_PackageRow(group: group, isFocused: identical(group, focused)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PackageRow extends StatelessWidget {
|
||
const _PackageRow({required this.group, this.isFocused = false});
|
||
|
||
final DestinationGroup group;
|
||
final bool isFocused;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final stage = group.stage;
|
||
return DmCardCell(
|
||
background: isFocused ? DmColors.brandSoft : null,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(group.destination.label, style: DmText.cardTitle),
|
||
const SizedBox(height: 3),
|
||
Text.rich(
|
||
TextSpan(
|
||
style: DmText.tiny,
|
||
children: [
|
||
TextSpan(
|
||
text: group.trackingId ?? '—',
|
||
style: DmText.monoSm,
|
||
),
|
||
TextSpan(
|
||
text:
|
||
' · ${group.packageCount} '
|
||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||
),
|
||
],
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
if (stage != null)
|
||
Flexible(
|
||
child: DmPill(
|
||
stage.milestoneLabel,
|
||
tone: stage == JourneyStage.delivered
|
||
? DmPillTone.done
|
||
: DmPillTone.neutral,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The backend's QA stage helper, as a control.
|
||
///
|
||
/// It is not a simulator: each tap posts to
|
||
/// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads
|
||
/// the booking, so what it shows is what the **server** recorded. Off entirely
|
||
/// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on
|
||
/// production or in a release.
|
||
class _StageStepper extends StatelessWidget {
|
||
const _StageStepper({required this.booking});
|
||
|
||
final Booking booking;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final app = AppScope.read(context);
|
||
final index = booking.stage.index;
|
||
final locked = booking.isCancelled;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20),
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 10, 10),
|
||
decoration: BoxDecoration(
|
||
color: DmColors.surfaceAlt,
|
||
border: Border.all(color: DmColors.border),
|
||
borderRadius: DmRadius.all(DmRadius.md),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Move stage (QA)',
|
||
style: DmText.label.copyWith(color: DmColors.ink3),
|
||
),
|
||
),
|
||
_StepButton(
|
||
icon: LucideIcons.minus,
|
||
onTap: locked || index == 0
|
||
? null
|
||
: () => app.advanceStage(booking, JourneyStage.at(index - 1)),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_StepButton(
|
||
icon: LucideIcons.plus,
|
||
onTap: locked || index == JourneyStage.values.length - 1
|
||
? null
|
||
: () => app.advanceStage(booking, JourneyStage.at(index + 1)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _StepButton extends StatelessWidget {
|
||
const _StepButton({required this.icon, this.onTap});
|
||
|
||
final IconData icon;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final enabled = onTap != null;
|
||
return Opacity(
|
||
opacity: enabled ? 1 : 0.35,
|
||
child: Material(
|
||
color: DmColors.surface,
|
||
borderRadius: DmRadius.all(DmRadius.xs),
|
||
child: InkWell(
|
||
borderRadius: DmRadius.all(DmRadius.xs),
|
||
onTap: onTap,
|
||
child: Ink(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
borderRadius: DmRadius.all(DmRadius.xs),
|
||
border: Border.all(color: DmColors.border),
|
||
),
|
||
child: Icon(icon, size: 18, color: DmColors.ink2),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "★ 4.9 · 1,240 deliveries · TN 37 BX 4412", minus whatever is missing.
|
||
class _Credentials extends StatelessWidget {
|
||
const _Credentials({required this.person});
|
||
|
||
final Person person;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final rest = [
|
||
if (person.trips != null)
|
||
'${person.trips} ${person.trips == 1 ? 'delivery' : 'deliveries'}',
|
||
if (person.vehicle.isNotEmpty) person.vehicle,
|
||
].join(' · ');
|
||
|
||
return Row(
|
||
children: [
|
||
if (person.rating != null) ...[
|
||
const Icon(LucideIcons.star, size: 12, color: DmColors.brand),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
person.rating!.toStringAsFixed(1),
|
||
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
|
||
),
|
||
if (rest.isNotEmpty) const SizedBox(width: 7),
|
||
],
|
||
if (rest.isNotEmpty)
|
||
Flexible(
|
||
child: Text(
|
||
rest,
|
||
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|