Booking and sign-in flow, and the offline build back under guard

Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.

Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-21 13:10:28 +05:30
parent 0d66627c3c
commit 8207e27a97
37 changed files with 1382 additions and 1566 deletions

View File

@@ -1,12 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
import 'signup_screen.dart';
@@ -95,24 +93,30 @@ class _LoginScreenState extends State<LoginScreen> {
@override
Widget build(BuildContext context) {
return AuthScaffold(
// The promise sits on the crimson, at display size, and the sheet gets
// on with the form. A "Welcome back" heading in the sheet said nothing
// the screen did not already say, and pushed the first control down.
heroTitle: 'Book a pickup from your door.',
heroSub: 'Track every parcel through to delivery.',
// ── The screen asks one question ──
//
// This opened with a quarter-screen crimson hero carrying a lockup, a
// CUSTOMER badge, a display-size promise and a sub-line, then a
// Phone/Email toggle, then a labelled field, then a card explaining what
// a verification code is. Six things to read before the one thing to do.
//
// Nobody arrives at a sign-in screen needing to be sold the product —
// they already downloaded it. Uber, Bolt and Porter all open the same
// way: a line telling you what to type, the field, the button. The
// marketing copy moves to where marketing belongs, which is not in front
// of a customer trying to get back into their account.
//
// So the banner compacts to the lockup, the badge goes, and the heading
// becomes an instruction rather than a promise.
compactBanner: true,
badge: false,
title: _isPhone ? 'Enter your mobile number' : 'Enter your email address',
// The CTA is pinned, not scrolled. In the scroll area it was clipped by
// the footer the moment the keyboard came up — the one control the screen
// exists for, hidden exactly when it is needed.
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
const SizedBox(height: 10),
AuthSwitchLink(
question: 'New to Doormile?',
action: 'Create account',
@@ -125,45 +129,70 @@ class _LoginScreenState extends State<LoginScreen> {
],
),
children: [
// The same chip the Orders filter and the address labels use. One
// selection control across the app means the customer learns it once,
// on the screen they meet first.
Row(
children: [
for (final mode in LoginMode.values) ...[
if (mode != LoginMode.values.first) const SizedBox(width: 8),
DmChoiceChip(
label: mode == LoginMode.phone ? 'Phone' : 'Email',
selected: _mode == mode,
expand: true,
onTap: () => setState(() {
_mode = mode;
_identifier.clear();
}),
),
],
],
),
const SizedBox(height: 22),
// No label above it. The heading already said what to type, and a
// field captioned "Phone number" under a heading reading "Enter your
// mobile number" is the same sentence twice.
DmTextField(
label: _isPhone ? 'Phone number' : 'Email address',
controller: _identifier,
hint: _isPhone ? '98765 43210' : 'you@example.com',
prefix: _isPhone ? '+91' : null,
keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress,
keyboardType: _isPhone
? TextInputType.phone
: TextInputType.emailAddress,
maxLength: _isPhone ? 10 : 60,
digitsOnly: _isPhone,
autofocus: true,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(),
),
// Sits under the field it explains rather than floating below the
// button, where it read as a fourth thing to deal with.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'Your number stays private',
message: "We'll send a 4-digit code to keep your parcels secure.",
// ── The button belongs under the field, not at the foot of the page ──
//
// It was pinned to the bottom, and with the marketing copy and the
// privacy card gone there was nothing left to fill the space between —
// half a screen of nothing, with the field at the top and the button
// stranded at the bottom.
//
// Pinning solved a real problem: in the scroll area it was clipped by
// the footer when the keyboard came up. But that was a crowded screen.
// This one is a heading, a field and a button, all of which fit above
// the keyboard together — which is exactly where Uber, Bolt and Porter
// put them. The footer keeps only what the customer is not acting on.
const SizedBox(height: 18),
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
// ── The other way in, offered rather than asked ──
//
// Phone and Email were two chips at the top of the screen, which made
// choosing a method the first decision — before the customer had seen
// what was being asked for. Almost everyone uses the phone; the toggle
// taxed all of them to serve a few.
//
// It is one quiet line under the field now, in the place these apps
// put it, and it reads as a way out rather than a fork.
const SizedBox(height: 14),
Center(
child: DmTextAction(
label: _isPhone ? 'Use email instead' : 'Use mobile number instead',
onPressed: () => setState(() {
_mode = _isPhone ? LoginMode.email : LoginMode.phone;
_identifier.clear();
}),
),
),
// The privacy card that stood here is gone. It explained that a
// 4-digit code would be sent — which the next screen demonstrates a
// second later — and asserted that a number "stays private", which is
// a claim the privacy policy makes properly and a card cannot. A
// reassurance nobody asked for reads as something to be reassured
// about.
],
);
}

View File

@@ -1,16 +1,15 @@
import 'package:flutter/material.dart';
/// Named steps in the booking flow.
/// Named destinations 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.
/// The flow is one screen plus two escape hatches, so this is shorter than it
/// was: [send] is the booking, [pickup] is the map-pin editor behind "Change",
/// and the rest are where you land afterwards.
class BookingRoutes {
BookingRoutes._();
static const send = 'booking/send';
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';
}
@@ -22,22 +21,3 @@ Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
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

@@ -1,7 +1,6 @@
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';
@@ -220,24 +219,12 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
],
),
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.',
),
const DmInfoBanner(
icon: LucideIcons.package,
tone: DmTagTone.neutral,
title: 'Keep the parcel ready',
message: 'Sealed and labelled with the reference above.',
),
],
],
),

View File

@@ -1,406 +0,0 @@
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

@@ -10,10 +10,7 @@ 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.
///
@@ -60,10 +57,9 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
appBar: const DmTopBar(title: 'Pickup point'),
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(),
@@ -143,20 +139,15 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
edge: true,
children: [
DmButton(
label: 'Next',
icon: LucideIcons.arrowRight,
label: 'Use this address',
icon: LucideIcons.check,
// 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(),
),
);
Navigator.of(context).maybePop();
},
),
],

View File

@@ -1,377 +0,0 @@
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,506 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.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/inputs.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import '../../widgets/summary.dart';
import '../sheets/window_sheet.dart';
import 'booking_routes.dart';
import 'confirmed_screen.dart';
import 'pickup_location_screen.dart';
/// Send a parcel — the whole booking, on one screen.
///
/// ── Why this replaced four screens ──
///
/// Booking used to be pickup point → destination → window → review: four page
/// turns, three of which asked one question each, and a review step whose job
/// was to show you what you had just typed on the three screens before it. The
/// customer could not see what the send would cost until screen four, and could
/// not see the shape of what they were arranging at all.
///
/// It is one screen now. Everything the contract needs is here — where it is
/// going, the street address, who receives it, the window, how many packages —
/// and the price sits in the footer beside the button that agrees to it. The
/// two escape hatches stay as they were: the map pin editor for the pickup
/// point, and the window sheet for a slot other than the first.
///
/// The state hierarchy is flattened on the way in (see [AppState.loadCities]):
/// nobody picks "Tamil Nadu" on the way to picking "Chennai".
class SendScreen extends StatefulWidget {
const SendScreen({super.key});
@override
State<SendScreen> createState() => _SendScreenState();
}
class _SendScreenState extends State<SendScreen> {
final _building = TextEditingController();
final _street = TextEditingController();
final _landmark = TextEditingController();
final _recipient = TextEditingController();
final _phone = TextEditingController();
bool _busy = false;
/// Whether the optional block — landmark and recipient — is open.
///
/// Starts closed, and deliberately stays closed on a screen the customer
/// comes back to: reopening it because something is filled in would undo the
/// point of folding it away, and the summary row already says what is there.
bool _showMore = false;
/// How many of the three optional fields have something in them.
int _filledOptional() => [
_landmark,
_recipient,
_phone,
].where((c) => c.text.trim().isNotEmpty).length;
/// The one row that stands in for three fields.
///
/// Counts rather than lists, because the count is what a customer scrolling
/// past needs — "is anything in there?" — and the labels are one tap away.
String _optionalSummary() {
final n = _filledOptional();
if (n == 0) return 'Add landmark or recipient details';
return '$n of 3 extra details added';
}
@override
void dispose() {
for (final c in [_building, _street, _landmark, _recipient, _phone]) {
c.dispose();
}
super.dispose();
}
/// Everything the customer typed, onto the draft, in one move.
///
/// Written at book time rather than on every keystroke: none of it changes
/// the price or unlocks the button, so rebuilding the screen as someone types
/// their street name would be work with nothing to show for it.
void _commitDetails(AppState app) {
app.draftDestinations.first.details
..building = _clean(_building.text)
..street = _clean(_street.text)
..landmark = _clean(_landmark.text)
..recipientName = _clean(_recipient.text)
..recipientPhone = _phone.text.trim().isEmpty
? null
: '+91 ${_phone.text.trim()}';
}
static String? _clean(String v) {
final t = v.trim();
return t.isEmpty ? null : t;
}
Future<void> _book(AppState app) async {
_commitDetails(app);
setState(() => _busy = true);
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 window that filled up, or whose time passed while this screen was
// open, is not something the customer can fix from here — the only move
// is to pick again, so open the sheet on the fresh list rather than
// making them find it.
if (e.needsFreshSlots && mounted) {
await showWindowSheet(context);
}
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final pickup = app.draftPickup ?? app.pickup;
final group = app.draftDestinations.first;
final city = group.destination;
final slot = app.slotById(app.draftSlotId);
final fare = app.draftFare;
final hasCity = city.districtCode != null;
final ready = hasCity && app.draftSlotId != null && app.draftReady;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Send a parcel'),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// Where it comes from, settled before this screen opened.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
eyebrow: 'Collecting from',
title: pickup?.title ?? 'Set a pickup address',
subtitle: pickup?.sub,
trailing: DmTextAction(
label: 'Change',
onPressed: () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
),
),
),
],
),
const DmMicroHead('Sending to', top: 18),
_CityStrip(
selectedCode: city.districtCode,
onPick: (picked) {
app.selectCity(picked);
// The window is worth nothing without a destination and is
// almost always the first one, so it is chosen here rather than
// asked for — the row below says which, and stays changeable.
unawaited(_ensureSlot(app));
},
),
// ── Neutral from here down ──
//
// This head was crimson, and so were "Collecting from", "Sending to"
// and "Recipient" — four accent heads on one screen, above a wash
// card that is itself tinted. An accent used five times is not an
// accent; it flattens into decoration and stops pointing at anything.
// Crimson now marks one thing on this screen — the destination, which
// is the only choice that changes the price — and everything
// structural is quiet.
const DmMicroHead('Street address', top: 18, brand: false),
DmTextField(
label: 'Flat or house number',
controller: _building,
hint: '12B, Nehru Apartments',
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Street and area',
controller: _street,
hint: 'Anna Nagar West, 3rd Street',
textInputAction: TextInputAction.next,
),
// ── The optional half, folded away ──
//
// Landmark, recipient name and recipient phone are all optional, and
// all three were drawn at exactly the weight of the two fields that
// are not. That put six rows of "you may skip this" between the
// address and the button, so the common path — city, flat, street,
// book — scrolled past everything it did not need in order to reach
// the thing it did.
//
// They are one row now. It counts what has been filled in rather than
// just saying "optional", because a customer who has added a phone
// number and scrolled away needs to see that it is still there
// without opening anything.
const SizedBox(height: 14),
DmCard(
children: [
DmCardRow(
leading: Icon(
_showMore
? LucideIcons.chevronDown
: LucideIcons.chevronRight,
size: 16,
color: DmColors.ink3,
),
title: _optionalSummary(),
titleStyle: DmText.cardTitle.copyWith(
color: _filledOptional() == 0 ? DmColors.ink3 : null,
),
onTap: () => setState(() => _showMore = !_showMore),
),
],
),
if (_showMore) ...[
const SizedBox(height: 4),
DmTextField(
label: 'Landmark',
optional: true,
controller: _landmark,
hint: 'Near the bus stand',
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Recipient name',
optional: true,
controller: _recipient,
hint: 'Meera Anand',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Recipient phone',
optional: true,
controller: _phone,
hint: '90031 44518',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
mono: true,
textInputAction: TextInputAction.done,
),
],
const SizedBox(height: 10),
// The two facts that are already decided, as rows rather than steps.
DmCard(
children: [
DmCardRow(
leading: const Icon(
LucideIcons.clock,
size: 16,
color: DmColors.ink3,
),
title: slot == null ? 'Choose a pickup window' : slot.label,
titleStyle: slot == null
? DmText.cardTitle.copyWith(color: DmColors.ink3)
: null,
trailing: DmTextAction(
label: slot == null ? 'Choose' : 'Change',
onPressed: () async {
await showWindowSheet(context);
},
),
),
DmCardRow(
leading: const Icon(
LucideIcons.package,
size: 16,
color: DmColors.ink3,
),
title:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
trailing: DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
),
],
),
const SizedBox(height: 12),
DmNote(
child: Text(
'This books one Miler visit. Nothing is charged now — the final '
'amount is confirmed after the Miler weighs your parcel at the '
'door, and cancelling is free until then.',
style: DmText.small.copyWith(height: 1.5),
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
// The price sits directly above the button that agrees to it.
if (ready && fare != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(child: Text('Estimated total', style: DmText.small)),
const SizedBox(width: 12),
Text(
fare.range,
style: DmText.monoLg.copyWith(
fontSize: 20,
color: DmColors.brand,
),
),
],
),
),
DmButton(
label: switch (true) {
_ when !hasCity => 'Pick a city to see the price',
_ when app.draftSlotId == null => 'Choose a pickup window',
_ => 'Book pickup',
},
icon: ready ? LucideIcons.arrowRight : null,
busy: _busy,
busyLabel: 'Booking…',
onPressed: ready && !_busy ? () => _book(app) : null,
),
],
),
);
}
/// Picks the first window the backend still has, if none is chosen yet.
Future<void> _ensureSlot(AppState app) async {
if (app.draftSlotId != null) return;
try {
final slots = await app.loadSlots();
final first = slots.where((s) => s.available).firstOrNull;
if (first != null && mounted && app.draftSlotId == null) {
app.selectSlot(first);
}
} on ApiException {
// No windows is a state the row already renders; it is not an error to
// interrupt someone's typing over.
}
}
}
/// The serviceable cities, as one horizontal strip.
class _CityStrip extends StatelessWidget {
const _CityStrip({required this.selectedCode, required this.onPick});
final String? selectedCode;
final ValueChanged<CityOption> onPick;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
// The height belongs to the LIST, not to the section: an empty or errored
// strip renders a full-size state with a retry, and boxing that into 104pt
// overflowed it by 227.
return DmAsyncList<CityOption>(
key: const ValueKey('cityStrip'),
skeletonRows: 1,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',
emptyMessage:
"We're not accepting new pickups right now. Please check back "
'shortly.',
builder: (context, cities) => SizedBox(
// Grows with the type inside it. The card holds a glyph, a city and a
// promise; at 1.8x accessibility text a fixed 104 clipped the promise
// by four points.
height: 104 +
(MediaQuery.textScalerOf(context).scale(13.5) - 13.5) * 2,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: cities.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) => _CityCard(
city: cities[i],
selected: cities[i].district.code == selectedCode,
onTap: () => onPick(cities[i]),
),
),
),
);
}
}
class _CityCard extends StatelessWidget {
const _CityCard({
required this.city,
required this.selected,
required this.onTap,
});
final CityOption city;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
// Wide enough for the promise under the name — "Next-day delivery"
// ellipsized on every card at 110, which made the one fact that
// distinguishes two cities unreadable on both of them.
width: 132,
padding: const EdgeInsets.fromLTRB(11, 12, 11, 10),
decoration: BoxDecoration(
// A wash and a crimson edge rather than a fill: a strip of these is
// scanned side by side, and one solid block pulls the eye off the
// neighbours still being compared.
color: selected ? DmColors.brandSoft : DmColors.surface,
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(
selected ? LucideIcons.mapPin : LucideIcons.building2,
size: 22,
color: selected ? DmColors.brand : DmColors.ink3,
),
const Spacer(),
Text(
city.district.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(
fontSize: 13.5,
color: selected ? DmColors.brand : DmColors.ink,
),
),
if (city.note != null)
Text(
city.note!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny.copyWith(
fontSize: 11,
color: selected ? DmColors.brandPress : DmColors.ink3,
),
),
],
),
),
),
);
}
}

View File

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

View File

@@ -11,7 +11,7 @@ import '../widgets/chrome.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart';
import 'booking/pickup_location_screen.dart';
import 'booking/send_screen.dart';
import 'shell_screen.dart';
import 'sheets/place_search_sheet.dart';
import 'tracking_screen.dart';
@@ -32,10 +32,7 @@ class HomeScreen extends StatelessWidget {
void startBooking() {
AppScope.read(context).startBooking();
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
}

View File

@@ -0,0 +1,192 @@
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/feedback.dart';
import '../../widgets/option_tile.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
/// Pickup window.
///
/// ── Why a sheet and not a step ──
///
/// Choosing a two-hour window used to be its own screen in a four-screen flow,
/// which put a full page turn between "where is it going" and "what will it
/// cost". It is one choice from a short list, and the answer is almost always
/// the first row — so it belongs over the send screen, not in front of it.
///
/// The send screen shows the chosen window inline and only opens this when the
/// customer wants a different one.
Future<PickupSlot?> showWindowSheet(BuildContext context) {
return showDmSheet<PickupSlot>(
context: context,
builder: (context) => const _WindowSheet(),
);
}
class _WindowSheet extends StatefulWidget {
const _WindowSheet();
@override
State<_WindowSheet> createState() => _WindowSheetState();
}
class _WindowSheetState extends State<_WindowSheet> {
String? _day;
PickupSlot? _picked;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DmSheetHeader(
title: 'Pickup window',
subtitle: (app.draftPickup ?? app.pickup) == null
? null
: 'Collecting from ${(app.draftPickup ?? app.pickup)!.title}',
),
),
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
),
],
),
Flexible(
child: DmAsyncList<PickupSlot>(
reloadToken: app.slotsEpoch,
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
emptyIcon: LucideIcons.clock,
emptyTitle: 'No pickup windows left',
emptyMessage:
'Every window is 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);
}
final day = days.contains(_day) ? _day! : days.first;
final shown = slots.where((s) => s.day == day).toList();
final selected = _picked ?? _slotById(slots, app.draftSlotId);
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
Row(
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 8),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
],
],
),
const SizedBox(height: 16),
for (final slot in shown) ...[
_SlotRow(
slot: slot,
selected: selected?.id == slot.id,
onTap: () => setState(() => _picked = slot),
),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
DmButton(
label: selected == null
? 'Choose a window'
: 'Use ${selected.window}',
onPressed: selected == null
? null
: () {
app.selectSlot(selected);
Navigator.of(context).pop(selected);
},
),
const SizedBox(height: 4),
],
),
);
},
),
),
],
);
}
static PickupSlot? _slotById(List<PickupSlot> slots, String? id) {
if (id == null) return null;
for (final slot in slots) {
if (slot.id == id) return slot;
}
return null;
}
}
class _SlotRow extends StatelessWidget {
const _SlotRow({
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,
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,
),
],
trailing: enabled
? null
: DmTextAction(
label: 'Notify me',
color: DmColors.ink3,
onPressed: () =>
DmToast.show(context, "We'll let you know if it frees up"),
),
);
}
}

View File

@@ -18,7 +18,7 @@ import '../widgets/route_rail.dart';
import '../widgets/states.dart';
import '../widgets/summary.dart';
import 'booking/booking_routes.dart';
import 'booking/pickup_location_screen.dart';
import 'booking/send_screen.dart';
import 'sheets/cancel_sheet.dart';
/// Live tracking.
@@ -242,10 +242,7 @@ class TrackingScreen extends StatelessWidget {
onPressed: () {
app.startBooking();
Navigator.of(context).pushAndRemoveUntil(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
(route) => route.isFirst,
);
},

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.dart';
import '../tokens.dart';
import 'buttons.dart';
@@ -335,30 +334,6 @@ class DmBrandHeader extends StatelessWidget {
children: [
?leading,
const Spacer(),
// ── A build on dev data says so, on every screen ──
//
// Twice now a booking made offline has been hunted for
// in the admin console. The Account screen named the
// mode, but nobody goes to Account before booking. It
// belongs where the booking is made.
if (AppConfig.useDevData) ...[
Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0x80FFFFFF)),
borderRadius: DmRadius.all(5),
),
child: Text(
'OFFLINE DATA',
style: DmText.eyebrow.copyWith(
fontSize: 10,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(width: 10),
],
?action,
],
),

View File

@@ -18,7 +18,7 @@ import 'cards.dart';
class DmTextField extends StatefulWidget {
const DmTextField({
super.key,
required this.label,
this.label,
required this.controller,
this.hint,
this.optional = false,
@@ -34,7 +34,12 @@ class DmTextField extends StatefulWidget {
this.onClear,
});
final String label;
/// The caption above the field, or null for a field that stands alone.
///
/// Nullable because a screen whose heading already says what to type does
/// not need to say it again directly underneath — "Enter your mobile number"
/// above a field captioned "Phone number" is one sentence printed twice.
final String? label;
final TextEditingController controller;
final String? hint;
final bool optional;
@@ -85,23 +90,25 @@ class _DmTextFieldState extends State<DmTextField> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
if (widget.label != null) ...[
Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
),
),
const SizedBox(height: 8),
const SizedBox(height: 8),
],
AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,