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:
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal file
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal file
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user