One Touch, and a pickup map worth the name

Two ways to book the same parcel, and the difference said where the
choice is made rather than in a paragraph about it.

ONE TOUCH is the sphere: a state, a district, a window, and nothing
else. The door, the recipient and the weight go out omitted, which the
contract already treats as "not added yet" - the Miler writes them down
standing at the address with the parcel in hand, which is the only
moment anybody knows them.

The pick/drop form under it is the other way in. PICKUP already carries
the detected address; DROP is the only row with a question on it, and
it asks for the full door - once per destination - before the window.
Captions rather than an explainer: "Just a city and a time" under the
sphere, "Enter the full address yourself" on the row.

The pickup screen is a map screen now instead of a screen with a map on
it. Full-bleed basemap, pin nailed to the centre, floating search and
back, and a fixed sheet carrying what the pin resolved to. The
collect-from sheet can hand off to it and carry the pin back.

Also:

* Home reads what-is-running, then the one gesture, then the form. The
  live card lost its courier row and half its height.
* The sphere turns inside out on press - white floods from the centre
  and the word inverts through the clip, not by tween - and bounces
  back on release.
* Order IDs copy, on Orders and on tracking.
* Tracking says PICKUP and DROP with an arrow between them, carries the
  reference at the foot of the card it identifies, and shows every
  timing exactly once.
* The wordmark bar is gone from all three roots; Orders and Account had
  no SafeArea under it, so their titles were sitting under the notch.

flutter analyze: clean. flutter test: 90 passing.

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-24 15:59:06 +05:30
parent 86b6af48c2
commit 8956e818a4
42 changed files with 2059 additions and 956 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB

View File

@@ -394,7 +394,26 @@ class AppState extends ChangeNotifier {
/// form, and cleared only when a booking actually lands. /// form, and cleared only when a booking actually lands.
String? _bookingIdempotencyKey; String? _bookingIdempotencyKey;
void startBooking() { /// Whether this booking asks the customer for the drop address.
///
/// ── Two ways to book the same parcel ──
///
/// **One Touch** asks three things: the state, the district and a pickup
/// window. Nothing else — no door number, no recipient, no weight. Those are
/// filled in by the Miler standing at the address with the parcel in their
/// hand, which is the only moment anybody actually knows them.
///
/// **The full form** is for the customer who already knows the door and
/// would rather type it than have it asked for later. It is the same
/// booking with `details` populated on the wire.
///
/// The contract has always taken both: [DestinationGroup.toBookingJson]
/// omits whatever is blank, and an omitted field is precisely the "not added
/// yet" state the Miler completes. This flag only decides what the app asks.
bool draftDetailed = false;
void startBooking({bool detailed = false}) {
draftDetailed = detailed;
draftPickup = pickup; draftPickup = pickup;
draftDestinations = [DestinationGroup()]; draftDestinations = [DestinationGroup()];
draftSlotId = null; draftSlotId = null;

View File

@@ -7,10 +7,8 @@ import '../../data/models.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../widgets/misc.dart'; import '../widgets/misc.dart';
import '../format.dart'; import '../format.dart';
import 'shell_screen.dart';
import '../widgets/buttons.dart'; import '../widgets/buttons.dart';
import '../widgets/cards.dart'; import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart'; import '../widgets/feedback.dart';
import '../widgets/inputs.dart'; import '../widgets/inputs.dart';
import 'auth/login_screen.dart'; import 'auth/login_screen.dart';
@@ -39,245 +37,261 @@ class AccountScreen extends StatelessWidget {
// Counted off what is already loaded, so the figures move with the list // Counted off what is already loaded, so the figures move with the list
// rather than claiming a lifetime total the client cannot know. // rather than claiming a lifetime total the client cannot know.
final completed = final completed = app.orders.where(
app.orders.where((o) => o.status == BookingStatus.completed); (o) => o.status == BookingStatus.completed,
);
final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages); final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages);
final cities = { final cities = {
for (final o in completed) for (final o in completed)
for (final g in o.destinations) for (final g in o.destinations) ?g.destination.districtCode,
?g.destination.districtCode,
}.length; }.length;
return Column( return SafeArea(
crossAxisAlignment: CrossAxisAlignment.stretch, bottom: false,
children: [ child: Column(
DmAppBar( crossAxisAlignment: CrossAxisAlignment.stretch,
unread: app.activeBookings.isNotEmpty, children: [
onSearch: () => ShellScreen.of(context)?.goToTab(1), // No wordmark bar, and no search or bell on it. Home and Orders lost
onNotifications: () => // the same one: a root that opens on the customer's own face does not
DmToast.show(context, 'Nothing new right now'), // need to be told which app it is, and the two glyphs beside it went
), // to screens that have their own way in.
// ── The profile, centred ── // ── The profile, centred ──
// //
// It was a 34pt name left-aligned with the number under it, which is // It was a 34pt name left-aligned with the number under it, which is
// the shape a *list* screen takes. The reference puts the person at // the shape a *list* screen takes. The reference puts the person at
// the top of their own account: a large avatar with an edit badge, the // the top of their own account: a large avatar with an edit badge, the
// name, the number, and the account's standing — and that is what // name, the number, and the account's standing — and that is what
// makes Account read as somewhere you are rather than as a menu. // makes Account read as somewhere you are rather than as a menu.
Padding( Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18), padding: const EdgeInsets.fromLTRB(
child: Column( DmSpace.pad,
children: [ 26,
Stack( DmSpace.pad,
clipBehavior: Clip.none, 18,
children: [ ),
DmAvatar(initials: initialsOf(name), size: 86), child: Column(
Positioned( children: [
right: -2, Stack(
bottom: -2, clipBehavior: Clip.none,
child: Container(
width: 30,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
boxShadow: DmShadow.card,
),
child: const Icon(
LucideIcons.pencil,
size: 14,
color: DmColors.ink2,
),
),
),
],
),
const SizedBox(height: 14),
Text(
name.isEmpty ? 'Your account' : name,
style: DmText.headerTitle.copyWith(fontSize: 24),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (phone.isNotEmpty) ...[
const SizedBox(height: 7),
Text(
phone,
style: DmText.monoSm.copyWith(
fontSize: 13,
color: DmColors.ink3,
),
),
],
// ── Two figures, both counted here ──
//
// The reference puts "24 all-time parcels" and "100% carbon
// offset" side by side under the name. The first is real — it
// is the orders already in memory — so it is shown. The second
// is not: nothing in this backend measures an offset, and a
// sustainability claim is the last thing an app should invent.
// Delivered parcels and cities reached are what the data
// actually supports.
if (delivered > 0) ...[
const SizedBox(height: 20),
Row(
children: [ children: [
Expanded( DmAvatar(initials: initialsOf(name), size: 86),
child: _Stat( Positioned(
icon: LucideIcons.package, right: -2,
label: 'DELIVERED', bottom: -2,
value: '$delivered', child: Container(
note: delivered == 1 ? 'parcel' : 'parcels', width: 30,
), height: 30,
), alignment: Alignment.center,
const SizedBox(width: 10), decoration: BoxDecoration(
Expanded( color: DmColors.surface,
child: _Stat( shape: BoxShape.circle,
icon: LucideIcons.mapPin, boxShadow: DmShadow.card,
label: 'CITIES', ),
value: '$cities', child: const Icon(
note: cities == 1 ? 'destination' : 'destinations', LucideIcons.pencil,
size: 14,
color: DmColors.ink2,
),
), ),
), ),
], ],
), ),
], const SizedBox(height: 14),
], Text(
), name.isEmpty ? 'Your account' : name,
), style: DmText.headerTitle.copyWith(fontSize: 24),
Expanded( textAlign: TextAlign.center,
child: ListView( maxLines: 2,
// Clears the floating tab bar, which the shell stacks over the overflow: TextOverflow.ellipsis,
// body rather than laying out beneath it. ),
padding: EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom), if (phone.isNotEmpty) ...[
children: [ const SizedBox(height: 7),
const DmMicroHead('Preferences', top: 4, brand: false), Text(
DmRowGroup( phone,
children: [ style: DmText.monoSm.copyWith(
DmRow( fontSize: 13,
icon: LucideIcons.mapPin,
label: 'Saved addresses',
note: 'Pickup and drop locations',
// No value. It was the pickup address, which is a street
// name and rendered as "38 Mettupal…" — a truncation
// nobody can read anything out of, in the slot meant for a
// word.
showChevron: true,
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) {
app.setPickup(place);
DmToast.show(context, 'Pickup location updated');
}
},
),
const DmRow(
icon: LucideIcons.bell,
label: 'Notifications',
note: 'Real-time SMS and push',
showChevron: true,
),
const DmRow(
icon: LucideIcons.wallet,
label: 'Payment methods',
note: 'UPI, on delivery',
showChevron: true,
),
],
),
// ── A second group, because the first was two things ──
//
// "Preferences" held three settings and one destination, and
// the destination — help — was the row a customer in trouble
// scans for. The two policy rows every app is expected to carry
// had nowhere to go at all, so they were simply missing.
//
// Split, each head says what its rows are: things you change,
// and things you read.
const DmMicroHead('About', brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.circleHelp,
label: 'Help and support',
note: 'Talk to the courier desk',
showChevron: true,
onTap: () => DmToast.show(context, 'Support is on the way'),
),
DmRow(
icon: LucideIcons.fileText,
label: 'Terms and policies',
note: 'What Doormile covers',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
),
DmRow(
icon: LucideIcons.info,
label: 'About Doormile',
note: 'Version and licences',
showChevron: true,
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
),
],
),
// The environment line is not a control — it is what a bug
// report needs in order to be actionable. Always shown when the
// session came from a dev token rather than a sign-in.
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
const DmMicroHead('Build', brand: false),
// Not a DmRow: that lays a short label against a short value
// on one line, and this value is a URL plus every flag the
// build carries. It overflowed by 59px the moment a bypass was
// on — which is exactly when someone needs to read it.
//
// Not a card either. It is a line of mono on the page, like
// every other thing on this screen.
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink3, color: DmColors.ink3,
), ),
), ),
), ],
// ── Two figures, both counted here ──
//
// The reference puts "24 all-time parcels" and "100% carbon
// offset" side by side under the name. The first is real — it
// is the orders already in memory — so it is shown. The second
// is not: nothing in this backend measures an offset, and a
// sustainability claim is the last thing an app should invent.
// Delivered parcels and cities reached are what the data
// actually supports.
if (delivered > 0) ...[
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _Stat(
icon: LucideIcons.package,
label: 'DELIVERED',
value: '$delivered',
note: delivered == 1 ? 'parcel' : 'parcels',
),
),
const SizedBox(width: 10),
Expanded(
child: _Stat(
icon: LucideIcons.mapPin,
label: 'CITIES',
value: '$cities',
note: cities == 1 ? 'destination' : 'destinations',
),
),
],
),
],
], ],
const SizedBox(height: 24), ),
DmButton( ),
label: 'Log Out', Expanded(
icon: LucideIcons.logOut, child: ListView(
iconLeading: true, // Clears the floating tab bar, which the shell stacks over the
kind: DmButtonKind.ghost, // body rather than laying out beneath it.
danger: true, padding: EdgeInsets.fromLTRB(
onPressed: () { DmSpace.pad,
app.signOut(); 4,
Navigator.of(context).pushAndRemoveUntil( DmSpace.pad,
MaterialPageRoute<void>(builder: (_) => const LoginScreen()), 74 + MediaQuery.paddingOf(context).bottom,
(route) => false,
);
},
), ),
const SizedBox(height: 6), children: [
Text.rich( const DmMicroHead('Preferences', top: 4, brand: false),
TextSpan( DmRowGroup(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [ children: [
const TextSpan(text: 'DOORMILE CUSTOMER '), DmRow(
TextSpan( icon: LucideIcons.mapPin,
text: 'v${AppConfig.appVersion}', label: 'Saved addresses',
style: DmText.monoSm.copyWith(color: DmColors.ink4), note: 'Pickup and drop locations',
// No value. It was the pickup address, which is a street
// name and rendered as "38 Mettupal…" — a truncation
// nobody can read anything out of, in the slot meant for a
// word.
showChevron: true,
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) {
app.setPickup(place);
DmToast.show(context, 'Pickup location updated');
}
},
),
const DmRow(
icon: LucideIcons.bell,
label: 'Notifications',
note: 'Real-time SMS and push',
showChevron: true,
),
const DmRow(
icon: LucideIcons.wallet,
label: 'Payment methods',
note: 'UPI, on delivery',
showChevron: true,
), ),
], ],
), ),
textAlign: TextAlign.center, // ── A second group, because the first was two things ──
), //
], // "Preferences" held three settings and one destination, and
// the destination — help — was the row a customer in trouble
// scans for. The two policy rows every app is expected to carry
// had nowhere to go at all, so they were simply missing.
//
// Split, each head says what its rows are: things you change,
// and things you read.
const DmMicroHead('About', brand: false),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.circleHelp,
label: 'Help and support',
note: 'Talk to the courier desk',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Support is on the way'),
),
DmRow(
icon: LucideIcons.fileText,
label: 'Terms and policies',
note: 'What Doormile covers',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Opening doormile.com…'),
),
DmRow(
icon: LucideIcons.info,
label: 'About Doormile',
note: 'Version and licences',
showChevron: true,
onTap: () =>
DmToast.show(context, 'Opening doormile.com…'),
),
],
),
// The environment line is not a control — it is what a bug
// report needs in order to be actionable. Always shown when the
// session came from a dev token rather than a sign-in.
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
const DmMicroHead('Build', brand: false),
// Not a DmRow: that lays a short label against a short value
// on one line, and this value is a URL plus every flag the
// build carries. It overflowed by 59px the moment a bypass was
// on — which is exactly when someone needs to read it.
//
// Not a card either. It is a line of mono on the page, like
// every other thing on this screen.
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink3,
),
),
),
],
const SizedBox(height: 24),
DmButton(
label: 'Log Out',
icon: LucideIcons.logOut,
iconLeading: true,
kind: DmButtonKind.ghost,
danger: true,
onPressed: () {
app.signOut();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => const LoginScreen(),
),
(route) => false,
);
},
),
const SizedBox(height: 6),
Text.rich(
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'DOORMILE CUSTOMER '),
TextSpan(
text: 'v${AppConfig.appVersion}',
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
),
textAlign: TextAlign.center,
),
],
),
), ),
), ],
], ),
); );
} }
} }

View File

@@ -7,17 +7,29 @@ import '../../../state/app_state.dart';
import '../../tokens.dart'; import '../../tokens.dart';
import '../../widgets/inputs.dart'; import '../../widgets/inputs.dart';
import '../../widgets/buttons.dart'; import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart'; import '../../widgets/feedback.dart';
import '../../widgets/map_panel.dart'; import '../../widgets/map_panel.dart';
import '../sheets/place_search_sheet.dart'; import '../sheets/place_search_sheet.dart';
/// Step 1 — where we collect from. /// Step 1 — where we collect from.
/// ///
/// One card: a real map on top with the pin locked to its centre, and the /// ── A map screen, not a screen with a map on it ──
/// address it resolves to in the box directly underneath. Drag the map, the ///
/// address follows. Nothing is typed unless the customer wants to. /// This was a title, a caption, and a bordered card holding a map, an address
/// strip and a phone field, on a canvas, under an app bar. Five surfaces for
/// one question, and the map — the only thing on the screen the customer can
/// actually *use* — got whatever height was left over.
///
/// It is the shape every ride app converged on now, and they converged on it
/// because it is the shape of the task: the map fills the screen, the pin is
/// nailed to the centre, and the world moves under it. Nothing else can be
/// dragged, so nothing else competes for the gesture.
///
/// Three things float over it. A search field at the top, because typing is
/// the right answer when you are not standing where the parcel is. A locate
/// button, because the device usually knows. And a sheet at the foot carrying
/// what the pin resolved to, who the Miler should ask for, and the button that
/// agrees to it.
class PickupLocationScreen extends StatefulWidget { class PickupLocationScreen extends StatefulWidget {
const PickupLocationScreen({super.key}); const PickupLocationScreen({super.key});
@@ -39,11 +51,16 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
/// explain in a remarks box nobody reads. /// explain in a remarks box nobody reads.
final _contact = TextEditingController(); final _contact = TextEditingController();
/// The contact field is folded away until asked for. On a screen whose job
/// is a pin, a phone number is the second question.
bool _contactOpen = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final app = AppScope.read(context); final app = AppScope.read(context);
_contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone); _contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone);
_contactOpen = app.draftContactPhone != null;
// Arriving without a pin — ask the device where we are. // Arriving without a pin — ask the device where we are.
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -79,7 +96,9 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
} }
Future<void> _search(AppState app) async { Future<void> _search(AppState app) async {
final picked = await showPlaceSearchSheet(context); // No map row inside the sheet this screen opens: it would push a second
// copy of this screen over the first.
final picked = await showPlaceSearchSheet(context, allowMap: false);
if (picked == null || !mounted) return; if (picked == null || !mounted) return;
app.setPickup(picked); app.setPickup(picked);
setState(() => _pinMoving = false); setState(() => _pinMoving = false);
@@ -91,232 +110,430 @@ class _PickupLocationScreenState extends State<PickupLocationScreen> {
final app = AppScope.of(context); final app = AppScope.of(context);
final place = app.pickup; final place = app.pickup;
final resolving = app.resolvingPickup || _pinMoving; final resolving = app.resolvingPickup || _pinMoving;
final top = MediaQuery.paddingOf(context).top;
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Pickup point'), // No app bar. The back button floats on the map with everything else,
body: Column( // because a bar would cut a strip off the one thing worth seeing.
body: Stack(
children: [ children: [
const Padding( // ── The map, edge to edge ──
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0), Positioned.fill(
child: _Intro(), child: DmMapPanel(
), lat: app.mapLat,
const SizedBox(height: 16), lng: app.mapLng,
deviceLat: app.deviceLat,
// The map and the address it resolves to are one object, so they get deviceLng: app.deviceLng,
// one card. Not a ListView: the map must win vertical drags. deviceAccuracy: app.deviceAccuracy,
Expanded( loading: resolving,
child: Padding( borderRadius: BorderRadius.zero,
padding: const EdgeInsets.fromLTRB( onPinMoveStart: () {
DmSpace.pad, if (!_pinMoving) setState(() => _pinMoving = true);
0, },
DmSpace.pad, onPinMoved: (lat, lng) {
14, setState(() => _pinMoving = false);
), app.setPickupFromPin(lat, lng);
child: Container( },
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
children: [
Expanded(
child: DmMapPanel(
lat: app.mapLat,
lng: app.mapLng,
deviceLat: app.deviceLat,
deviceLng: app.deviceLng,
deviceAccuracy: app.deviceAccuracy,
loading: resolving,
borderRadius: BorderRadius.zero,
onLocate: () {
setState(() => _pinMoving = false);
app.detectPickupLocation();
},
onPinMoveStart: () {
if (!_pinMoving) setState(() => _pinMoving = true);
},
onPinMoved: (lat, lng) {
setState(() => _pinMoving = false);
app.setPickupFromPin(lat, lng);
},
),
),
_AddressBox(
title: place?.title,
sub: place?.sub,
resolving: resolving,
onChange: () => _search(app),
),
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,
),
),
],
),
),
), ),
), ),
if (app.locationDenial != null) // ── Back, and the way to type instead ──
Padding( Positioned(
padding: const EdgeInsets.fromLTRB( left: DmSpace.pad,
DmSpace.pad, right: DmSpace.pad,
0, top: top + 10,
DmSpace.pad, child: Row(
12, children: [
), _FloatingCircle(
child: _LocationNotice( icon: LucideIcons.arrowLeft,
denial: app.locationDenial!, tooltip: 'Back',
onSettings: app.openLocationSettings, onPressed: () => Navigator.of(context).maybePop(),
onSearch: () => _search(app), ),
), const SizedBox(width: 10),
Expanded(child: _SearchBar(onTap: () => _search(app))),
],
),
),
// ── The device knows, usually ──
//
// It sits just above the sheet rather than in the map's own corner:
// the sheet is a fixed height, so the button can be placed against
// it and stay off the attribution line.
Positioned(
right: DmSpace.pad,
bottom: _sheetHeight(context, app) + 14,
child: _FloatingCircle(
icon: LucideIcons.locateFixed,
tooltip: 'Use my current location',
onPressed: () {
setState(() => _pinMoving = false);
app.detectPickupLocation();
},
),
),
// ── What the pin resolved to, and the button that agrees ──
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _ConfirmSheet(
title: place?.title,
sub: place?.sub,
resolving: resolving,
denial: app.locationDenial,
contact: _contact,
contactOpen: _contactOpen,
onToggleContact: () =>
setState(() => _contactOpen = !_contactOpen),
onEdit: () => _search(app),
onSettings: app.openLocationSettings,
onConfirm: !app.hasPickupPoint || resolving
? null
: () {
app.draftPickup = place;
_commitContact(app);
// Popped *with* the place, so the search sheet that
// pushed this screen can close carrying the pin back to
// whoever opened it. Review ignores the result and reads
// `draftPickup`, which this has already written.
Navigator.of(context).pop(place);
},
), ),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Use this address',
icon: LucideIcons.check,
// Gated on the pin, not just the address: a booking without
// coordinates is one the Miler cannot be routed to.
onPressed: !app.hasPickupPoint || resolving
? null
: () {
app.draftPickup = place;
_commitContact(app);
Navigator.of(context).maybePop();
},
), ),
], ],
), ),
); );
} }
/// Roughly how tall the sheet is, so the locate button can sit on top of it.
/// It only has to be close: the button is placed against the sheet, not
/// aligned to a hairline in it.
double _sheetHeight(BuildContext context, AppState app) {
final scale = MediaQuery.textScalerOf(context).scale(1);
return (app.locationDenial != null ? 268.0 : 202.0) * scale +
(_contactOpen ? 86 : 0) +
MediaQuery.paddingOf(context).bottom;
}
} }
class _Intro extends StatelessWidget { /// A white disc that floats over the map — back, and locate.
const _Intro(); class _FloatingCircle extends StatelessWidget {
const _FloatingCircle({
required this.icon,
required this.tooltip,
required this.onPressed,
});
final IconData icon;
final String tooltip;
final VoidCallback onPressed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Tooltip(
crossAxisAlignment: CrossAxisAlignment.start, message: tooltip,
mainAxisSize: MainAxisSize.min, child: Semantics(
children: [ button: true,
Text('Where should we pick up?', style: DmText.title), label: tooltip,
const SizedBox(height: 8), child: GestureDetector(
Text('Drag the map to move the pin.', style: DmText.lede), behavior: HitTestBehavior.opaque,
], onTap: onPressed,
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
boxShadow: DmShadow.cardRaised,
),
child: Icon(icon, size: 20, color: DmColors.ink),
),
),
),
); );
} }
} }
/// The address under the map. It is the same card, so a hairline separates /// "Search for a building or street" — the way in for somebody who is not
/// them rather than a gap. /// standing at the door.
class _AddressBox extends StatelessWidget { ///
const _AddressBox({ /// A field that opens a sheet rather than a field you type into: the search
/// itself has a list under it and belongs on a surface of its own. This is the
/// affordance, and it is shaped like a field because that is what the gesture
/// expects.
class _SearchBar extends StatelessWidget {
const _SearchBar({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Search for an address',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.cardRaised,
),
child: Row(
children: [
const Icon(LucideIcons.search, size: 17, color: DmColors.ink3),
const SizedBox(width: 10),
Expanded(
child: Text(
'Search for a building or street',
style: DmText.body.copyWith(
fontSize: 14.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}
}
/// The sheet the map runs under.
///
/// ── Everything the screen asks, in one surface ──
///
/// The address the pin resolved to, who the Miler should ask for, and the
/// button. It is a fixed panel rather than a draggable sheet on purpose: a
/// sheet that can be dragged competes with a map that must be, and there is
/// nothing below the fold worth reaching for.
///
/// The contact is folded away behind a line of text. It is right nearly every
/// time — it opens on the account's own number — and the one screen that
/// exists to place a pin should not open with a keyboard-bearing field on it.
class _ConfirmSheet extends StatelessWidget {
const _ConfirmSheet({
required this.title, required this.title,
required this.sub, required this.sub,
required this.resolving, required this.resolving,
required this.onChange, required this.denial,
required this.contact,
required this.contactOpen,
required this.onToggleContact,
required this.onEdit,
required this.onSettings,
required this.onConfirm,
}); });
final String? title; final String? title;
final String? sub; final String? sub;
final bool resolving; final bool resolving;
final VoidCallback onChange; final LocationDenial? denial;
final TextEditingController contact;
final bool contactOpen;
final VoidCallback onToggleContact;
final VoidCallback onEdit;
final VoidCallback onSettings;
/// Null while there is no pin, or while the address is still coming back.
final VoidCallback? onConfirm;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.fromLTRB(14, 13, 10, 14), decoration: BoxDecoration(
decoration: const BoxDecoration( color: DmColors.surface,
color: DmColors.brandSoft, borderRadius: const BorderRadius.vertical(
border: Border(top: BorderSide(color: DmColors.border)), top: Radius.circular(DmRadius.header),
),
boxShadow: DmShadow.sheet,
), ),
child: Row( child: SafeArea(
children: [ top: false,
// The resolved address is the map's answer, so it takes the wash: it child: Padding(
// is brand-owned context, not a field waiting to be filled. padding: const EdgeInsets.fromLTRB(
const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash), DmSpace.pad,
const SizedBox(width: 12), 18,
Expanded( DmSpace.pad,
child: AnimatedSwitcher( 14,
duration: DmMotion.base, ),
child: resolving child: Column(
? const _ResolvingLines(key: ValueKey('resolving')) mainAxisSize: MainAxisSize.min,
: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
key: ValueKey('$title|$sub'), children: [
crossAxisAlignment: CrossAxisAlignment.start, Text('PICKUP FROM', style: DmText.eyebrow),
mainAxisSize: MainAxisSize.min, const SizedBox(height: 10),
_Resolved(
title: title,
sub: sub,
resolving: resolving,
onEdit: onEdit,
),
if (denial != null) ...[
const SizedBox(height: 14),
_LocationNotice(
denial: denial!,
onSettings: onSettings,
onSearch: onEdit,
),
],
const SizedBox(height: 14),
// The second question, folded.
//
// Its own row rather than a `DmTextAction`, which centres its
// label — a centred line under a left-aligned address reads as a
// caption for the sheet rather than as a control in it.
Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onToggleContact,
child: SizedBox(
height: 34,
child: Row(
children: [ children: [
Text( Icon(
title ?? 'Locating you…', contactOpen
key: const ValueKey('pickupAddressTitle'), ? LucideIcons.chevronUp
maxLines: 1, : LucideIcons.userRoundPlus,
overflow: TextOverflow.ellipsis, size: 16,
style: DmText.cardTitle, color: DmColors.ink3,
), ),
const SizedBox(height: 3), const SizedBox(width: 9),
Text( Expanded(
sub ?? 'Please wait a moment', child: Text(
maxLines: 1, contactOpen
overflow: TextOverflow.ellipsis, ? 'Hide contact number'
style: DmText.small.copyWith(color: DmColors.brandPress), : 'Someone else is handing it over?',
style: DmText.small.copyWith(
fontSize: 13.5,
color: DmColors.ink2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
), ),
),
),
AnimatedSize(
duration: DmMotion.base,
curve: DmMotion.ease,
alignment: Alignment.topCenter,
child: contactOpen
? Padding(
padding: const EdgeInsets.only(top: 10),
child: DmTextField(
label: 'Who the Miler asks for',
controller: contact,
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
mono: true,
textInputAction: TextInputAction.done,
),
)
: const SizedBox(width: double.infinity),
),
const SizedBox(height: 14),
DmButton(
label: 'Confirm pickup point',
icon: LucideIcons.check,
// Gated on the pin, not just the address: a booking without
// coordinates is one the Miler cannot be routed to.
onPressed: onConfirm,
),
],
), ),
DmTextAction(label: 'Change', onPressed: onChange), ),
],
), ),
); );
} }
} }
/// What the address box shows while the pin is moving: the shape of an /// What the pin is sitting on, and the way to change it by typing.
/// address, so the card never collapses or jumps. class _Resolved extends StatelessWidget {
class _ResolvingLines extends StatelessWidget { const _Resolved({
const _ResolvingLines({super.key}); required this.title,
required this.sub,
required this.resolving,
required this.onEdit,
});
final String? title;
final String? sub;
final bool resolving;
final VoidCallback onEdit;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( if (resolving) {
return Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation(DmColors.brand),
),
),
const SizedBox(width: 12),
Text(
'Getting address…',
style: DmText.cardTitle.copyWith(
fontSize: 17,
color: DmColors.ink3,
),
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Expanded(
'Getting address…', child: Column(
style: DmText.cardTitle.copyWith(color: DmColors.ink3), crossAxisAlignment: CrossAxisAlignment.start,
), mainAxisSize: MainAxisSize.min,
const SizedBox(height: 6), children: [
Container( Text(
width: 148, title ?? 'Move the map to set the pin',
height: 9, // Keyed, because the tests read the resolved address off this
decoration: BoxDecoration( // exact widget to prove GPS, a map drag and a search all write
color: DmColors.brandLine, // the same pickup point.
borderRadius: BorderRadius.circular(4), key: const ValueKey('pickupAddressTitle'),
style: DmText.cardTitle.copyWith(fontSize: 17.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (sub != null && sub!.isNotEmpty)
Text(
sub!,
style: DmText.small.copyWith(color: DmColors.ink3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
const SizedBox(width: 10),
DmTextAction(label: 'Edit', minHeight: 24, onPressed: onEdit),
], ],
); );
} }

View File

@@ -15,6 +15,7 @@ import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart'; import '../../widgets/feedback.dart';
import '../../widgets/pieces.dart'; import '../../widgets/pieces.dart';
import '../sheets/destination_sheet.dart'; import '../sheets/destination_sheet.dart';
import '../sheets/drop_address_sheet.dart';
import '../sheets/window_sheet.dart'; import '../sheets/window_sheet.dart';
import 'booking_routes.dart'; import 'booking_routes.dart';
import 'confirmed_screen.dart'; import 'confirmed_screen.dart';
@@ -111,7 +112,10 @@ class _SendScreenState extends State<SendScreen> {
final fare = app.draftFare; final fare = app.draftFare;
final ready = final ready =
city.districtCode != null && app.draftSlotId != null && app.draftReady; city.districtCode != null &&
app.draftSlotId != null &&
app.draftReady &&
(!app.draftDetailed || group.details.exactAddress != null);
return Scaffold( return Scaffold(
backgroundColor: DmColors.canvas, backgroundColor: DmColors.canvas,
@@ -125,8 +129,9 @@ class _SendScreenState extends State<SendScreen> {
// arrived here from Home's sphere has answered two questions and // arrived here from Home's sphere has answered two questions and
// wants to know this is the last one. Three dots said the same thing // wants to know this is the last one. Three dots said the same thing
// and made you count them. // and made you count them.
const _StepHead(step: 3, of: 3), // Two steps, not three. BOOK asks when, this screen asks the rest.
const SizedBox(height: 14), const _StepHead(step: 2, of: 2, title: 'Review and book'),
const SizedBox(height: 16),
// The route, as one card with the two ends and a thread between // The route, as one card with the two ends and a thread between
// them. It was four ruled rows of equal weight, so where it comes // them. It was four ruled rows of equal weight, so where it comes
// from, where it goes, when and how many all read as the same kind // from, where it goes, when and how many all read as the same kind
@@ -141,8 +146,12 @@ class _SendScreenState extends State<SendScreen> {
from: pickup?.title ?? 'Set a pickup address', from: pickup?.title ?? 'Set a pickup address',
fromNote: pickup?.sub, fromNote: pickup?.sub,
to: [for (final g in app.draftDestinations) g.destination], to: [for (final g in app.draftDestinations) g.destination],
// Typed, because the screen pops with the chosen place so
// the search sheet can carry it back. Review does not need
// the result — it reads `draftPickup` — but a `Route<void>`
// asserts when something is popped through it.
onTapFrom: () => Navigator.of(context).push( onTapFrom: () => Navigator.of(context).push(
bookingRoute<void>( bookingRoute<Place?>(
BookingRoutes.pickup, BookingRoutes.pickup,
(_) => const PickupLocationScreen(), (_) => const PickupLocationScreen(),
), ),
@@ -160,12 +169,17 @@ class _SendScreenState extends State<SendScreen> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// BOOK asks where it is going; the window is asked here. Unset it
// takes the brand, so the one row still to be filled is the one the
// eye lands on — the same treatment the destination row gets when
// it is the one outstanding.
_DetailCard( _DetailCard(
icon: LucideIcons.calendar, icon: LucideIcons.calendar,
label: 'PICKUP WINDOW', label: 'PICKUP WINDOW',
value: slot == null value: slot == null
? 'Choose a window' ? 'Choose a window'
: '${slot.day}, ${slot.window}', : '${slot.day}, ${slot.window}',
unset: slot == null,
onTap: () => showWindowSheet(context), onTap: () => showWindowSheet(context),
trailing: slot == null trailing: slot == null
? null ? null
@@ -175,6 +189,31 @@ class _SendScreenState extends State<SendScreen> {
color: DmColors.ok, color: DmColors.ok,
), ),
), ),
// ── Only the long way asks for this ──
//
// One Touch books on a state, a district and a window; the door is
// filled in by the Miler who is standing at it. A customer who chose
// the full form is choosing to save them that step, so the row is
// here, it is outstanding until it is answered, and the button waits
// for it.
if (app.draftDetailed) ...[
const SizedBox(height: 10),
_DetailCard(
icon: LucideIcons.house,
label: 'DROP ADDRESS',
value: group.details.exactAddress ?? 'Add the full address',
note: group.details.recipientName,
unset: group.details.exactAddress == null,
onTap: () async {
final saved = await showDropAddressSheet(
context,
details: group.details,
district: group.destination.shortLabel,
);
if (saved == true && context.mounted) setState(() {});
},
),
],
const SizedBox(height: 10), const SizedBox(height: 10),
_DetailCard( _DetailCard(
icon: LucideIcons.package, icon: LucideIcons.package,
@@ -234,11 +273,17 @@ class _SendScreenState extends State<SendScreen> {
child: Text('ESTIMATED', style: DmText.eyebrow), child: Text('ESTIMATED', style: DmText.eyebrow),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( // Stretched. The footer centres what it cannot stretch, so a short
fare?.range ?? '—', // value — the em dash before a fare has been quoted — drifted to
style: DmText.headerTitle.copyWith(fontSize: 30), // the middle while its own label stayed at the margin.
maxLines: 1, SizedBox(
overflow: TextOverflow.ellipsis, width: double.infinity,
child: Text(
fare?.range ?? '—',
style: DmText.headerTitle.copyWith(fontSize: 30),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Row( Row(
@@ -285,40 +330,55 @@ class _SendScreenState extends State<SendScreen> {
/// read rather than parsed, and it leaves the screen's own title to the thing /// read rather than parsed, and it leaves the screen's own title to the thing
/// the customer is about to press. /// the customer is about to press.
class _StepHead extends StatelessWidget { class _StepHead extends StatelessWidget {
const _StepHead({required this.step, required this.of}); const _StepHead({
required this.step,
required this.of,
required this.title,
});
final int step; final int step;
final int of; final int of;
final String title;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Semantics( return Semantics(
header: true, header: true,
label: 'Step $step of $of', label: '$title. Step $step of $of',
excludeSemantics: true, excludeSemantics: true,
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Row(
width: 22, children: [
height: 22, Container(
alignment: Alignment.center, width: 22,
decoration: const BoxDecoration( height: 22,
color: DmColors.ink, alignment: Alignment.center,
shape: BoxShape.circle, decoration: const BoxDecoration(
), color: DmColors.ink,
child: Text( shape: BoxShape.circle,
'$step', ),
style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white), child: Text(
), '$step',
), style: DmText.label.copyWith(
const SizedBox(width: 10), fontSize: 11.5,
Text( color: Colors.white,
'Step $step of $of', ),
style: DmText.cardTitle.copyWith( ),
fontSize: 14.5, ),
color: DmColors.ink2, const SizedBox(width: 10),
), Text(
'Step $step of $of',
style: DmText.cardTitle.copyWith(
fontSize: 14.5,
color: DmColors.ink2,
),
),
],
), ),
const SizedBox(height: 12),
Text(title, style: DmText.display),
], ],
), ),
); );
@@ -365,8 +425,14 @@ class _RouteHead extends StatelessWidget {
for (var i = 0; i < to.length; i++) for (var i = 0; i < to.length; i++)
_Stop( _Stop(
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}', label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
value: to[i].shortLabel, // BOOK no longer asks where it is going, so this row is where the
note: to[i].stateName, // question lands. Unset it reads as an invitation rather than as
// the em dash `shortLabel` falls back to.
value: to[i].districtCode == null
? 'Choose a destination'
: to[i].shortLabel,
unset: to[i].districtCode == null,
note: to[i].districtCode == null ? null : to[i].stateName,
onTap: onTapTo, onTap: onTapTo,
origin: false, origin: false,
last: i == to.length - 1, last: i == to.length - 1,
@@ -384,6 +450,7 @@ class _Stop extends StatelessWidget {
required this.origin, required this.origin,
required this.last, required this.last,
this.note, this.note,
this.unset = false,
}); });
final String label; final String label;
@@ -393,11 +460,15 @@ class _Stop extends StatelessWidget {
final bool origin; final bool origin;
final bool last; final bool last;
/// Nothing chosen yet — the node hollows out and the value takes the brand,
/// so the one row still to be filled is the one the eye lands on.
final bool unset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Semantics( return Semantics(
button: true, button: true,
label: '$label $value', label: '$label $value. Change',
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
@@ -416,8 +487,13 @@ class _Stop extends StatelessWidget {
width: 10, width: 10,
height: 10, height: 10,
decoration: BoxDecoration( decoration: BoxDecoration(
color: origin ? DmColors.ink : DmColors.brand, color: unset
? Colors.transparent
: (origin ? DmColors.ink : DmColors.brand),
shape: BoxShape.circle, shape: BoxShape.circle,
border: unset
? Border.all(color: DmColors.brand, width: 2)
: null,
), ),
), ),
if (!last) if (!last)
@@ -434,16 +510,45 @@ class _Stop extends StatelessWidget {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16), padding: EdgeInsets.fromLTRB(0, 8, 2, last ? 4 : 16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(label, style: DmText.eyebrow), // ── The label, and the way to change it ──
//
// Both stops were tappable and neither said so. The
// thread and the two places read as a printed summary,
// which is exactly what a review screen looks like when
// it is *not* editable — so the one thing the screen is
// for was invisible.
//
// "Change" against the label, rather than a chevron at
// the end of the row: a chevron on a row inside a card
// means "opens a screen", and these open a sheet over
// this one.
Row(
children: [
Expanded(
child: Text(label, style: DmText.eyebrow),
),
const SizedBox(width: 10),
Text(
'Change',
style: DmText.label.copyWith(
fontSize: 12,
color: DmColors.brand,
),
),
],
),
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
value, value,
style: DmText.cardTitle.copyWith(fontSize: 16.5), style: DmText.cardTitle.copyWith(
fontSize: 16.5,
color: unset ? DmColors.brand : null,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -479,6 +584,7 @@ class _DetailCard extends StatelessWidget {
this.note, this.note,
this.onTap, this.onTap,
this.trailing, this.trailing,
this.unset = false,
}); });
final IconData icon; final IconData icon;
@@ -488,6 +594,10 @@ class _DetailCard extends StatelessWidget {
final VoidCallback? onTap; final VoidCallback? onTap;
final Widget? trailing; final Widget? trailing;
/// Nothing chosen yet — the value takes the brand and the glyph well warms
/// with it, so an unanswered row is visibly the next thing to do.
final bool unset;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return DmCard( return DmCard(
@@ -502,10 +612,14 @@ class _DetailCard extends StatelessWidget {
height: 38, height: 38,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: DmColors.surfaceAlt, color: unset ? DmColors.brandSoft : DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs), borderRadius: DmRadius.all(DmRadius.xs),
), ),
child: Icon(icon, size: 18, color: DmColors.ink2), child: Icon(
icon,
size: 18,
color: unset ? DmColors.brand : DmColors.ink2,
),
), ),
const SizedBox(width: 13), const SizedBox(width: 13),
Expanded( Expanded(
@@ -517,7 +631,10 @@ class _DetailCard extends StatelessWidget {
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
value, value,
style: DmText.cardTitle.copyWith(fontSize: 15.5), style: DmText.cardTitle.copyWith(
fontSize: 15.5,
color: unset ? DmColors.brand : null,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),

View File

@@ -15,6 +15,7 @@ import '../widgets/misc.dart';
import 'booking/booking_routes.dart'; import 'booking/booking_routes.dart';
import 'booking/send_screen.dart'; import 'booking/send_screen.dart';
import 'sheets/destination_sheet.dart'; import 'sheets/destination_sheet.dart';
import 'sheets/drop_address_sheet.dart';
import 'sheets/place_search_sheet.dart'; import 'sheets/place_search_sheet.dart';
import 'sheets/window_sheet.dart'; import 'sheets/window_sheet.dart';
import 'tracking_screen.dart'; import 'tracking_screen.dart';
@@ -52,19 +53,18 @@ class _HomeScreenState extends State<HomeScreen> {
/// BOOK. The one action on this screen, and the whole flow's front door. /// 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, then when, then the review ──
/// ///
/// where is it going? → when shall we collect? → the details /// Two sheets in sequence: states → districts, then the pickup window. The
/// second rises while the first is still falling, so the handover reads as
/// one surface changing its question rather than as a sheet failing and
/// another arriving.
/// ///
/// Both used to be answered somewhere else. The destination was a strip of /// Dismissing the window sheet is allowed. The review screen carries the
/// cards halfway down the form; the window was picked *for* the customer, /// window as its own row — brand-coloured while it is unanswered — and the
/// silently, the moment they chose a city — they arrived at a booking with a /// button stays disabled until there is one, so backing out of the second
/// time on it that they had never agreed to. A pickup window is a promise /// sheet costs a tap rather than the booking.
/// about somebody's afternoon, and it is not ours to assume. Future<void> _book({bool detailed = false}) async {
///
/// 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 app = AppScope.read(context);
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
@@ -73,9 +73,26 @@ class _HomeScreenState extends State<HomeScreen> {
final places = await showDestinationSheet(context); final places = await showDestinationSheet(context);
if (places == null || places.isEmpty || !mounted) return; if (places == null || places.isEmpty || !mounted) return;
app.startBooking(); app.startBooking(detailed: detailed);
app.setDestinations(places); app.setDestinations(places);
// The full form asks for the door before it asks for the window, and it
// asks once per destination: a visit that fans out to three places is
// three addresses, and "for all parcels" is the whole point of choosing
// this path over One Touch.
if (detailed) {
for (final group in app.draftDestinations) {
if (!mounted) return;
final saved = await showDropAddressSheet(
context,
details: group.details,
district: group.destination.shortLabel,
);
if (saved != true) break;
}
}
if (!mounted) return;
await showWindowSheet(context); await showWindowSheet(context);
if (!mounted) return; if (!mounted) return;
@@ -95,7 +112,11 @@ class _HomeScreenState extends State<HomeScreen> {
// The sphere's own field, floored so a very short window leaves it a // The sphere's own field, floored so a very short window leaves it a
// sphere rather than a slot. It is a *minimum*: the scroll view hands it // sphere rather than a slot. It is a *minimum*: the scroll view hands it
// whatever is left once the card below has taken what it needs. // whatever is left once the card below has taken what it needs.
final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38); // Lower than it was. The sphere gained a second control under it and the
// live card was sitting a third of a screen below both; the field only has
// to be big enough for the sphere to float in, not to push everything else
// off the fold.
final orbHeight = math.max(260.0, MediaQuery.sizeOf(context).height * 0.30);
return SafeArea( return SafeArea(
bottom: false, bottom: false,
@@ -114,7 +135,7 @@ class _HomeScreenState extends State<HomeScreen> {
// this is the top of the screen, so it takes the inset the bar used // this is the top of the screen, so it takes the inset the bar used
// to hold. // to hold.
Padding( Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0), padding: const EdgeInsets.fromLTRB(DmSpace.pad, 26, DmSpace.pad, 0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -155,17 +176,29 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
], ],
), ),
const SizedBox(height: 12),
_PickupLine(
title: app.pickup?.title ?? 'Set a pickup address',
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
),
], ],
), ),
), ),
// ── What is running comes first ──
//
// The live card sat under the sphere, at the foot of the screen. A
// customer with a parcel in the network opens this app to check on
// it, not to send another one — so it is the first thing under their
// own name, and the sphere is what they scroll past it to.
if (active != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
14,
DmSpace.pad,
0,
),
child: _LiveCard(booking: active),
)
.animate()
.fadeIn(duration: 380.ms, delay: 160.ms)
.slideY(begin: -0.08, curve: DmMotion.ease),
Expanded( Expanded(
child: AnimatedScale( child: AnimatedScale(
scale: _handingOver ? 0.96 : 1, scale: _handingOver ? 0.96 : 1,
@@ -201,7 +234,30 @@ class _HomeScreenState extends State<HomeScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
DmBookOrb(onTap: _book, held: _handingOver) // ── What each way asks for ──
//
// Two ways to book the same parcel is not
// something a customer can infer from a sphere
// and a form. One line each, in place: this one
// under the sphere, the other on the DROP row.
// Said side by side in a paragraph they would be
// a comparison nobody reads; said where the
// choice is made they are two captions.
DmBookOrb(
onTap: _book,
held: _handingOver,
// Short enough to sit on one line, and
// written to be read against the DROP
// row's note rather than on its own:
//
// Just a city and a time
// Enter the full address yourself
//
// Two captions, same length, opposite
// answers. A paragraph comparing them
// would say more and be read by nobody.
caption: 'Just a city and a time',
)
.animate() .animate()
.fadeIn(duration: 420.ms) .fadeIn(duration: 420.ms)
.scale( .scale(
@@ -210,18 +266,6 @@ class _HomeScreenState extends State<HomeScreen> {
duration: 620.ms, duration: 620.ms,
curve: Curves.easeOutBack, curve: Curves.easeOutBack,
), ),
const SizedBox(height: 20),
// The promise, as a chip. It was a sentence under
// the sphere on first run and nothing afterwards;
// a returning customer is the one who most wants
// to know a Miler is three minutes away.
const _PromiseChip(
icon: LucideIcons.zap,
label: '3 min pickup',
).animate().fadeIn(
duration: 380.ms,
delay: 220.ms,
),
], ],
), ),
), ),
@@ -232,29 +276,29 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
), ),
), ),
// ── The foot: what is running, and nothing else ── // ── Everything under the sphere is pinned ──
// //
// A "quick dispatch" row of shortcut cities lived here and was // The scroll view centres the sphere in whatever height is left, and
// removed. It was a second way to start the one thing this screen // it cannot know about anything laid out after it — so anything laid
// starts, and it cost the sphere the space that makes it read as an // out inside that column pushed the thing before it under the fold.
// object floating in a page rather than as a button in a stack. // The form sits outside it, and the sphere floats in what remains.
// //
// The card sits outside the scroll view, so the sphere is centred in // A "3 min pickup" chip used to sit here. It was riding the sphere's
// what is actually left rather than in a viewport pretending the // lower arc — the field is centred on the disc, not on the glow
// card is not there. // round it, so a chip pinned directly under the column overlapped
if (active != null) // the shadow the sphere throws.
Padding( Padding(
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
DmSpace.pad, child: _PickDropForm(
14, pickup: app.pickup?.title,
DmSpace.pad, onPickup: () async {
6, final place = await showPlaceSearchSheet(context);
), if (place != null && context.mounted) app.setPickup(place);
child: _LiveCard(booking: active), },
) onDrop: () => _book(detailed: true),
.animate() ).animate().fadeIn(duration: 380.ms, delay: 300.ms),
.fadeIn(duration: 380.ms, delay: 160.ms) ),
.slideY(begin: 0.12, curve: DmMotion.ease),
// Clears the floating tab bar, which the shell stacks over the body. // Clears the floating tab bar, which the shell stacks over the body.
// //
// Measured, not guessed. A fixed 74 was right on the 844pt golden // Measured, not guessed. A fixed 74 was right on the 844pt golden
@@ -273,22 +317,18 @@ class _HomeScreenState extends State<HomeScreen> {
} }
} }
/// The live booking, at the foot of Home. /// The live booking, at the head of Home.
/// ///
/// ── The loader went, and the card grew ── /// ── Smaller, and without the courier ──
/// ///
/// It carried a thin filled line under the destination — a progress bar across /// It carried a rule and a second row naming the Miler and their plate. Both
/// the five customer milestones. It was the one fact that cannot be written as /// are real and neither belongs here: this card sits above the sphere now, so
/// a word, and it still read as a *loader*: a bar that fills is the shape an /// every point it takes is a point the one action on the screen gives up — and
/// app uses to say "wait", and a parcel two days into a journey is not /// the Miler's name is on the screen this card opens, beside the button that
/// something the customer is waiting through. The rail on tracking says the /// rings them, which is where it is any use.
/// same thing with five labelled nodes and no ambiguity.
/// ///
/// What the space bought is size. This is the only thing on Home besides the /// What is left is what a glance asks: what is happening, where, and when.
/// sphere and it was a 90pt strip — a chip, a place and a courier compressed /// Three lines and an arrow.
/// into three tight lines. The destination is set at display weight with its
/// promise under it now, a rule separates the journey from the person carrying
/// it, and the arrow is a target rather than a glyph.
class _LiveCard extends StatelessWidget { class _LiveCard extends StatelessWidget {
const _LiveCard({required this.booking}); const _LiveCard({required this.booking});
@@ -298,7 +338,6 @@ class _LiveCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final app = AppScope.read(context); final app = AppScope.read(context);
final slot = app.slotById(booking.slotId); final slot = app.slotById(booking.slotId);
final miler = booking.miler ?? booking.deliveryAgent;
// The district alone. "Chennai, Tamil Nadu" shares its row with the state // The district alone. "Chennai, Tamil Nadu" shares its row with the state
// and the reference, and on a 402pt device it rendered "Chenna…" — the // and the reference, and on a 402pt device it rendered "Chenna…" — the
@@ -334,20 +373,20 @@ class _LiveCard extends StatelessWidget {
}, },
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 17, 18, 18), padding: const EdgeInsets.fromLTRB(16, 13, 14, 14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Row(
children: [ children: [
const DmLiveDot(size: 7), const DmLiveDot(size: 6),
const SizedBox(width: 9), const SizedBox(width: 8),
Flexible( Flexible(
child: Text( child: Text(
booking.stage.chipLabel.toUpperCase(), booking.stage.chipLabel.toUpperCase(),
style: DmText.eyebrow.copyWith( style: DmText.eyebrow.copyWith(
fontSize: 10, fontSize: 9.5,
letterSpacing: 0.9, letterSpacing: 0.9,
color: DmColors.brand, color: DmColors.brand,
), ),
@@ -359,7 +398,10 @@ class _LiveCard extends StatelessWidget {
Flexible( Flexible(
child: Text( child: Text(
booking.displayId, booking.displayId,
style: DmText.monoSm.copyWith(color: DmColors.ink3), style: DmText.monoSm.copyWith(
fontSize: 11,
color: DmColors.ink3,
),
textAlign: TextAlign.right, textAlign: TextAlign.right,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -367,7 +409,7 @@ class _LiveCard extends StatelessWidget {
), ),
], ],
), ),
const SizedBox(height: 14), const SizedBox(height: 9),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -377,15 +419,14 @@ class _LiveCard extends StatelessWidget {
children: [ children: [
Text( Text(
place, place,
style: DmText.headerTitle.copyWith(fontSize: 26), style: DmText.headerTitle.copyWith(fontSize: 21),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 3),
Text( Text(
promise, promise,
style: DmText.body.copyWith( style: DmText.small.copyWith(
fontSize: 14.5, fontSize: 13,
color: DmColors.ink3, color: DmColors.ink3,
), ),
maxLines: 1, maxLines: 1,
@@ -395,12 +436,9 @@ class _LiveCard extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// A disc, not a bare chevron. It is the only affordance on
// the card and it sits against 26pt of type, where a 17pt
// glyph on its own disappears.
Container( Container(
width: 44, width: 36,
height: 44, height: 36,
alignment: Alignment.center, alignment: Alignment.center,
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: DmColors.brandSoft, color: DmColors.brandSoft,
@@ -408,47 +446,12 @@ class _LiveCard extends StatelessWidget {
), ),
child: const Icon( child: const Icon(
LucideIcons.arrowRight, LucideIcons.arrowRight,
size: 19, size: 17,
color: DmColors.brand, color: DmColors.brand,
), ),
), ),
], ],
), ),
if (miler != null) ...[
const SizedBox(height: 16),
const Divider(
height: 1,
thickness: 1,
color: DmColors.hairline,
),
const SizedBox(height: 14),
Row(
children: [
DmAvatar(initials: miler.initials, size: 38),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
miler.name,
style: DmText.cardTitle.copyWith(fontSize: 14.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
miler.vehicle,
style: DmText.monoSm.copyWith(color: DmColors.ink3),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
],
], ],
), ),
), ),
@@ -457,103 +460,178 @@ class _LiveCard extends StatelessWidget {
} }
} }
/// Where the Miler is coming to, under the greeting. /// Pickup over drop, on one card — the shape every ride app asks a journey in.
/// ///
/// ── Box → line → pill → line → row → pill ── /// ── Why the app has two ways to start and only one of them is a sphere ──
/// ///
/// It has been most shapes. The one it has landed on is the reference's: a /// One Touch is the product: a state, a district, a window, and the Miler
/// small bordered pill holding a brand pin, the address, and a chevron-down. /// writes down the door when they get there. It is a sphere because it is one
/// gesture, and a gesture cannot be a form.
/// ///
/// The pill is right *here* and was wrong before because the screen changed /// This is the same booking for a customer who already knows the door. Both
/// around it. On a page whose only other objects were a name and a sphere it /// ends are here because a journey has two, and seeing them together is what
/// was a container built to hold one line of text. On a page that now opens /// makes it obvious that one is already answered — the pickup carries the
/// with a wordmark bar and ends with a floating one, it is the middle term — /// address the app detected, so the only row with a question on it is the
/// and the chevron-down is the honest glyph, because what it opens is a /// drop.
/// chooser, not another screen. ///
class _PickupLine extends StatelessWidget { /// It replaced a line of text reading "Enter the full address instead", which
const _PickupLine({required this.title, required this.onTap}); /// said what would happen and showed none of it. A form that is already half
/// filled in is a better argument for itself than a sentence about forms.
class _PickDropForm extends StatelessWidget {
const _PickDropForm({
required this.pickup,
required this.onPickup,
required this.onDrop,
});
final String title; final String? pickup;
final VoidCallback onPickup;
final VoidCallback onDrop;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 6, 14, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_End(
label: 'PICKUP',
value: pickup ?? 'Set a pickup address',
unset: pickup == null,
origin: true,
last: false,
onTap: onPickup,
),
_End(
label: 'DROP',
value: 'Where is it going?',
note: 'Enter the full address yourself',
unset: true,
origin: false,
last: true,
onTap: onDrop,
),
],
),
),
],
);
}
}
/// One end of the journey: a node on a thread, a micro-label, and the answer
/// or the question.
class _End extends StatelessWidget {
const _End({
required this.label,
required this.value,
required this.unset,
required this.origin,
required this.last,
required this.onTap,
this.note,
});
final String label;
final String value;
/// What choosing this row means. Only the drop carries one — the pickup is
/// already answered, and a caption under an answer is noise.
final String? note;
final bool unset;
final bool origin;
final bool last;
final VoidCallback onTap; final VoidCallback onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Semantics( return Semantics(
button: true, button: true,
label: 'Pickup from $title. Change', label: '$label $value',
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Align( child: IntrinsicHeight(
alignment: Alignment.centerLeft, child: Row(
child: Container( crossAxisAlignment: CrossAxisAlignment.stretch,
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300), children: [
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8), // The node and the thread it hangs from. The thread belongs to
decoration: BoxDecoration( // the stop above the join, so the last one ends clean.
color: DmColors.surface, SizedBox(
borderRadius: DmRadius.all(DmRadius.pill), width: 20,
border: Border.all(color: DmColors.border), child: Column(
), children: [
child: Row( const SizedBox(height: 17),
mainAxisSize: MainAxisSize.min, Container(
children: [ width: 9,
const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand), height: 9,
const SizedBox(width: 8), decoration: BoxDecoration(
Flexible( color: unset && !origin
child: Text( ? Colors.transparent
title, : (origin ? DmColors.ink : DmColors.brand),
maxLines: 1, shape: BoxShape.circle,
overflow: TextOverflow.ellipsis, border: unset && !origin
style: DmText.cardTitle.copyWith(fontSize: 14), ? Border.all(color: DmColors.brand, width: 2)
: null,
),
),
if (!last)
const Expanded(
child: VerticalDivider(
width: 1,
thickness: 1,
color: DmColors.border,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(0, 10, 0, last ? 12 : 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 2),
Text(
value,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
color: unset ? DmColors.brand : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
const SizedBox(width: 6), ),
const Icon( const SizedBox(width: 8),
LucideIcons.chevronDown, const Icon(
size: 15, LucideIcons.chevronRight,
color: DmColors.ink4, size: 17,
), color: DmColors.ink4,
], ),
), ],
), ),
), ),
), ),
); );
} }
} }
/// The promise under the sphere: a glyph, a figure, a tint.
///
/// It was a full sentence — "A Miler collects from your door." — shown on the
/// first run only. That is the app explaining itself to somebody who has
/// already pressed nothing; what a customer actually wants before they commit
/// is how long they will wait, and that is worth saying every time.
class _PromiseChip extends StatelessWidget {
const _PromiseChip({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(11, 7, 13, 8),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.card,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: DmColors.brand),
const SizedBox(width: 7),
Text(
label,
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
),
],
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../data/models.dart'; import '../../data/models.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
@@ -7,6 +8,7 @@ import '../tokens.dart';
import '../widgets/chrome.dart'; import '../widgets/chrome.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../widgets/cards.dart'; import '../widgets/cards.dart';
import '../widgets/feedback.dart';
import 'booking/booking_routes.dart'; import 'booking/booking_routes.dart';
import 'order_details_screen.dart'; import 'order_details_screen.dart';
import 'tracking_screen.dart'; import 'tracking_screen.dart';
@@ -81,11 +83,10 @@ class OrderCard extends StatelessWidget {
_ => (dateLabel(booking.createdAt), false), _ => (dateLabel(booking.createdAt), false),
}; };
// Second line, right: what is in it. The weight appears only once the // What is in it, for the foot. The weight of *these* parcels, and only
// Miler has actually weighed it at the door. // once a Miler has actually weighed them at the door —
// The weight of *these* parcels, and only once a Miler has actually // `totalWeightLabel` is the whole visit's, which on one destination's row
// weighed them. `totalWeightLabel` is the whole visit's, which on one // is somebody else's parcel.
// destination's row is somebody else's parcel.
final weight = entry.isPickup final weight = entry.isPickup
? booking.totalWeightLabel ? booking.totalWeightLabel
: group!.verification?.weightLabel; : group!.verification?.weightLabel;
@@ -94,13 +95,17 @@ class OrderCard extends StatelessWidget {
?weight, ?weight,
].join(' · '); ].join(' · ');
// The foot: who has it, or when it was promised. // The foot says who has it and what is in it. The state is the chip at
// the top of the card, so naming it here again was the card saying one
// thing twice while the contents had nowhere to go.
final miler = booking.miler ?? booking.deliveryAgent; final miler = booking.miler ?? booking.deliveryAgent;
final footNote = miler != null final footNote = [
? '${miler.name} · ${stage.chipLabel}' if (miler != null) miler.name,
: (booking.status == BookingStatus.completed contents,
? 'Delivered${booking.amountPaid == null ? '' : ' · ₹${booking.amountPaid}'}' if (booking.status == BookingStatus.completed &&
: stage.milestoneLabel); booking.amountPaid != null)
'₹${booking.amountPaid}',
].join(' · ');
void open() { void open() {
app.openTracking(booking.reference); app.openTracking(booking.reference);
@@ -125,45 +130,29 @@ class OrderCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ── Two on the top line, not three ──
//
// The reference, the state and the time shared this row, and a
// `Row` of flexible children splits the width by flex rather
// than by need — so a reference wanting 120pt of a 322pt card
// was handed 107 and rendered "#DM-482…". Truncating the one
// string on the card that exists to be quoted, and which now
// copies when pressed, is the one thing this row must not do.
//
// The time moved down beside the destination, where there was
// already space to its right.
Row( Row(
children: [ children: [
Flexible(child: _RefChip(reference: reference)), Flexible(flex: 3, child: _RefChip(reference: reference)),
const SizedBox(width: 8), const SizedBox(width: 8),
Flexible( Flexible(
flex: 2,
child: DmStatusChip( child: DmStatusChip(
label: label, label: label,
tone: tone, tone: tone,
quiet: !live, quiet: !live,
), ),
), ),
const SizedBox(width: 8),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (urgent) ...[
const Icon(
LucideIcons.clock,
size: 12,
color: DmColors.brand,
),
const SizedBox(width: 5),
],
Flexible(
child: Text(
timing,
style: DmText.small.copyWith(
fontSize: 12.5,
color: urgent ? DmColors.brand : DmColors.ink3,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -184,15 +173,32 @@ class OrderCard extends StatelessWidget {
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Flexible( Flexible(
child: Text( child: Row(
contents, mainAxisSize: MainAxisSize.min,
textAlign: TextAlign.right, children: [
style: DmText.small.copyWith( if (urgent) ...[
fontSize: 12.5, const Icon(
color: DmColors.ink3, LucideIcons.clock,
), size: 12,
maxLines: 1, color: DmColors.brand,
overflow: TextOverflow.ellipsis, ),
const SizedBox(width: 5),
],
Flexible(
child: Text(
timing,
textAlign: TextAlign.right,
style: DmText.small.copyWith(
fontSize: 12.5,
color: urgent
? DmColors.brand
: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
), ),
), ),
], ],
@@ -240,11 +246,21 @@ class OrderCard extends StatelessWidget {
} }
} }
/// The reference, in a quiet well. /// The reference, in a quiet well — and the only thing on the card you can
/// take away with you.
/// ///
/// It reads as an identifier rather than as a sentence — which is what it is, /// ── Why it copies ──
/// and the reason it can sit at 11.5pt beside a 26pt place name without ///
/// disappearing. /// A reference exists to be quoted: into a chat with support, into a message
/// to whoever is receiving the parcel, into a form somewhere else. Every one
/// of those ends with a customer reading eight characters off a screen and
/// typing them somewhere, which is the one interaction a phone is worst at and
/// a clipboard is perfect at.
///
/// It has its own tap, so it wins the hit test over the card underneath: a
/// press on the chip copies, a press anywhere else opens the order. That is
/// the whole reason it is set in a well rather than as bare type — the well is
/// what says the chip is its own object.
class _RefChip extends StatelessWidget { class _RefChip extends StatelessWidget {
const _RefChip({required this.reference}); const _RefChip({required this.reference});
@@ -252,17 +268,42 @@ class _RefChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final shown = reference.startsWith('#') ? reference : '#$reference';
padding: const EdgeInsets.fromLTRB(8, 4, 8, 5), return Semantics(
decoration: BoxDecoration( button: true,
color: DmColors.surfaceAlt, label: 'Copy order ID $reference',
borderRadius: DmRadius.all(DmRadius.chip), child: GestureDetector(
), behavior: HitTestBehavior.opaque,
child: Text( onTap: () async {
reference.startsWith('#') ? reference : '#$reference', await Clipboard.setData(ClipboardData(text: reference));
style: DmText.monoSm.copyWith(fontSize: 11, color: DmColors.ink2), await HapticFeedback.selectionClick();
maxLines: 1, if (context.mounted) DmToast.show(context, 'Order ID copied');
overflow: TextOverflow.ellipsis, },
child: Container(
padding: const EdgeInsets.fromLTRB(10, 6, 9, 7),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
shown,
style: DmText.monoSm.copyWith(
fontSize: 12.5,
color: DmColors.ink,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 7),
const Icon(LucideIcons.copy, size: 12, color: DmColors.ink3),
],
),
),
), ),
); );
} }

View File

@@ -6,8 +6,6 @@ import '../../data/models.dart';
import '../../state/app_scope.dart'; import '../../state/app_scope.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../widgets/inputs.dart'; import '../widgets/inputs.dart';
import '../widgets/feedback.dart';
import '../widgets/chrome.dart';
import '../widgets/states.dart'; import '../widgets/states.dart';
import 'order_row.dart'; import 'order_row.dart';
@@ -61,100 +59,117 @@ class _OrdersScreenState extends State<OrdersScreen> {
final app = AppScope.of(context); final app = AppScope.of(context);
final q = _query.text.trim(); final q = _query.text.trim();
final all = app.entriesOf(app.ordersTab); final all = app.entriesOf(app.ordersTab);
final rows = [for (final e in all) if (_matches(e, q)) e]; final rows = [
for (final e in all)
if (_matches(e, q)) e,
];
final index = _tabs.indexOf(app.ordersTab); final index = _tabs.indexOf(app.ordersTab);
return Column( return SafeArea(
crossAxisAlignment: CrossAxisAlignment.stretch, bottom: false,
children: [ child: Column(
DmAppBar( crossAxisAlignment: CrossAxisAlignment.stretch,
unread: app.activeBookings.isNotEmpty, children: [
onSearch: () => DmToast.show(context, 'Search is coming soon'), // No wordmark bar, and no search or bell on it.
onNotifications: () =>
DmToast.show(context, 'Nothing new right now'),
),
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 14),
// The filter gets its own row.
// //
// Beside the title it had a third of the width for three segments, // The bar was carrying the app's name over a screen already titled
// and rendered "Act… Pa… Ca…" — the reference fits its control on // "Orders", and the search glyph beside it duplicated the field this
// the title line because it carries two segments, not three. A row // screen now has of its own. Home lost the same bar for the same
// is cheaper than an unreadable control. // reason: a root that opens on a title does not need to be told which
child: Column( // app it is.
crossAxisAlignment: CrossAxisAlignment.stretch, Padding(
children: [ padding: const EdgeInsets.fromLTRB(
Text( DmSpace.pad,
'Orders', 26,
style: DmText.headerTitle.copyWith(fontSize: 26), DmSpace.pad,
), 14,
const SizedBox(height: 14), ),
_Filter( // The filter gets its own row.
labels: const ['Active', 'Past', 'Cancelled'], //
counts: [for (final tab in _tabs) app.entriesOf(tab).length], // Beside the title it had a third of the width for three segments,
index: index < 0 ? 0 : index, // and rendered "Act… Pa… Ca…" — the reference fits its control on
onChanged: (i) => app.setOrdersTab(_tabs[i]), // the title line because it carries two segments, not three. A row
), // is cheaper than an unreadable control.
const SizedBox(height: 12), child: Column(
// ── A search box, because the list is the archive ── crossAxisAlignment: CrossAxisAlignment.stretch,
// children: [
// Orders is the only screen that grows without bound: every Text(
// parcel a customer has ever sent ends up here. Scrolling is 'Orders',
// fine at four and useless at forty, and the two things they style: DmText.headerTitle.copyWith(fontSize: 26),
// have to go on are the reference somebody quoted at them and ),
// the city they sent it to. Both are matched. const SizedBox(height: 14),
DmSearchField( _Filter(
controller: _query, labels: const ['Active', 'Past', 'Cancelled'],
hint: 'Search by order ID or city', counts: [for (final tab in _tabs) app.entriesOf(tab).length],
onChanged: (_) => setState(() {}), index: index < 0 ? 0 : index,
), onChanged: (i) => app.setOrdersTab(_tabs[i]),
], ),
), const SizedBox(height: 12),
), // ── A search box, because the list is the archive ──
Expanded( //
child: RefreshIndicator( // Orders is the only screen that grows without bound: every
color: DmColors.brand, // parcel a customer has ever sent ends up here. Scrolling is
backgroundColor: DmColors.surface, // fine at four and useless at forty, and the two things they
onRefresh: app.refreshOrders, // have to go on are the reference somebody quoted at them and
child: ListView.separated( // the city they sent it to. Both are matched.
physics: const AlwaysScrollableScrollPhysics(), DmSearchField(
// The last card clears the floating tab bar, which the shell controller: _query,
// stacks over the body rather than laying out beneath it. hint: 'Search by order ID or city',
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom), onChanged: (_) => setState(() {}),
itemCount: rows.isEmpty ? 1 : rows.length, ),
separatorBuilder: (_, _) => const SizedBox(height: 12), ],
itemBuilder: (context, i) => rows.isEmpty
? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
: OrderCard(entry: rows[i]),
), ),
), ),
), Expanded(
], child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: app.refreshOrders,
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
// The last card clears the floating tab bar, which the shell
// stacks over the body rather than laying out beneath it.
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
74 + MediaQuery.paddingOf(context).bottom,
),
itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, i) => rows.isEmpty
? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
: OrderCard(entry: rows[i]),
),
),
),
],
),
); );
} }
/// Nothing matched what they typed — which is a different problem from /// Nothing matched what they typed — which is a different problem from
/// having no orders, and says so. /// having no orders, and says so.
Widget _noMatch(String query) => DmEmptyState( Widget _noMatch(String query) => DmEmptyState(
icon: LucideIcons.search, icon: LucideIcons.search,
title: 'No orders match "$query"', title: 'No orders match "$query"',
message: 'Try an order ID, or the city it was going to.', message: 'Try an order ID, or the city it was going to.',
); );
Widget _empty(BookingStatus tab) { Widget _empty(BookingStatus tab) {
final (title, message) = switch (tab) { final (title, message) = switch (tab) {
BookingStatus.active => ( BookingStatus.active => (
'No active pickups', 'No active pickups',
'Book a pickup and it will show up here.', 'Book a pickup and it will show up here.',
), ),
BookingStatus.completed => ( BookingStatus.completed => (
'Nothing completed yet', 'Nothing completed yet',
'Delivered orders will appear here.', 'Delivered orders will appear here.',
), ),
BookingStatus.cancelled => ( BookingStatus.cancelled => (
'No cancelled bookings', 'No cancelled bookings',
'Anything you cancel will appear here.', 'Anything you cancel will appear here.',
), ),
}; };
return DmEmptyState( return DmEmptyState(
icon: LucideIcons.package, icon: LucideIcons.package,

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
/// The full drop address, for a booking made the long way.
///
/// ── Why this is a sheet and not a screen ──
///
/// Every field on it is optional to the contract and every one of them is
/// something the Miler can fill in at the door. That is the whole argument for
/// One Touch. A customer who chooses the full form is choosing to save the
/// Miler that step, so the form has to be quick enough to be worth choosing:
/// one surface, five fields, one button.
///
/// Nothing here is validated beyond being non-empty. A street name the
/// backend has never heard of is still better than no street name — it is
/// read by a person, not matched against a table.
Future<bool?> showDropAddressSheet(
BuildContext context, {
required DeliveryDetails details,
required String district,
}) {
return showDmSheet<bool>(
context: context,
builder: (context) => _DropAddressBody(details: details, district: district),
);
}
class _DropAddressBody extends StatefulWidget {
const _DropAddressBody({required this.details, required this.district});
final DeliveryDetails details;
final String district;
@override
State<_DropAddressBody> createState() => _DropAddressBodyState();
}
class _DropAddressBodyState extends State<_DropAddressBody> {
late final _building = TextEditingController(text: widget.details.building);
late final _street = TextEditingController(text: widget.details.street);
late final _landmark = TextEditingController(text: widget.details.landmark);
late final _name = TextEditingController(text: widget.details.recipientName);
late final _phone = TextEditingController(
text: _digitsOf(widget.details.recipientPhone),
);
@override
void initState() {
super.initState();
// The button turns on the moment there is enough to find a door, so the
// two fields that decide it are listened to rather than read on submit.
_building.addListener(_refresh);
_street.addListener(_refresh);
}
void _refresh() => setState(() {});
@override
void dispose() {
_building.removeListener(_refresh);
_street.removeListener(_refresh);
_building.dispose();
_street.dispose();
_landmark.dispose();
_name.dispose();
_phone.dispose();
super.dispose();
}
/// The ten digits, whatever shape the number arrived in — the field carries
/// its own `+91`.
static String _digitsOf(String? phone) {
final digits = (phone ?? '').replaceAll(RegExp(r'\D'), '');
return digits.length <= 10 ? digits : digits.substring(digits.length - 10);
}
/// Enough to send a Miler to. A building or a street will do — between them
/// they are what a person needs to find a door, and the rest is help.
bool get _enough =>
_building.text.trim().isNotEmpty || _street.text.trim().isNotEmpty;
void _save() {
final phone = _phone.text.trim();
widget.details.update(
building: _building.text,
street: _street.text,
landmark: _landmark.text,
recipientName: _name.text,
recipientPhone: phone.isEmpty ? null : '+91 $phone',
instructions: widget.details.instructions,
);
Navigator.of(context).pop(true);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmSheetHeader(
title: 'Where in ${widget.district}?',
subtitle: 'The door the Miler is delivering to',
),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmTextField(
label: 'Flat, house or building',
controller: _building,
),
const SizedBox(height: 12),
DmTextField(
label: 'Street and area',
controller: _street,
),
const SizedBox(height: 12),
DmTextField(
label: 'Landmark',
hint: 'Optional',
controller: _landmark,
),
const SizedBox(height: 20),
Text('WHO RECEIVES IT', style: DmText.eyebrow),
const SizedBox(height: 10),
DmTextField(
label: 'Name',
hint: 'Optional',
controller: _name,
),
const SizedBox(height: 12),
DmTextField(
label: 'Phone',
hint: 'Optional',
controller: _phone,
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
mono: true,
textInputAction: TextInputAction.done,
),
const SizedBox(height: 18),
],
),
),
),
DmButton(
label: 'Save address',
icon: _enough ? LucideIcons.check : null,
onPressed: _enough ? _save : null,
),
],
);
}
}

View File

@@ -11,21 +11,32 @@ import '../../widgets/cards.dart';
import '../../widgets/feedback.dart'; import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart'; import '../../widgets/inputs.dart';
import '../../widgets/states.dart'; import '../../widgets/states.dart';
import '../booking/pickup_location_screen.dart';
/// Alias used from Review, where the sheet edits the pickup point in place. /// Alias used from Review, where the sheet edits the pickup point in place.
Future<Place?> showPlaceSearchSheetForReview(BuildContext context) => Future<Place?> showPlaceSearchSheetForReview(BuildContext context) =>
showPlaceSearchSheet(context); showPlaceSearchSheet(context);
/// Pickup-location picker. Returns the chosen [Place], or null if dismissed. /// Pickup-location picker. Returns the chosen [Place], or null if dismissed.
Future<Place?> showPlaceSearchSheet(BuildContext context) { ///
/// [allowMap] offers "Select on map", which pushes the map screen over this
/// sheet and closes it carrying whatever pin comes back. It is off when the
/// map screen is the one asking — a row that opens the screen you are already
/// on is a loop with a label.
Future<Place?> showPlaceSearchSheet(
BuildContext context, {
bool allowMap = true,
}) {
return showDmSheet<Place>( return showDmSheet<Place>(
context: context, context: context,
builder: (context) => const _PlaceSearchBody(), builder: (context) => _PlaceSearchBody(allowMap: allowMap),
); );
} }
class _PlaceSearchBody extends StatefulWidget { class _PlaceSearchBody extends StatefulWidget {
const _PlaceSearchBody(); const _PlaceSearchBody({required this.allowMap});
final bool allowMap;
@override @override
State<_PlaceSearchBody> createState() => _PlaceSearchBodyState(); State<_PlaceSearchBody> createState() => _PlaceSearchBodyState();
@@ -44,6 +55,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
super.dispose(); super.dispose();
} }
/// Pushes the map *over* this sheet rather than replacing it.
///
/// The sheet stays mounted underneath, so a customer who opens the map and
/// changes their mind lands back on the search they came from instead of on
/// Home. It closes only when the map hands a pin back.
Future<void> _openMap() async {
final place = await Navigator.of(context, rootNavigator: true).push<Place>(
MaterialPageRoute(builder: (_) => const PickupLocationScreen()),
);
if (place == null || !mounted) return;
Navigator.of(context).pop(place);
}
void _onChanged(String value) { void _onChanged(String value) {
_debounce?.cancel(); _debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 220), () { _debounce = Timer(const Duration(milliseconds: 220), () {
@@ -57,12 +81,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const DmSheetHeader(title: 'Collect from'), const DmSheetHeader(
title: 'Collect from',
subtitle: 'Search for it, or drop a pin where you are standing',
),
DmSearchField( DmSearchField(
controller: _query, controller: _query,
hint: 'Search street, area or landmark', hint: 'Search street, area or landmark',
onChanged: _onChanged, onChanged: _onChanged,
), ),
if (widget.allowMap) ...[
const SizedBox(height: 12),
_MapRow(onTap: _openMap),
],
const SizedBox(height: 8), const SizedBox(height: 8),
Flexible( Flexible(
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -146,3 +177,81 @@ class _PlaceRow extends StatelessWidget {
); );
} }
} }
/// "Select on map" — the way in for somebody who cannot name where they are.
///
/// It sits above the results rather than among them because it is not a
/// result: search answers "which of these", and this answers "none of these,
/// let me point at it". A pin on a map is the only honest way to give a Miler
/// a gate, a side entrance or a building with no number.
class _MapRow extends StatelessWidget {
const _MapRow({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 60),
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
boxShadow: DmShadow.card,
),
child: Row(
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brandSoft,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.mapPinned,
size: 18,
color: DmColors.brand,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Select on map',
style: DmText.cardTitle.copyWith(fontSize: 15.5),
),
Text(
'Drop a pin at your exact door',
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.ink4,
),
],
),
),
),
);
}
}

View File

@@ -81,8 +81,8 @@ class _TabBar extends StatelessWidget {
(label: 'Account', icon: LucideIcons.userRound), (label: 'Account', icon: LucideIcons.userRound),
]; ];
static const double _slot = 62; static const double _slot = 74;
static const double _height = 58; static const double _height = 62;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -161,30 +161,30 @@ class _TabButtonState extends State<_TabButton> {
onTapCancel: () => setState(() => _down = false), onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap, onTap: widget.onTap,
child: AnimatedScale( child: AnimatedScale(
scale: _down ? 0.9 : 1, scale: _down ? 0.88 : 1,
duration: DmMotion.fast, duration: DmMotion.fast,
curve: DmMotion.ease, curve: DmMotion.ease,
child: Center( child: Center(
child: AnimatedContainer( // ── Colour alone ──
duration: DmMotion.base, //
curve: DmMotion.ease, // The glyph sat in a `brandSoft` rounded square, which made the
width: 42, // selected tab the only filled object in a bar whose whole job is
height: 42, // to recede. The reference marks it with the brand and nothing
alignment: Alignment.center, // else, and with three glyphs in a white pill that is unambiguous
decoration: BoxDecoration( // — there is only one coloured thing on the bar.
color: selected ? DmColors.brandSoft : Colors.transparent, //
borderRadius: DmRadius.all(DmRadius.sm), // It arrives with a shade of overshoot, so the tab reacts to the
), // press rather than recolouring after it.
child: AnimatedScale( child: AnimatedScale(
// A shade of overshoot as the tint arrives under it, so the scale: selected ? 1.08 : 1,
// two read as one gesture rather than as a glyph that duration: DmMotion.slow,
// recolours when a rectangle happens to appear behind it. curve: DmMotion.glide,
scale: selected ? 1.06 : 1, child: AnimatedContainer(
duration: DmMotion.slow, duration: DmMotion.base,
curve: DmMotion.glide, curve: DmMotion.ease,
child: Icon( child: Icon(
widget.icon, widget.icon,
size: 21, size: 23,
color: selected ? DmColors.brand : DmColors.ink4, color: selected ? DmColors.brand : DmColors.ink4,
), ),
), ),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart'; import '../../data/models.dart';
@@ -252,17 +253,17 @@ class _TrackCard extends StatelessWidget {
final group = focused ?? booking.destinations.first; final group = focused ?? booking.destinations.first;
// What the customer can act on: when, before the handover; where, after. // What the customer can act on: when, before the handover; where, after.
final when = switch (true) { // ── One line only, and only when it is not a time ──
_ when cancelled => 'You were not charged', //
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 => // This carried "Today · 2:00 – 4:00 PM" under the headline while the strip
'Arriving in about ${booking.milerEtaMinutes} min', // below it carried "WINDOW · 2:00 – 4:00 PM" — the same promise twice,
_ when stage == JourneyStage.arrived => 'At your door now', // eighty points apart. Every timing this used to show has a home of its
_ when stage.index <= JourneyStage.assigned.index && slot != null => // own now: the ETA is the pill beside the headline, the window and the
'Today · ${slot!.window}', // expected date are cells in the strip.
_ when booking.expectedDelivery != null => //
'Expected ${booking.expectedDelivery}', // What is left is the one thing that is not a time and has nowhere else
_ => null, // to go.
}; final when = cancelled ? 'You were not charged' : null;
final eta = booking.milerEtaMinutes; final eta = booking.milerEtaMinutes;
final reference = focused?.trackingId ?? booking.displayId; final reference = focused?.trackingId ?? booking.displayId;
@@ -270,22 +271,30 @@ class _TrackCard extends StatelessWidget {
// Only figures the backend actually sent. A strip with one cell in it is // Only figures the backend actually sent. A strip with one cell in it is
// a fact centred in a grey band, which is worse than the fact on its own // a fact centred in a grey band, which is worse than the fact on its own
// — so it is drawn at two or more. // — so it is drawn at two or more.
// The parcel is collected once it is past the handover, and after that
// the window it was collected in is history — what the customer wants is
// when it lands.
final collected = stage.index >= JourneyStage.pickedUp.index;
final figures = <Widget>[ final figures = <Widget>[
if (booking.fare?.routeKm != null)
_Figure(
label: 'Distance',
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
),
_Figure( _Figure(
label: booking.totalPackages == 1 ? 'Package' : 'Packages', label: booking.totalPackages == 1 ? 'Package' : 'Packages',
value: '${booking.totalPackages}', value: '${booking.totalPackages}',
), ),
// The timing, in the one place it appears.
if (collected && booking.expectedDelivery != null)
_Figure(label: 'Expected', value: booking.expectedDelivery!)
else if (!collected && slot != null)
_Figure(label: 'Window', value: slot!.window),
if (booking.totalWeightLabel != null) if (booking.totalWeightLabel != null)
_Figure(label: 'Weight', value: booking.totalWeightLabel!) _Figure(label: 'Weight', value: booking.totalWeightLabel!)
else if (booking.fare?.routeKm != null)
_Figure(
label: 'Distance',
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
)
else if (booking.miler?.vehicleType != null) else if (booking.miler?.vehicleType != null)
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!) _Figure(label: 'Vehicle', value: booking.miler!.vehicleType!),
else if (slot != null)
_Figure(label: 'Window', value: slot!.window),
]; ];
return Padding( return Padding(
@@ -293,20 +302,12 @@ class _TrackCard extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // The reference used to float here as a small crimson chip above
children: [ // the headline — an identifier introducing a state, which is the
DmStatusChip( // wrong way round and left it as the smallest thing on the screen
label: reference.startsWith('#') ? reference : '#$reference', // in the loudest position. It is the last row of the shipment card
tone: cancelled ? DmColors.ink4 : DmColors.brand, // now, where it identifies the record it belongs to and can be
quiet: cancelled, // copied.
),
// No priority chip. The reference carries "EXPRESS PRIORITY"
// beside the reference; this backend has no express tier, and a
// badge that every booking wears is decoration.
],
),
const SizedBox(height: 14),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -351,20 +352,49 @@ class _TrackCard extends StatelessWidget {
children: [ children: [
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16), padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
// ── Pickup → drop ──
//
// "ORIGIN" and "DESTINATION" are the words a logistics system
// uses about a consignment. The customer's parcel is collected
// from somewhere and dropped somewhere, and those are the two
// words they already use for it.
//
// The arrow between them is the point: two labelled columns
// are a table, and a table does not say which way the parcel
// is going. It sits in a well of its own so it reads as a
// joint rather than as a stray glyph.
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: _Place( child: _Place(
label: 'ORIGIN', label: 'PICKUP',
value: booking.pickup.title, value: booking.pickup.title,
note: booking.pickup.sub, note: booking.pickup.sub,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(top: 14),
child: Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.surfaceAlt,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.arrowRight,
size: 14,
color: DmColors.ink2,
),
),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: _Place( child: _Place(
label: 'DESTINATION', label: 'DROP',
value: group.destination.shortLabel, value: group.destination.shortLabel,
note: group.destination.stateName, note: group.destination.stateName,
end: true, end: true,
@@ -373,13 +403,29 @@ class _TrackCard extends StatelessWidget {
], ],
), ),
), ),
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 13, 12, 13),
child: _Reference(
reference: reference,
cancelled: cancelled,
),
),
if (figures.length > 1) if (figures.length > 1)
DmCardCell( DmCardCell(
padding: const EdgeInsets.fromLTRB(4, 12, 4, 13), padding: const EdgeInsets.fromLTRB(4, 13, 4, 14),
// `groove`, not `surfaceAlt`: the latter is within a point // ── Red glass, not grey ──
// of the canvas, so the strip read as spilling out of the //
// bottom of the card rather than as a band inside it. // It was `groove`, a warm grey chosen so the band read as
background: DmColors.groove, // *inside* the card rather than spilling out of it. That
// worked and said nothing: a neutral strip under a white
// card is a strip. Eight percent of the brand is a tint the
// white above it can still be read against, and it ties the
// figures to the shipment rather than to the furniture.
//
// The type stays ink. Crimson on a crimson wash is the one
// combination this design has never allowed, and the figures
// are facts — they are not the live thing on the screen.
background: DmColors.brand.withValues(alpha: 0.08),
child: Row(children: figures), child: Row(children: figures),
), ),
], ],
@@ -454,14 +500,23 @@ class _Figure extends StatelessWidget {
children: [ children: [
Text( Text(
label.toUpperCase(), label.toUpperCase(),
style: DmText.eyebrow.copyWith(fontSize: 9.5), // A step darker than the eyebrow's usual grey. On the tint the
// default `ink3` went soft, and a label nobody can read is a row
// of numbers with no names.
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
color: DmColors.ink2,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 3), const SizedBox(height: 3),
Text( Text(
value, value,
style: DmText.cardTitle.copyWith(fontSize: 14.5), style: DmText.cardTitle.copyWith(
fontSize: 14.5,
color: DmColors.ink,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@@ -634,8 +689,15 @@ class _ReachState extends State<_Reach> {
width: 44, width: 44,
height: 44, height: 44,
alignment: Alignment.center, alignment: Alignment.center,
// ── Call is green ──
//
// It was the brand, which made a phone call the same colour as
// BOOK, the primary button and every live mark in the app. Green
// is what a call button is in every phone anybody owns, and it
// buys back a crimson on a screen that already spends one on the
// current milestone and one on Cancel.
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.filled ? DmColors.brand : DmColors.surfaceAlt, color: widget.filled ? DmColors.ok : DmColors.surfaceAlt,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
@@ -880,3 +942,61 @@ class _Credentials extends StatelessWidget {
); );
} }
} }
/// The booking's own number, at the foot of the shipment card.
///
/// ── Why it copies, and why it is here ──
///
/// A reference exists to be quoted — into a chat with support, into a message
/// to whoever is receiving the parcel. Every one of those ends with somebody
/// reading eight characters off a screen and typing them somewhere else, which
/// is the one interaction a phone is worst at. Orders learned this first; this
/// is the same chip in the same colours.
///
/// It sits under the route and the figures because that is what it identifies.
/// Above the headline it was an identifier introducing a state, which reads
/// backwards: you learn *what is happening* first and reach for the number
/// only when you need to tell somebody about it.
class _Reference extends StatelessWidget {
const _Reference({required this.reference, required this.cancelled});
final String reference;
final bool cancelled;
@override
Widget build(BuildContext context) {
final shown = reference.startsWith('#') ? reference : '#$reference';
return Semantics(
button: true,
label: 'Copy order ID $reference',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await Clipboard.setData(ClipboardData(text: reference));
await HapticFeedback.selectionClick();
if (context.mounted) DmToast.show(context, 'Order ID copied');
},
child: Row(
children: [
Text('ORDER ID', style: DmText.eyebrow),
const Spacer(),
Flexible(
child: Text(
shown,
style: DmText.monoSm.copyWith(
fontSize: 13,
color: cancelled ? DmColors.ink3 : DmColors.ink,
),
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 9),
const Icon(LucideIcons.copy, size: 14, color: DmColors.ink3),
],
),
),
);
}
}

View File

@@ -60,8 +60,7 @@ class DmBookOrb extends StatefulWidget {
const DmBookOrb({ const DmBookOrb({
super.key, super.key,
required this.onTap, required this.onTap,
this.label = 'BOOK NOW', this.label = 'ONE TOUCH',
this.strapline = 'INSTANT PARCEL',
this.caption, this.caption,
this.held = false, this.held = false,
}); });
@@ -81,9 +80,6 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself. /// second use of this widget elsewhere is not forced to repeat itself.
final String? caption; final String? caption;
/// The small line inside the sphere, under the label.
final String strapline;
@override @override
State<DmBookOrb> createState() => _DmBookOrbState(); State<DmBookOrb> createState() => _DmBookOrbState();
} }
@@ -104,12 +100,33 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
duration: const Duration(milliseconds: 4000), duration: const Duration(milliseconds: 4000),
)..repeat(reverse: true); )..repeat(reverse: true);
/// The press turning the sphere inside out.
///
/// White floods from the centre to the rim and the word flips to crimson —
/// the inverse of the resting state, which is what makes it read as the
/// button having been *pressed* rather than as a new object appearing. It
/// holds while the sheet it opened is up and drains back on the way out.
late final AnimationController _reveal = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
reverseDuration: const Duration(milliseconds: 300),
);
bool _down = false; bool _down = false;
@override
void didUpdateWidget(covariant DmBookOrb old) {
super.didUpdateWidget(old);
// Held while the destination sheet is up; released when the flow returns
// or is dismissed, and the sphere fills back in.
if (old.held && !widget.held) _reveal.reverse();
}
@override @override
void dispose() { void dispose() {
_radar.dispose(); _radar.dispose();
_breath.dispose(); _breath.dispose();
_reveal.dispose();
super.dispose(); super.dispose();
} }
@@ -121,19 +138,23 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
return Semantics( return Semantics(
button: true, button: true,
label: widget.caption == null label: widget.caption == null
? '${widget.label}. ${widget.strapline}' ? widget.label
: '${widget.label}. ${widget.strapline}. ${widget.caption}', : '${widget.label}. ${widget.caption}',
child: Column( child: Column(
children: [ children: [
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true), onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false), onTapCancel: () {
setState(() => _down = false);
_reveal.reverse();
},
onTap: () { onTap: () {
setState(() => _down = false); setState(() => _down = false);
// The press is a commitment — it opens the booking flow — and a // The press is a commitment — it opens the booking flow — and a
// sphere gives no edge feedback the way a bordered button does. // sphere gives no edge feedback the way a bordered button does.
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
_reveal.forward();
widget.onTap(); widget.onTap();
}, },
child: SizedBox( child: SizedBox(
@@ -154,20 +175,32 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
), ),
), ),
), ),
// ── In, then out ──
//
// Down is quick and flat: 140ms of `easeOutCubic`, so the
// finger feels the give rather than watching it happen. Up
// is slower and overshoots — `easeOutBack` carries the scale
// past 1 before it settles — which is the small outward
// bounce a physical button gives back when you let go.
AnimatedScale( AnimatedScale(
scale: _down || widget.held ? 0.94 : 1, scale: _down || widget.held ? 0.93 : 1,
// Just past the press, so the finger feels the give rather duration: Duration(
// than watching it happen. milliseconds: _down || widget.held ? 140 : 420,
duration: const Duration(milliseconds: 140), ),
curve: Curves.easeOutCubic, curve: _down || widget.held
? Curves.easeOutCubic
: Curves.easeOutBack,
child: ScaleTransition( child: ScaleTransition(
scale: Tween(begin: 1.0, end: 1.012).animate( scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut), CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
), ),
child: _Sphere( child: AnimatedBuilder(
size: disc, animation: _reveal,
label: widget.label, builder: (_, _) => _Sphere(
strapline: widget.strapline, size: disc,
label: widget.label,
reveal: Curves.easeOutCubic.transform(_reveal.value),
),
), ),
), ),
), ),
@@ -195,12 +228,15 @@ class _Sphere extends StatelessWidget {
const _Sphere({ const _Sphere({
required this.size, required this.size,
required this.label, required this.label,
required this.strapline, required this.reveal,
}); });
final double size; final double size;
final String label; final String label;
final String strapline;
/// 0 at rest, 1 fully pressed. Drives the white circle that opens from the
/// centre and the crimson word laid over it.
final double reveal;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -273,57 +309,113 @@ class _Sphere extends StatelessWidget {
// //
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking, // It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
// which filled the sphere edge to edge and read as a warning label. // which filled the sphere edge to edge and read as a warning label.
// The reference sets a glyph, a sentence-case line and a small // A glyph over a single line, with air round it, is what makes the
// strapline — the same three parts every physical button that has // circle read as an object rather than as a word in a red disc.
// ever been designed well carries, and the air round them is what //
// makes the circle read as an object rather than as a word in a // "INSTANT PARCEL" sat under it and is gone: on a screen whose only
// red disc. // other text is the customer's own name and their address, a
Column( // strapline is the app talking about itself.
mainAxisSize: MainAxisSize.min, _face(Colors.white, shadowed: true),
children: [
const Icon( // ── The press, turning it inside out ──
LucideIcons.navigation, //
size: 21, // A white disc opens from the centre to the rim, clipped to the
color: Colors.white, // sphere, with the word re-drawn crimson on top of it. Two complete
// faces rather than an interpolated colour: the word has to invert
// *as the white passes under it*, and only a clip can do that — a
// tween would fade the whole label at once while half of it is still
// sitting on red.
if (reveal > 0)
Positioned.fill(
child: ClipPath(
clipper: _Reveal(reveal),
child: Stack(
alignment: Alignment.center,
children: [
// White, with a crimson hairline round it. Without the
// ring the pressed state is a white disc on a near-white
// page — the shape the whole screen is built around
// dissolves at exactly the moment it is being pressed.
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: DmColors.brandLine,
width: 1.5,
),
),
),
),
_face(DmColors.brand),
],
),
), ),
const SizedBox(height: 11), ),
Text( ],
label, ),
textAlign: TextAlign.center, );
style: DmText.heading.copyWith( }
fontSize: 23,
height: 1.05, /// One face, in whichever ink the layer under it calls for.
letterSpacing: -0.5, ///
fontWeight: FontWeight.w700, /// The drop shadow belongs to the white word only — it is there to lift type
fontVariations: const [FontVariation('wght', 700)], /// off a saturated gradient, and under crimson on white it would read as
color: Colors.white, /// grime.
shadows: const [ Widget _face(Color ink, {bool shadowed = false}) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.navigation, size: 21, color: ink),
const SizedBox(height: 11),
Text(
label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith(
fontSize: 23,
height: 1.05,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
color: ink,
shadows: shadowed
? const [
Shadow( Shadow(
color: Color(0x4D5C0802), color: Color(0x4D5C0802),
blurRadius: 12, blurRadius: 12,
offset: Offset(0, 2), offset: Offset(0, 2),
), ),
], ]
), : null,
),
const SizedBox(height: 5),
Text(
strapline,
textAlign: TextAlign.center,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 1.7,
color: const Color(0xB3FFFFFF),
),
),
],
), ),
], ),
), ],
); );
} }
} }
/// A circle growing from the centre of the sphere to its rim.
class _Reveal extends CustomClipper<Path> {
const _Reveal(this.t);
final double t;
@override
Path getClip(Size size) => Path()
..addOval(
Rect.fromCircle(
center: Offset(size.width / 2, size.height / 2),
// A shade past the rim at full extent, so the last frame is a clean
// white disc rather than a hairline of crimson round the edge.
radius: size.width / 2 * t * 1.02,
),
);
@override
bool shouldReclip(_Reveal old) => old.t != t;
}
/// A rim that changes with the light: white along the top arc, shadow along /// A rim that changes with the light: white along the top arc, shadow along
/// the bottom, nothing at the sides. /// the bottom, nothing at the sides.
class _RimPainter extends CustomPainter { class _RimPainter extends CustomPainter {

View File

@@ -81,23 +81,15 @@ Future<void> serverAdvance(
/// Home ▸ the BOOK orb ▸ the destination sheet. /// Home ▸ the BOOK orb ▸ the destination sheet.
/// ///
/// BOOK no longer opens the form. It opens one question — where is it going — /// BOOK asks one question — where is it going — and the review screen is what
/// and the form is what the answer leads to, so every booking test is /// the answer leads to, so every booking test is [openSend] then [pickCity].
/// [openSend] followed by [pickCity]. /// The sheet is left open: tests that are about the sheet itself go no
/// further.
Future<void> openSend(WidgetTester tester) async { Future<void> openSend(WidgetTester tester) async {
await tester.tap(find.text('BOOK NOW')); await tester.tap(find.text('ONE TOUCH'));
await settle(tester); await settle(tester);
} }
/// Answers the destination sheet, then the window sheet behind it.
///
/// Two sheets, because the customer now chooses their own pickup window
/// instead of being handed the first one silently. [takeWindow] false leaves
/// the booking without a slot, which is what dismissing the sheet does.
///
/// The destination is reached by searching rather than by opening its state:
/// search cuts across every state from the first step, and it is the path a
/// customer who already knows where they are sending actually takes.
/// A finder restricted to the open bottom sheet. /// A finder restricted to the open bottom sheet.
/// ///
/// Home's live card heads with the district alone — "Chennai", not /// Home's live card heads with the district alone — "Chennai", not
@@ -107,6 +99,15 @@ Future<void> openSend(WidgetTester tester) async {
Finder inSheet(Finder matching) => Finder inSheet(Finder matching) =>
find.descendant(of: find.byType(BottomSheet), matching: matching); find.descendant(of: find.byType(BottomSheet), matching: matching);
/// Answers the destination sheet, then the window sheet behind it.
///
/// The destination is reached by searching rather than by opening its state:
/// search cuts across every state from the first step, and it is the path a
/// customer who already knows where they are sending actually takes.
///
/// [takeWindow] false dismisses the window sheet rather than answering it,
/// which leaves the booking without a slot and Confirm disabled — the review
/// screen then carries "Choose a window" as its outstanding row.
Future<void> pickCity( Future<void> pickCity(
WidgetTester tester, WidgetTester tester,
String name, { String name, {
@@ -118,16 +119,11 @@ Future<void> pickCity(
// //
// `find.text` also matches `EditableText`, so once the city's name is in the // `find.text` also matches `EditableText`, so once the city's name is in the
// search box the plain finder returns the box as well — and Home, behind the // search box the plain finder returns the box as well — and Home, behind the
// scrim, now heads its live card with the district alone, so an unscoped // scrim, heads its live card with the district alone, so an unscoped finder
// finder can land on a widget the sheet is covering. Scoping to the modal // can land on a widget the sheet is covering. Scoping to the modal route
// route fixes both. // fixes both.
await tester.tap( await tester.tap(
find inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first,
.descendant(
of: find.byType(BottomSheet),
matching: find.byWidgetPredicate((w) => w is Text && w.data == name),
)
.first,
); );
await settle(tester); await settle(tester);
@@ -140,6 +136,7 @@ Future<void> pickCity(
await settle(tester); await settle(tester);
} }
// The window sheet follows the destination on its own.
final use = find.textContaining('Use '); final use = find.textContaining('Use ');
if (takeWindow && use.evaluate().isNotEmpty) { if (takeWindow && use.evaluate().isNotEmpty) {
await tester.tap(use.first); await tester.tap(use.first);
@@ -209,7 +206,7 @@ void main() {
await settle(tester); await settle(tester);
// All four digits arrived, so it verified and moved on. // All four digits arrived, so it verified and moved on.
expect(find.text('BOOK NOW'), findsOneWidget); expect(find.text('ONE TOUCH'), findsOneWidget);
await drainToasts(tester); await drainToasts(tester);
}); });
@@ -218,7 +215,7 @@ void main() {
// Home greets by name, carries the sphere, and ends on the live card. // Home greets by name, carries the sphere, and ends on the live card.
expect(find.text('Hello, Joe'), findsOneWidget); expect(find.text('Hello, Joe'), findsOneWidget);
expect(find.text('BOOK NOW'), findsOneWidget); expect(find.text('ONE TOUCH'), findsOneWidget);
// The seeded active booking surfaces on Home. Its state is the uppercase // The seeded active booking surfaces on Home. Its state is the uppercase
// micro-label beside the destination, in the chip vocabulary — "Assigned", // micro-label beside the destination, in the chip vocabulary — "Assigned",
// not the rail's "Miler assigned". // not the rail's "Miler assigned".
@@ -250,7 +247,7 @@ void main() {
// Which is what the editor carries back to the send screen. // Which is what the editor carries back to the send screen.
final corrected = pickupAddress(tester); final corrected = pickupAddress(tester);
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Confirm pickup point'));
await settle(tester); await settle(tester);
// Back on the send screen: its title is the route, not a heading. // Back on the send screen: its title is the route, not a heading.
expect(find.text('DESTINATION'), findsOneWidget); expect(find.text('DESTINATION'), findsOneWidget);
@@ -258,6 +255,103 @@ void main() {
await drainToasts(tester); await drainToasts(tester);
}); });
testWidgets('the full form asks for the drop address; One Touch does not',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// One Touch: state, district, window, and nothing else is asked.
await openSend(tester);
await pickCity(tester, 'Chennai');
expect(state.draftDetailed, isFalse);
expect(find.text('DROP ADDRESS'), findsNothing);
expect(
tester
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
.onPressed,
isNotNull,
);
// Back to Home, and in again through the form's DROP row.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
await tester.tap(find.text('DROP'));
await settle(tester);
// Destination first, then the door — the address sheet comes on its own.
await tester.enterText(find.byType(TextField).first, 'Chennai');
await settle(tester, 250);
await tester.tap(
inSheet(
find.byWidgetPredicate((w) => w is Text && w.data == 'Chennai'),
).first,
);
await settle(tester);
final send = find.textContaining('Send to ');
if (send.evaluate().isNotEmpty) {
await tester.tap(send.first);
await settle(tester);
}
expect(state.draftDetailed, isTrue);
expect(find.text('Where in Chennai?'), findsOneWidget);
// A building or a street is enough to send somebody to.
await tester.enterText(find.byType(TextField).first, '3B Lotus Apartments');
await settle(tester, 200);
await tester.tap(find.text('Save address'));
await settle(tester);
// Then the window, then the review — which carries the address as a row.
await tester.tap(find.textContaining('Use ').first);
await settle(tester);
expect(find.text('DROP ADDRESS'), findsOneWidget);
final group = state.draftDestinations.first;
expect(group.details.building, '3B Lotus Apartments');
expect(group.details.exactAddress, isNotNull);
expect(
tester
.widget<DmButton>(find.widgetWithText(DmButton, 'Confirm Booking'))
.onPressed,
isNotNull,
);
await drainToasts(tester);
});
testWidgets('the collect-from sheet hands off to the map and back',
(tester) async {
final state = AppState();
await signIn(tester, state: state);
// The PICKUP row of Home's pick/drop form opens the sheet.
await tester.tap(find.text('PICKUP'));
await settle(tester);
expect(find.text('Collect from'), findsOneWidget);
// "Select on map" pushes the map screen over the sheet, which stays
// mounted underneath.
await tester.tap(find.text('Select on map'));
await settle(tester);
expect(find.text('Confirm pickup point'), findsOneWidget);
expect(find.text('PICKUP FROM'), findsOneWidget);
// Backing out leaves the customer on the search they came from, not Home.
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await settle(tester);
expect(find.text('Collect from'), findsOneWidget);
// Confirming closes both and writes the pin as the pickup.
await tester.tap(find.text('Select on map'));
await settle(tester);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
expect(find.text('Collect from'), findsNothing);
expect(state.pickup, isNotNull);
expect(state.hasPickupPoint, isTrue);
await drainToasts(tester);
});
testWidgets('GPS, map drag and search all write one pickup point', testWidgets('GPS, map drag and search all write one pickup point',
(tester) async { (tester) async {
final state = AppState( final state = AppState(
@@ -286,7 +380,7 @@ void main() {
// 3 — a search result overwrites it, coordinates included, and the map // 3 — a search result overwrites it, coordinates included, and the map
// follows the address rather than staying where the drag left it. // follows the address rather than staying where the drag left it.
await tester.tap(find.text('Change')); await tester.tap(find.text('Edit'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Brookefields Mall')); await tester.tap(find.text('Brookefields Mall'));
await settle(tester); await settle(tester);
@@ -300,7 +394,7 @@ void main() {
expect(pickupAddress(tester), 'Brookefields Mall'); expect(pickupAddress(tester), 'Brookefields Mall');
// …and that one point is what the booking carries forward. // …and that one point is what the booking carries forward.
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Confirm pickup point'));
await settle(tester); await settle(tester);
expect(find.text('DESTINATION'), findsOneWidget); expect(find.text('DESTINATION'), findsOneWidget);
expect(state.draftPickup!.lat, searched.lat); expect(state.draftPickup!.lat, searched.lat);
@@ -396,7 +490,7 @@ void main() {
expect( expect(
tester tester
.widget<DmButton>( .widget<DmButton>(
find.widgetWithText(DmButton, 'Use this address'), find.widgetWithText(DmButton, 'Confirm pickup point'),
) )
.onPressed, .onPressed,
isNotNull, isNotNull,
@@ -410,12 +504,17 @@ void main() {
await signIn(tester, state: state); await signIn(tester, state: state);
await openSend(tester); await openSend(tester);
// BOOK asks one question, and it is the only thing on screen. // BOOK asks one question, and it is the only thing on screen. Scoped to
expect(find.text('Where is it going?'), findsOneWidget); // the sheet: Home's DROP row asks the same question behind the scrim.
expect(inSheet(find.text('Where is it going?')), findsOneWidget);
expect(find.text('Flat or house number'), findsNothing); expect(find.text('Flat or house number'), findsNothing);
await pickCity(tester, 'Chennai'); await pickCity(tester, 'Chennai');
// The window was the outstanding row on review, and answering it is what
// enables the button.
expect(find.text('Review and book'), findsOneWidget);
// Everything else the booking needs is on one screen, and the route to // Everything else the booking needs is on one screen, and the route to
// the top of it is two rows with no labels but FROM and TO. // the top of it is two rows with no labels but FROM and TO.
expect(find.text('ORIGIN'), findsOneWidget); expect(find.text('ORIGIN'), findsOneWidget);
@@ -449,6 +548,11 @@ void main() {
await signIn(tester, state: state); await signIn(tester, state: state);
await openPickupEditor(tester); await openPickupEditor(tester);
// The contact is folded away — the screen exists to place a pin, and the
// number is right nearly every time — so it has to be asked for.
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
// Opens on the signed-in customer's own number, without the country code, // Opens on the signed-in customer's own number, without the country code,
// because the field carries its own prefix. // because the field carries its own prefix.
final contact = find.widgetWithText(TextField, '9876543210'); final contact = find.widgetWithText(TextField, '9876543210');
@@ -456,16 +560,18 @@ void main() {
// Left alone it stays null, so the request carries the account rather than // Left alone it stays null, so the request carries the account rather than
// a copy of it that can drift. // a copy of it that can drift.
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Confirm pickup point'));
await settle(tester); await settle(tester);
expect(state.draftContactPhone, isNull); expect(state.draftContactPhone, isNull);
// Changed, it is the number the Miler will ring at the door. // Changed, it is the number the Miler will ring at the door.
await tester.tap(find.text('ORIGIN')); await tester.tap(find.text('ORIGIN'));
await settle(tester); await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
await tester.enterText(find.byType(TextField).first, '9003144518'); await tester.enterText(find.byType(TextField).first, '9003144518');
await settle(tester, 200); await settle(tester, 200);
await tester.tap(find.text('Use this address')); await tester.tap(find.text('Confirm pickup point'));
await settle(tester); await settle(tester);
expect(state.draftContactPhone, '+91 9003144518'); expect(state.draftContactPhone, '+91 9003144518');
await drainToasts(tester); await drainToasts(tester);
@@ -694,9 +800,6 @@ void main() {
}); });
testWidgets('shows the empty service-area state', (tester) async { testWidgets('shows the empty service-area state', (tester) async {
// Set before signing in. Home's quick-dispatch row calls `loadCities()`
// as soon as it builds, which warms `statesCache` — good for the sheet,
// which now opens instantly, and fatal to a flag flipped afterwards.
api.flags.emptyServiceAreas = true; api.flags.emptyServiceAreas = true;
await signIn(tester); await signIn(tester);
@@ -757,11 +860,21 @@ void main() {
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
await signIn(tester); // login + OTP await signIn(tester); // login + OTP
expect(find.text('BOOK NOW'), findsOneWidget); // home expect(find.text('ONE TOUCH'), findsOneWidget); // home
await openSend(tester); // the whole booking, one screen await openSend(tester); // the whole booking, one screen
await pickCity(tester, 'Chennai'); await pickCity(tester, 'Chennai');
// The pickup map: a fixed-height sheet over a full-bleed map, so large
// text has nowhere to grow into. Opening the contact fold is the tallest
// it ever gets.
await tester.tap(find.text('ORIGIN'));
await settle(tester);
await tester.tap(find.text('Someone else is handing it over?'));
await settle(tester);
await tester.tap(find.text('Confirm pickup point'));
await settle(tester);
await tester.tap(find.text('Confirm Booking')); await tester.tap(find.text('Confirm Booking'));
await settle(tester); // confirmation await settle(tester); // confirmation
await tester.tap(find.text('Track pickup')); await tester.tap(find.text('Track pickup'));

View File

@@ -182,8 +182,8 @@ void main() {
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester); await _settle(tester);
// Booking: state, district, window, then one screen. // Booking: BOOK asks where, then the review screen asks the rest.
await tester.tap(find.text('BOOK NOW')); await tester.tap(find.text('ONE TOUCH'));
await _settle(tester); await _settle(tester);
await _shot(tester, '07-destination-states'); await _shot(tester, '07-destination-states');
@@ -191,10 +191,14 @@ void main() {
await _settle(tester); await _settle(tester);
await _shot(tester, '07b-destination-districts'); await _shot(tester, '07b-destination-districts');
// The live card, by its reference. Home's quick-dispatch chips are await tester.tap(
// named after cities too, so a bare 'Chennai' finder now hits a find
// shortcut that starts a new booking instead. .descendant(
await tester.tap(find.text('DM-482913')); of: find.byType(BottomSheet),
matching: find.text('Chennai'),
)
.first,
);
await _settle(tester); await _settle(tester);
// The dev backend allows several destinations, so the row ticked rather // The dev backend allows several destinations, so the row ticked rather
// than closing the sheet — the choice is confirmed. // than closing the sheet — the choice is confirmed.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 183 KiB