One Touch, and a pickup map worth the name
Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
164
lib/ui/screens/sheets/drop_address_sheet.dart
Normal file
164
lib/ui/screens/sheets/drop_address_sheet.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
|
||||
/// The full drop address, for a booking made the long way.
|
||||
///
|
||||
/// ── Why this is a sheet and not a screen ──
|
||||
///
|
||||
/// Every field on it is optional to the contract and every one of them is
|
||||
/// something the Miler can fill in at the door. That is the whole argument for
|
||||
/// One Touch. A customer who chooses the full form is choosing to save the
|
||||
/// Miler that step, so the form has to be quick enough to be worth choosing:
|
||||
/// one surface, five fields, one button.
|
||||
///
|
||||
/// Nothing here is validated beyond being non-empty. A street name the
|
||||
/// backend has never heard of is still better than no street name — it is
|
||||
/// read by a person, not matched against a table.
|
||||
Future<bool?> showDropAddressSheet(
|
||||
BuildContext context, {
|
||||
required DeliveryDetails details,
|
||||
required String district,
|
||||
}) {
|
||||
return showDmSheet<bool>(
|
||||
context: context,
|
||||
builder: (context) => _DropAddressBody(details: details, district: district),
|
||||
);
|
||||
}
|
||||
|
||||
class _DropAddressBody extends StatefulWidget {
|
||||
const _DropAddressBody({required this.details, required this.district});
|
||||
|
||||
final DeliveryDetails details;
|
||||
final String district;
|
||||
|
||||
@override
|
||||
State<_DropAddressBody> createState() => _DropAddressBodyState();
|
||||
}
|
||||
|
||||
class _DropAddressBodyState extends State<_DropAddressBody> {
|
||||
late final _building = TextEditingController(text: widget.details.building);
|
||||
late final _street = TextEditingController(text: widget.details.street);
|
||||
late final _landmark = TextEditingController(text: widget.details.landmark);
|
||||
late final _name = TextEditingController(text: widget.details.recipientName);
|
||||
late final _phone = TextEditingController(
|
||||
text: _digitsOf(widget.details.recipientPhone),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// The button turns on the moment there is enough to find a door, so the
|
||||
// two fields that decide it are listened to rather than read on submit.
|
||||
_building.addListener(_refresh);
|
||||
_street.addListener(_refresh);
|
||||
}
|
||||
|
||||
void _refresh() => setState(() {});
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_building.removeListener(_refresh);
|
||||
_street.removeListener(_refresh);
|
||||
_building.dispose();
|
||||
_street.dispose();
|
||||
_landmark.dispose();
|
||||
_name.dispose();
|
||||
_phone.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// The ten digits, whatever shape the number arrived in — the field carries
|
||||
/// its own `+91`.
|
||||
static String _digitsOf(String? phone) {
|
||||
final digits = (phone ?? '').replaceAll(RegExp(r'\D'), '');
|
||||
return digits.length <= 10 ? digits : digits.substring(digits.length - 10);
|
||||
}
|
||||
|
||||
/// Enough to send a Miler to. A building or a street will do — between them
|
||||
/// they are what a person needs to find a door, and the rest is help.
|
||||
bool get _enough =>
|
||||
_building.text.trim().isNotEmpty || _street.text.trim().isNotEmpty;
|
||||
|
||||
void _save() {
|
||||
final phone = _phone.text.trim();
|
||||
widget.details.update(
|
||||
building: _building.text,
|
||||
street: _street.text,
|
||||
landmark: _landmark.text,
|
||||
recipientName: _name.text,
|
||||
recipientPhone: phone.isEmpty ? null : '+91 $phone',
|
||||
instructions: widget.details.instructions,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmSheetHeader(
|
||||
title: 'Where in ${widget.district}?',
|
||||
subtitle: 'The door the Miler is delivering to',
|
||||
),
|
||||
Flexible(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
DmTextField(
|
||||
label: 'Flat, house or building',
|
||||
controller: _building,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Street and area',
|
||||
controller: _street,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Landmark',
|
||||
hint: 'Optional',
|
||||
controller: _landmark,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text('WHO RECEIVES IT', style: DmText.eyebrow),
|
||||
const SizedBox(height: 10),
|
||||
DmTextField(
|
||||
label: 'Name',
|
||||
hint: 'Optional',
|
||||
controller: _name,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DmTextField(
|
||||
label: 'Phone',
|
||||
hint: 'Optional',
|
||||
controller: _phone,
|
||||
prefix: '+91',
|
||||
keyboardType: TextInputType.phone,
|
||||
digitsOnly: true,
|
||||
maxLength: 10,
|
||||
mono: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
DmButton(
|
||||
label: 'Save address',
|
||||
icon: _enough ? LucideIcons.check : null,
|
||||
onPressed: _enough ? _save : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Place?> showPlaceSearchSheetForReview(BuildContext context) =>
|
||||
showPlaceSearchSheet(context);
|
||||
|
||||
/// Pickup-location picker. Returns the chosen [Place], or null if dismissed.
|
||||
Future<Place?> showPlaceSearchSheet(BuildContext context) {
|
||||
///
|
||||
/// [allowMap] offers "Select on map", which pushes the map screen over this
|
||||
/// sheet and closes it carrying whatever pin comes back. It is off when the
|
||||
/// map screen is the one asking — a row that opens the screen you are already
|
||||
/// on is a loop with a label.
|
||||
Future<Place?> showPlaceSearchSheet(
|
||||
BuildContext context, {
|
||||
bool allowMap = true,
|
||||
}) {
|
||||
return showDmSheet<Place>(
|
||||
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<void> _openMap() async {
|
||||
final place = await Navigator.of(context, rootNavigator: true).push<Place>(
|
||||
MaterialPageRoute(builder: (_) => const PickupLocationScreen()),
|
||||
);
|
||||
if (place == null || !mounted) return;
|
||||
Navigator.of(context).pop(place);
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
_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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user