Files
doormile_customer_app/lib/ui/screens/tracking_screen.dart
Thiru-tenext 8956e818a4 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
2026-09-24 15:59:06 +05:30

1003 lines
36 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
],
),
),
);
}
}