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

559
lib/ui/widgets/pieces.dart Normal file
View File

@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'chrome.dart';
enum DmTagTone {
/// The default: a quiet sunken chip carrying a fact — "2 Milers nearby".
neutral,
/// Outlined crimson. The one tag in a group that is a recommendation
/// — "Fastest".
brand,
/// Kept for the delivered state.
ok,
warn,
/// On a crimson field.
onFill,
}
/// Small badge: "Fastest pickup", "Transit", "4 Milers nearby".
class DmTag extends StatelessWidget {
const DmTag(
this.label, {
super.key,
this.tone = DmTagTone.neutral,
this.icon,
this.mono = false,
});
final String label;
final DmTagTone tone;
final IconData? icon;
/// For a vehicle plate or a reference carried as a tag.
final bool mono;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null),
DmTagTone.brand => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
),
DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null),
DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null),
DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null),
};
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5),
decoration: BoxDecoration(
color: bg,
border: edge,
borderRadius: DmRadius.all(5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: mono
? DmText.monoSm.copyWith(fontSize: 12, color: fg)
: DmText.label.copyWith(fontSize: 12, color: fg),
),
),
],
),
);
}
}
/// A selection chip — the design's `.filt`.
///
/// This is the app's one selection control: it is the sign-in method switch,
/// the address label picker, the slot day filter and the rating tags. Selected
/// is a filled crimson chip with a small glow; unselected is a white chip with
/// a hairline. There is no third state to learn.
class DmChoiceChip extends StatelessWidget {
const DmChoiceChip({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.onTap,
this.count,
this.expand = false,
});
final String label;
final bool selected;
final bool enabled;
final VoidCallback? onTap;
/// A figure carried inside the chip, as on the Orders filters.
final int? count;
/// Fills the available width — for a row of chips that splits evenly.
final bool expand;
@override
Widget build(BuildContext context) {
final fg = selected
? Colors.white
: (enabled ? DmColors.ink2 : DmColors.ink4);
final chip = AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 38),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.xs),
boxShadow: selected ? DmShadow.liftSm : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: fg),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? Colors.white : DmColors.ink3,
),
),
),
],
],
),
);
final tappable = Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: chip,
),
);
return expand ? Expanded(child: tappable) : tappable;
}
}
/// A destination card in a horizontal strip — the design's `.ccard`.
///
/// Selection is a wash and a crimson edge rather than a fill: a strip of these
/// is scanned, and a solid crimson block in a scrollable row pulls the eye off
/// the neighbours the customer is still comparing.
class DmGridTile extends StatelessWidget {
const DmGridTile({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.note,
this.onTap,
this.icon,
});
final String label;
final bool selected;
final bool enabled;
final String? note;
final VoidCallback? onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final titleColor = enabled
? (selected ? DmColors.brand : DmColors.ink)
: DmColors.ink4;
final noteColor = selected
? DmColors.brandPress
: (enabled ? DmColors.ink3 : DmColors.ink4);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.fromLTRB(13, 12, 13, 12),
decoration: BoxDecoration(
color: selected
? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 18,
color: selected ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 10),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(color: titleColor),
),
if (note != null) ...[
const SizedBox(height: 2),
Text(
// Secondary: the promise may ellipsize, the place name
// never does.
note!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor),
),
],
],
),
),
if (selected)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Icon(LucideIcons.check, size: 17, color: DmColors.brand),
),
],
),
),
),
);
}
}
/// "Step 2 of 4 · Pickup" with its segmented rail.
class DmStepHeader extends StatelessWidget {
const DmStepHeader({
super.key,
required this.step,
required this.total,
required this.label,
});
final int step;
final int total;
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
child: DmStepBar(
progress: step / total,
steps: total,
current: step,
label: label,
),
);
}
}
/// A washed strip carrying one piece of supporting information.
class DmInfoBanner extends StatelessWidget {
const DmInfoBanner({
super.key,
required this.icon,
required this.title,
this.message,
this.tone = DmTagTone.brand,
});
final IconData icon;
final String title;
final String? message;
final DmTagTone tone;
@override
Widget build(BuildContext context) {
final (bg, edge, fg, sub) = switch (tone) {
DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok),
DmTagTone.warn => (
const Color(0xFFFFF3DF),
const Color(0xFFF6E3C2),
const Color(0xFF9C5303),
const Color(0xFF9C5303),
),
DmTagTone.neutral => (
DmColors.surfaceAlt,
DmColors.border,
DmColors.ink,
DmColors.ink2,
),
_ => (
DmColors.brandSoft,
DmColors.brandLine,
DmColors.ink,
DmColors.brandPress,
),
};
return Container(
padding: const EdgeInsets.fromLTRB(13, 13, 14, 13),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: edge),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(
icon,
size: 18,
color: tone == DmTagTone.brand ? DmColors.brand : fg,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)),
if (message != null) ...[
const SizedBox(height: 2),
Text(message!, style: DmText.small.copyWith(color: sub)),
],
],
),
),
],
),
);
}
}
/// A reference number with a copy affordance. The number is mono and large
/// enough to read back over a phone.
class DmCopyRow extends StatefulWidget {
const DmCopyRow({
super.key,
required this.label,
required this.value,
this.trailing,
this.onWash = false,
});
final String label;
final String value;
final Widget? trailing;
/// Sitting inside a wash card, where the label goes crimson.
final bool onWash;
@override
State<DmCopyRow> createState() => _DmCopyRowState();
}
class _DmCopyRowState extends State<DmCopyRow> {
bool _copied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.value));
await HapticFeedback.mediumImpact();
if (!mounted) return;
// The confirmation lands on the control that was pressed rather than in a
// snackbar over the footer: it is the row the customer is looking at.
setState(() => _copied = true);
await Future<void>.delayed(const Duration(milliseconds: 1600));
if (mounted) setState(() => _copied = false);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.label.toUpperCase(),
style: DmText.eyebrow.copyWith(
color: widget.onWash ? DmColors.brand : DmColors.ink3,
),
),
const SizedBox(height: 4),
Text(
widget.value,
style: DmText.monoLg.copyWith(
color: widget.onWash ? DmColors.brandPress : DmColors.ink,
),
),
],
),
),
?widget.trailing,
DmTextAction(
label: _copied ? 'Copied' : 'Copy',
icon: _copied ? LucideIcons.check : LucideIcons.copy,
onPressed: _copy,
),
],
);
}
}
/// − n + quantity control. Used for how many packages go to one place.
class DmStepper extends StatelessWidget {
const DmStepper({
super.key,
required this.value,
required this.onChanged,
this.min = 1,
this.canIncrease = true,
});
final int value;
final ValueChanged<int> onChanged;
final int min;
/// False when a cap has been reached; the + greys out rather than vanishing.
final bool canIncrease;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Step(
icon: LucideIcons.minus,
onTap: value > min ? () => onChanged(value - 1) : null,
),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 30),
child: Text(
'$value',
textAlign: TextAlign.center,
style: DmText.monoLg.copyWith(fontSize: 17),
),
),
_Step(
icon: LucideIcons.plus,
onTap: canIncrease ? () => onChanged(value + 1) : null,
),
],
),
);
}
}
class _Step extends StatelessWidget {
const _Step({required this.icon, this.onTap});
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Semantics(
button: true,
enabled: enabled,
child: InkResponse(
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap!();
}
: null,
radius: 20,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
icon,
size: 18,
color: enabled ? DmColors.ink : DmColors.ink4,
),
),
),
);
}
}