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:
2026-09-22 17:45:54 +05:30
parent 8207e27a97
commit 06fa6b797a
107 changed files with 4597 additions and 2071 deletions

View File

@@ -71,7 +71,7 @@ class AccountScreen extends StatelessWidget {
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
children: [
const DmMicroHead('Preferences', top: 16),
const DmMicroHead('Preferences', top: 16, brand: false),
DmRowGroup(
children: [
DmRow(

View File

@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../../data/app_config.dart';
import '../../tokens.dart';
import '../../widgets/chrome.dart';
@@ -177,6 +179,33 @@ class AuthScaffold extends StatelessWidget {
),
child: footer!,
),
// Which build this is, on the one screen that cannot
// reach the Account panel that normally says so.
//
// The offline build and the real one are identical
// here, and the difference decides whether 1234 signs
// you in or the server refuses it — which has cost two
// rounds of "the code does not work" already. Debug
// only: `kReleaseMode` takes it out of anything that
// could ship.
if (!kReleaseMode)
Padding(
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
6 + _lift,
),
child: Text(
AppConfig.describe,
textAlign: TextAlign.center,
style: DmText.monoSm.copyWith(
fontSize: 10,
color: DmColors.ink4,
),
maxLines: 2,
),
),
],
),
),

View File

@@ -43,29 +43,39 @@ class OtpScreen extends StatefulWidget {
class _OtpScreenState extends State<OtpScreen> {
late OtpChallenge _challenge = widget.challenge;
late final _boxes = List.generate(
_challenge.codeLength,
(_) => TextEditingController(),
);
late final _focus = List.generate(
_challenge.codeLength,
(_) => FocusNode(),
);
// ── One field, four boxes that only draw ──
//
// This was four separate `TextField`s, each holding one digit and moving
// focus to the next on change. It dropped digits. Type "1234" at any speed a
// real thumb manages and a keystroke lands while focus is still in flight
// between two boxes: it either goes nowhere, or it arrives in the box you
// just left, making that box's value two characters long — which the handler
// read as a *paste* and used to overwrite the whole row. The code ended up
// three digits, and three digits is refused with "That code did not match",
// so the screen blamed the customer for its own bug.
//
// There is one controller now. The boxes below are display: they read a
// character out of [_code] and draw it. Nothing moves focus, so nothing can
// be dropped between two things that have it. Paste works because pasting
// into one field is just text, and [AutofillHints.oneTimeCode] can fill it
// from an SMS for the same reason.
final _code = TextEditingController();
final _node = FocusNode();
Timer? _ticker;
late int _resendIn = _challenge.resendAfterSeconds;
bool _verifying = false;
int get _length => _boxes.length;
int get _length => _challenge.codeLength;
String get _code => _boxes.map((c) => c.text).join();
bool get _complete => _code.length == _length;
String get _digits => _code.text;
bool get _complete => _digits.length == _length;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus());
WidgetsBinding.instance.addPostFrameCallback((_) => _node.requestFocus());
}
void _startResendTimer() {
@@ -82,12 +92,8 @@ class _OtpScreenState extends State<OtpScreen> {
@override
void dispose() {
_ticker?.cancel();
for (final c in _boxes) {
c.dispose();
}
for (final f in _focus) {
f.dispose();
}
_code.dispose();
_node.dispose();
super.dispose();
}
@@ -120,7 +126,7 @@ class _OtpScreenState extends State<OtpScreen> {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.verifyOtp(widget.identifier, _code, name: widget.name);
await app.verifyOtp(widget.identifier, _digits, name: widget.name);
if (!mounted) return;
app.detectPickupLocation();
unawaited(HapticFeedback.mediumImpact());
@@ -131,27 +137,13 @@ class _OtpScreenState extends State<OtpScreen> {
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _verifying = false);
for (final c in _boxes) {
c.clear();
}
_focus.first.requestFocus();
_code.clear();
_node.requestFocus();
DmToast.show(context, e.message);
}
}
/// Accepts a pasted code by spreading it across the boxes.
void _onChanged(int index, String value) {
if (value.length > 1) {
final digits = value.replaceAll(RegExp(r'\D'), '');
for (var i = 0; i < _boxes.length; i++) {
_boxes[i].text = i < digits.length ? digits[i] : '';
}
_focus[digits.length.clamp(0, _length - 1)].requestFocus();
} else if (value.isNotEmpty && index < 3) {
_focus[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focus[index - 1].requestFocus();
}
void _onChanged(String _) {
setState(() {});
if (_complete && !_verifying) _verify();
}
@@ -221,18 +213,50 @@ class _OtpScreenState extends State<OtpScreen> {
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Row(
child: Stack(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
controller: _boxes[i],
focusNode: _focus[i],
onChanged: (v) => _onChanged(i, v),
Row(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
digit: i < _digits.length ? _digits[i] : '',
// The box the next digit will land in, which is the
// one a cursor would be sitting in. Focus is on the
// field underneath, not on any box.
active: _node.hasFocus && i == _digits.length,
),
),
],
],
),
// The real field, over the top and invisible. Over, so a tap
// anywhere on the row opens the keyboard; invisible, because
// the boxes are already drawing everything it holds.
Positioned.fill(
child: Opacity(
opacity: 0,
child: TextField(
controller: _code,
focusNode: _node,
onChanged: _onChanged,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.oneTimeCode],
enableInteractiveSelection: false,
showCursor: false,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(_length),
],
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
),
),
),
],
),
],
),
),
@@ -273,57 +297,35 @@ class _OtpScreenState extends State<OtpScreen> {
}
}
/// One digit of the code.
/// One digit of the code — a drawing, not a field.
///
/// Was a filled `TextField` with no border, which read as a disabled field:
/// four grey slabs with nothing to say which one you were typing into. It is
/// the only thing on the screen, so it carries the state instead — empty is
/// outlined, focused takes the brand edge and lifts, filled tints. The
/// `TextField` inside is bare; the container owns every visual.
class _OtpBox extends StatefulWidget {
const _OtpBox({
required this.controller,
required this.focusNode,
required this.onChanged,
});
/// It used to be a real `TextField`, one of four, and the focus dance between
/// them is what dropped digits. It holds no controller and no focus node now:
/// [digit] is what to show and [active] is whether the next keystroke lands
/// here. Everything typed lives in the single field stacked over the row.
///
/// The states are unchanged — empty is outlined, active takes the brand edge
/// and lifts, filled tints — because it is still the only thing on the screen
/// and still has to carry them.
class _OtpBox extends StatelessWidget {
const _OtpBox({required this.digit, required this.active});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
@override
State<_OtpBox> createState() => _OtpBoxState();
}
class _OtpBoxState extends State<_OtpBox> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocus);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocus);
super.dispose();
}
void _onFocus() => setState(() {});
final String digit;
final bool active;
@override
Widget build(BuildContext context) {
final focused = widget.focusNode.hasFocus;
final filled = widget.controller.text.isNotEmpty;
final filled = digit.isNotEmpty;
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
height: 60,
decoration: BoxDecoration(
color: filled && !focused ? DmColors.brandSoft : DmColors.surface,
color: filled && !active ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(
color: focused
color: active
? DmColors.brand
: filled
? DmColors.brandLine
@@ -331,7 +333,7 @@ class _OtpBoxState extends State<_OtpBox> {
),
// The focus ring, exactly as the fields draw it — this is the same
// control, one digit wide.
boxShadow: focused
boxShadow: active
? const [
BoxShadow(
color: DmColors.brandSoft,
@@ -341,29 +343,8 @@ class _OtpBoxState extends State<_OtpBox> {
]
: null,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
cursorColor: DmColors.brand,
cursorWidth: 2,
showCursor: true,
style: DmText.otp,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
counterText: '',
isDense: true,
filled: false,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
alignment: Alignment.center,
child: Text(digit, style: DmText.otp),
);
}
}

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';
@@ -82,8 +80,11 @@ class _SignUpScreenState extends State<SignUpScreen> {
// sign up are one doorway with two doors, so they cannot be laid out
// differently — a customer who bounces between them should see the
// screen change its words, not its shape.
heroTitle: 'Create your account.',
heroSub: 'A few details and you can book your first pickup.',
// One line, no supporting sentence. "A few details and you can book
// your first pickup" described the three fields directly underneath it,
// on a screen whose whole content is those three fields.
heroTitle: 'Create your account',
badge: false,
// Pinned for the same reason as sign in: a three-field form plus the
// keyboard leaves no room, and the CTA must not be the thing that loses.
footer: Column(
@@ -106,17 +107,22 @@ class _SignUpScreenState extends State<SignUpScreen> {
],
),
children: [
// Floating labels, and no hints behind them: a caption above an empty
// field and an example inside it are two ways of asking the same
// question, and three of those stacked is six rows of chrome around
// three answers. The label sits where the answer will go and floats
// out once there is something to caption.
DmTextField(
label: 'Full name',
floating: true,
controller: _name,
hint: 'Joe Oommen',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Phone number',
floating: true,
controller: _phone,
hint: '98765 43210',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
@@ -125,21 +131,24 @@ class _SignUpScreenState extends State<SignUpScreen> {
),
DmTextField(
label: 'Email',
floating: true,
optional: true,
controller: _email,
hint: 'you@example.com',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
),
// The same reassurance sign in carries, in the same wash card, under
// the field it is about.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'We verify every number',
message: "A 4-digit code confirms it's you and keeps your parcels "
'secure.',
),
// ── The reassurance banner is gone ──
//
// "We verify every number · A 4-digit code confirms it's you and keeps
// your parcels secure." It was the last thing in a scrolling list
// under a pinned footer, so on a 720p phone the footer cut it in half
// and the customer read "…keeps your parcels secu". A reassurance that
// is clipped reassures nobody.
//
// It also answered a question nobody had yet: the customer finds out
// about the code on the next screen, which is called "Verify your
// number" and says so.
],
);
}

View File

@@ -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);
}

View File

@@ -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.',
),
],
],
),

View File

@@ -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();
},
),

View File

@@ -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,
),
),
],
),
],
);
}
}

View File

@@ -1,41 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/book_orb.dart';
import '../widgets/chrome.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart';
import 'booking/send_screen.dart';
import 'shell_screen.dart';
import 'sheets/destination_sheet.dart';
import 'sheets/place_search_sheet.dart';
import 'sheets/window_sheet.dart';
import 'tracking_screen.dart';
/// Home — one question, one dominant action, and whatever is live right now.
/// Nothing else competes; history belongs to the Orders tab.
class HomeScreen extends StatelessWidget {
/// Home — two questions, and nothing else on the screen.
///
/// *Can I book?* The sphere.
/// *Is anything happening?* One row, when there is.
///
/// ── What was removed ──
///
/// A two-line caption under the sphere that a returning customer had read a
/// hundred times; a four-cell card for the live booking carrying a status pill,
/// a reference, a route rail, a courier sentence, a window and a Track button —
/// six facts where the customer wanted two; and a three-row explainer with a
/// subtitle under every row.
///
/// The caption is first-run only now and one line. The live booking is one row.
/// The explainer lost its subtitles. Home went from roughly sixty words to
/// under twenty for a new customer, and to five for a returning one.
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
/// True while the destination sheet is up.
///
/// Home does not just sit behind the scrim — it scales back a little and
/// fades, so the sheet reads as having been pushed up by the sphere rather
/// than as a new screen that arrived over the top of an unrelated one.
bool _handingOver = false;
/// BOOK. The one action on this screen, and the whole flow's front door.
///
/// Two questions are asked here, as sheets over Home, before any form:
///
/// where is it going? → when shall we collect? → the details
///
/// Both used to be answered somewhere else. The destination was a strip of
/// cards halfway down the form; the window was picked *for* the customer,
/// silently, the moment they chose a city — they arrived at a booking with a
/// time on it that they had never agreed to. A pickup window is a promise
/// about somebody's afternoon, and it is not ours to assume.
///
/// Dismissing the window sheet is allowed: the send screen carries "Choose a
/// window" in the footer and its button stays disabled until there is one.
Future<void> _book() async {
final app = AppScope.read(context);
final navigator = Navigator.of(context);
setState(() => _handingOver = true);
try {
final places = await showDestinationSheet(context);
if (places == null || places.isEmpty || !mounted) return;
app.startBooking();
app.setDestinations(places);
await showWindowSheet(context);
if (!mounted) return;
await navigator.push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
} finally {
if (mounted) setState(() => _handingOver = false);
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
final firstRun = app.orders.isEmpty;
// The pickup point is already confirmed on this screen, so booking starts
// at the destination — two taps from here to a reserved slot.
void startBooking() {
AppScope.read(context).startBooking();
Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -45,7 +100,10 @@ class HomeScreen extends StatelessWidget {
action: InkResponse(
onTap: () => ShellScreen.of(context)?.goToTab(2),
radius: 26,
child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true),
child: DmAvatar(
initials: initialsOf(app.customer?.name),
onBrand: true,
),
),
// The address is the one fact every booking starts from, so it sits
// in the header as a live value the customer can change in place —
@@ -70,23 +128,62 @@ class HomeScreen extends StatelessWidget {
),
),
Expanded(
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([app.detectPickupLocation(), app.refreshOrders()]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
children: [
_BookCta(onTap: startBooking),
if (active != null) ...[
const DmMicroHead('Happening now'),
_ActiveCard(booking: active),
],
if (firstRun) const _HowItWorks(),
],
child: AnimatedScale(
scale: _handingOver ? 0.96 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: AnimatedOpacity(
opacity: _handingOver ? 0.45 : 1,
duration: DmMotion.base,
curve: DmMotion.ease,
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([
app.detectPickupLocation(),
app.refreshOrders(),
]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
16,
DmSpace.pad,
28,
),
children: [
const SizedBox(height: 6),
DmBookOrb(
onTap: _book,
held: _handingOver,
// A returning customer knows what BOOK does. The line is
// for the first run only, and one line is enough to say
// what kind of app this is.
caption: firstRun
? 'A Miler collects from your door.'
: null,
).animate().fadeIn(duration: 420.ms).scale(
begin: const Offset(0.82, 0.82),
end: const Offset(1, 1),
duration: 620.ms,
// A shade of overshoot, so it arrives rather than
// appears.
curve: Curves.easeOutBack,
),
if (active != null)
Padding(
padding: const EdgeInsets.only(top: 22),
child: _LiveRow(booking: active),
).animate().fadeIn(duration: 380.ms, delay: 160.ms).slideY(
begin: 0.12,
curve: DmMotion.ease,
),
if (firstRun) const _HowItWorks(),
],
),
),
),
),
),
@@ -100,96 +197,89 @@ class HomeScreen extends StatelessWidget {
}
}
/// The screen's one action, as a filled crimson card.
/// The live booking, as one row.
///
/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object,
/// which on first run told a new customer nothing about what the app does. The
/// card says what will happen ("a Miler collects from your door in a window you
/// choose") and puts the action inside it, so the promise and the button are
/// one object rather than a shout followed by an explanation elsewhere.
class _BookCta extends StatelessWidget {
const _BookCta({required this.onTap});
/// This was a card with four cells. A customer glancing at Home wants two
/// things from it — *what is happening* and *how long* — and everything else it
/// carried was available one tap away on the screen this row opens. The route,
/// the reference and the package count all went back there.
class _LiveRow extends StatelessWidget {
const _LiveRow({required this.booking});
final VoidCallback onTap;
final Booking booking;
@override
Widget build(BuildContext context) {
return DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Book a pickup',
style: DmText.heading.copyWith(fontSize: 19, color: Colors.white),
),
const SizedBox(height: 4),
Text(
'A Miler collects from your door in a window you choose.',
style: DmText.small.copyWith(color: DmColors.onBrand2),
),
const SizedBox(height: 14),
// White on crimson: inside a filled card the primary action has
// to invert, or it disappears into its own background.
_WhiteButton(label: 'Start booking', onTap: onTap),
],
),
),
],
);
}
}
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
class _WhiteButton extends StatefulWidget {
const _WhiteButton({required this.label, required this.onTap});
// One supporting line, chosen by what the customer can act on right now:
// before the handover that is when the Miler is coming, after it that is
// where the parcel is going.
final support = switch (booking.stage) {
JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay
when slot != null =>
'${slot.day} · ${slot.window}',
JourneyStage.arrived => 'At your door now',
_ =>
booking.isMultiDestination
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
};
final String label;
final VoidCallback onTap;
@override
State<_WhiteButton> createState() => _WhiteButtonState();
}
class _WhiteButtonState extends State<_WhiteButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
// A minimum, not a height: at large accessibility text the label
// needs the extra line, and a fixed 46 would clip it.
constraints: const BoxConstraints(minHeight: 46),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
),
);
},
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
decoration: BoxDecoration(
color: _down ? DmColors.brandSoft : Colors.white,
borderRadius: DmRadius.all(DmRadius.sm),
color: DmColors.brandSoft,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: DmColors.brandLine),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.label,
textAlign: TextAlign.center,
style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand),
const DmLiveDot(size: 7),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
booking.stage.milestoneLabel,
style: DmText.cardTitle.copyWith(
color: DmColors.brandPress,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
support,
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 9),
const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand),
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.brand,
),
],
),
),
@@ -198,15 +288,19 @@ class _WhiteButtonState extends State<_WhiteButton> {
}
}
/// Shown on first run only: three numbered rows that match the booking flow
/// exactly, so the first send has no surprises in it.
/// How it works, on first run only.
///
/// This was a bordered card holding three numbered rows, each with a subtitle.
/// It is a timeline now — glyph, hairline, title — and the subtitles are gone:
/// they described the step the title had already named, on the one screen where
/// a new customer is least willing to read.
class _HowItWorks extends StatelessWidget {
const _HowItWorks();
static const _steps = [
('Tell us where it goes', 'City, address and recipient'),
('Pick a two-hour window', 'With live Miler availability'),
('Hand it over and track', 'Door to hub to doorstep, with proof'),
(LucideIcons.mapPin, 'Tell us where it goes'),
(LucideIcons.clock3, 'Pick a two-hour window'),
(LucideIcons.packageCheck, 'Hand it over and track'),
];
@override
@@ -214,129 +308,69 @@ class _HowItWorks extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmMicroHead('How it works'),
DmCard(
children: [
for (var i = 0; i < _steps.length; i++)
DmCardRow(
leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand),
title: _steps[i].$1,
subtitle: _steps[i].$2,
),
],
),
const SizedBox(height: 30),
for (var i = 0; i < _steps.length; i++)
_Step(
icon: _steps[i].$1,
title: _steps[i].$2,
last: i == _steps.length - 1,
),
],
).animate(delay: 320.ms).fadeIn(duration: 520.ms).slideY(
begin: 0.06,
curve: DmMotion.ease,
);
}
}
class _ActiveCard extends StatelessWidget {
const _ActiveCard({required this.booking});
class _Step extends StatelessWidget {
const _Step({required this.icon, required this.title, required this.last});
final Booking booking;
final IconData icon;
final String title;
final bool last;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index;
final windowLabel = slot == null
? 'Pickup scheduled'
: '${slot.day} · ${slot.window}';
final courierLine = switch (booking.stage) {
JourneyStage.booked => 'Matching the nearest Miler',
JourneyStage.assigned when booking.miler != null =>
'${booking.miler!.name} will collect your parcel',
JourneyStage.onTheWay when booking.miler != null =>
'${booking.miler!.name} is on route'
'${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}',
JourneyStage.arrived when booking.miler != null =>
'${booking.miler!.name} is at your door',
_ =>
booking.isMultiDestination
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
};
return DmCard(
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
);
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(booking.stage.milestoneLabel)),
const SizedBox(width: 10),
Expanded(
child: Text(
booking.displayId,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// The rail: a washed disc per step, joined by a hairline that runs
// the height of that step's text. Sized by [IntrinsicHeight] so the
// connector always meets the next disc, whatever the text wraps to.
SizedBox(
width: 34,
child: Column(
children: [
Container(
width: 34,
height: 34,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: DmColors.brandSoft,
),
child: Icon(icon, size: 15, color: DmColors.brand),
),
),
],
),
),
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
),
const SizedBox(height: 12),
Text(courierLine, style: DmText.small),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
Icon(
beforeHandover ? LucideIcons.clock : LucideIcons.truck,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
beforeHandover ? windowLabel : 'On its way',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Track',
icon: LucideIcons.arrowRight,
onPressed: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
if (!last)
const Expanded(
child: SizedBox(
width: 1,
child: ColoredBox(color: DmColors.border),
),
);
},
),
],
),
],
),
),
),
],
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 8, bottom: last ? 0 : 20),
child: Text(title, style: DmText.cardTitle),
),
),
],
),
);
}
}

View File

@@ -17,13 +17,19 @@ import 'tracking_screen.dart';
/// its own tracking number and journey, so it gets its own card with a quiet
/// line back to the visit they were collected in.
///
/// ── Status first, reference beside it ──
/// ── Status first, reference quiet ──
///
/// The state and the identifier sit together in a washed strip across the top,
/// and the route sits underneath. A customer scanning this list is asking one
/// question — what is happening to my parcel — and the answer is now the first
/// thing on every card, at the same height on each, instead of being a pill
/// tucked under a route of variable length.
/// A customer scanning this list is asking one question — what is happening to
/// my parcel — so the state is the first thing on every card, at the same
/// height on each.
///
/// The reference used to share a crimson-washed strip with it, which gave a
/// string nobody reads while scanning the same weight as the only word they do.
/// It is a quiet identifier on the same line now, and the strip is gone: one
/// cell, not two.
///
/// "Collected on DM-471200" went with it. It explained why two orders share a
/// pickup, which is a question asked on the order, not in a list of them.
class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.entry});
@@ -62,12 +68,8 @@ class OrderCard extends StatelessWidget {
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
final meta = [
'$packages ${packages == 1 ? 'package' : 'packages'}',
if (entry.isPickup && booking.isMultiDestination)
'${booking.destinations.length} destinations',
if (booking.status == BookingStatus.completed && booking.amountPaid != null)
'₹${booking.amountPaid} paid'
else
dateLabel(booking.createdAt),
'₹${booking.amountPaid} paid',
].join(' · ');
return DmCard(
@@ -88,51 +90,59 @@ class OrderCard extends StatelessWidget {
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(label, tone: tone)),
const SizedBox(width: 10),
Expanded(
child: Text(
reference,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
// The date belongs at the right edge, on the same line on
// every card, so the eye can run down the column. Left to the
// default packing it sits wherever the status text happens to
// end, which is a different place on all four.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Flexible, not Expanded: the pill is sized by its own text
// and only gives way if the row runs out of room. Expanded
// made it fill every spare pixel, which drew "In transit"
// as a 600pt bar with one word in it. Expanding the *date*
// instead — the version before that — clipped the only word
// on the row that matters, to make space for "Yesterday".
// Neither of them is flexible; the pill is.
Flexible(child: DmPill(label, tone: tone)),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
dateLabel(booking.createdAt),
maxLines: 1,
style: DmText.tiny,
),
),
],
),
const SizedBox(height: 13),
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: drop,
),
const SizedBox(height: 11),
Text(meta, style: DmText.tiny),
if (entry.collectedTogether) ...[
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'Collected on '),
TextSpan(
text: booking.reference,
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
Row(
// The date belongs at the right edge, on the same line on
// every card, so the eye can run down the column. Left to the
// default packing it sits wherever the status text happens to
// end, which is a different place on all four.
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(meta, style: DmText.tiny)),
const SizedBox(width: 10),
Text(
reference,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
),
],
],
),
],
),
),

View File

@@ -0,0 +1,446 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../state_marks.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/states.dart';
/// "Where is it going?" — the first and only question after BOOK.
///
/// ── State, then district ──
///
/// It was one flat list of every serviceable district, on the argument that
/// "nobody picks Tamil Nadu on the way to picking Chennai". True of twelve
/// districts and false of sixty: the list this app will have in a year is a
/// scroll, not a glance.
///
/// Browsing is two steps, and the **states carry the marks**. Districts do not:
/// at state level a mark is a bounded editorial problem, and at district level
/// it is an opinion about every district in India that nobody can keep honest.
///
/// **Search cuts straight through.** Typing at the first step searches
/// districts across every state and returns them flat — somebody who knows
/// they are sending to Chennai should not have to know which state it is in.
///
/// ── Several places in one pickup, but only when the server says so ──
///
/// One Miler visit can collect for more than one destination, and this sheet
/// is where that is chosen: districts toggle, the selection survives moving
/// between states, and the footer says how many are in.
///
/// It is single-select whenever [BookingLimits.allowsMultipleDestinations] is
/// false, which is what the live backend advertises today. That is not a
/// nicety. Multi-destination is undeliverable until the Miler build keys its
/// work on `consignmentid`, so a sheet that let a customer pick three places
/// against a server capped at one would sell them a booking the network cannot
/// complete. The cap is read from the server on every launch and this sheet
/// obeys it: one place means one tap and no confirm button, because a
/// "Continue" under a single-select list asks the customer to say the same
/// thing twice.
Future<List<CityOption>?> showDestinationSheet(BuildContext context) {
return showDmSheet<List<CityOption>>(
context: context,
builder: (context) => const _DestinationSheet(),
);
}
class _DestinationSheet extends StatefulWidget {
const _DestinationSheet();
@override
State<_DestinationSheet> createState() => _DestinationSheetState();
}
class _DestinationSheetState extends State<_DestinationSheet> {
final _query = TextEditingController();
String _q = '';
/// The state being browsed, or null while the states themselves are.
ServiceArea? _openState;
/// Chosen districts, by district code, in the order they were picked.
final _chosen = <String, CityOption>{};
@override
void dispose() {
_query.dispose();
super.dispose();
}
void _open(ServiceArea area) {
HapticFeedback.selectionClick();
setState(() {
_openState = area;
_query.clear();
_q = '';
});
}
void _back() {
setState(() {
_openState = null;
_query.clear();
_q = '';
});
}
void _tap(CityOption city, {required bool multi, required int cap}) {
HapticFeedback.selectionClick();
if (!multi) {
Navigator.of(context).pop([city]);
return;
}
final code = city.district.code;
setState(() {
if (_chosen.remove(code) != null) return;
if (_chosen.length >= cap) {
DmToast.show(
context,
'One pickup can serve up to $cap places',
);
return;
}
_chosen[code] = city;
});
}
/// The states, derived from the district list rather than fetched.
///
/// `loadCities` already returns every *serviceable* district with its state
/// attached, so grouping it is one request instead of two and is the only
/// version that cannot show a state with nothing open inside it.
List<ServiceArea> _statesOf(List<CityOption> cities) {
final seen = <String, ServiceArea>{};
for (final city in cities) {
seen.putIfAbsent(city.state.code, () => city.state);
}
return seen.values.toList();
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final browsing = _openState;
final cap = app.limits.maxDestinations;
final multi = app.allowsMultipleDestinations;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back inside the sheet, not out of it: the customer is one level
// into a question they are still answering, and dismissing the
// whole thing to change their mind about a state would throw away
// both the step they got right and everything they have ticked.
if (browsing != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: InkResponse(
onTap: _back,
radius: 22,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const SizedBox(
width: 36,
height: 36,
child: Icon(
LucideIcons.arrowLeft,
size: 20,
color: DmColors.ink,
),
),
),
),
Expanded(
child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?',
),
),
],
),
DmSearchField(
controller: _query,
hint: browsing == null
? 'Search for a city'
: 'Search in ${browsing.name}',
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: 6),
Flexible(
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
skeletonRows: 4,
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) => SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
..._body(cities, multi: multi, cap: cap),
const SizedBox(height: 4),
],
),
),
),
),
if (multi && _chosen.isNotEmpty) ...[
const SizedBox(height: 10),
DmButton(
label: _chosen.length == 1
? 'Send to ${_chosen.values.first.district.name}'
: 'Send to ${_chosen.length} places',
icon: LucideIcons.arrowRight,
onPressed: () =>
Navigator.of(context).pop(_chosen.values.toList()),
),
],
],
);
}
List<Widget> _body(
List<CityOption> cities, {
required bool multi,
required int cap,
}) {
final browsing = _openState;
Widget district(CityOption city) => _DistrictRow(
city: city,
multi: multi,
chosen: _chosen.containsKey(city.district.code),
onTap: () => _tap(city, multi: multi, cap: cap),
);
// Searching from the top level goes straight to districts, across every
// state. Searching inside one stays inside it.
if (_q.isNotEmpty) {
final pool = browsing == null
? cities
: cities.where((c) => c.state.code == browsing.code);
final matches = pool
.where((c) => c.label.toLowerCase().contains(_q))
.toList();
if (matches.isEmpty) {
return const [
DmEmptyState(
icon: LucideIcons.searchX,
title: 'No match',
message: 'Try another city.',
),
];
}
return [for (final city in matches) district(city)];
}
if (browsing != null) {
return [
for (final city in cities.where((c) => c.state.code == browsing.code))
district(city),
];
}
return [
for (final area in _statesOf(cities))
_StateRow(
area: area,
// The count is the reason to open it, and it is free: the list it
// came from is the list the next step shows.
count: cities.where((c) => c.state.code == area.code).length,
// How many of this state's districts are already in, so a customer
// who has ticked two in Tamil Nadu can see that from the top.
chosen: cities
.where(
(c) =>
c.state.code == area.code &&
_chosen.containsKey(c.district.code),
)
.length,
onTap: () => _open(area),
),
];
}
}
/// One state: its mark, its name, and how many cities are open inside it.
class _StateRow extends StatelessWidget {
const _StateRow({
required this.area,
required this.count,
required this.chosen,
required this.onTap,
});
final ServiceArea area;
final int count;
final int chosen;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash),
title: area.name,
subtitle:
'$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
trailing: chosen == 0
? null
: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$chosen',
style: DmText.label.copyWith(color: Colors.white),
),
),
);
}
}
/// One district. No mark — the name and the promise are the whole row, and a
/// glyph beside every one of them would be decoration competing with the only
/// two things on it that differ.
class _DistrictRow extends StatelessWidget {
const _DistrictRow({
required this.city,
required this.multi,
required this.chosen,
required this.onTap,
});
final CityOption city;
final bool multi;
final bool chosen;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
title: city.district.name,
subtitle: city.note,
selected: chosen,
// A tick box only where ticking is possible. Where the server caps the
// booking at one place the row is a choice, not a checklist, and a box
// would promise a second pick the backend will refuse.
trailing: multi ? _Check(on: chosen) : null,
);
}
}
class _Check extends StatelessWidget {
const _Check({required this.on});
final bool on;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
width: 22,
height: 22,
decoration: BoxDecoration(
color: on ? DmColors.brand : Colors.transparent,
shape: BoxShape.circle,
border: Border.all(
color: on ? DmColors.brand : DmColors.border,
width: 1.5,
),
),
child: on
? const Icon(LucideIcons.check, size: 14, color: Colors.white)
: null,
);
}
}
/// The shape both rows share: a target, a title, a quiet line, a trailing mark.
class _Row extends StatelessWidget {
const _Row({
required this.onTap,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.selected = false,
});
final VoidCallback onTap;
final String title;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
final bool selected;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.hairline)),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 14)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: DmText.cardTitle.copyWith(
color: selected ? DmColors.brand : null,
),
),
if (subtitle != null)
Text(
subtitle!,
style: DmText.tiny,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
trailing ??
const Icon(
LucideIcons.chevronRight,
size: 16,
color: DmColors.ink4,
),
],
),
),
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/states.dart';
/// Alias used from Review, where the sheet edits the pickup point in place.
@@ -56,37 +57,11 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmSheetHeader(
title: 'Pickup location',
subtitle: 'Search or pick a saved place.',
),
TextField(
const DmSheetHeader(title: 'Collect from'),
DmSearchField(
controller: _query,
hint: 'Search street, area or landmark',
onChanged: _onChanged,
autofocus: false,
cursorColor: DmColors.brand,
style: DmText.field,
decoration: InputDecoration(
hintText: 'Search street, area or landmark',
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.lineStrong, 1),
enabledBorder: _border(DmColors.lineStrong, 1),
focusedBorder: _border(DmColors.brand, 2),
),
),
const SizedBox(height: 8),
Flexible(
@@ -125,10 +100,6 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
);
}
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}
class _PlaceRow extends StatelessWidget {

View File

@@ -50,14 +50,7 @@ class _WindowSheetState extends State<_WindowSheet> {
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}',
),
),
const Expanded(child: DmSheetHeader(title: 'Pickup window')),
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
@@ -80,7 +73,13 @@ class _WindowSheetState extends State<_WindowSheet> {
}
final day = days.contains(_day) ? _day! : days.first;
final shown = slots.where((s) => s.day == day).toList();
final selected = _picked ?? _slotById(slots, app.draftSlotId);
// Opens on something valid: the slot already chosen, or the
// first one still available. A sheet whose only button is
// disabled on arrival has made the customer work to reach the
// state it could have started in.
final selected = _picked ??
_slotById(slots, app.draftSlotId) ??
shown.where((s) => s.available).firstOrNull;
return SingleChildScrollView(
child: Column(
@@ -158,27 +157,24 @@ class _SlotRow extends StatelessWidget {
Widget build(BuildContext context) {
final enabled = slot.available;
// ── The time, and nothing else ──
//
// Each row carried up to four things: a "Fastest pickup" tag, a
// "4 Milers nearby" tag, the window, and a caption like "Relaxed evening
// handover". Three of those were the backend selling a slot to somebody
// who had already decided to book. The only question this sheet asks is
// *when*, and the answer is a time — so the time is the row.
//
// An unavailable slot keeps its note, because "Fully booked" is the reason
// the row cannot be tapped and removing it would leave a dead row with no
// explanation.
return DmOptionTile(
title: slot.window,
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
titleStyle: DmText.display.copyWith(fontSize: 21, height: 1.2),
subtitle: enabled ? null : (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(

View File

@@ -60,6 +60,22 @@ class ShellScreenState extends State<ShellScreen> {
/// object moving rather than three tabs changing colour in place — and the
/// label under it stays crimson, so the tab is still identifiable in the
/// half-second the pill is in flight.
///
/// ── What the 2026-09-22 pass changed ──
///
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson
/// shadow built for the committing button on a screen — which on a bar resting
/// against the bottom edge read as a smear under the tab rather than as lift.
/// It is a quarter of that strength now and sits closer to the shape.
///
/// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands
/// under it, on the same overshoot curve the pill travels on, so the two read
/// as one gesture instead of a moving rectangle and an icon that recolours when
/// it happens to be covered.
///
/// **A tab gives way when pressed.** The whole column scales to 0.9 under the
/// finger. Without it the only feedback for a tap was the pill starting to
/// move, which on the tab you are already on is no feedback at all.
class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged});
@@ -80,6 +96,18 @@ class _TabBar extends StatelessWidget {
static const double _pillWidth = 62;
static const double _pillHeight = 34;
/// The pill's own lift. Not [DmShadow.liftSm]: that is the glow under a
/// committing action, and at the bottom edge of the screen it read as dirt
/// under the tab.
static const _pillShadow = [
BoxShadow(
color: Color(0x2E8F0F06),
blurRadius: 10,
spreadRadius: -2,
offset: Offset(0, 4),
),
];
@override
Widget build(BuildContext context) {
return DecoratedBox(
@@ -115,7 +143,7 @@ class _TabBar extends StatelessWidget {
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.liftSm,
boxShadow: _pillShadow,
),
),
),
@@ -143,7 +171,7 @@ class _TabBar extends StatelessWidget {
}
}
class _TabButton extends StatelessWidget {
class _TabButton extends StatefulWidget {
const _TabButton({
required this.label,
required this.icon,
@@ -156,44 +184,74 @@ class _TabButton extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
@override
State<_TabButton> createState() => _TabButtonState();
}
class _TabButtonState extends State<_TabButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
selected: widget.selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
icon,
key: ValueKey(selected),
size: 22,
color: selected ? Colors.white : DmColors.ink3,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedScale(
// The whole column, including the label: a press that only moved the
// glyph would read as the icon coming loose from its own tab.
scale: _down ? 0.9 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedScale(
// Lands with the pill, on the pill's own curve. The
// overshoot is what ties the two together — the glyph is
// reacting to the thing arriving under it.
scale: widget.selected ? 1.14 : 1,
duration: DmMotion.slow,
curve: DmMotion.glide,
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
widget.icon,
key: ValueKey(widget.selected),
size: 21,
color: widget.selected ? Colors.white : DmColors.ink3,
),
),
),
),
),
),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: selected ? DmColors.brand : DmColors.ink3,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: widget.selected ? DmColors.brand : DmColors.ink3,
fontWeight:
widget.selected ? FontWeight.w700 : FontWeight.w600,
),
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
],
),
),
),
);

View File

@@ -0,0 +1,383 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lottie/lottie.dart';
import '../tokens.dart';
/// What the app opens on: the mark, then something moving.
///
/// ── Why there is a splash at all ──
///
/// The Android window background already shows the mark from process start, so
/// this is not covering a blank screen. It exists because the first thing that
/// happens after it is a *question* — a login form, or a Home screen the
/// customer has to read — and arriving at either one mid-stride is worse than
/// arriving at it after a beat that says whose app this is.
///
/// It also has real work to hide behind it. `restoreSession` reads the keychain
/// and may refresh a 60-day token against the network, which is the one moment
/// the app cannot say whether the customer is signed in. Showing a login screen
/// that vanishes a moment later is the failure this replaces.
///
/// ── It never makes anybody wait longer than it has to ──
///
/// Two clocks, and the screen leaves on the later of them: [_minimum], so the
/// animation is not a flicker, and whatever the session restore takes. A cold
/// start with a stored session usually finishes inside the minimum, so the
/// splash costs nothing; a slow network extends it rather than flashing.
///
/// [_maximum] is the safety net. If the restore hangs — a captive portal, a
/// server not answering — the app moves on rather than holding a customer on a
/// logo forever, and `_Launch` shows the login screen, which is the right place
/// for somebody whose session could not be read.
///
/// ── Two beats, in this order ──
///
/// The animation first, alone, and the mark after it. It was the other way
/// round — mark, then the animation appearing underneath it — which read as a
/// logo with a loading bar stuck to the bottom. Running them in sequence makes
/// the first beat about *what this app does* and the second about *whose app
/// it is*, and neither is competing with the other for the same moment.
///
/// ── The animation is a drop-in, and it is recoloured ──
///
/// If `assets/animations/splash.json` exists it is played. If it does not, the
/// road loader below runs instead.
///
/// The file shipped as black line art, which is not a colour this app owns —
/// the darkest thing in the palette is `DmColors.ink`, and the splash is the
/// one screen that is nothing but brand. `tool/lottie_brand.py` rewrites its
/// near-black fills and strokes to the crimson; it is idempotent, so run it
/// again after replacing the file. Both are the same size and the same beat, so
/// which one is present changes nothing about the screen's timing — see
/// `assets/animations/README.txt`.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key, required this.ready, required this.onDone});
/// True once the session restore has finished, however it finished.
final bool ready;
/// Called once, when the splash has served its purpose.
final VoidCallback onDone;
/// Long enough for both beats to read: the animation, then the mark.
static const _minimum = Duration(milliseconds: 2800);
/// Past this the app moves on whatever the session is doing.
static const _maximum = Duration(seconds: 7);
/// When the animation gives way to the mark.
///
/// The truck runs six seconds end to end, so this shows the opening of it
/// rather than the whole thing. That is the right trade: a launch screen
/// that waits out a six-second loop is a launch screen nobody thanks you
/// for, and the part worth seeing — the truck arriving — is at the front.
static const _handover = Duration(milliseconds: 1800);
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
/// Started at the handover, not at launch: the mark is the second beat.
late final AnimationController _mark = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
);
/// False while the animation has the screen; true once the mark takes it.
bool _onMark = false;
bool _started = false;
bool _elapsed = false;
bool _left = false;
Timer? _minTimer;
Timer? _maxTimer;
Timer? _holdTimer;
/// The Lottie file, if somebody dropped one in. Resolved once, at launch:
/// a `FutureBuilder` on every frame of a splash is work for nothing.
Future<LottieComposition?>? _composition;
@override
void initState() {
super.initState();
// ── The clock starts when the splash is on screen, not when it is built ──
//
// These timers used to start here, and the truck got 0.45s of a 1.8s beat.
// Two things happen between `initState` and the customer seeing anything:
// Android holds its own splash over the window until Flutter's first
// frame, and parsing a 300KB Lottie takes a few hundred milliseconds on a
// mid-range phone. Both were being spent against a clock that was already
// running, so most of the first beat elapsed behind a white rectangle and
// the animation looked like it flashed past on the way to the logo.
//
// Now the clock starts once three things are true: the composition has
// settled, the engine has **rasterized** its first frame, and the system
// splash has had time to fade out over it.
//
// Rasterized, not built. `addPostFrameCallback` fires on the first frame
// Flutter *assembles*, which on a debug build happens over a second before
// anything reaches the glass — that was the first attempt at this fix, and
// it moved the problem without solving it. `waitUntilFirstFrameRasterized`
// is the signal Android itself waits on before taking its splash down.
_composition = _loadComposition();
unawaited(_waitForScreen());
// The safety net runs from launch regardless. If the composition never
// resolves, the app must still move on.
_maxTimer = Timer(SplashScreen._maximum, _leave);
}
/// Blocks until there is a customer looking at this, then starts the clock.
///
/// The rasterizer wait is raced against a timeout so a binding that never
/// reports one — the widget-test harness, a platform that does not
/// implement it — cannot leave the app sitting on a splash forever. The
/// extra beat after it covers the system splash's own exit animation.
Future<void> _waitForScreen() async {
await _composition;
await WidgetsBinding.instance.waitUntilFirstFrameRasterized.timeout(
const Duration(milliseconds: 900),
onTimeout: () {},
);
await Future<void>.delayed(const Duration(milliseconds: 220));
_start();
}
/// Begins the two beats. Idempotent: whichever of the waits above finishes
/// last is the one that counts.
void _start() {
if (_started || !mounted) return;
_started = true;
_holdTimer = Timer(SplashScreen._handover, () {
if (!mounted) return;
setState(() => _onMark = true);
_mark.forward();
});
_minTimer = Timer(SplashScreen._minimum, () {
_elapsed = true;
_leaveIfDone();
});
}
/// Null when there is no file, which is not an error — it is the default.
Future<LottieComposition?> _loadComposition() async {
try {
return await AssetLottie('assets/animations/splash.json').load();
} catch (_) {
return null;
}
}
@override
void didUpdateWidget(SplashScreen old) {
super.didUpdateWidget(old);
if (widget.ready && !old.ready) _leaveIfDone();
}
void _leaveIfDone() {
if (_elapsed && widget.ready) _leave();
}
void _leave() {
if (_left || !mounted) return;
_left = true;
widget.onDone();
}
@override
void dispose() {
_minTimer?.cancel();
_maxTimer?.cancel();
_holdTimer?.cancel();
_mark.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final entrance = CurvedAnimation(parent: _mark, curve: Curves.easeOutBack);
return Scaffold(
backgroundColor: DmColors.surface,
body: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: DmColors.surface,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Semantics(
label: 'Doormile',
child: Center(
// One box, two beats, cross-faded. A `Column` holding both would
// reserve height for the one that is not showing and push the
// other off centre.
// Sized to the screen, not to a number: the splash is the one
// surface with nothing else on it, so both beats are given most
// of the width rather than sitting small in the middle of it.
child: SizedBox(
height: 320,
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onMark
? ScaleTransition(
key: const ValueKey('mark'),
scale: Tween(begin: 0.82, end: 1.0).animate(entrance),
child: FadeTransition(
opacity: _mark,
child: Image.asset(
'assets/images/doormile-icon.png',
width: 210,
height: 210,
filterQuality: FilterQuality.medium,
),
),
)
: FutureBuilder<LottieComposition?>(
key: const ValueKey('animation'),
future: _composition,
builder: (context, snap) {
final composition = snap.data;
if (composition == null) return const _RoadLoader();
return Lottie(
composition: composition,
width: 340,
height: 320,
fit: BoxFit.contain,
repeat: true,
);
},
),
),
),
),
),
),
);
}
}
/// The built-in animation: a parcel travelling a road.
///
/// Not a spinner. A spinner says "waiting"; this says what the waiting is for,
/// in the one shape the mark already contains — the road curving through the D.
/// It is also two primitives and no dependency, so the splash works on a fresh
/// checkout with nothing downloaded.
class _RoadLoader extends StatefulWidget {
const _RoadLoader();
@override
State<_RoadLoader> createState() => _RoadLoaderState();
}
class _RoadLoaderState extends State<_RoadLoader>
with SingleTickerProviderStateMixin {
late final AnimationController _run = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
@override
void dispose() {
_run.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: SizedBox(
width: 190,
height: 40,
child: AnimatedBuilder(
animation: _run,
builder: (_, _) => CustomPaint(painter: _RoadPainter(_run.value)),
),
),
);
}
}
class _RoadPainter extends CustomPainter {
const _RoadPainter(this.t);
/// 0→1, repeating.
final double t;
@override
void paint(Canvas canvas, Size size) {
final y = size.height * 0.72;
// The road: a full-width track at the weight of a hairline, so the parcel
// is the only thing on it with any presence.
canvas.drawLine(
Offset(0, y),
Offset(size.width, y),
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
..color = DmColors.border,
);
// Lane markings, travelling backwards under the parcel. They are what
// makes it read as movement rather than as a dot sliding on a line.
const dash = 14.0;
const gap = 12.0;
final shift = -t * (dash + gap);
for (var x = shift; x < size.width; x += dash + gap) {
final a = x.clamp(0.0, size.width);
final b = (x + dash).clamp(0.0, size.width);
if (b <= a) continue;
canvas.drawLine(
Offset(a, y),
Offset(b, y),
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
// Strong enough to read as movement. At `brandLine` on white they
// were technically present and invisible, which made the parcel look
// like it was sliding on nothing.
..color = DmColors.brand.withValues(alpha: 0.28),
);
}
// The parcel. Eased at both ends so it leaves and arrives rather than
// scrolling past at a constant speed.
final eased = Curves.easeInOutCubic.transform(t);
final cx = 16 + eased * (size.width - 32);
final rect = RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(cx, y - 13), width: 22, height: 18),
const Radius.circular(4),
);
canvas.drawRRect(
rect.shift(const Offset(0, 3)),
Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)
..color = DmColors.brand.withValues(alpha: 0.22),
);
canvas.drawRRect(rect, Paint()..color = DmColors.brand);
// The seam across the box, which is what makes it a parcel and not a
// rectangle.
canvas.drawLine(
Offset(cx, y - 22),
Offset(cx, y - 4),
Paint()
..strokeWidth = 1.6
..color = Colors.white.withValues(alpha: 0.55),
);
}
@override
bool shouldRepaint(_RoadPainter old) => old.t != t;
}

View File

@@ -81,79 +81,122 @@ class TrackingScreen extends StatelessWidget {
style: DmText.mono,
),
),
body: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
_StatusHeader(booking: booking, slot: slot, focused: focused),
body: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () => app.refreshBooking(booking.reference),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
_StatusHeader(booking: booking, slot: slot, focused: focused),
if (assigning)
const _AssigningHero()
else if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
// The map is a cell of a card, not a floating rounded rectangle
// of its own: it gets the card's edge, and the line under it
// carries the fact the picture cannot state precisely.
child: DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: booking.pickup,
destination: (focused ?? booking.destinations.first).hub,
showMiler: milerEnRoute,
progress: _routeProgress(stage),
pickupLabel: booking.pickup.title,
destinationLabel: booking.destination.districtName,
badge: milerEnRoute && booking.milerDistanceKm != null
? _Badge(
icon: LucideIcons.bike,
label: booking.milerDistanceKm == 0
? 'At your door'
: '${booking.miler?.name ?? 'Your Miler'}, '
'${booking.milerDistanceKm} km away',
)
: null,
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
const Icon(
LucideIcons.clock,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// Short enough to sit beside Refresh without
// ellipsizing, which is the whole job of this line.
milerEnRoute && booking.milerEtaMinutes != null
? 'Pickup in about ${booking.milerEtaMinutes} min'
: 'Tracking your parcel live',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Refresh',
onPressed: app.refreshOrders,
),
],
// ── No radar while a Miler is being found ──
//
// This showed a 200pt card holding a pulsing radar with a bike
// glyph at its centre, then a banner counting "verified Milers
// active in your zone". Both were animation standing in for an
// answer: the radar is not looking at anything, and the count is a
// number the customer cannot use. The rail below says *Booking
// created* with the line stopped there, and the crimson card above
// says what is happening in words. That is the answer, and it is
// the same rail whether a Miler has been found or not.
if (!assigning && !cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
// The map is a cell of a card, not a floating rounded rectangle
// of its own: it gets the card's edge, and the line under it
// carries the fact the picture cannot state precisely.
child: DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: booking.pickup,
destination: (focused ?? booking.destinations.first).hub,
showMiler: milerEnRoute,
progress: _routeProgress(stage),
pickupLabel: booking.pickup.title,
destinationLabel: booking.destination.districtName,
badge: milerEnRoute && booking.milerDistanceKm != null
? _Badge(
icon: LucideIcons.bike,
label: booking.milerDistanceKm == 0
? 'At your door'
: '${booking.miler?.name ?? 'Your Miler'}, '
'${booking.milerDistanceKm} km away',
)
: null,
),
),
],
// Only when there is something to say. This cell used to
// read "Tracking your parcel live" whenever no ETA existed,
// beside a Refresh action — a sentence that described the
// screen the customer was already looking at, and a button
// for a gesture the list now handles by being pulled down.
if (milerEnRoute && booking.milerEtaMinutes != null)
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.clock,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Pickup in about '
'${booking.milerEtaMinutes} min',
style: DmText.cardTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
if (milerEnRoute)
_MilerCard(person: booking.miler!, role: 'Your Miler'),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(
person: booking.deliveryAgent!,
role: 'Delivery Miler',
),
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
),
if (assigning && booking.milersInZone > 0)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
@@ -161,57 +204,19 @@ class TrackingScreen extends StatelessWidget {
DmSpace.pad,
10,
),
child: DmInfoBanner(
icon: LucideIcons.users,
title:
'${booking.milersInZone} verified Milers active in your zone',
message: 'Most pickups are accepted within a few minutes.',
child: _RouteOutline(
booking: booking,
slot: slot,
focused: focused,
),
),
if (milerEnRoute)
_MilerCard(person: booking.miler!, role: 'Your Miler'),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'),
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: _RouteOutline(
booking: booking,
slot: slot,
focused: focused,
),
),
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
),
),
bottomNavigationBar: DmFooter(
edge: true,
@@ -233,7 +238,11 @@ class TrackingScreen extends StatelessWidget {
/// full-width outlined button in the danger colour — the most prominent
/// control on a screen whose job is to reassure someone that their parcel is
/// fine.
List<Widget> _footerActions(BuildContext context, AppState app, Booking booking) {
List<Widget> _footerActions(
BuildContext context,
AppState app,
Booking booking,
) {
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
return [
DmButton(
@@ -384,7 +393,9 @@ class _StatusHeader extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small.copyWith(color: DmColors.onBrand3),
style: DmText.small.copyWith(
color: DmColors.onBrand3,
),
),
),
],
@@ -392,25 +403,35 @@ class _StatusHeader extends StatelessWidget {
const SizedBox(height: 12),
Text(
headline,
style: DmText.display.copyWith(fontSize: 27, color: Colors.white),
style: DmText.display.copyWith(
fontSize: 27,
color: Colors.white,
),
),
// The supporting sentence is gone. It restated the headline
// — "Arun Kumar is assigned" over "Your Miler will arrive in
// your pickup window" — and sat above a milestone label
// saying "Miler assigned" and a counter saying "Step 2 of 7".
// Four lines to answer a question the first one had answered.
//
// What is left is the one fact the headline does not carry:
// when. It is set at heading size because after "what is
// happening" it is the only thing the customer came for.
const SizedBox(height: 14),
Text(
_footnote(stage),
style: DmText.heading.copyWith(
fontSize: 17,
color: DmColors.onBrand2,
),
),
const SizedBox(height: 6),
Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)),
const SizedBox(height: 16),
DmStepBar(
progress: step / total,
steps: total,
current: step,
onFill: true,
// The milestone, not the window: the window is already the
// line underneath, and at this width it clipped to
// "Window 2:00 – 4:…".
label: CustomerMilestone.of(stage).label,
),
const SizedBox(height: 10),
Text(
_footnote(stage),
style: DmText.tiny.copyWith(color: DmColors.onBrand3),
caption: false,
),
],
),
@@ -436,26 +457,23 @@ class _StatusHeader extends StatelessWidget {
}
}
/// The journey, compressed to what just happened.
class _Journey extends StatefulWidget {
/// The journey. All of it, always.
///
/// It used to show the three most recent milestones behind a "Full timeline"
/// action, which was the right compression for seven and is the wrong one for
/// five: the whole shipment now fits in the space the summary occupied, and a
/// customer who opens tracking to ask "where is my parcel" should not have to
/// tap to see the answer's context.
class _Journey extends StatelessWidget {
const _Journey({required this.booking});
final Booking booking;
@override
State<_Journey> createState() => _JourneyState();
}
class _JourneyState extends State<_Journey> {
bool _all = false;
@override
Widget build(BuildContext context) {
return DmMilestones(
booking: widget.booking,
title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey',
maxRows: _all ? null : 3,
onExpand: () => setState(() => _all = true),
booking: booking,
title: booking.trackingId == null ? 'Journey' : 'Live journey',
);
}
}
@@ -497,42 +515,6 @@ class _Badge extends StatelessWidget {
}
/// Radar shown while the backend matches a Miler.
class _AssigningHero extends StatelessWidget {
const _AssigningHero();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20),
child: Container(
height: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: DmRadarPulse(
size: 170,
child: Container(
width: 62,
height: 62,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.bike,
size: 28,
color: Colors.white,
),
),
),
),
);
}
}
/// Who is collecting, how good they are, and how to reach them.
///
/// A wash rather than a plain card: this is the one row on the screen about a
@@ -608,7 +590,8 @@ class _MilerCard extends StatelessWidget {
label: 'Call',
icon: LucideIcons.phone,
filled: true,
onPressed: () => DmToast.show(context, 'Calling ${person.name}…'),
onPressed: () =>
DmToast.show(context, 'Calling ${person.name}…'),
),
const SizedBox(width: 8),
DmMiniButton(
@@ -645,37 +628,37 @@ class _RouteOutline extends StatelessWidget {
return DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: group != null
? group.destination.label
: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote: group != null
? '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}'
: booking.isMultiDestination
? booking.packageSummary
: booking.details.exactAddress ??
(recipient == null
? 'Exact address collected at pickup'
: 'Recipient · $recipient'),
),
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
DmSummaryRow(
label: (group?.trackingId ?? booking.trackingId) == null
? 'Booking reference'
: 'Tracking number',
value: group?.trackingId ?? booking.displayId,
mono: true,
secondary: group == null
? null
: 'Collected together · Pickup ${booking.reference}',
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: group != null
? group.destination.label
: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote: group != null
? '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}'
: booking.isMultiDestination
? booking.packageSummary
: booking.details.exactAddress ??
(recipient == null
? 'Exact address collected at pickup'
: 'Recipient · $recipient'),
),
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
DmSummaryRow(
label: (group?.trackingId ?? booking.trackingId) == null
? 'Booking reference'
: 'Tracking number',
value: group?.trackingId ?? booking.displayId,
mono: true,
secondary: group == null
? null
: 'Collected together · Pickup ${booking.reference}',
),
],
);
}
@@ -722,10 +705,7 @@ class _YourPackages extends StatelessWidget {
),
),
for (final group in booking.destinations)
_PackageRow(
group: group,
isFocused: identical(group, focused),
),
_PackageRow(group: group, isFocused: identical(group, focused)),
],
);
}
@@ -760,7 +740,8 @@ class _PackageRow extends StatelessWidget {
style: DmText.monoSm,
),
TextSpan(
text: ' · ${group.packageCount} '
text:
' · ${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],