Files
doormile_customer_app/lib/ui/screens/home_screen.dart
Thiru-tenext 0d66627c3c 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>
2026-09-15 16:07:33 +05:30

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(),
),
);
},
),
],
),
),
],
);
}
}