Redesign: Poppins, a two-step destination, and a splash that says what the app does
The effort pass, end to end. Every screen was run through one test — if I remove this sentence, does the customer make a worse decision? — and the parts that failed it are gone. The flow Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked BOOK opens a sheet, not a form. The destination is browsed state-then-district because a flat list of every serviceable district survives twelve and not sixty, and search cuts across states because somebody who knows they are sending to Chennai should not have to know which state it is in. Districts multi-select, but only where the server allows it: BookingLimits advertises maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet that ignored that would sell a booking the network cannot complete. The pickup window is now a step the customer answers rather than a slot chosen for them. A pickup window is a promise about somebody's afternoon. What the screens stopped saying Home lost the orb caption for returning customers and a four-cell live card. Send lost the city strip, both address fields, the optional disclosure and three sentences about charging — the route, the packages and the button are what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone count, a "Step 2 of 7" and a sentence describing the screen you were looking at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed evening handover". Type Poppins, which has no variable release — four static cuts, and the sans styles set fontWeight alone because fontVariations on a static font is ignored in silence. Every weight dropped a step and the tracking went deeper: Poppins is built on near-circles and carries more ink than the humanist faces before it. Objects One lit sphere on Home, and the primary button now takes its gradient and rim because a committing action that is not lit like the hero reads as a different material. The tracking rail's connector is crimson as far as the parcel has come, so the line is the progress bar. Confirmation is a white tick on green: crimson is this app's action colour and that screen has nothing left to do. Bugs found on the way The OTP screen dropped digits. Four fields passing focus along lose a keystroke that arrives mid-transition, so "1234" became "124" and the screen answered "That code did not match" — blaming the customer for its own race. One field now, four boxes that only draw. Nothing ever asked for the customer's location: detectPickupLocation was the OTP screen's job, so a restored session or an auto-login never triggered the permission prompt and the pickup map had nothing to centre on. The launcher icon and both splash screens pointed at a house drawn as two vector paths — a placeholder that shipped. The splash clock started when the widget was built rather than when it was visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash. It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a binding that never reports one cannot strand the app. Also: design/screens/ holds all 19 screens under readable names, tool/ has the scripts that refresh them and rebrand the Lottie, and DESIGN.md is current. flutter analyze clean. 88 tests, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
@@ -15,9 +15,26 @@ class BookingRoutes {
|
||||
}
|
||||
|
||||
/// A page route tagged with its step name.
|
||||
///
|
||||
/// [DmPageRoute] rather than [MaterialPageRoute] so the flow's own transition
|
||||
/// gets the time it needs — see `_DmPageTransitions` in `main.dart`. Material's
|
||||
/// 300ms is tuned for a page that slides the full width; this one travels a
|
||||
/// short distance and reads as clipped at that speed.
|
||||
Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
|
||||
return MaterialPageRoute<T>(
|
||||
return DmPageRoute<T>(
|
||||
settings: RouteSettings(name: name),
|
||||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's page route. Same transition as every other push — it is the
|
||||
/// duration that differs from Material's.
|
||||
class DmPageRoute<T> extends MaterialPageRoute<T> {
|
||||
DmPageRoute({required super.builder, super.settings});
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 420);
|
||||
|
||||
@override
|
||||
Duration get reverseTransitionDuration => const Duration(milliseconds: 340);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,25 @@ import 'booking_routes.dart';
|
||||
/// 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.
|
||||
///
|
||||
/// ── The third pass: the tick left the red card ──
|
||||
///
|
||||
/// See the comment on the header below. Crimson is the app's action colour and
|
||||
/// this is the one screen with nothing left to do, so the payoff is a white
|
||||
/// tick on green, centred, with no box around it.
|
||||
///
|
||||
/// ── The second pass, 2026-09-22 ──
|
||||
///
|
||||
/// The crimson card still carried a two-line sentence — "We've reserved your
|
||||
/// slot. A nearby Miler is being assigned to collect your package." — under a
|
||||
/// heading that already said *Pickup booked*, and the searching row underneath
|
||||
/// was saying the second half of it again, live. The window took its place:
|
||||
/// after "did it work?", *when are they coming?* is the only question this
|
||||
/// screen is asked, and it was four surfaces down in a tinted strip.
|
||||
///
|
||||
/// The "Keep the parcel ready" banner went too. It is advice for later, on the
|
||||
/// screen where the customer is least likely to be still reading, and tracking
|
||||
/// repeats it when it matters.
|
||||
class ConfirmedScreen extends StatefulWidget {
|
||||
const ConfirmedScreen({super.key});
|
||||
|
||||
@@ -53,7 +72,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
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,
|
||||
@@ -61,52 +79,69 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
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,
|
||||
// ── The payoff is centred and unboxed ──
|
||||
//
|
||||
// It was a filled crimson card: a translucent disc holding a tick,
|
||||
// then "Pickup booked", then the window, all inside the same red
|
||||
// rectangle. Three problems. Crimson is the app's *action* colour —
|
||||
// the sphere, the button, the live tracking card — so a screen
|
||||
// whose entire job is "this is finished" was wearing the colour
|
||||
// that means "do something". A tick on a wash of its own background
|
||||
// reads as a watermark rather than a confirmation. And a card here
|
||||
// is a box drawn around the one thing on the screen that has
|
||||
// nothing to be separated from.
|
||||
//
|
||||
// A white tick on a green disc is what every app that has to say
|
||||
// "this worked" settles on, and green is already in this design
|
||||
// system for exactly one meaning: completed. It is used here for
|
||||
// the first time outside a delivered order, which is the only other
|
||||
// place the app gets to say the same word.
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
ScaleTransition(
|
||||
scale: CurvedAnimation(
|
||||
parent: _pop,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
child: Container(
|
||||
width: 76,
|
||||
height: 76,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.ok,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Color(0x3D0A6B56),
|
||||
blurRadius: 24,
|
||||
spreadRadius: -6,
|
||||
offset: Offset(0, 10),
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 24,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pickup booked',
|
||||
style: DmText.display.copyWith(color: Colors.white),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 38,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
Text('Pickup booked', style: DmText.display),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
slot == null
|
||||
? 'Your slot is reserved'
|
||||
: '${slot.day} · ${slot.window}',
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 18,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (booking != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
@@ -144,46 +179,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
],
|
||||
),
|
||||
),
|
||||
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(
|
||||
@@ -218,13 +213,6 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const DmInfoBanner(
|
||||
icon: LucideIcons.package,
|
||||
tone: DmTagTone.neutral,
|
||||
title: 'Keep the parcel ready',
|
||||
message: 'Sealed and labelled with the reference above.',
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../data/location_service.dart';
|
||||
import '../../../state/app_scope.dart';
|
||||
import '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
@@ -28,9 +29,22 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
/// True between the first touch on the map and the address coming back.
|
||||
bool _pinMoving = false;
|
||||
|
||||
/// Who the Miler asks for at this door.
|
||||
///
|
||||
/// Opens on the account's number, because that is the answer nearly every
|
||||
/// time and typing it again is work for nothing. It is a field rather than a
|
||||
/// line of text because it is often *not* the answer: the parcel is with a
|
||||
/// colleague, a shop, a parent — somebody who is actually at the address
|
||||
/// when the Miler arrives, and who the customer would otherwise have to
|
||||
/// explain in a remarks box nobody reads.
|
||||
final _contact = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final app = AppScope.read(context);
|
||||
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
|
||||
|
||||
// Arriving without a pin — ask the device where we are.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
@@ -41,6 +55,29 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contact.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The ten digits, whatever shape the number arrived in — the account's is
|
||||
/// "+91 98765 43210" and the field carries its own prefix.
|
||||
static String _digitsOf(String? phone) {
|
||||
final digits = (phone ?? '').replaceAll(RegExp(r'\D'), '');
|
||||
return digits.length <= 10 ? digits : digits.substring(digits.length - 10);
|
||||
}
|
||||
|
||||
/// Null when it is still the customer's own number, so the request carries
|
||||
/// the account rather than a copy of it that can drift.
|
||||
void _commitContact(AppState app) {
|
||||
final typed = _contact.text.trim();
|
||||
app.draftContactPhone =
|
||||
typed.isEmpty || typed == _digitsOf(app.customer?.phone)
|
||||
? null
|
||||
: '+91 $typed';
|
||||
}
|
||||
|
||||
Future<void> _search(AppState app) async {
|
||||
final picked = await showPlaceSearchSheet(context);
|
||||
if (picked == null || !mounted) return;
|
||||
@@ -113,6 +150,24 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
resolving: resolving,
|
||||
onChange: () => _search(app),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
16,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
child: DmTextField(
|
||||
label: 'Who the Miler asks for',
|
||||
controller: _contact,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -147,6 +202,7 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
|
||||
? null
|
||||
: () {
|
||||
app.draftPickup = place;
|
||||
_commitContact(app);
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,41 +5,63 @@ 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/destination_sheet.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.
|
||||
/// The rest of the booking, once the destination is known.
|
||||
///
|
||||
/// ── Why this replaced four screens ──
|
||||
/// ── What this screen stopped asking ──
|
||||
///
|
||||
/// 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 used to ask for the destination too, as a horizontal strip of cards
|
||||
/// halfway down, which meant it opened as a form the customer could not yet
|
||||
/// complete under a disabled button reading "Pick a city to see the price".
|
||||
/// The destination is chosen in a sheet before this screen exists, so this one
|
||||
/// opens with a price already on it.
|
||||
///
|
||||
/// 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.
|
||||
/// It also used to carry: a crimson head over each of four groups, a wash card
|
||||
/// with an icon tile and three lines for the pickup address, a "Street address"
|
||||
/// head above a field labelled "Flat or house number", a card holding the
|
||||
/// window beside the package count, and a three-sentence paragraph about
|
||||
/// charging. All of that said things that did not change what the customer was
|
||||
/// about to do.
|
||||
///
|
||||
/// The state hierarchy is flattened on the way in (see [AppState.loadCities]):
|
||||
/// nobody picks "Tamil Nadu" on the way to picking "Chennai".
|
||||
/// ── And then the address fields went too ──
|
||||
///
|
||||
/// "Flat or house number" and "Street and area" were the last two things this
|
||||
/// screen asked for, and they were asked on the screen where the customer has
|
||||
/// already decided and just wants to be finished. They are optional on the
|
||||
/// contract — every test that books without them has always passed — and the
|
||||
/// Miler gets the door from the recipient's phone number at the destination
|
||||
/// end, which is behind the one disclosure that remains.
|
||||
///
|
||||
/// [DeliveryDetails.building] and `.street` still exist and still go out on
|
||||
/// the request. They are simply left unset. If they come back, they belong on
|
||||
/// a screen of their own after the booking is made, not in front of it.
|
||||
///
|
||||
/// ── And then the disclosure went ──
|
||||
///
|
||||
/// "Add landmark or recipient details" folded three more optional fields away,
|
||||
/// which was an improvement on showing them and still one more thing between
|
||||
/// the customer and the button. The contact the Miler actually needs is the
|
||||
/// one at the *pickup* door, and that is asked for where the pickup is chosen,
|
||||
/// prefilled from the account. Per-destination recipient details belong to
|
||||
/// whoever is receiving, on a screen after the booking exists.
|
||||
///
|
||||
/// ── What is left ──
|
||||
///
|
||||
/// The route — where it comes from, where it goes, one row per destination.
|
||||
/// How many packages. And a footer carrying the two commitments, the window
|
||||
/// and the price, above the button that agrees to them.
|
||||
class SendScreen extends StatefulWidget {
|
||||
const SendScreen({super.key});
|
||||
|
||||
@@ -48,76 +70,19 @@ class SendScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
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()),
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.confirmed,
|
||||
(_) => const ConfirmedScreen(),
|
||||
),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
} on ApiException catch (e) {
|
||||
@@ -143,220 +108,89 @@ class _SendScreenState extends State<SendScreen> {
|
||||
final slot = app.slotById(app.draftSlotId);
|
||||
final fare = app.draftFare;
|
||||
|
||||
final hasCity = city.districtCode != null;
|
||||
final ready = hasCity && app.draftSlotId != null && app.draftReady;
|
||||
final ready = city.districtCode != null &&
|
||||
app.draftSlotId != null &&
|
||||
app.draftReady;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Send a parcel'),
|
||||
appBar: const DmTopBar(),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Where it comes from and where it goes, as one object.
|
||||
//
|
||||
// These were two separate blocks — an eyebrow, a 25pt crimson city
|
||||
// and a Change action, then a bordered line with a label, an
|
||||
// address and a second Change. Seven strings and two identical
|
||||
// words to say what a thread between two points says without any.
|
||||
_RouteHead(
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
to: [for (final g in app.draftDestinations) g.destination.label],
|
||||
onTapFrom: () => 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));
|
||||
),
|
||||
onTapTo: () async {
|
||||
final picked = await showDestinationSheet(context);
|
||||
if (picked != null && picked.isNotEmpty && context.mounted) {
|
||||
app.setDestinations(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// ── 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: 4),
|
||||
const SizedBox(height: 14),
|
||||
DmCard(
|
||||
// One row, no card around it: a card holding a single control is a
|
||||
// border drawn for its own sake.
|
||||
Row(
|
||||
children: [
|
||||
DmCardRow(
|
||||
leading: Icon(
|
||||
_showMore
|
||||
? LucideIcons.chevronDown
|
||||
: LucideIcons.chevronRight,
|
||||
size: 16,
|
||||
color: DmColors.ink3,
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 17,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.cardTitle,
|
||||
),
|
||||
title: _optionalSummary(),
|
||||
titleStyle: DmText.cardTitle.copyWith(
|
||||
color: _filledOptional() == 0 ? DmColors.ink3 : null,
|
||||
),
|
||||
onTap: () => setState(() => _showMore = !_showMore),
|
||||
),
|
||||
DmStepper(
|
||||
value: group.packageCount,
|
||||
min: 1,
|
||||
canIncrease: app.canAddPackageTo(0),
|
||||
onChanged: (v) => app.setPackageCount(0, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
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),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Was three sentences about weighing, charging and cancelling. The
|
||||
// customer is about to commit, so the only part that changes the
|
||||
// decision is that committing costs nothing yet.
|
||||
Text(
|
||||
'Nothing is charged now · Free cancellation',
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Commitments(
|
||||
window: slot == null ? null : '${slot.day} · ${slot.window}',
|
||||
price: fare?.range,
|
||||
onChangeWindow: () => showWindowSheet(context),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmButton(
|
||||
label: switch (true) {
|
||||
_ when !hasCity => 'Pick a city to see the price',
|
||||
_ when app.draftSlotId == null => 'Choose a pickup window',
|
||||
_ => 'Book pickup',
|
||||
},
|
||||
label: 'Book pickup',
|
||||
icon: ready ? LucideIcons.arrowRight : null,
|
||||
busy: _busy,
|
||||
busyLabel: 'Booking…',
|
||||
@@ -367,136 +201,133 @@ class _SendScreenState extends State<SendScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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});
|
||||
/// Where it comes from, where it goes, and both of them tappable.
|
||||
///
|
||||
/// Deliberately not [DmRouteRail]: that one is a read-only thread used on
|
||||
/// receipts, orders and tracking, and giving it tap targets would make every
|
||||
/// other use of it look pressable. This is two controls that happen to be
|
||||
/// drawn as a thread.
|
||||
///
|
||||
/// There are no "Change" labels. A chevron at the end of a row is the whole
|
||||
/// sentence, and this screen had two of the word stacked one above the other.
|
||||
class _RouteHead extends StatelessWidget {
|
||||
const _RouteHead({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onTapFrom,
|
||||
required this.onTapTo,
|
||||
});
|
||||
|
||||
final String? selectedCode;
|
||||
final ValueChanged<CityOption> onPick;
|
||||
final String from;
|
||||
|
||||
/// One row per destination. A pickup that fans out to three places is three
|
||||
/// rows on one thread, not "3 destinations" standing in for them — the whole
|
||||
/// point of the screen is that the customer can see what they arranged.
|
||||
final List<String> to;
|
||||
|
||||
final VoidCallback onTapFrom;
|
||||
final VoidCallback onTapTo;
|
||||
|
||||
@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]),
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_RouteRow(
|
||||
icon: LucideIcons.locateFixed,
|
||||
eyebrow: 'FROM',
|
||||
value: from,
|
||||
onTap: onTapFrom,
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < to.length; i++)
|
||||
_RouteRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
// Numbered only when there is more than one, because "TO 1" on a
|
||||
// single-destination booking is a count of one.
|
||||
eyebrow: to.length == 1 ? 'TO' : 'TO · ${i + 1}',
|
||||
value: to[i],
|
||||
onTap: onTapTo,
|
||||
strong: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CityCard extends StatelessWidget {
|
||||
const _CityCard({
|
||||
required this.city,
|
||||
required this.selected,
|
||||
class _RouteRow extends StatelessWidget {
|
||||
const _RouteRow({
|
||||
required this.icon,
|
||||
required this.eyebrow,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
this.strong = false,
|
||||
});
|
||||
|
||||
final CityOption city;
|
||||
final bool selected;
|
||||
final IconData icon;
|
||||
final String eyebrow;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// The destination is set in the brand colour: it is the only choice on this
|
||||
/// screen that moves the price.
|
||||
final bool strong;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
label: '$eyebrow $value',
|
||||
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,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: DmSpace.tap),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
selected ? LucideIcons.mapPin : LucideIcons.building2,
|
||||
size: 22,
|
||||
color: selected ? DmColors.brand : DmColors.ink3,
|
||||
icon,
|
||||
size: 18,
|
||||
color: strong ? 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,
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eyebrow, style: DmText.eyebrow),
|
||||
Text(
|
||||
value,
|
||||
// One weight down from where this started. At w700
|
||||
// crimson, five destinations stacked read as five
|
||||
// headings — the page shouted its own contents back. A
|
||||
// list of places the customer chose is a list, not a
|
||||
// series of titles.
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (city.note != null)
|
||||
Text(
|
||||
city.note!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.tiny.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? DmColors.brandPress : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -504,3 +335,83 @@ class _CityCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The two things the button is about to agree to, side by side above it.
|
||||
///
|
||||
/// The window used to be a row in a card in the middle of the form and the
|
||||
/// price used to appear in the footer only once everything was valid, which
|
||||
/// meant the customer had to scroll back up to check what time they had been
|
||||
/// given. Both live here now, and the left half is tappable.
|
||||
class _Commitments extends StatelessWidget {
|
||||
const _Commitments({
|
||||
required this.window,
|
||||
required this.price,
|
||||
required this.onChangeWindow,
|
||||
});
|
||||
|
||||
final String? window;
|
||||
final String? price;
|
||||
final VoidCallback onChangeWindow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onChangeWindow,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('PICKUP', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
window ?? 'Choose a window',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: window == null ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('ESTIMATED', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
price ?? '—',
|
||||
style: DmText.monoLg.copyWith(
|
||||
fontSize: 18,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user