Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
879 lines
29 KiB
Dart
879 lines
29 KiB
Dart
import 'package:flutter/material.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/pieces.dart';
|
||
import '../widgets/route_map.dart';
|
||
import '../widgets/route_rail.dart';
|
||
import '../widgets/states.dart';
|
||
import '../widgets/summary.dart';
|
||
import 'booking/booking_routes.dart';
|
||
import 'booking/pickup_location_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: 'Live 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;
|
||
final assigning = !cancelled && stage == JourneyStage.booked;
|
||
|
||
return Scaffold(
|
||
backgroundColor: DmColors.canvas,
|
||
appBar: DmTopBar(
|
||
title: cancelled ? 'Booking' : 'Live tracking',
|
||
// The reference belongs in the bar, not in a card three scrolls down:
|
||
// it is what a customer reads out when they call about this parcel.
|
||
trailing: Text(
|
||
focused?.trackingId ?? booking.displayId,
|
||
style: DmText.mono,
|
||
),
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.only(bottom: 24),
|
||
children: [
|
||
_StatusHeader(booking: booking, slot: slot, focused: focused),
|
||
|
||
if (assigning)
|
||
const _AssigningHero()
|
||
else if (!cancelled)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
DmSpace.pad,
|
||
0,
|
||
DmSpace.pad,
|
||
10,
|
||
),
|
||
// The map is a cell of a card, not a floating rounded rectangle
|
||
// of its own: it gets the card's edge, and the line under it
|
||
// carries the fact the picture cannot state precisely.
|
||
child: DmCard(
|
||
children: [
|
||
DmRouteMap(
|
||
height: 178,
|
||
radius: 0,
|
||
pickup: booking.pickup,
|
||
destination: (focused ?? booking.destinations.first).hub,
|
||
showMiler: milerEnRoute,
|
||
progress: _routeProgress(stage),
|
||
pickupLabel: booking.pickup.title,
|
||
destinationLabel: booking.destination.districtName,
|
||
badge: milerEnRoute && booking.milerDistanceKm != null
|
||
? _Badge(
|
||
icon: LucideIcons.bike,
|
||
label: booking.milerDistanceKm == 0
|
||
? 'At your door'
|
||
: '${booking.miler?.name ?? 'Your Miler'}, '
|
||
'${booking.milerDistanceKm} km away',
|
||
)
|
||
: null,
|
||
),
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
||
child: Row(
|
||
children: [
|
||
const Icon(
|
||
LucideIcons.clock,
|
||
size: 15,
|
||
color: DmColors.ink3,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
// Short enough to sit beside Refresh without
|
||
// ellipsizing, which is the whole job of this line.
|
||
milerEnRoute && booking.milerEtaMinutes != null
|
||
? 'Pickup in about ${booking.milerEtaMinutes} min'
|
||
: 'Tracking your parcel live',
|
||
style: DmText.small,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
DmTextAction(
|
||
label: 'Refresh',
|
||
onPressed: app.refreshOrders,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
if (assigning && booking.milersInZone > 0)
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
DmSpace.pad,
|
||
0,
|
||
DmSpace.pad,
|
||
10,
|
||
),
|
||
child: DmInfoBanner(
|
||
icon: LucideIcons.users,
|
||
title:
|
||
'${booking.milersInZone} verified Milers active in your zone',
|
||
message: 'Most pickups are accepted within a few minutes.',
|
||
),
|
||
),
|
||
|
||
if (milerEnRoute)
|
||
_MilerCard(person: booking.miler!, role: 'Your Miler'),
|
||
if (!cancelled &&
|
||
stage == JourneyStage.outForDelivery &&
|
||
booking.deliveryAgent != null)
|
||
_MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'),
|
||
|
||
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),
|
||
),
|
||
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||
child: _RouteOutline(
|
||
booking: booking,
|
||
slot: slot,
|
||
focused: focused,
|
||
),
|
||
),
|
||
|
||
// 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),
|
||
],
|
||
),
|
||
bottomNavigationBar: DmFooter(
|
||
edge: true,
|
||
children: _footerActions(context, app, booking),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// How far along the route the Miler is drawn, by stage.
|
||
static double _routeProgress(JourneyStage stage) => switch (stage) {
|
||
JourneyStage.assigned => 0.08,
|
||
JourneyStage.onTheWay => 0.42,
|
||
JourneyStage.arrived || JourneyStage.pickedUp => 0.92,
|
||
_ => 0.5,
|
||
};
|
||
|
||
/// 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.pickup,
|
||
(_) => const PickupLocationScreen(),
|
||
),
|
||
(route) => route.isFirst,
|
||
);
|
||
},
|
||
),
|
||
];
|
||
}
|
||
|
||
return [
|
||
DmButton(
|
||
label: 'Share live tracking',
|
||
icon: LucideIcons.share2,
|
||
kind: DmButtonKind.outline,
|
||
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 screen's one loud surface: what is happening, right now.
|
||
///
|
||
/// ── Why this is a filled card and not a heading ──
|
||
///
|
||
/// The status used to be an eyebrow, a 32pt headline and a row of chips laid
|
||
/// straight onto the canvas. It said the right words, but it looked like every
|
||
/// other heading in the app, so the screen opened with no focal point and the
|
||
/// map below it — a decoration — became the loudest thing on the page.
|
||
///
|
||
/// It is now the one crimson card the design allows per screen. It carries the
|
||
/// live pulse, the headline, the journey's own progress and the window, so the
|
||
/// whole answer is in one object above the fold. A cancelled booking drops to a
|
||
/// plain card: nothing is live, and a crimson field would keep insisting it is.
|
||
class _StatusHeader extends StatelessWidget {
|
||
const _StatusHeader({
|
||
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 headline = switch (true) {
|
||
_ when cancelled => 'Booking cancelled',
|
||
_ when stage == JourneyStage.booked => 'Finding your Miler',
|
||
_ when stage == JourneyStage.assigned && booking.miler != null =>
|
||
'${booking.miler!.name} is assigned',
|
||
_ when stage == JourneyStage.onTheWay && booking.miler != null =>
|
||
booking.milerEtaMinutes == null
|
||
? '${booking.miler!.name} is on the way'
|
||
: '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away',
|
||
_ when stage == JourneyStage.arrived && booking.miler != null =>
|
||
'${booking.miler!.name} is at your door',
|
||
_ => stage.headline,
|
||
};
|
||
|
||
final sub = cancelled
|
||
? 'This pickup was cancelled. You were not charged.'
|
||
: stage.sub;
|
||
|
||
final total = CustomerMilestone.values.length;
|
||
final step = CustomerMilestone.of(stage).index + 1;
|
||
|
||
if (cancelled) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||
child: DmCard(
|
||
children: [
|
||
DmCardCell(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const DmPill('Cancelled', tone: DmPillTone.off),
|
||
const SizedBox(height: 12),
|
||
Text(headline, style: DmText.display.copyWith(fontSize: 25)),
|
||
const SizedBox(height: 6),
|
||
Text(sub, style: DmText.small),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||
child: DmCard(
|
||
tone: DmCardTone.fill,
|
||
children: [
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const DmLiveDot(size: 9, color: Colors.white),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'LIVE',
|
||
style: DmText.eyebrow.copyWith(
|
||
fontSize: 11,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (focused != null)
|
||
Flexible(
|
||
child: Text(
|
||
'To ${focused!.destination.label}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
textAlign: TextAlign.right,
|
||
style: DmText.small.copyWith(color: DmColors.onBrand3),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text(
|
||
headline,
|
||
style: DmText.display.copyWith(fontSize: 27, color: Colors.white),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)),
|
||
const SizedBox(height: 16),
|
||
DmStepBar(
|
||
progress: step / total,
|
||
steps: total,
|
||
current: step,
|
||
onFill: true,
|
||
// The milestone, not the window: the window is already the
|
||
// line underneath, and at this width it clipped to
|
||
// "Window 2:00 – 4:…".
|
||
label: CustomerMilestone.of(stage).label,
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
_footnote(stage),
|
||
style: DmText.tiny.copyWith(color: DmColors.onBrand3),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// The one fact under the rail: whatever is most useful at this stage.
|
||
String _footnote(JourneyStage stage) {
|
||
if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) {
|
||
return 'Arriving in about ${booking.milerEtaMinutes} min';
|
||
}
|
||
if (stage == JourneyStage.arrived) return 'Waiting at your door';
|
||
if (stage.index <= JourneyStage.assigned.index && slot != null) {
|
||
return '${slot!.day}, ${slot!.window}';
|
||
}
|
||
if (booking.expectedDelivery != null) {
|
||
return 'Expected ${booking.expectedDelivery}';
|
||
}
|
||
return CustomerMilestone.of(stage).label;
|
||
}
|
||
}
|
||
|
||
/// The journey, compressed to what just happened.
|
||
class _Journey extends StatefulWidget {
|
||
const _Journey({required this.booking});
|
||
|
||
final Booking booking;
|
||
|
||
@override
|
||
State<_Journey> createState() => _JourneyState();
|
||
}
|
||
|
||
class _JourneyState extends State<_Journey> {
|
||
bool _all = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return DmMilestones(
|
||
booking: widget.booking,
|
||
title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey',
|
||
maxRows: _all ? null : 3,
|
||
onExpand: () => setState(() => _all = true),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A chip floating on the map — "En route, 1.4 km away".
|
||
class _Badge extends StatelessWidget {
|
||
const _Badge({required this.icon, required this.label});
|
||
|
||
final IconData icon;
|
||
final String label;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
|
||
decoration: BoxDecoration(
|
||
color: DmColors.surface,
|
||
border: Border.all(color: DmColors.border),
|
||
borderRadius: DmRadius.all(6),
|
||
boxShadow: DmShadow.float,
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(icon, size: 14, color: DmColors.brand),
|
||
const SizedBox(width: 6),
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Radar shown while the backend matches a Miler.
|
||
class _AssigningHero extends StatelessWidget {
|
||
const _AssigningHero();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20),
|
||
child: Container(
|
||
height: 200,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: DmColors.surface,
|
||
border: Border.all(color: DmColors.border),
|
||
borderRadius: DmRadius.all(DmRadius.md),
|
||
),
|
||
child: DmRadarPulse(
|
||
size: 170,
|
||
child: Container(
|
||
width: 62,
|
||
height: 62,
|
||
decoration: const BoxDecoration(
|
||
color: DmColors.brand,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: const Icon(
|
||
LucideIcons.bike,
|
||
size: 28,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Who is collecting, how good they are, and how to reach them.
|
||
///
|
||
/// A wash rather than a plain card: this is the one row on the screen about a
|
||
/// person rather than a parcel, and the tint is what separates "who" from the
|
||
/// facts stacked above and below it. The plate is mono — it is read off a
|
||
/// number plate at the door.
|
||
class _MilerCard extends StatelessWidget {
|
||
const _MilerCard({required this.person, required this.role});
|
||
|
||
final Person person;
|
||
final String role;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||
child: DmCard(
|
||
tone: DmCardTone.wash,
|
||
children: [
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||
child: Row(
|
||
children: [
|
||
DmAvatar(initials: person.initials, size: 44),
|
||
const SizedBox(width: 13),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(person.name, style: DmText.cardTitle),
|
||
const SizedBox(height: 2),
|
||
// Absent unless the backend sent a rating or a pickup
|
||
// count. A default would put an invented reputation on a
|
||
// real person's card.
|
||
if (person.credentials != null)
|
||
Row(
|
||
children: [
|
||
const Icon(
|
||
LucideIcons.star,
|
||
size: 12,
|
||
color: DmColors.brand,
|
||
),
|
||
const SizedBox(width: 5),
|
||
Flexible(
|
||
child: Text(
|
||
person.credentials!,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: DmText.tiny,
|
||
),
|
||
),
|
||
],
|
||
)
|
||
else
|
||
Text(role, style: DmText.tiny),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 122),
|
||
child: DmTag(person.vehicle, mono: true),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
DmCardCell(
|
||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 13),
|
||
child: Row(
|
||
children: [
|
||
DmMiniButton(
|
||
label: 'Call',
|
||
icon: LucideIcons.phone,
|
||
filled: true,
|
||
onPressed: () => DmToast.show(context, 'Calling ${person.name}…'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
DmMiniButton(
|
||
label: 'Message',
|
||
icon: LucideIcons.messageSquare,
|
||
onPressed: () =>
|
||
DmToast.show(context, 'Chat is not available yet'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Where it is going, and the few facts worth keeping on screen.
|
||
class _RouteOutline extends StatelessWidget {
|
||
const _RouteOutline({
|
||
required this.booking,
|
||
required this.slot,
|
||
this.focused,
|
||
});
|
||
|
||
final Booking booking;
|
||
final PickupSlot? slot;
|
||
final DestinationGroup? focused;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final group = focused;
|
||
final recipient = (group ?? booking.destinations.first).details.recipient;
|
||
|
||
return DmCard(
|
||
children: [
|
||
DmCardCell(
|
||
child: DmRouteRail(
|
||
pickup: booking.pickup.title,
|
||
pickupNote: booking.pickup.sub,
|
||
drop: group != null
|
||
? group.destination.label
|
||
: booking.isMultiDestination
|
||
? '${booking.destinations.length} destinations'
|
||
: booking.destination.label,
|
||
dropNote: group != null
|
||
? '${group.packageCount} '
|
||
'${group.packageCount == 1 ? 'package' : 'packages'}'
|
||
: booking.isMultiDestination
|
||
? booking.packageSummary
|
||
: booking.details.exactAddress ??
|
||
(recipient == null
|
||
? 'Exact address collected at pickup'
|
||
: 'Recipient · $recipient'),
|
||
),
|
||
),
|
||
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
|
||
DmSummaryRow(
|
||
label: (group?.trackingId ?? booking.trackingId) == null
|
||
? 'Booking reference'
|
||
: 'Tracking number',
|
||
value: group?.trackingId ?? booking.displayId,
|
||
mono: true,
|
||
secondary: group == null
|
||
? null
|
||
: 'Collected together · Pickup ${booking.reference}',
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|