Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
346 lines
11 KiB
Dart
346 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/pickup_location_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.pickup,
|
|
(_) => const PickupLocationScreen(),
|
|
),
|
|
);
|
|
}
|
|
|
|
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(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|