Replace the customer app with Doormile CX
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>
This commit is contained in:
345
lib/ui/screens/home_screen.dart
Normal file
345
lib/ui/screens/home_screen.dart
Normal file
@@ -0,0 +1,345 @@
|
||||
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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user