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,43 @@
import 'package:flutter/material.dart';
/// Named steps in the booking flow.
///
/// Naming the routes lets Review's "Edit" actions pop straight back to the
/// step being corrected instead of stacking a second copy of it.
class BookingRoutes {
BookingRoutes._();
static const pickup = 'booking/pickup';
static const destination = 'booking/destination';
static const slot = 'booking/slot';
static const review = 'booking/review';
static const confirmed = 'booking/confirmed';
static const tracking = 'tracking';
}
/// A page route tagged with its step name.
Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
return MaterialPageRoute<T>(
settings: RouteSettings(name: name),
builder: builder,
);
}
/// Returns to an earlier step if it is still on the stack, otherwise pushes a
/// fresh copy of it. The navigator is captured up front because popping
/// unmounts the calling screen.
void editStep(
BuildContext context,
String name,
Widget Function(BuildContext) builder,
) {
final navigator = Navigator.of(context);
var found = false;
navigator.popUntil((route) {
if (route.settings.name == name) found = true;
return found || route.isFirst;
});
if (!found) {
navigator.push(bookingRoute<void>(name, builder));
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/app_config.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/misc.dart';
import '../../widgets/pieces.dart';
import '../../widgets/route_rail.dart';
import '../tracking_screen.dart';
import 'booking_routes.dart';
/// Booking confirmed.
///
/// A booking reference and a reserved window — not a shipment. The tracking
/// number appears later, once the Miler completes pickup.
///
/// ── What this screen stopped doing ──
///
/// It opened with a green tick in a circle, then a map, then a reference, then
/// a summary card, then two banners: five surfaces to say one thing. The tick
/// was also the only green in the product, which made the app's happiest moment
/// look like a system dialog rather than like Doormile.
///
/// It is one crimson card now — the payoff — followed by the reference, the
/// route as a thread, and the live search for a Miler as a row on that same
/// card. The map is gone: the customer chose this route two screens ago and a
/// second picture of it says nothing new while the parcel has not moved.
class ConfirmedScreen extends StatefulWidget {
const ConfirmedScreen({super.key});
@override
State<ConfirmedScreen> createState() => _ConfirmedScreenState();
}
class _ConfirmedScreenState extends State<ConfirmedScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _pop = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 520),
)..forward();
@override
void dispose() {
_pop.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final booking = app.trackedBooking;
final slot = booking == null ? null : app.slotById(booking.slotId);
final packages = booking?.totalPackages ?? 1;
return Scaffold(
backgroundColor: DmColors.canvas,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 22, DmSpace.pad, 24),
children: [
DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScaleTransition(
scale: CurvedAnimation(
parent: _pop,
curve: Curves.easeOutBack,
),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.onBrandRaise,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
size: 24,
color: Colors.white,
),
),
),
const SizedBox(height: 16),
Text(
'Pickup booked',
style: DmText.display.copyWith(color: Colors.white),
),
const SizedBox(height: 8),
Text(
booking == null
? "We've reserved your slot."
: "We've reserved your slot. A nearby Miler is being "
'assigned to collect '
'${packages == 1 ? 'your package' : 'your packages'}.',
style: DmText.lede.copyWith(color: DmColors.onBrand2),
),
],
),
),
],
),
if (booking != null) ...[
const SizedBox(height: 10),
DmCard(
tone: DmCardTone.wash,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
child: DmCopyRow(
label: 'Booking reference',
value: booking.reference,
onWash: true,
),
),
],
),
const SizedBox(height: 10),
DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drops: [
for (final group in booking.destinations)
DmRailStop(
label: group.destination.label,
note: booking.isMultiDestination
? null
: group.details.exactAddress,
trailing:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],
),
),
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.calendarCheck,
size: 15,
color: DmColors.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// The day rides with the label so the window itself
// stays on one line. "Scheduled window" against
// "Today · 2:00 – 4:00 PM" broke across two, and
// wrapped a time range mid-range.
slot == null
? 'Scheduled window'
: 'Scheduled ${slot.day.toLowerCase()}',
style: DmText.small.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
slot?.window ?? '—',
textAlign: TextAlign.right,
style: DmText.cardTitle.copyWith(
color: DmColors.brandPress,
),
),
),
],
),
),
// The search is live from the moment this screen opens, so it
// belongs on the card rather than in a banner underneath it.
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const DmPill('Searching', tone: DmPillTone.live),
const SizedBox(width: 12),
Expanded(
child: Text(
'Finding the nearest Miler',
style: DmText.small.copyWith(
color: DmColors.brand,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation(DmColors.brand),
),
),
],
),
),
],
),
const SizedBox(height: 10),
// The one screen where a fake booking is most convincing, so it
// is the one screen that has to admit it.
if (AppConfig.useDevData)
const DmInfoBanner(
icon: LucideIcons.wifiOff,
tone: DmTagTone.warn,
title: 'Offline dev data — nothing was sent',
message: 'This build is not talking to a server, so this '
'booking exists only on this phone and will not appear '
'in the admin console.',
)
else
const DmInfoBanner(
icon: LucideIcons.package,
tone: DmTagTone.neutral,
title: 'Keep the parcel ready',
message: 'Sealed and labelled with the reference above.',
),
],
],
),
),
bottomNavigationBar: DmFooter(
children: [
DmButton(
label: 'Track pickup',
icon: LucideIcons.arrowRight,
onPressed: () => Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
),
),
DmButton(
label: 'Back to home',
kind: DmButtonKind.ghost,
onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
),
],
),
);
}
}

View File

@@ -0,0 +1,406 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import 'booking_routes.dart';
import 'slot_screen.dart';
/// Step 2 — where the packages are going.
///
/// Opens as a single destination, which is the common case: pick a state, pick
/// a district, done. Package count is never asked for — it keeps its model
/// default of 1 — and sending to a second place is a secondary action that only
/// appears once the first one is settled. A customer with one package to one
/// district never sees the multi-destination machinery.
class DestinationScreen extends StatefulWidget {
const DestinationScreen({super.key});
@override
State<DestinationScreen> createState() => _DestinationScreenState();
}
class _DestinationScreenState extends State<DestinationScreen> {
/// Which block is open for editing. Null when every block is settled, so
/// coming back from Review shows the chosen places rather than re-opening
/// the picker.
int? _editing;
@override
void initState() {
super.initState();
final groups = AppScope.read(context).draftDestinations;
final firstIncomplete = groups.indexWhere((g) => !g.isComplete);
_editing = firstIncomplete == -1 ? null : firstIncomplete;
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final groups = app.draftDestinations;
final limits = app.limits;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 2, total: 4, label: 'Destination'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
28,
),
children: [
// The question is the instruction. The line under it said
// "Choose from the areas we currently serve", which is what the
// list below already demonstrates by only containing those
// areas — a caption for a control that captions itself.
Text('Where are you sending it?', style: DmText.title),
const SizedBox(height: 20),
for (var i = 0; i < groups.length; i++) ...[
_DestinationBlock(
index: i,
group: groups[i],
editing: _editing == i || !groups[i].isComplete,
showRemove: groups.length > 1,
onEdit: () => setState(() => _editing = i),
onSettled: () => setState(() => _editing = null),
onRemove: () {
app.removeDestination(i);
setState(() => _editing = null);
},
),
const SizedBox(height: 12),
],
// Offered only where the backend allows more than one place.
// While `maxDestinations` is 1 there is nothing to explain and
// nothing to disable — a control that can never be used is
// better absent than permanently greyed.
if (app.allowsMultipleDestinations)
_AddDestination(
enabled: app.canAddDestination,
reason: _capReason(app, limits),
onTap: () {
if (app.addDestination()) {
setState(
() => _editing = app.draftDestinations.length - 1,
);
}
},
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Next',
icon: LucideIcons.arrowRight,
onPressed: app.draftReady
? () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.slot,
(_) => const SlotScreen(),
),
)
: null,
),
],
),
);
}
/// Friendly, non-alarming explanation when a cap blocks another place.
String? _capReason(dynamic app, BookingLimits limits) {
if (app.canAddDestination as bool) return null;
// Mid-selection is not a problem to explain — the action simply is not
// offered yet.
if (!(app.draftReady as bool)) return null;
if ((app.draftDestinations as List).length >= limits.maxDestinations) {
return 'Up to ${limits.maxDestinations} places in one pickup. '
'Book another pickup for more.';
}
return 'Up to ${limits.maxPackages} packages in one pickup.';
}
}
/// One place: state, district, how many packages, optional details.
class _DestinationBlock extends StatelessWidget {
const _DestinationBlock({
required this.index,
required this.group,
required this.editing,
required this.showRemove,
required this.onEdit,
required this.onSettled,
required this.onRemove,
});
final int index;
final DestinationGroup group;
final bool editing;
final bool showRemove;
final VoidCallback onEdit;
final VoidCallback onSettled;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: DmMotion.base,
curve: DmMotion.ease,
alignment: Alignment.topCenter,
child: editing
? _Picker(index: index, group: group, onSettled: onSettled)
: _Settled(
index: index,
group: group,
showRemove: showRemove,
onEdit: onEdit,
onRemove: onRemove,
),
);
}
}
/// Choose the state, then the districts open beneath it.
class _Picker extends StatelessWidget {
const _Picker({
required this.index,
required this.group,
required this.onSettled,
});
final int index;
final DestinationGroup group;
final VoidCallback onSettled;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final destination = group.destination;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DmMicroHead('Sending to', top: 0, bottom: 10),
DmAsyncList<ServiceArea>(
skeletonRows: 1,
load: ({bool refresh = false}) => app.loadStates(refresh: refresh),
emptyTitle: 'No service areas available',
emptyMessage: "We're not accepting new pickups right now. Please check back shortly.",
builder: (context, states) => SizedBox(
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: states.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, i) => DmChoiceChip(
label: states[i].name,
selected: destination.stateCode == states[i].code,
onTap: () => app.selectStateFor(index, states[i]),
),
),
),
),
if (destination.stateCode != null) ...[
const SizedBox(height: 18),
const DmMicroHead('District', top: 0, bottom: 10),
DmAsyncList<District>(
key: ValueKey('districts-${destination.stateCode}'),
skeletonRows: 3,
load: ({bool refresh = false}) =>
app.loadDistricts(destination.stateCode!, refresh: refresh),
emptyTitle: 'No districts open here yet',
emptyMessage:
"We're not delivering inside ${destination.stateName} right now. "
'Pick another state above.',
emptyActionLabel: 'Got it',
onEmptyAction: () {},
builder: (context, districts) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: districts.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// The cell grows with the reader's text size rather than
// clipping the district name.
// One per row as soon as the reader's text grows, so the
// place name always has room.
crossAxisCount:
MediaQuery.textScalerOf(context).scale(1) > 1.15
? 1
: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: MediaQuery.textScalerOf(context).scale(70),
),
itemBuilder: (context, i) {
final d = districts[i];
return DmGridTile(
// Just the place. The delivery promise appears on the
// settled card, where it actually informs a decision.
label: d.name,
selected: destination.districtCode == d.code,
onTap: () {
app.selectDistrictFor(index, d);
onSettled();
},
);
},
),
// Places we are opening are named once, quietly — never as a
// grid of tiles the customer cannot use.
if (app.upcomingDistricts(destination.stateCode!).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12, left: 2, right: 2),
child: Text(
'Coming soon: '
'${app.upcomingDistricts(destination.stateCode!).join(', ')}',
style: DmText.small.copyWith(fontSize: 12.5),
),
),
],
),
),
],
],
);
}
}
/// A chosen place, collapsed to a card with its package count.
class _Settled extends StatelessWidget {
const _Settled({
required this.index,
required this.group,
required this.showRemove,
required this.onEdit,
required this.onRemove,
});
final int index;
final DestinationGroup group;
final bool showRemove;
final VoidCallback onEdit;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
onTap: onEdit,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
// Numbered only when there is more than one to tell apart.
eyebrow: showRemove ? 'Destination ${index + 1}' : 'Sending to',
title: group.destination.label,
subtitle: group.district?.promise,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
DmTextAction(label: 'Change', onPressed: onEdit),
if (showRemove)
IconButton(
onPressed: onRemove,
icon: const Icon(LucideIcons.x, size: 17),
color: DmColors.ink3,
tooltip: 'Remove this destination',
visualDensity: VisualDensity.compact,
),
],
),
),
// ── Two rows removed: package count, and exact delivery details ──
//
// A destination block was a card containing a divider, a stepper in
// its own row, another divider, and a row that opened a bottom sheet
// for optional detail — four decisions stacked under an address the
// customer had already given. Almost every booking is one package to
// one door, so the common path was paying for two controls it never
// touched.
//
// `packageCount` keeps its model default and the read-only screens
// still show it; what is gone is asking. The delivery-details sheet
// is deleted outright — see `sheets/`.
],
);
}
}
/// The quiet secondary action that unlocks multi-destination pickups.
class _AddDestination extends StatelessWidget {
const _AddDestination({
required this.enabled,
required this.reason,
required this.onTap,
});
final bool enabled;
final String? reason;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (!enabled) {
return reason == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Text(reason!, style: DmText.small),
);
}
return InkWell(
onTap: onTap,
borderRadius: DmRadius.all(DmRadius.xs),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
LucideIcons.circlePlus,
size: 19,
color: DmColors.brand,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'Send to another place',
style: DmText.label.copyWith(
fontSize: 15,
color: DmColors.brand,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/location_service.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/map_panel.dart';
import '../../widgets/pieces.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'destination_screen.dart';
/// Step 1 — where we collect from.
///
/// One card: a real map on top with the pin locked to its centre, and the
/// address it resolves to in the box directly underneath. Drag the map, the
/// address follows. Nothing is typed unless the customer wants to.
class PickupLocationScreen extends StatefulWidget {
const PickupLocationScreen({super.key});
@override
State<PickupLocationScreen> createState() => _PickupLocationScreenState();
}
class _PickupLocationScreenState extends State<PickupLocationScreen> {
/// True between the first touch on the map and the address coming back.
bool _pinMoving = false;
@override
void initState() {
super.initState();
// Arriving without a pin — ask the device where we are.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final app = AppScope.read(context);
if (app.pickup == null && !app.resolvingPickup) {
app.detectPickupLocation();
}
});
}
Future<void> _search(AppState app) async {
final picked = await showPlaceSearchSheet(context);
if (picked == null || !mounted) return;
app.setPickup(picked);
setState(() => _pinMoving = false);
DmToast.show(context, 'Pickup location updated');
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final place = app.pickup;
final resolving = app.resolvingPickup || _pinMoving;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 1, total: 4, label: 'Pickup point'),
const Padding(
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
child: _Intro(),
),
const SizedBox(height: 16),
// The map and the address it resolves to are one object, so they get
// one card. Not a ListView: the map must win vertical drags.
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
14,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
children: [
Expanded(
child: DmMapPanel(
lat: app.mapLat,
lng: app.mapLng,
deviceLat: app.deviceLat,
deviceLng: app.deviceLng,
deviceAccuracy: app.deviceAccuracy,
loading: resolving,
borderRadius: BorderRadius.zero,
onLocate: () {
setState(() => _pinMoving = false);
app.detectPickupLocation();
},
onPinMoveStart: () {
if (!_pinMoving) setState(() => _pinMoving = true);
},
onPinMoved: (lat, lng) {
setState(() => _pinMoving = false);
app.setPickupFromPin(lat, lng);
},
),
),
_AddressBox(
title: place?.title,
sub: place?.sub,
resolving: resolving,
onChange: () => _search(app),
),
],
),
),
),
),
if (app.locationDenial != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
12,
),
child: _LocationNotice(
denial: app.locationDenial!,
onSettings: app.openLocationSettings,
onSearch: () => _search(app),
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Next',
icon: LucideIcons.arrowRight,
// Gated on the pin, not just the address: a booking without
// coordinates is one the Miler cannot be routed to.
onPressed: !app.hasPickupPoint || resolving
? null
: () {
app.draftPickup = place;
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.destination,
(_) => const DestinationScreen(),
),
);
},
),
],
),
);
}
}
class _Intro extends StatelessWidget {
const _Intro();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Where should we pick up?', style: DmText.title),
const SizedBox(height: 8),
Text('Drag the map to move the pin.', style: DmText.lede),
],
);
}
}
/// The address under the map. It is the same card, so a hairline separates
/// them rather than a gap.
class _AddressBox extends StatelessWidget {
const _AddressBox({
required this.title,
required this.sub,
required this.resolving,
required this.onChange,
});
final String? title;
final String? sub;
final bool resolving;
final VoidCallback onChange;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 13, 10, 14),
decoration: const BoxDecoration(
color: DmColors.brandSoft,
border: Border(top: BorderSide(color: DmColors.border)),
),
child: Row(
children: [
// The resolved address is the map's answer, so it takes the wash: it
// is brand-owned context, not a field waiting to be filled.
const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash),
const SizedBox(width: 12),
Expanded(
child: AnimatedSwitcher(
duration: DmMotion.base,
child: resolving
? const _ResolvingLines(key: ValueKey('resolving'))
: Column(
key: ValueKey('$title|$sub'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title ?? 'Locating you…',
key: const ValueKey('pickupAddressTitle'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle,
),
const SizedBox(height: 3),
Text(
sub ?? 'Please wait a moment',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(color: DmColors.brandPress),
),
],
),
),
),
DmTextAction(label: 'Change', onPressed: onChange),
],
),
);
}
}
/// What the address box shows while the pin is moving: the shape of an
/// address, so the card never collapses or jumps.
class _ResolvingLines extends StatelessWidget {
const _ResolvingLines({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Getting address…',
style: DmText.cardTitle.copyWith(color: DmColors.ink3),
),
const SizedBox(height: 6),
Container(
width: 148,
height: 9,
decoration: BoxDecoration(
color: DmColors.brandLine,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}
/// Shown only when the device refused us a position. Says what happened and
/// offers the way out, rather than leaving the pin sitting on a default.
class _LocationNotice extends StatelessWidget {
const _LocationNotice({
required this.denial,
required this.onSettings,
required this.onSearch,
});
final LocationDenial denial;
final VoidCallback onSettings;
final VoidCallback onSearch;
@override
Widget build(BuildContext context) {
final settings = denial == LocationDenial.deniedForever;
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
const Icon(
LucideIcons.mapPinOff,
size: 18,
color: DmColors.ink2,
),
const SizedBox(width: 10),
Expanded(
child: Text(
LocationFix.denied(denial).message,
style: DmText.small.copyWith(color: DmColors.ink2),
),
),
const SizedBox(width: 8),
DmChipButton(
label: settings ? 'Settings' : 'Search',
onPressed: settings ? onSettings : onSearch,
),
],
),
);
}
}

View File

@@ -0,0 +1,377 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/pieces.dart';
import '../../widgets/route_map.dart';
import '../../widgets/route_rail.dart';
import '../../widgets/summary.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'confirmed_screen.dart';
import 'destination_screen.dart';
import 'slot_screen.dart';
/// Step 4 — review.
///
/// Reads the same whether the pickup carries one package to one district or
/// six across three. Nothing is charged here: the fare is indicative until the
/// Miler verifies the packages at the door.
class ReviewScreen extends StatefulWidget {
const ReviewScreen({super.key});
@override
State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
bool _busy = false;
Future<void> _confirm() async {
setState(() => _busy = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.confirmBooking();
unawaited(HapticFeedback.mediumImpact());
navigator.pushAndRemoveUntil(
bookingRoute<void>(BookingRoutes.confirmed, (_) => const ConfirmedScreen()),
(route) => route.isFirst,
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _busy = false);
DmToast.show(context, e.message);
// A slot that filled up, or whose window passed while this screen was
// open, is not something the customer can fix from Review — the only
// move is to pick again. `confirmBooking` has already dropped the stale
// list, so going back re-reads it.
if (e.needsFreshSlots) {
navigator.popUntil(
(route) => route.settings.name == BookingRoutes.slot || route.isFirst,
);
}
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final pickup = app.draftPickup ?? app.pickup;
final slot = app.slotById(app.draftSlotId);
final groups = app.draftDestinations;
final multi = groups.length > 1;
final packages = app.draftTotalPackages;
final fare = app.draftFare;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 4, total: 4, label: 'Review'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// "Pay nothing now" belongs beside the amount, where the
// summary already states it — not as a paragraph under the
// heading that has to be read before the thing it describes.
Text('Review pickup details', style: DmText.title),
const SizedBox(height: 18),
DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: pickup,
destination: groups.first.hub,
pickupLabel: pickup?.title,
destinationLabel: multi
? '${groups.length} destinations'
: groups.first.destination.districtName,
badge: _MapBadge(
icon: multi ? LucideIcons.route : LucideIcons.arrowRight,
label: multi
? '${groups.length} destinations'
: 'Door to door',
),
),
],
),
const SizedBox(height: 10),
DmSummaryCard(
children: [
// One journey, one rail: pickup connected to everywhere it
// is going, exactly as an order card reads.
_RouteSection(
pickup: pickup,
groups: groups,
onEditPickup: () async {
final picked = await showPlaceSearchSheet(context);
if (picked != null && context.mounted) {
app.setPickup(picked);
app.draftPickup = picked;
}
},
onChangeDestinations: () => editStep(
context,
BookingRoutes.destination,
(_) => const DestinationScreen(),
),
),
DmSummaryRow(
label: 'Pickup time',
value: slot?.label ?? '—',
actionLabel: 'Change',
onAction: () => editStep(
context,
BookingRoutes.slot,
(_) => const SlotScreen(),
),
),
],
),
if (multi) ...[
const SizedBox(height: 10),
Row(
children: [
const Icon(
LucideIcons.package,
size: 17,
color: DmColors.ink3,
),
const SizedBox(width: 8),
Flexible(
child: Text(
'$packages packages · ${groups.length} destinations',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 14),
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
'One Miler visit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small,
),
),
],
),
],
const SizedBox(height: 10),
DmNote(
child: Text.rich(
TextSpan(
style: DmText.small.copyWith(color: DmColors.ink2, height: 1.5),
children: [
const TextSpan(text: 'This books '),
const TextSpan(
text: 'one Miler visit',
style: TextStyle(
fontWeight: FontWeight.w700,
color: DmColors.ink,
),
),
TextSpan(
text: '. Tracking is created once your packages are '
'collected, and cancelling is free until then. '
'The amount below covers ',
),
TextSpan(
text: '$packages '
'${packages == 1 ? 'package' : 'packages'} and is '
'confirmed after the Miler verifies them at your '
'door.',
),
],
),
),
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
// The price sits directly above the button that agrees to it. It
// used to live in a card halfway up the page, which meant the last
// thing on screen before "Book Pickup" could easily be a note about
// cancellation rather than the amount.
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Estimated total', style: DmText.small),
// The method is its own line: appended to the label it
// made one long string that truncated mid-word, and the
// customer reads the two facts separately anyway.
if (fare?.paymentMethod != null)
Text(
fare!.paymentMethod!,
style: DmText.tiny.copyWith(fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Text(
fare?.range ?? 'Calculating…',
style: DmText.monoLg.copyWith(fontSize: 20, color: DmColors.brand),
),
],
),
),
DmButton(
label: 'Book pickup',
icon: LucideIcons.arrowRight,
busy: _busy,
busyLabel: 'Booking…',
onPressed: app.draftReady ? _confirm : null,
),
DmButton(
label: 'Edit details',
kind: DmButtonKind.ghost,
onPressed: _busy ? null : () => Navigator.of(context).maybePop(),
),
],
),
);
}
}
/// Pickup and every destination on one rail, with the two edit actions.
class _RouteSection extends StatelessWidget {
const _RouteSection({
required this.pickup,
required this.groups,
required this.onEditPickup,
required this.onChangeDestinations,
});
final Place? pickup;
final List<DestinationGroup> groups;
final VoidCallback onEditPickup;
final VoidCallback onChangeDestinations;
bool get _anyMissingDetails => groups.any(
(g) => g.details.exactAddress == null && g.details.recipient == null,
);
@override
Widget build(BuildContext context) {
final multi = groups.length > 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
pickupEyebrow: 'Pickup',
pickup: pickup?.title ?? '—',
pickupNote: pickup?.sub,
pickupTrailing: DmTextAction(
label: 'Edit',
onPressed: onEditPickup,
minHeight: 32,
),
dropsEyebrow: 'Sending to',
drops: [
for (final group in groups)
DmRailStop(
label: group.destination.label,
trailing:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
note: multi
? null
: (group.details.exactAddress ??
(group.details.recipient == null
? 'Not added — the Miler can confirm this at pickup.'
: 'Recipient · ${group.details.recipient}')),
),
],
),
// With several places, say it once rather than per line.
if (multi && _anyMissingDetails)
Padding(
padding: const EdgeInsets.only(top: 8, left: 30),
child: Text(
'Exact addresses not added — the Miler can confirm these at pickup.',
style: DmText.small.copyWith(fontSize: 12.5),
),
),
Align(
alignment: Alignment.centerRight,
child: DmTextAction(
label: multi ? 'Change destinations' : 'Change destination',
onPressed: onChangeDestinations,
minHeight: 36,
),
),
],
),
);
}
}
class _MapBadge extends StatelessWidget {
const _MapBadge({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),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/option_tile.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'review_screen.dart';
/// Step 2 — Pickup timing.
///
/// Confirms where we are collecting from and which window the customer wants,
/// showing only what the backend still has capacity for.
class SlotScreen extends StatefulWidget {
const SlotScreen({super.key});
@override
State<SlotScreen> createState() => _SlotScreenState();
}
class _SlotScreenState extends State<SlotScreen> {
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final pickup = app.draftPickup ?? app.pickup;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 3, total: 4, label: 'Pickup time'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// Reassurance, not instruction: "A verified Miler arrives at
// your door inside the window you pick" restated the heading,
// the product, and the list of windows underneath it. The
// customer is here to pick a time.
Text('When should we come?', style: DmText.title),
const SizedBox(height: 20),
// ── Where we are collecting from ──
//
// A wash card, because it is brand-owned context rather than a
// field: the customer already chose this address on the screen
// before, and it is here to be confirmed at a glance, not
// re-entered. Change is the only control on it.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
eyebrow: 'Collecting from',
title: pickup?.title ?? 'Locating you…',
subtitle: pickup?.sub,
trailing: DmTextAction(
label: 'Change',
onPressed: () async {
final picked = await showPlaceSearchSheet(context);
if (picked != null && context.mounted) {
app.setPickup(picked);
app.draftPickup = picked;
DmToast.show(context, 'Pickup location updated');
}
},
),
),
],
),
const SizedBox(height: 22),
// ---- available windows --------------------------------------
// One eyebrow, not two. "Local time (IST)" sat opposite this
// one explaining a timezone the customer is standing in.
const DmMicroHead('Available windows', top: 0, bottom: 10),
DmAsyncList<PickupSlot>(
// Re-reads whenever the slot list is invalidated — starting a
// booking, or the server refusing a window that has passed.
reloadToken: app.slotsEpoch,
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
emptyIcon: LucideIcons.clock,
emptyTitle: 'No pickup slots left today',
emptyMessage:
'All windows are full right now. Try again in a little while.',
builder: (context, slots) {
final days = <String>[];
for (final slot in slots) {
if (!days.contains(slot.day)) days.add(slot.day);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final day in days) ...[
Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Text(day, style: DmText.sectionHead),
),
for (final slot in slots.where((s) => s.day == day))
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SlotCard(
slot: slot,
selected: app.draftSlotId == slot.id,
onTap: () => app.selectSlot(slot),
),
),
const SizedBox(height: 10),
],
],
);
},
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Review booking',
icon: LucideIcons.arrowRight,
onPressed: app.draftSlotId == null
? null
: () {
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.review,
(_) => const ReviewScreen(),
),
);
},
),
],
),
);
}
}
/// One window.
///
/// Selection **fills** here (see [DmOptionTile]): on a list of five windows a
/// tinted card was too quiet to answer "which one did I pick?" at a glance,
/// and this is the last decision before the customer commits to a price.
class _SlotCard extends StatelessWidget {
const _SlotCard({required this.slot, required this.selected, required this.onTap});
final PickupSlot slot;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final enabled = slot.available;
return DmOptionTile(
title: slot.window,
// The time is the thing being chosen, so it is set in the display face
// rather than at row weight.
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
selected: selected,
enabled: enabled,
onTap: onTap,
tags: [
if (slot.tag != null)
DmTag(
slot.tag!,
tone: selected ? DmTagTone.onFill : DmTagTone.brand,
icon: LucideIcons.zap,
),
if (slot.milersNearby > 0)
DmTag(
'${slot.milersNearby} Milers nearby',
tone: selected ? DmTagTone.onFill : DmTagTone.neutral,
icon: LucideIcons.userRound,
),
],
// A full window offers the only thing left to do with it.
trailing: enabled
? null
: DmTextAction(
label: 'Notify me',
color: DmColors.ink3,
onPressed: () =>
DmToast.show(context, "We'll let you know if it frees up"),
),
);
}
}