Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
883 lines
30 KiB
Dart
883 lines
30 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/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.
|
|
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,
|
|
};
|
|
|
|
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.
|
|
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}',
|
|
),
|
|
if (booking.totalWeightLabel != null)
|
|
_Figure(label: 'Weight', value: booking.totalWeightLabel!)
|
|
else if (booking.miler?.vehicleType != null)
|
|
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!)
|
|
else if (slot != null)
|
|
_Figure(label: 'Window', value: slot!.window),
|
|
];
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
|
|
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),
|
|
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),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: _Place(
|
|
label: 'ORIGIN',
|
|
value: booking.pickup.title,
|
|
note: booking.pickup.sub,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _Place(
|
|
label: 'DESTINATION',
|
|
value: group.destination.shortLabel,
|
|
note: group.destination.stateName,
|
|
end: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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,
|
|
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(),
|
|
style: DmText.eyebrow.copyWith(fontSize: 9.5),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
value,
|
|
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
|
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,
|
|
decoration: BoxDecoration(
|
|
color: widget.filled ? DmColors.brand : 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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|