diff --git a/design/screens/04-home.png b/design/screens/04-home.png index e26342a..9c46372 100644 Binary files a/design/screens/04-home.png and b/design/screens/04-home.png differ diff --git a/design/screens/05-pickup-search.png b/design/screens/05-pickup-search.png index b7f19fb..212648d 100644 Binary files a/design/screens/05-pickup-search.png and b/design/screens/05-pickup-search.png differ diff --git a/design/screens/06-orders-active.png b/design/screens/06-orders-active.png index d0f2a60..f4c057b 100644 Binary files a/design/screens/06-orders-active.png and b/design/screens/06-orders-active.png differ diff --git a/design/screens/07-orders-completed.png b/design/screens/07-orders-completed.png index ccc5f96..79392d4 100644 Binary files a/design/screens/07-orders-completed.png and b/design/screens/07-orders-completed.png differ diff --git a/design/screens/08-account.png b/design/screens/08-account.png index c699b38..48d3deb 100644 Binary files a/design/screens/08-account.png and b/design/screens/08-account.png differ diff --git a/design/screens/09-tracking.png b/design/screens/09-tracking.png index 1fc5cd6..2bc9c7d 100644 Binary files a/design/screens/09-tracking.png and b/design/screens/09-tracking.png differ diff --git a/design/screens/10-cancel-pickup.png b/design/screens/10-cancel-pickup.png index e27905b..a3c3085 100644 Binary files a/design/screens/10-cancel-pickup.png and b/design/screens/10-cancel-pickup.png differ diff --git a/design/screens/11-destination-states.png b/design/screens/11-destination-states.png index 08275bf..855bc9e 100644 Binary files a/design/screens/11-destination-states.png and b/design/screens/11-destination-states.png differ diff --git a/design/screens/12-destination-districts.png b/design/screens/12-destination-districts.png index fe148d0..fd721df 100644 Binary files a/design/screens/12-destination-districts.png and b/design/screens/12-destination-districts.png differ diff --git a/design/screens/13-pickup-window.png b/design/screens/13-pickup-window.png index c674ac9..4e9e7c8 100644 Binary files a/design/screens/13-pickup-window.png and b/design/screens/13-pickup-window.png differ diff --git a/design/screens/14-send-a-parcel.png b/design/screens/14-send-a-parcel.png index 21d225c..cf72bd7 100644 Binary files a/design/screens/14-send-a-parcel.png and b/design/screens/14-send-a-parcel.png differ diff --git a/design/screens/15-pickup-map.png b/design/screens/15-pickup-map.png index 1d79060..b55b148 100644 Binary files a/design/screens/15-pickup-map.png and b/design/screens/15-pickup-map.png differ diff --git a/design/screens/16-pickup-booked.png b/design/screens/16-pickup-booked.png index 2cf2184..246b23e 100644 Binary files a/design/screens/16-pickup-booked.png and b/design/screens/16-pickup-booked.png differ diff --git a/design/screens/17-receipt.png b/design/screens/17-receipt.png index ac4fc0a..f7bfe21 100644 Binary files a/design/screens/17-receipt.png and b/design/screens/17-receipt.png differ diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 9e924a0..a3ee894 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -394,7 +394,26 @@ class AppState extends ChangeNotifier { /// form, and cleared only when a booking actually lands. 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; draftDestinations = [DestinationGroup()]; draftSlotId = null; diff --git a/lib/ui/screens/account_screen.dart b/lib/ui/screens/account_screen.dart index 6bb4548..ab64436 100644 --- a/lib/ui/screens/account_screen.dart +++ b/lib/ui/screens/account_screen.dart @@ -7,10 +7,8 @@ import '../../data/models.dart'; import '../tokens.dart'; import '../widgets/misc.dart'; import '../format.dart'; -import 'shell_screen.dart'; import '../widgets/buttons.dart'; import '../widgets/cards.dart'; -import '../widgets/chrome.dart'; import '../widgets/feedback.dart'; import '../widgets/inputs.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 // rather than claiming a lifetime total the client cannot know. - final completed = - app.orders.where((o) => o.status == BookingStatus.completed); + final completed = app.orders.where( + (o) => o.status == BookingStatus.completed, + ); final delivered = completed.fold(0, (n, o) => n + o.totalPackages); final cities = { for (final o in completed) - for (final g in o.destinations) - ?g.destination.districtCode, + for (final g in o.destinations) ?g.destination.districtCode, }.length; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DmAppBar( - unread: app.activeBookings.isNotEmpty, - onSearch: () => ShellScreen.of(context)?.goToTab(1), - onNotifications: () => - DmToast.show(context, 'Nothing new right now'), - ), - // ── The profile, centred ── - // - // 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 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 - // makes Account read as somewhere you are rather than as a menu. - Padding( - padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18), - child: Column( - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - DmAvatar(initials: initialsOf(name), size: 86), - Positioned( - right: -2, - bottom: -2, - 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( + return SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // No wordmark bar, and no search or bell on it. Home and Orders lost + // the same one: a root that opens on the customer's own face does not + // 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 ── + // + // 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 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 + // makes Account read as somewhere you are rather than as a menu. + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 26, + DmSpace.pad, + 18, + ), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, 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', + DmAvatar(initials: initialsOf(name), size: 86), + Positioned( + right: -2, + bottom: -2, + 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, + ), ), ), ], ), - ], - ], - ), - ), - Expanded( - child: ListView( - // Clears the floating tab bar, which the shell stacks over the - // body rather than laying out beneath it. - padding: EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom), - children: [ - const DmMicroHead('Preferences', top: 4, brand: false), - DmRowGroup( - children: [ - DmRow( - 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, + 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: [ + 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', - icon: LucideIcons.logOut, - iconLeading: true, - kind: DmButtonKind.ghost, - danger: true, - onPressed: () { - app.signOut(); - Navigator.of(context).pushAndRemoveUntil( - MaterialPageRoute(builder: (_) => const LoginScreen()), - (route) => false, - ); - }, + ), + ), + Expanded( + child: ListView( + // Clears the floating tab bar, which the shell stacks over the + // body rather than laying out beneath it. + padding: EdgeInsets.fromLTRB( + DmSpace.pad, + 4, + DmSpace.pad, + 74 + MediaQuery.paddingOf(context).bottom, ), - const SizedBox(height: 6), - Text.rich( - TextSpan( - style: DmText.tiny.copyWith(color: DmColors.ink4), + children: [ + const DmMicroHead('Preferences', top: 4, brand: false), + DmRowGroup( children: [ - const TextSpan(text: 'DOORMILE CUSTOMER '), - TextSpan( - text: 'v${AppConfig.appVersion}', - style: DmText.monoSm.copyWith(color: DmColors.ink4), + DmRow( + 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, ), ], ), - 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( + 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, + ), + ], + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/ui/screens/booking/pickup_location_screen.dart b/lib/ui/screens/booking/pickup_location_screen.dart index 54689ff..d1c61de 100644 --- a/lib/ui/screens/booking/pickup_location_screen.dart +++ b/lib/ui/screens/booking/pickup_location_screen.dart @@ -7,17 +7,29 @@ import '../../../state/app_state.dart'; import '../../tokens.dart'; import '../../widgets/inputs.dart'; import '../../widgets/buttons.dart'; -import '../../widgets/cards.dart'; -import '../../widgets/chrome.dart'; import '../../widgets/feedback.dart'; import '../../widgets/map_panel.dart'; import '../sheets/place_search_sheet.dart'; /// Step 1 — where we collect from. /// -/// One card: a real map on top with the pin locked to its centre, and the -/// address it resolves to in the box directly underneath. Drag the map, the -/// address follows. Nothing is typed unless the customer wants to. +/// ── A map screen, not a screen with a map on it ── +/// +/// 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 { const PickupLocationScreen({super.key}); @@ -39,11 +51,16 @@ class _PickupLocationScreenState extends State { /// explain in a remarks box nobody reads. 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 void initState() { super.initState(); final app = AppScope.read(context); _contact.text = _digitsOf(app.draftContactPhone ?? app.customer?.phone); + _contactOpen = app.draftContactPhone != null; // Arriving without a pin — ask the device where we are. WidgetsBinding.instance.addPostFrameCallback((_) { @@ -79,7 +96,9 @@ class _PickupLocationScreenState extends State { } Future _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; app.setPickup(picked); setState(() => _pinMoving = false); @@ -91,232 +110,430 @@ class _PickupLocationScreenState extends State { final app = AppScope.of(context); final place = app.pickup; final resolving = app.resolvingPickup || _pinMoving; + final top = MediaQuery.paddingOf(context).top; return Scaffold( backgroundColor: DmColors.canvas, - appBar: const DmTopBar(title: 'Pickup point'), - body: Column( + // No app bar. The back button floats on the map with everything else, + // because a bar would cut a strip off the one thing worth seeing. + body: Stack( children: [ - const Padding( - padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0), - child: _Intro(), - ), - const SizedBox(height: 16), - - // The map and the address it resolves to are one object, so they get - // one card. Not a ListView: the map must win vertical drags. - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB( - DmSpace.pad, - 0, - DmSpace.pad, - 14, - ), - child: Container( - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: DmColors.surface, - border: Border.all(color: DmColors.border), - borderRadius: DmRadius.all(DmRadius.md), - ), - child: Column( - children: [ - Expanded( - child: DmMapPanel( - lat: app.mapLat, - lng: app.mapLng, - deviceLat: app.deviceLat, - deviceLng: app.deviceLng, - deviceAccuracy: app.deviceAccuracy, - loading: resolving, - borderRadius: BorderRadius.zero, - onLocate: () { - setState(() => _pinMoving = false); - app.detectPickupLocation(); - }, - onPinMoveStart: () { - if (!_pinMoving) setState(() => _pinMoving = true); - }, - onPinMoved: (lat, lng) { - setState(() => _pinMoving = false); - app.setPickupFromPin(lat, lng); - }, - ), - ), - _AddressBox( - title: place?.title, - sub: place?.sub, - resolving: resolving, - onChange: () => _search(app), - ), - 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, - ), - ), - ], - ), - ), + // ── The map, edge to edge ── + Positioned.fill( + child: DmMapPanel( + lat: app.mapLat, + lng: app.mapLng, + deviceLat: app.deviceLat, + deviceLng: app.deviceLng, + deviceAccuracy: app.deviceAccuracy, + loading: resolving, + borderRadius: BorderRadius.zero, + onPinMoveStart: () { + if (!_pinMoving) setState(() => _pinMoving = true); + }, + onPinMoved: (lat, lng) { + setState(() => _pinMoving = false); + app.setPickupFromPin(lat, lng); + }, ), ), - if (app.locationDenial != null) - Padding( - padding: const EdgeInsets.fromLTRB( - DmSpace.pad, - 0, - DmSpace.pad, - 12, - ), - child: _LocationNotice( - denial: app.locationDenial!, - onSettings: app.openLocationSettings, - onSearch: () => _search(app), - ), + // ── Back, and the way to type instead ── + Positioned( + left: DmSpace.pad, + right: DmSpace.pad, + top: top + 10, + child: Row( + children: [ + _FloatingCircle( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onPressed: () => Navigator.of(context).maybePop(), + ), + 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 { - const _Intro(); +/// A white disc that floats over the map — back, and locate. +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 Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text('Where should we pick up?', style: DmText.title), - const SizedBox(height: 8), - Text('Drag the map to move the pin.', style: DmText.lede), - ], + return Tooltip( + message: tooltip, + child: Semantics( + button: true, + label: tooltip, + child: GestureDetector( + 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 -/// them rather than a gap. -class _AddressBox extends StatelessWidget { - const _AddressBox({ +/// "Search for a building or street" — the way in for somebody who is not +/// standing at the door. +/// +/// 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.sub, 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? sub; 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 Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.fromLTRB(14, 13, 10, 14), - decoration: const BoxDecoration( - color: DmColors.brandSoft, - border: Border(top: BorderSide(color: DmColors.border)), + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DmRadius.header), + ), + boxShadow: DmShadow.sheet, ), - child: Row( - children: [ - // The resolved address is the map's answer, so it takes the wash: it - // is brand-owned context, not a field waiting to be filled. - const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash), - const SizedBox(width: 12), - Expanded( - child: AnimatedSwitcher( - duration: DmMotion.base, - child: resolving - ? const _ResolvingLines(key: ValueKey('resolving')) - : Column( - key: ValueKey('$title|$sub'), - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 18, + DmSpace.pad, + 14, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('PICKUP FROM', style: DmText.eyebrow), + 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: [ - Text( - title ?? 'Locating you…', - key: const ValueKey('pickupAddressTitle'), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DmText.cardTitle, + Icon( + contactOpen + ? LucideIcons.chevronUp + : LucideIcons.userRoundPlus, + size: 16, + color: DmColors.ink3, ), - const SizedBox(height: 3), - Text( - sub ?? 'Please wait a moment', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DmText.small.copyWith(color: DmColors.brandPress), + const SizedBox(width: 9), + Expanded( + child: Text( + contactOpen + ? 'Hide contact number' + : '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 -/// address, so the card never collapses or jumps. -class _ResolvingLines extends StatelessWidget { - const _ResolvingLines({super.key}); +/// What the pin is sitting on, and the way to change it by typing. +class _Resolved extends StatelessWidget { + const _Resolved({ + 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 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, - mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Getting address…', - style: DmText.cardTitle.copyWith(color: DmColors.ink3), - ), - const SizedBox(height: 6), - Container( - width: 148, - height: 9, - decoration: BoxDecoration( - color: DmColors.brandLine, - borderRadius: BorderRadius.circular(4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title ?? 'Move the map to set the pin', + // Keyed, because the tests read the resolved address off this + // exact widget to prove GPS, a map drag and a search all write + // the same pickup point. + 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), ], ); } diff --git a/lib/ui/screens/booking/send_screen.dart b/lib/ui/screens/booking/send_screen.dart index ef0b397..0bbb121 100644 --- a/lib/ui/screens/booking/send_screen.dart +++ b/lib/ui/screens/booking/send_screen.dart @@ -15,6 +15,7 @@ import '../../widgets/chrome.dart'; import '../../widgets/feedback.dart'; import '../../widgets/pieces.dart'; import '../sheets/destination_sheet.dart'; +import '../sheets/drop_address_sheet.dart'; import '../sheets/window_sheet.dart'; import 'booking_routes.dart'; import 'confirmed_screen.dart'; @@ -111,7 +112,10 @@ class _SendScreenState extends State { final fare = app.draftFare; 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( backgroundColor: DmColors.canvas, @@ -125,8 +129,9 @@ class _SendScreenState extends State { // 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 // and made you count them. - const _StepHead(step: 3, of: 3), - const SizedBox(height: 14), + // Two steps, not three. BOOK asks when, this screen asks the rest. + 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 // 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 @@ -141,8 +146,12 @@ class _SendScreenState extends State { from: pickup?.title ?? 'Set a pickup address', fromNote: pickup?.sub, 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` + // asserts when something is popped through it. onTapFrom: () => Navigator.of(context).push( - bookingRoute( + bookingRoute( BookingRoutes.pickup, (_) => const PickupLocationScreen(), ), @@ -160,12 +169,17 @@ class _SendScreenState extends State { ], ), 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( icon: LucideIcons.calendar, label: 'PICKUP WINDOW', value: slot == null ? 'Choose a window' : '${slot.day}, ${slot.window}', + unset: slot == null, onTap: () => showWindowSheet(context), trailing: slot == null ? null @@ -175,6 +189,31 @@ class _SendScreenState extends State { 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), _DetailCard( icon: LucideIcons.package, @@ -234,11 +273,17 @@ class _SendScreenState extends State { child: Text('ESTIMATED', style: DmText.eyebrow), ), const SizedBox(height: 2), - Text( - fare?.range ?? '—', - style: DmText.headerTitle.copyWith(fontSize: 30), - maxLines: 1, - overflow: TextOverflow.ellipsis, + // Stretched. The footer centres what it cannot stretch, so a short + // value — the em dash before a fare has been quoted — drifted to + // the middle while its own label stayed at the margin. + SizedBox( + width: double.infinity, + child: Text( + fare?.range ?? '—', + style: DmText.headerTitle.copyWith(fontSize: 30), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), const SizedBox(height: 6), Row( @@ -285,40 +330,55 @@ class _SendScreenState extends State { /// read rather than parsed, and it leaves the screen's own title to the thing /// the customer is about to press. 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 of; + final String title; @override Widget build(BuildContext context) { return Semantics( header: true, - label: 'Step $step of $of', + label: '$title. Step $step of $of', excludeSemantics: true, - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 22, - height: 22, - alignment: Alignment.center, - decoration: const BoxDecoration( - color: DmColors.ink, - shape: BoxShape.circle, - ), - child: Text( - '$step', - style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white), - ), - ), - const SizedBox(width: 10), - Text( - 'Step $step of $of', - style: DmText.cardTitle.copyWith( - fontSize: 14.5, - color: DmColors.ink2, - ), + Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: DmColors.ink, + shape: BoxShape.circle, + ), + child: Text( + '$step', + style: DmText.label.copyWith( + fontSize: 11.5, + color: Colors.white, + ), + ), + ), + 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++) _Stop( label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}', - value: to[i].shortLabel, - note: to[i].stateName, + // BOOK no longer asks where it is going, so this row is where the + // 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, origin: false, last: i == to.length - 1, @@ -384,6 +450,7 @@ class _Stop extends StatelessWidget { required this.origin, required this.last, this.note, + this.unset = false, }); final String label; @@ -393,11 +460,15 @@ class _Stop extends StatelessWidget { final bool origin; 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 Widget build(BuildContext context) { return Semantics( button: true, - label: '$label $value', + label: '$label $value. Change', child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, @@ -416,8 +487,13 @@ class _Stop extends StatelessWidget { width: 10, height: 10, decoration: BoxDecoration( - color: origin ? DmColors.ink : DmColors.brand, + color: unset + ? Colors.transparent + : (origin ? DmColors.ink : DmColors.brand), shape: BoxShape.circle, + border: unset + ? Border.all(color: DmColors.brand, width: 2) + : null, ), ), if (!last) @@ -434,16 +510,45 @@ class _Stop extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Padding( - padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16), + padding: EdgeInsets.fromLTRB(0, 8, 2, last ? 4 : 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, 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), Text( value, - style: DmText.cardTitle.copyWith(fontSize: 16.5), + style: DmText.cardTitle.copyWith( + fontSize: 16.5, + color: unset ? DmColors.brand : null, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -479,6 +584,7 @@ class _DetailCard extends StatelessWidget { this.note, this.onTap, this.trailing, + this.unset = false, }); final IconData icon; @@ -488,6 +594,10 @@ class _DetailCard extends StatelessWidget { final VoidCallback? onTap; 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 Widget build(BuildContext context) { return DmCard( @@ -502,10 +612,14 @@ class _DetailCard extends StatelessWidget { height: 38, alignment: Alignment.center, decoration: BoxDecoration( - color: DmColors.surfaceAlt, + color: unset ? DmColors.brandSoft : DmColors.surfaceAlt, 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), Expanded( @@ -517,7 +631,10 @@ class _DetailCard extends StatelessWidget { const SizedBox(height: 2), Text( value, - style: DmText.cardTitle.copyWith(fontSize: 15.5), + style: DmText.cardTitle.copyWith( + fontSize: 15.5, + color: unset ? DmColors.brand : null, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart index a22eccc..cf75c8d 100644 --- a/lib/ui/screens/home_screen.dart +++ b/lib/ui/screens/home_screen.dart @@ -15,6 +15,7 @@ import '../widgets/misc.dart'; import 'booking/booking_routes.dart'; import 'booking/send_screen.dart'; import 'sheets/destination_sheet.dart'; +import 'sheets/drop_address_sheet.dart'; import 'sheets/place_search_sheet.dart'; import 'sheets/window_sheet.dart'; import 'tracking_screen.dart'; @@ -52,19 +53,18 @@ class _HomeScreenState extends State { /// 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 - /// cards halfway down the form; the window was picked *for* the customer, - /// silently, the moment they chose a city — they arrived at a booking with a - /// time on it that they had never agreed to. A pickup window is a promise - /// about somebody's afternoon, and it is not ours to assume. - /// - /// Dismissing the window sheet is allowed: the send screen carries "Choose a - /// window" in the footer and its button stays disabled until there is one. - Future _book() async { + /// Dismissing the window sheet is allowed. The review screen carries the + /// window as its own row — brand-coloured while it is unanswered — and the + /// button stays disabled until there is one, so backing out of the second + /// sheet costs a tap rather than the booking. + Future _book({bool detailed = false}) async { final app = AppScope.read(context); final navigator = Navigator.of(context); @@ -73,9 +73,26 @@ class _HomeScreenState extends State { final places = await showDestinationSheet(context); if (places == null || places.isEmpty || !mounted) return; - app.startBooking(); + app.startBooking(detailed: detailed); 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); if (!mounted) return; @@ -95,7 +112,11 @@ class _HomeScreenState extends State { // 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 // 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( bottom: false, @@ -114,7 +135,7 @@ class _HomeScreenState extends State { // this is the top of the screen, so it takes the inset the bar used // to hold. Padding( - padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0), + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 26, DmSpace.pad, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -155,17 +176,29 @@ class _HomeScreenState extends State { ), ], ), - 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( child: AnimatedScale( scale: _handingOver ? 0.96 : 1, @@ -201,7 +234,30 @@ class _HomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, 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() .fadeIn(duration: 420.ms) .scale( @@ -210,18 +266,6 @@ class _HomeScreenState extends State { duration: 620.ms, 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 { ), ), ), - // ── 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 - // removed. It was a second way to start the one thing this screen - // starts, and it cost the sphere the space that makes it read as an - // object floating in a page rather than as a button in a stack. + // The scroll view centres the sphere in whatever height is left, and + // it cannot know about anything laid out after it — so anything laid + // out inside that column pushed the thing before it under the fold. + // 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 - // what is actually left rather than in a viewport pretending the - // card is not there. - if (active != null) - Padding( - padding: const EdgeInsets.fromLTRB( - DmSpace.pad, - 14, - DmSpace.pad, - 6, - ), - child: _LiveCard(booking: active), - ) - .animate() - .fadeIn(duration: 380.ms, delay: 160.ms) - .slideY(begin: 0.12, curve: DmMotion.ease), + // A "3 min pickup" chip used to sit here. It was riding the sphere's + // lower arc — the field is centred on the disc, not on the glow + // round it, so a chip pinned directly under the column overlapped + // the shadow the sphere throws. + Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0), + child: _PickDropForm( + pickup: app.pickup?.title, + onPickup: () async { + final place = await showPlaceSearchSheet(context); + if (place != null && context.mounted) app.setPickup(place); + }, + onDrop: () => _book(detailed: true), + ).animate().fadeIn(duration: 380.ms, delay: 300.ms), + ), + // Clears the floating tab bar, which the shell stacks over the body. // // Measured, not guessed. A fixed 74 was right on the 844pt golden @@ -273,22 +317,18 @@ class _HomeScreenState extends State { } } -/// 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 -/// the five customer milestones. It was the one fact that cannot be written as -/// a word, and it still read as a *loader*: a bar that fills is the shape an -/// app uses to say "wait", and a parcel two days into a journey is not -/// something the customer is waiting through. The rail on tracking says the -/// same thing with five labelled nodes and no ambiguity. +/// It carried a rule and a second row naming the Miler and their plate. Both +/// are real and neither belongs here: this card sits above the sphere now, so +/// every point it takes is a point the one action on the screen gives up — and +/// the Miler's name is on the screen this card opens, beside the button that +/// rings them, which is where it is any use. /// -/// What the space bought is size. This is the only thing on Home besides the -/// sphere and it was a 90pt strip — a chip, a place and a courier compressed -/// 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. +/// What is left is what a glance asks: what is happening, where, and when. +/// Three lines and an arrow. class _LiveCard extends StatelessWidget { const _LiveCard({required this.booking}); @@ -298,7 +338,6 @@ class _LiveCard extends StatelessWidget { Widget build(BuildContext context) { final app = AppScope.read(context); final slot = app.slotById(booking.slotId); - final miler = booking.miler ?? booking.deliveryAgent; // The district alone. "Chennai, Tamil Nadu" shares its row with the state // and the reference, and on a 402pt device it rendered "Chenna…" — the @@ -334,20 +373,20 @@ class _LiveCard extends StatelessWidget { }, children: [ DmCardCell( - padding: const EdgeInsets.fromLTRB(18, 17, 18, 18), + padding: const EdgeInsets.fromLTRB(16, 13, 14, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ - const DmLiveDot(size: 7), - const SizedBox(width: 9), + const DmLiveDot(size: 6), + const SizedBox(width: 8), Flexible( child: Text( booking.stage.chipLabel.toUpperCase(), style: DmText.eyebrow.copyWith( - fontSize: 10, + fontSize: 9.5, letterSpacing: 0.9, color: DmColors.brand, ), @@ -359,7 +398,10 @@ class _LiveCard extends StatelessWidget { Flexible( child: Text( booking.displayId, - style: DmText.monoSm.copyWith(color: DmColors.ink3), + style: DmText.monoSm.copyWith( + fontSize: 11, + color: DmColors.ink3, + ), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -367,7 +409,7 @@ class _LiveCard extends StatelessWidget { ), ], ), - const SizedBox(height: 14), + const SizedBox(height: 9), Row( children: [ Expanded( @@ -377,15 +419,14 @@ class _LiveCard extends StatelessWidget { children: [ Text( place, - style: DmText.headerTitle.copyWith(fontSize: 26), + style: DmText.headerTitle.copyWith(fontSize: 21), maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 3), Text( promise, - style: DmText.body.copyWith( - fontSize: 14.5, + style: DmText.small.copyWith( + fontSize: 13, color: DmColors.ink3, ), maxLines: 1, @@ -395,12 +436,9 @@ class _LiveCard extends StatelessWidget { ), ), 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( - width: 44, - height: 44, + width: 36, + height: 36, alignment: Alignment.center, decoration: const BoxDecoration( color: DmColors.brandSoft, @@ -408,47 +446,12 @@ class _LiveCard extends StatelessWidget { ), child: const Icon( LucideIcons.arrowRight, - size: 19, + size: 17, 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 -/// small bordered pill holding a brand pin, the address, and a chevron-down. +/// One Touch is the product: a state, a district, a window, and the Miler +/// 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 -/// around it. On a page whose only other objects were a name and a sphere it -/// was a container built to hold one line of text. On a page that now opens -/// with a wordmark bar and ends with a floating one, it is the middle term — -/// and the chevron-down is the honest glyph, because what it opens is a -/// chooser, not another screen. -class _PickupLine extends StatelessWidget { - const _PickupLine({required this.title, required this.onTap}); +/// This is the same booking for a customer who already knows the door. Both +/// ends are here because a journey has two, and seeing them together is what +/// makes it obvious that one is already answered — the pickup carries the +/// address the app detected, so the only row with a question on it is the +/// drop. +/// +/// It replaced a line of text reading "Enter the full address instead", which +/// 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; @override Widget build(BuildContext context) { return Semantics( button: true, - label: 'Pickup from $title. Change', + label: '$label $value', child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, - child: Align( - alignment: Alignment.centerLeft, - child: Container( - constraints: const BoxConstraints(minHeight: 40, maxWidth: 300), - padding: const EdgeInsets.fromLTRB(11, 8, 11, 8), - decoration: BoxDecoration( - color: DmColors.surface, - borderRadius: DmRadius.all(DmRadius.pill), - border: Border.all(color: DmColors.border), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand), - const SizedBox(width: 8), - Flexible( - child: Text( - title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DmText.cardTitle.copyWith(fontSize: 14), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // The node and the thread it hangs from. The thread belongs to + // the stop above the join, so the last one ends clean. + SizedBox( + width: 20, + child: Column( + children: [ + const SizedBox(height: 17), + Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: unset && !origin + ? Colors.transparent + : (origin ? DmColors.ink : DmColors.brand), + shape: BoxShape.circle, + border: unset && !origin + ? 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( - LucideIcons.chevronDown, - size: 15, - color: DmColors.ink4, - ), - ], - ), + ), + const SizedBox(width: 8), + const Icon( + LucideIcons.chevronRight, + 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), - ), - ], - ), - ); - } -} diff --git a/lib/ui/screens/order_row.dart b/lib/ui/screens/order_row.dart index 6570b16..c40fd30 100644 --- a/lib/ui/screens/order_row.dart +++ b/lib/ui/screens/order_row.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../../data/models.dart'; import '../../state/app_scope.dart'; @@ -7,6 +8,7 @@ import '../tokens.dart'; import '../widgets/chrome.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../widgets/cards.dart'; +import '../widgets/feedback.dart'; import 'booking/booking_routes.dart'; import 'order_details_screen.dart'; import 'tracking_screen.dart'; @@ -81,11 +83,10 @@ class OrderCard extends StatelessWidget { _ => (dateLabel(booking.createdAt), false), }; - // Second line, right: what is in it. The weight appears only once the - // Miler has actually weighed it at the door. - // The weight of *these* parcels, and only once a Miler has actually - // weighed them. `totalWeightLabel` is the whole visit's, which on one - // destination's row is somebody else's parcel. + // What is in it, for the foot. The weight of *these* parcels, and only + // once a Miler has actually weighed them at the door — + // `totalWeightLabel` is the whole visit's, which on one destination's row + // is somebody else's parcel. final weight = entry.isPickup ? booking.totalWeightLabel : group!.verification?.weightLabel; @@ -94,13 +95,17 @@ class OrderCard extends StatelessWidget { ?weight, ].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 footNote = miler != null - ? '${miler.name} · ${stage.chipLabel}' - : (booking.status == BookingStatus.completed - ? 'Delivered${booking.amountPaid == null ? '' : ' · ₹${booking.amountPaid}'}' - : stage.milestoneLabel); + final footNote = [ + if (miler != null) miler.name, + contents, + if (booking.status == BookingStatus.completed && + booking.amountPaid != null) + '₹${booking.amountPaid}', + ].join(' · '); void open() { app.openTracking(booking.reference); @@ -125,45 +130,29 @@ class OrderCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, 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( children: [ - Flexible(child: _RefChip(reference: reference)), + Flexible(flex: 3, child: _RefChip(reference: reference)), const SizedBox(width: 8), Flexible( + flex: 2, child: DmStatusChip( label: label, tone: tone, 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), @@ -184,15 +173,32 @@ class OrderCard extends StatelessWidget { ), const SizedBox(width: 10), Flexible( - child: Text( - contents, - textAlign: TextAlign.right, - style: DmText.small.copyWith( - fontSize: 12.5, - color: DmColors.ink3, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + 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, + 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, -/// and the reason it can sit at 11.5pt beside a 26pt place name without -/// disappearing. +/// ── Why it copies ── +/// +/// 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 { const _RefChip({required this.reference}); @@ -252,17 +268,42 @@ class _RefChip extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 5), - decoration: BoxDecoration( - color: DmColors.surfaceAlt, - borderRadius: DmRadius.all(DmRadius.chip), - ), - child: Text( - reference.startsWith('#') ? reference : '#$reference', - style: DmText.monoSm.copyWith(fontSize: 11, color: DmColors.ink2), - maxLines: 1, - overflow: TextOverflow.ellipsis, + 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: 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), + ], + ), + ), ), ); } diff --git a/lib/ui/screens/orders_screen.dart b/lib/ui/screens/orders_screen.dart index d546962..eda327e 100644 --- a/lib/ui/screens/orders_screen.dart +++ b/lib/ui/screens/orders_screen.dart @@ -6,8 +6,6 @@ import '../../data/models.dart'; import '../../state/app_scope.dart'; import '../tokens.dart'; import '../widgets/inputs.dart'; -import '../widgets/feedback.dart'; -import '../widgets/chrome.dart'; import '../widgets/states.dart'; import 'order_row.dart'; @@ -61,100 +59,117 @@ class _OrdersScreenState extends State { final app = AppScope.of(context); final q = _query.text.trim(); 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); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - DmAppBar( - unread: app.activeBookings.isNotEmpty, - onSearch: () => DmToast.show(context, 'Search is coming soon'), - 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. + return SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // No wordmark bar, and no search or bell on it. // - // Beside the title it had a third of the width for three segments, - // and rendered "Act… Pa… Ca…" — the reference fits its control on - // the title line because it carries two segments, not three. A row - // is cheaper than an unreadable control. - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Orders', - style: DmText.headerTitle.copyWith(fontSize: 26), - ), - const SizedBox(height: 14), - _Filter( - labels: const ['Active', 'Past', 'Cancelled'], - counts: [for (final tab in _tabs) app.entriesOf(tab).length], - index: index < 0 ? 0 : index, - onChanged: (i) => app.setOrdersTab(_tabs[i]), - ), - const SizedBox(height: 12), - // ── A search box, because the list is the archive ── - // - // Orders is the only screen that grows without bound: every - // parcel a customer has ever sent ends up here. Scrolling is - // fine at four and useless at forty, and the two things they - // have to go on are the reference somebody quoted at them and - // the city they sent it to. Both are matched. - DmSearchField( - controller: _query, - hint: 'Search by order ID or city', - onChanged: (_) => setState(() {}), - ), - ], - ), - ), - 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]), + // The bar was carrying the app's name over a screen already titled + // "Orders", and the search glyph beside it duplicated the field this + // screen now has of its own. Home lost the same bar for the same + // reason: a root that opens on a title does not need to be told which + // app it is. + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 26, + DmSpace.pad, + 14, + ), + // The filter gets its own row. + // + // Beside the title it had a third of the width for three segments, + // and rendered "Act… Pa… Ca…" — the reference fits its control on + // the title line because it carries two segments, not three. A row + // is cheaper than an unreadable control. + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Orders', + style: DmText.headerTitle.copyWith(fontSize: 26), + ), + const SizedBox(height: 14), + _Filter( + labels: const ['Active', 'Past', 'Cancelled'], + counts: [for (final tab in _tabs) app.entriesOf(tab).length], + index: index < 0 ? 0 : index, + onChanged: (i) => app.setOrdersTab(_tabs[i]), + ), + const SizedBox(height: 12), + // ── A search box, because the list is the archive ── + // + // Orders is the only screen that grows without bound: every + // parcel a customer has ever sent ends up here. Scrolling is + // fine at four and useless at forty, and the two things they + // have to go on are the reference somebody quoted at them and + // the city they sent it to. Both are matched. + DmSearchField( + controller: _query, + hint: 'Search by order ID or city', + onChanged: (_) => setState(() {}), + ), + ], ), ), - ), - ], + 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 /// having no orders, and says so. Widget _noMatch(String query) => DmEmptyState( - icon: LucideIcons.search, - title: 'No orders match "$query"', - message: 'Try an order ID, or the city it was going to.', - ); + icon: LucideIcons.search, + title: 'No orders match "$query"', + message: 'Try an order ID, or the city it was going to.', + ); Widget _empty(BookingStatus tab) { final (title, message) = switch (tab) { BookingStatus.active => ( - 'No active pickups', - 'Book a pickup and it will show up here.', - ), + 'No active pickups', + 'Book a pickup and it will show up here.', + ), BookingStatus.completed => ( - 'Nothing completed yet', - 'Delivered orders will appear here.', - ), + 'Nothing completed yet', + 'Delivered orders will appear here.', + ), BookingStatus.cancelled => ( - 'No cancelled bookings', - 'Anything you cancel will appear here.', - ), + 'No cancelled bookings', + 'Anything you cancel will appear here.', + ), }; return DmEmptyState( icon: LucideIcons.package, diff --git a/lib/ui/screens/sheets/drop_address_sheet.dart b/lib/ui/screens/sheets/drop_address_sheet.dart new file mode 100644 index 0000000..fe59075 --- /dev/null +++ b/lib/ui/screens/sheets/drop_address_sheet.dart @@ -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 showDropAddressSheet( + BuildContext context, { + required DeliveryDetails details, + required String district, +}) { + return showDmSheet( + 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, + ), + ], + ); + } +} diff --git a/lib/ui/screens/sheets/place_search_sheet.dart b/lib/ui/screens/sheets/place_search_sheet.dart index c2a0cd0..94a19a9 100644 --- a/lib/ui/screens/sheets/place_search_sheet.dart +++ b/lib/ui/screens/sheets/place_search_sheet.dart @@ -11,21 +11,32 @@ import '../../widgets/cards.dart'; import '../../widgets/feedback.dart'; import '../../widgets/inputs.dart'; import '../../widgets/states.dart'; +import '../booking/pickup_location_screen.dart'; /// Alias used from Review, where the sheet edits the pickup point in place. Future showPlaceSearchSheetForReview(BuildContext context) => showPlaceSearchSheet(context); /// Pickup-location picker. Returns the chosen [Place], or null if dismissed. -Future 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 showPlaceSearchSheet( + BuildContext context, { + bool allowMap = true, +}) { return showDmSheet( context: context, - builder: (context) => const _PlaceSearchBody(), + builder: (context) => _PlaceSearchBody(allowMap: allowMap), ); } class _PlaceSearchBody extends StatefulWidget { - const _PlaceSearchBody(); + const _PlaceSearchBody({required this.allowMap}); + + final bool allowMap; @override State<_PlaceSearchBody> createState() => _PlaceSearchBodyState(); @@ -44,6 +55,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> { 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 _openMap() async { + final place = await Navigator.of(context, rootNavigator: true).push( + MaterialPageRoute(builder: (_) => const PickupLocationScreen()), + ); + if (place == null || !mounted) return; + Navigator.of(context).pop(place); + } + void _onChanged(String value) { _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 220), () { @@ -57,12 +81,19 @@ class _PlaceSearchBodyState extends State<_PlaceSearchBody> { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const DmSheetHeader(title: 'Collect from'), + const DmSheetHeader( + title: 'Collect from', + subtitle: 'Search for it, or drop a pin where you are standing', + ), DmSearchField( controller: _query, hint: 'Search street, area or landmark', onChanged: _onChanged, ), + if (widget.allowMap) ...[ + const SizedBox(height: 12), + _MapRow(onTap: _openMap), + ], const SizedBox(height: 8), Flexible( 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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/shell_screen.dart b/lib/ui/screens/shell_screen.dart index 882cc2e..eb845f6 100644 --- a/lib/ui/screens/shell_screen.dart +++ b/lib/ui/screens/shell_screen.dart @@ -81,8 +81,8 @@ class _TabBar extends StatelessWidget { (label: 'Account', icon: LucideIcons.userRound), ]; - static const double _slot = 62; - static const double _height = 58; + static const double _slot = 74; + static const double _height = 62; @override Widget build(BuildContext context) { @@ -161,30 +161,30 @@ class _TabButtonState extends State<_TabButton> { onTapCancel: () => setState(() => _down = false), onTap: widget.onTap, child: AnimatedScale( - scale: _down ? 0.9 : 1, + scale: _down ? 0.88 : 1, duration: DmMotion.fast, curve: DmMotion.ease, child: Center( - child: AnimatedContainer( - duration: DmMotion.base, - curve: DmMotion.ease, - width: 42, - height: 42, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected ? DmColors.brandSoft : Colors.transparent, - borderRadius: DmRadius.all(DmRadius.sm), - ), - child: AnimatedScale( - // A shade of overshoot as the tint arrives under it, so the - // two read as one gesture rather than as a glyph that - // recolours when a rectangle happens to appear behind it. - scale: selected ? 1.06 : 1, - duration: DmMotion.slow, - curve: DmMotion.glide, + // ── Colour alone ── + // + // The glyph sat in a `brandSoft` rounded square, which made the + // selected tab the only filled object in a bar whose whole job is + // to recede. The reference marks it with the brand and nothing + // else, and with three glyphs in a white pill that is unambiguous + // — there is only one coloured thing on the bar. + // + // It arrives with a shade of overshoot, so the tab reacts to the + // press rather than recolouring after it. + child: AnimatedScale( + scale: selected ? 1.08 : 1, + duration: DmMotion.slow, + curve: DmMotion.glide, + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, child: Icon( widget.icon, - size: 21, + size: 23, color: selected ? DmColors.brand : DmColors.ink4, ), ), diff --git a/lib/ui/screens/tracking_screen.dart b/lib/ui/screens/tracking_screen.dart index de942c1..637d30d 100644 --- a/lib/ui/screens/tracking_screen.dart +++ b/lib/ui/screens/tracking_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../data/models.dart'; @@ -252,17 +253,17 @@ class _TrackCard extends StatelessWidget { final group = focused ?? booking.destinations.first; // What the customer can act on: when, before the handover; where, after. - final when = switch (true) { - _ when cancelled => 'You were not charged', - _ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 => - 'Arriving in about ${booking.milerEtaMinutes} min', - _ when stage == JourneyStage.arrived => 'At your door now', - _ when stage.index <= JourneyStage.assigned.index && slot != null => - 'Today · ${slot!.window}', - _ when booking.expectedDelivery != null => - 'Expected ${booking.expectedDelivery}', - _ => null, - }; + // ── One line only, and only when it is not a time ── + // + // This carried "Today · 2:00 – 4:00 PM" under the headline while the strip + // below it carried "WINDOW · 2:00 – 4:00 PM" — the same promise twice, + // eighty points apart. Every timing this used to show has a home of its + // own now: the ETA is the pill beside the headline, the window and the + // expected date are cells in the strip. + // + // What is left is the one thing that is not a time and has nowhere else + // to go. + final when = cancelled ? 'You were not charged' : null; final eta = booking.milerEtaMinutes; 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 // a fact centred in a grey band, which is worse than the fact on its own // — 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 = [ - if (booking.fare?.routeKm != null) - _Figure( - label: 'Distance', - value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km', - ), _Figure( label: booking.totalPackages == 1 ? 'Package' : 'Packages', 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) _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) - _Figure(label: 'Vehicle', value: booking.miler!.vehicleType!) - else if (slot != null) - _Figure(label: 'Window', value: slot!.window), + _Figure(label: 'Vehicle', value: booking.miler!.vehicleType!), ]; return Padding( @@ -293,20 +302,12 @@ class _TrackCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - DmStatusChip( - label: reference.startsWith('#') ? reference : '#$reference', - tone: cancelled ? DmColors.ink4 : DmColors.brand, - quiet: cancelled, - ), - // 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), + // The reference used to float here as a small crimson chip above + // the headline — an identifier introducing a state, which is the + // wrong way round and left it as the smallest thing on the screen + // in the loudest position. It is the last row of the shipment card + // now, where it identifies the record it belongs to and can be + // copied. Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -351,20 +352,49 @@ class _TrackCard extends StatelessWidget { children: [ DmCardCell( 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( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _Place( - label: 'ORIGIN', + label: 'PICKUP', value: booking.pickup.title, 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( child: _Place( - label: 'DESTINATION', + label: 'DROP', value: group.destination.shortLabel, note: group.destination.stateName, 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) DmCardCell( - padding: const EdgeInsets.fromLTRB(4, 12, 4, 13), - // `groove`, not `surfaceAlt`: the latter is within a point - // of the canvas, so the strip read as spilling out of the - // bottom of the card rather than as a band inside it. - background: DmColors.groove, + padding: const EdgeInsets.fromLTRB(4, 13, 4, 14), + // ── Red glass, not grey ── + // + // It was `groove`, a warm grey chosen so the band read as + // *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), ), ], @@ -454,14 +500,23 @@ class _Figure extends StatelessWidget { children: [ Text( 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, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), Text( value, - style: DmText.cardTitle.copyWith(fontSize: 14.5), + style: DmText.cardTitle.copyWith( + fontSize: 14.5, + color: DmColors.ink, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -634,8 +689,15 @@ class _ReachState extends State<_Reach> { width: 44, height: 44, 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( - color: widget.filled ? DmColors.brand : DmColors.surfaceAlt, + color: widget.filled ? DmColors.ok : DmColors.surfaceAlt, shape: BoxShape.circle, ), 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), + ], + ), + ), + ); + } +} diff --git a/lib/ui/widgets/book_orb.dart b/lib/ui/widgets/book_orb.dart index a6d90ea..55ddf60 100644 --- a/lib/ui/widgets/book_orb.dart +++ b/lib/ui/widgets/book_orb.dart @@ -60,8 +60,7 @@ class DmBookOrb extends StatefulWidget { const DmBookOrb({ super.key, required this.onTap, - this.label = 'BOOK NOW', - this.strapline = 'INSTANT PARCEL', + this.label = 'ONE TOUCH', this.caption, this.held = false, }); @@ -81,9 +80,6 @@ class DmBookOrb extends StatefulWidget { /// second use of this widget elsewhere is not forced to repeat itself. final String? caption; - /// The small line inside the sphere, under the label. - final String strapline; - @override State createState() => _DmBookOrbState(); } @@ -104,12 +100,33 @@ class _DmBookOrbState extends State with TickerProviderStateMixin { duration: const Duration(milliseconds: 4000), )..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; + @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 void dispose() { _radar.dispose(); _breath.dispose(); + _reveal.dispose(); super.dispose(); } @@ -121,19 +138,23 @@ class _DmBookOrbState extends State with TickerProviderStateMixin { return Semantics( button: true, label: widget.caption == null - ? '${widget.label}. ${widget.strapline}' - : '${widget.label}. ${widget.strapline}. ${widget.caption}', + ? widget.label + : '${widget.label}. ${widget.caption}', child: Column( children: [ GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _down = true), - onTapCancel: () => setState(() => _down = false), + onTapCancel: () { + setState(() => _down = false); + _reveal.reverse(); + }, onTap: () { setState(() => _down = false); // The press is a commitment — it opens the booking flow — and a // sphere gives no edge feedback the way a bordered button does. HapticFeedback.mediumImpact(); + _reveal.forward(); widget.onTap(); }, child: SizedBox( @@ -154,20 +175,32 @@ class _DmBookOrbState extends State 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( - scale: _down || widget.held ? 0.94 : 1, - // Just past the press, so the finger feels the give rather - // than watching it happen. - duration: const Duration(milliseconds: 140), - curve: Curves.easeOutCubic, + scale: _down || widget.held ? 0.93 : 1, + duration: Duration( + milliseconds: _down || widget.held ? 140 : 420, + ), + curve: _down || widget.held + ? Curves.easeOutCubic + : Curves.easeOutBack, child: ScaleTransition( scale: Tween(begin: 1.0, end: 1.012).animate( CurvedAnimation(parent: _breath, curve: Curves.easeInOut), ), - child: _Sphere( - size: disc, - label: widget.label, - strapline: widget.strapline, + child: AnimatedBuilder( + animation: _reveal, + builder: (_, _) => _Sphere( + size: disc, + label: widget.label, + reveal: Curves.easeOutCubic.transform(_reveal.value), + ), ), ), ), @@ -195,12 +228,15 @@ class _Sphere extends StatelessWidget { const _Sphere({ required this.size, required this.label, - required this.strapline, + required this.reveal, }); final double size; 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 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, // 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 - // strapline — the same three parts every physical button that has - // 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 - // red disc. - Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - LucideIcons.navigation, - size: 21, - color: Colors.white, + // A glyph over a single line, with air round it, is what makes the + // circle read as an object rather than as a word in a red disc. + // + // "INSTANT PARCEL" sat under it and is gone: on a screen whose only + // other text is the customer's own name and their address, a + // strapline is the app talking about itself. + _face(Colors.white, shadowed: true), + + // ── The press, turning it inside out ── + // + // A white disc opens from the centre to the rim, clipped to the + // 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, - letterSpacing: -0.5, - fontWeight: FontWeight.w700, - fontVariations: const [FontVariation('wght', 700)], - color: Colors.white, - shadows: const [ + ), + ], + ), + ); + } + + /// One face, in whichever ink the layer under it calls for. + /// + /// The drop shadow belongs to the white word only — it is there to lift type + /// off a saturated gradient, and under crimson on white it would read as + /// grime. + 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( color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2), ), - ], - ), - ), - const SizedBox(height: 5), - Text( - strapline, - textAlign: TextAlign.center, - style: DmText.eyebrow.copyWith( - fontSize: 9.5, - letterSpacing: 1.7, - color: const Color(0xB3FFFFFF), - ), - ), - ], + ] + : null, ), - ], - ), + ), + ], ); } } +/// A circle growing from the centre of the sphere to its rim. +class _Reveal extends CustomClipper { + 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 /// the bottom, nothing at the sides. class _RimPainter extends CustomPainter { diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart index ae7660b..734d8a4 100644 --- a/test/booking_flow_test.dart +++ b/test/booking_flow_test.dart @@ -81,23 +81,15 @@ Future serverAdvance( /// Home ▸ the BOOK orb ▸ the destination sheet. /// -/// BOOK no longer opens the form. It opens one question — where is it going — -/// and the form is what the answer leads to, so every booking test is -/// [openSend] followed by [pickCity]. +/// BOOK asks one question — where is it going — and the review screen is what +/// the answer leads to, so every booking test is [openSend] then [pickCity]. +/// The sheet is left open: tests that are about the sheet itself go no +/// further. Future openSend(WidgetTester tester) async { - await tester.tap(find.text('BOOK NOW')); + await tester.tap(find.text('ONE TOUCH')); 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. /// /// Home's live card heads with the district alone — "Chennai", not @@ -107,6 +99,15 @@ Future openSend(WidgetTester tester) async { Finder inSheet(Finder 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 pickCity( WidgetTester tester, String name, { @@ -118,16 +119,11 @@ Future pickCity( // // `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 - // scrim, now heads its live card with the district alone, so an unscoped - // finder can land on a widget the sheet is covering. Scoping to the modal - // route fixes both. + // scrim, heads its live card with the district alone, so an unscoped finder + // can land on a widget the sheet is covering. Scoping to the modal route + // fixes both. await tester.tap( - find - .descendant( - of: find.byType(BottomSheet), - matching: find.byWidgetPredicate((w) => w is Text && w.data == name), - ) - .first, + inSheet(find.byWidgetPredicate((w) => w is Text && w.data == name)).first, ); await settle(tester); @@ -140,6 +136,7 @@ Future pickCity( await settle(tester); } + // The window sheet follows the destination on its own. final use = find.textContaining('Use '); if (takeWindow && use.evaluate().isNotEmpty) { await tester.tap(use.first); @@ -209,7 +206,7 @@ void main() { await settle(tester); // 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); }); @@ -218,7 +215,7 @@ void main() { // Home greets by name, carries the sphere, and ends on the live card. 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 // micro-label beside the destination, in the chip vocabulary — "Assigned", // not the rail's "Miler assigned". @@ -250,7 +247,7 @@ void main() { // Which is what the editor carries back to the send screen. final corrected = pickupAddress(tester); - await tester.tap(find.text('Use this address')); + await tester.tap(find.text('Confirm pickup point')); await settle(tester); // Back on the send screen: its title is the route, not a heading. expect(find.text('DESTINATION'), findsOneWidget); @@ -258,6 +255,103 @@ void main() { 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(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(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', (tester) async { final state = AppState( @@ -286,7 +380,7 @@ void main() { // 3 — a search result overwrites it, coordinates included, and the map // 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 tester.tap(find.text('Brookefields Mall')); await settle(tester); @@ -300,7 +394,7 @@ void main() { expect(pickupAddress(tester), 'Brookefields Mall'); // …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); expect(find.text('DESTINATION'), findsOneWidget); expect(state.draftPickup!.lat, searched.lat); @@ -396,7 +490,7 @@ void main() { expect( tester .widget( - find.widgetWithText(DmButton, 'Use this address'), + find.widgetWithText(DmButton, 'Confirm pickup point'), ) .onPressed, isNotNull, @@ -410,12 +504,17 @@ void main() { await signIn(tester, state: state); await openSend(tester); - // BOOK asks one question, and it is the only thing on screen. - expect(find.text('Where is it going?'), findsOneWidget); + // BOOK asks one question, and it is the only thing on screen. Scoped to + // 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); 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 // the top of it is two rows with no labels but FROM and TO. expect(find.text('ORIGIN'), findsOneWidget); @@ -449,6 +548,11 @@ void main() { await signIn(tester, state: state); 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, // because the field carries its own prefix. 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 // 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); expect(state.draftContactPhone, isNull); // Changed, it is the number the Miler will ring at the door. 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.enterText(find.byType(TextField).first, '9003144518'); await settle(tester, 200); - await tester.tap(find.text('Use this address')); + await tester.tap(find.text('Confirm pickup point')); await settle(tester); expect(state.draftContactPhone, '+91 9003144518'); await drainToasts(tester); @@ -694,9 +800,6 @@ void main() { }); 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; await signIn(tester); @@ -757,11 +860,21 @@ void main() { addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); 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 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 settle(tester); // confirmation await tester.tap(find.text('Track pickup')); diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart index 55de716..1ac938a 100644 --- a/test/design_snapshot_test.dart +++ b/test/design_snapshot_test.dart @@ -182,8 +182,8 @@ void main() { await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); await _settle(tester); - // Booking: state, district, window, then one screen. - await tester.tap(find.text('BOOK NOW')); + // Booking: BOOK asks where, then the review screen asks the rest. + await tester.tap(find.text('ONE TOUCH')); await _settle(tester); await _shot(tester, '07-destination-states'); @@ -191,10 +191,14 @@ void main() { await _settle(tester); await _shot(tester, '07b-destination-districts'); - // The live card, by its reference. Home's quick-dispatch chips are - // named after cities too, so a bare 'Chennai' finder now hits a - // shortcut that starts a new booking instead. - await tester.tap(find.text('DM-482913')); + await tester.tap( + find + .descendant( + of: find.byType(BottomSheet), + matching: find.text('Chennai'), + ) + .first, + ); await _settle(tester); // The dev backend allows several destinations, so the row ticked rather // than closing the sheet — the choice is confirmed. diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png index e26342a..9c46372 100644 Binary files a/test/snapshots/03-home.png and b/test/snapshots/03-home.png differ diff --git a/test/snapshots/03b-pickup-search.png b/test/snapshots/03b-pickup-search.png index b7f19fb..212648d 100644 Binary files a/test/snapshots/03b-pickup-search.png and b/test/snapshots/03b-pickup-search.png differ diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png index d0f2a60..f4c057b 100644 Binary files a/test/snapshots/04-orders.png and b/test/snapshots/04-orders.png differ diff --git a/test/snapshots/04b-orders-completed.png b/test/snapshots/04b-orders-completed.png index ccc5f96..79392d4 100644 Binary files a/test/snapshots/04b-orders-completed.png and b/test/snapshots/04b-orders-completed.png differ diff --git a/test/snapshots/05-account.png b/test/snapshots/05-account.png index c699b38..48d3deb 100644 Binary files a/test/snapshots/05-account.png and b/test/snapshots/05-account.png differ diff --git a/test/snapshots/06-tracking.png b/test/snapshots/06-tracking.png index 1fc5cd6..2bc9c7d 100644 Binary files a/test/snapshots/06-tracking.png and b/test/snapshots/06-tracking.png differ diff --git a/test/snapshots/06b-cancel-sheet.png b/test/snapshots/06b-cancel-sheet.png index e27905b..a3c3085 100644 Binary files a/test/snapshots/06b-cancel-sheet.png and b/test/snapshots/06b-cancel-sheet.png differ diff --git a/test/snapshots/07-destination-states.png b/test/snapshots/07-destination-states.png index 08275bf..855bc9e 100644 Binary files a/test/snapshots/07-destination-states.png and b/test/snapshots/07-destination-states.png differ diff --git a/test/snapshots/07b-destination-districts.png b/test/snapshots/07b-destination-districts.png index fe148d0..fd721df 100644 Binary files a/test/snapshots/07b-destination-districts.png and b/test/snapshots/07b-destination-districts.png differ diff --git a/test/snapshots/08-send.png b/test/snapshots/08-send.png index 21d225c..cf72bd7 100644 Binary files a/test/snapshots/08-send.png and b/test/snapshots/08-send.png differ diff --git a/test/snapshots/09-window-sheet.png b/test/snapshots/09-window-sheet.png index c674ac9..4e9e7c8 100644 Binary files a/test/snapshots/09-window-sheet.png and b/test/snapshots/09-window-sheet.png differ diff --git a/test/snapshots/10-pickup-map.png b/test/snapshots/10-pickup-map.png index 1d79060..b55b148 100644 Binary files a/test/snapshots/10-pickup-map.png and b/test/snapshots/10-pickup-map.png differ diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png index 2cf2184..246b23e 100644 Binary files a/test/snapshots/11-booked.png and b/test/snapshots/11-booked.png differ diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png index ac4fc0a..f7bfe21 100644 Binary files a/test/snapshots/12-receipt.png and b/test/snapshots/12-receipt.png differ