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:
43
lib/ui/screens/booking/booking_routes.dart
Normal file
43
lib/ui/screens/booking/booking_routes.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
263
lib/ui/screens/booking/confirmed_screen.dart
Normal file
263
lib/ui/screens/booking/confirmed_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
406
lib/ui/screens/booking/destination_screen.dart
Normal file
406
lib/ui/screens/booking/destination_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal file
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
377
lib/ui/screens/booking/review_screen.dart
Normal file
377
lib/ui/screens/booking/review_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/ui/screens/booking/slot_screen.dart
Normal file
206
lib/ui/screens/booking/slot_screen.dart
Normal 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"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user