Picks up where 0d66627 left off. Three things.
SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.
Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.
`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.
BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".
Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.
Together those put the window, the package count and the CTA above the fold.
THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:
* `useDevData` is false in a release whatever the defines say;
* `describe` leads with DEV DATA (offline) and shows "no network" rather than
a host the build never contacts.
It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.
flutter run --dart-define=DM_MOCK=true
86 tests green, analyze clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
343 lines
11 KiB
Dart
343 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../data/models.dart';
|
|
import '../../state/app_scope.dart';
|
|
import '../format.dart';
|
|
import '../tokens.dart';
|
|
import '../widgets/buttons.dart';
|
|
import '../widgets/cards.dart';
|
|
import '../widgets/chrome.dart';
|
|
import '../widgets/misc.dart';
|
|
import '../widgets/route_rail.dart';
|
|
import 'booking/booking_routes.dart';
|
|
import 'booking/send_screen.dart';
|
|
import 'shell_screen.dart';
|
|
import 'sheets/place_search_sheet.dart';
|
|
import 'tracking_screen.dart';
|
|
|
|
/// Home — one question, one dominant action, and whatever is live right now.
|
|
/// Nothing else competes; history belongs to the Orders tab.
|
|
class HomeScreen extends StatelessWidget {
|
|
const HomeScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final app = AppScope.of(context);
|
|
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
|
final firstRun = app.orders.isEmpty;
|
|
|
|
// The pickup point is already confirmed on this screen, so booking starts
|
|
// at the destination — two taps from here to a reserved slot.
|
|
void startBooking() {
|
|
AppScope.read(context).startBooking();
|
|
Navigator.of(context).push(
|
|
bookingRoute<void>(BookingRoutes.send, (_) => const SendScreen()),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DmBrandHeader(
|
|
eyebrow: 'Welcome, ${_firstName(app.customer?.name)}',
|
|
title: firstRun ? 'Send your first parcel' : 'Send a parcel',
|
|
action: InkResponse(
|
|
onTap: () => ShellScreen.of(context)?.goToTab(2),
|
|
radius: 26,
|
|
child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true),
|
|
),
|
|
// The address is the one fact every booking starts from, so it sits
|
|
// in the header as a live value the customer can change in place —
|
|
// not as the first question of the flow.
|
|
child: DmHeaderTrack(
|
|
leading: const DmLiveDot(size: 6, color: Colors.white),
|
|
trailing: const Icon(
|
|
LucideIcons.chevronDown,
|
|
size: 16,
|
|
color: DmColors.onBrand3,
|
|
),
|
|
onTap: () async {
|
|
final place = await showPlaceSearchSheet(context);
|
|
if (place != null && context.mounted) app.setPickup(place);
|
|
},
|
|
child: Text(
|
|
app.pickup?.title ?? 'Set a pickup address',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: DmText.cardTitle.copyWith(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
color: DmColors.brand,
|
|
backgroundColor: DmColors.surface,
|
|
onRefresh: () async {
|
|
await Future.wait([app.detectPickupLocation(), app.refreshOrders()]);
|
|
},
|
|
child: ListView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
|
|
children: [
|
|
_BookCta(onTap: startBooking),
|
|
if (active != null) ...[
|
|
const DmMicroHead('Happening now'),
|
|
_ActiveCard(booking: active),
|
|
],
|
|
if (firstRun) const _HowItWorks(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
static String _firstName(String? name) {
|
|
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
|
|
return first.isEmpty ? 'there' : first;
|
|
}
|
|
}
|
|
|
|
/// The screen's one action, as a filled crimson card.
|
|
///
|
|
/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object,
|
|
/// which on first run told a new customer nothing about what the app does. The
|
|
/// card says what will happen ("a Miler collects from your door in a window you
|
|
/// choose") and puts the action inside it, so the promise and the button are
|
|
/// one object rather than a shout followed by an explanation elsewhere.
|
|
class _BookCta extends StatelessWidget {
|
|
const _BookCta({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DmCard(
|
|
tone: DmCardTone.fill,
|
|
children: [
|
|
DmCardCell(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Book a pickup',
|
|
style: DmText.heading.copyWith(fontSize: 19, color: Colors.white),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'A Miler collects from your door in a window you choose.',
|
|
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
|
),
|
|
const SizedBox(height: 14),
|
|
// White on crimson: inside a filled card the primary action has
|
|
// to invert, or it disappears into its own background.
|
|
_WhiteButton(label: 'Start booking', onTap: onTap),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WhiteButton extends StatefulWidget {
|
|
const _WhiteButton({required this.label, required this.onTap});
|
|
|
|
final String label;
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
State<_WhiteButton> createState() => _WhiteButtonState();
|
|
}
|
|
|
|
class _WhiteButtonState extends State<_WhiteButton> {
|
|
bool _down = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: widget.label,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (_) => setState(() => _down = true),
|
|
onTapUp: (_) => setState(() => _down = false),
|
|
onTapCancel: () => setState(() => _down = false),
|
|
onTap: widget.onTap,
|
|
child: AnimatedContainer(
|
|
duration: DmMotion.fast,
|
|
curve: DmMotion.ease,
|
|
// A minimum, not a height: at large accessibility text the label
|
|
// needs the extra line, and a fixed 46 would clip it.
|
|
constraints: const BoxConstraints(minHeight: 46),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: _down ? DmColors.brandSoft : Colors.white,
|
|
borderRadius: DmRadius.all(DmRadius.sm),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
widget.label,
|
|
textAlign: TextAlign.center,
|
|
style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand),
|
|
),
|
|
),
|
|
const SizedBox(width: 9),
|
|
const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shown on first run only: three numbered rows that match the booking flow
|
|
/// exactly, so the first send has no surprises in it.
|
|
class _HowItWorks extends StatelessWidget {
|
|
const _HowItWorks();
|
|
|
|
static const _steps = [
|
|
('Tell us where it goes', 'City, address and recipient'),
|
|
('Pick a two-hour window', 'With live Miler availability'),
|
|
('Hand it over and track', 'Door to hub to doorstep, with proof'),
|
|
];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
const DmMicroHead('How it works'),
|
|
DmCard(
|
|
children: [
|
|
for (var i = 0; i < _steps.length; i++)
|
|
DmCardRow(
|
|
leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand),
|
|
title: _steps[i].$1,
|
|
subtitle: _steps[i].$2,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ActiveCard extends StatelessWidget {
|
|
const _ActiveCard({required this.booking});
|
|
|
|
final Booking booking;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final app = AppScope.read(context);
|
|
final slot = app.slotById(booking.slotId);
|
|
final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index;
|
|
final windowLabel = slot == null
|
|
? 'Pickup scheduled'
|
|
: '${slot.day} · ${slot.window}';
|
|
|
|
final courierLine = switch (booking.stage) {
|
|
JourneyStage.booked => 'Matching the nearest Miler',
|
|
JourneyStage.assigned when booking.miler != null =>
|
|
'${booking.miler!.name} will collect your parcel',
|
|
JourneyStage.onTheWay when booking.miler != null =>
|
|
'${booking.miler!.name} is on route'
|
|
'${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}',
|
|
JourneyStage.arrived when booking.miler != null =>
|
|
'${booking.miler!.name} is at your door',
|
|
_ =>
|
|
booking.isMultiDestination
|
|
? 'To ${booking.destinations.length} destinations'
|
|
: 'To ${booking.destination.label}',
|
|
};
|
|
|
|
return DmCard(
|
|
onTap: () {
|
|
app.openTracking(booking.reference);
|
|
Navigator.of(context).push(
|
|
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
|
|
);
|
|
},
|
|
children: [
|
|
DmCardCell(
|
|
background: DmColors.brandSoft,
|
|
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
|
|
child: Row(
|
|
children: [
|
|
Flexible(child: DmPill(booking.stage.milestoneLabel)),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
booking.displayId,
|
|
textAlign: TextAlign.right,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: DmText.mono,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
DmCardCell(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DmRouteRail(
|
|
compact: true,
|
|
pickup: booking.pickup.title,
|
|
drop: booking.isMultiDestination
|
|
? '${booking.destinations.length} destinations'
|
|
: booking.destination.label,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(courierLine, style: DmText.small),
|
|
],
|
|
),
|
|
),
|
|
DmCardCell(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
beforeHandover ? LucideIcons.clock : LucideIcons.truck,
|
|
size: 15,
|
|
color: DmColors.ink3,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(
|
|
beforeHandover ? windowLabel : 'On its way',
|
|
style: DmText.small,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
DmTextAction(
|
|
label: 'Track',
|
|
icon: LucideIcons.arrowRight,
|
|
onPressed: () {
|
|
app.openTracking(booking.reference);
|
|
Navigator.of(context).push(
|
|
bookingRoute<void>(
|
|
BookingRoutes.tracking,
|
|
(_) => const TrackingScreen(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|