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:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

View File

@@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
/// 17. Cancel booking confirmation.
///
/// Only reachable while the backend still allows cancellation — see
/// `DoormileApi.isCancellable`. Returns true when the booking was cancelled.
Future<bool?> showCancelSheet(BuildContext context, Booking booking) {
return showDmSheet<bool>(
context: context,
builder: (context) => _CancelBody(booking: booking),
);
}
class _CancelBody extends StatefulWidget {
const _CancelBody({required this.booking});
final Booking booking;
@override
State<_CancelBody> createState() => _CancelBodyState();
}
class _CancelBodyState extends State<_CancelBody> {
static const _reasons = [
'Booked by mistake',
'Package not ready',
'Sending it another day',
'Changed the destination',
'Other',
];
String? _reason;
bool _busy = false;
Future<void> _cancel() async {
setState(() => _busy = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.cancelBooking(widget.booking, _reason);
unawaited(HapticFeedback.mediumImpact());
navigator.pop(true);
} catch (_) {
if (!mounted) return;
setState(() => _busy = false);
DmToast.show(context, "Couldn't cancel. Try again.");
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(widget.booking.slotId);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmSheetHeader(
title: 'Cancel this pickup?',
subtitle:
'${widget.booking.reference} · ${slot?.label ?? 'Pickup scheduled'}. '
'You can cancel free of charge until the Miler collects your package.',
),
Text('REASON (OPTIONAL)', style: DmText.eyebrow.copyWith(color: DmColors.brand)),
const SizedBox(height: 10),
for (final reason in _reasons) ...[
_ReasonTile(
label: reason,
selected: _reason == reason,
onTap: () => setState(() => _reason = reason),
),
const SizedBox(height: 8),
],
const SizedBox(height: 12),
DmButton(
label: 'Keep my pickup',
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
),
const SizedBox(height: 4),
DmButton(
label: 'Yes, cancel pickup',
kind: DmButtonKind.ghost,
danger: true,
busy: _busy,
busyLabel: 'Cancelling…',
onPressed: _cancel,
),
],
);
}
}
class _ReasonTile extends StatelessWidget {
const _ReasonTile({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
constraints: const BoxConstraints(minHeight: 46),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: selected ? DmColors.brand : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
boxShadow: selected ? DmShadow.liftSm : null,
),
child: Text(
label,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? Colors.white : DmColors.ink2,
),
),
),
);
}
}

View File

@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/states.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) {
return showDmSheet<Place>(
context: context,
builder: (context) => const _PlaceSearchBody(),
);
}
class _PlaceSearchBody extends StatefulWidget {
const _PlaceSearchBody();
@override
State<_PlaceSearchBody> createState() => _PlaceSearchBodyState();
}
class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
final _query = TextEditingController();
Timer? _debounce;
late final AppState _app = AppScope.read(context);
late Future<List<Place>> _results = _app.searchPlaces('');
@override
void dispose() {
_debounce?.cancel();
_query.dispose();
super.dispose();
}
void _onChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 220), () {
setState(() => _results = _app.searchPlaces(value));
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmSheetHeader(
title: 'Pickup location',
subtitle: 'Search or pick a saved place.',
),
TextField(
controller: _query,
onChanged: _onChanged,
autofocus: false,
cursorColor: DmColors.brand,
style: DmText.field,
decoration: InputDecoration(
hintText: 'Search street, area or landmark',
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.lineStrong, 1),
enabledBorder: _border(DmColors.lineStrong, 1),
focusedBorder: _border(DmColors.brand, 2),
),
),
const SizedBox(height: 8),
Flexible(
child: SingleChildScrollView(
child: FutureBuilder<List<Place>>(
future: _results,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.only(top: 12),
child: DmSkeleton(rows: 3),
);
}
final places = snap.data ?? const <Place>[];
if (places.isEmpty) {
return const DmEmptyState(
icon: LucideIcons.searchX,
title: 'No places found',
message: 'Try a different street or landmark.',
);
}
return Column(
children: [
for (final place in places)
_PlaceRow(
place: place,
onTap: () => Navigator.of(context).pop(place),
),
],
);
},
),
),
),
],
);
}
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}
class _PlaceRow extends StatelessWidget {
const _PlaceRow({required this.place, required this.onTap});
final Place place;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 60),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.line)),
),
child: Row(
children: [
const DmTile(icon: LucideIcons.mapPin),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
place.title,
style: DmText.cardTitle,
),
Text(
place.sub,
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
}