Replace the customer app with Doormile CX

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>
This commit is contained in:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

View File

@@ -0,0 +1,878 @@
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),
),
),
),
);
}
}