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:
655
lib/ui/widgets/chrome.dart
Normal file
655
lib/ui/widgets/chrome.dart
Normal file
@@ -0,0 +1,655 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/app_config.dart';
|
||||
import '../tokens.dart';
|
||||
import 'buttons.dart';
|
||||
|
||||
/// Top bar for pushed screens: a square back affordance, a quiet title, an
|
||||
/// optional trailing slot (usually the booking reference in mono), and the
|
||||
/// booking step indicator.
|
||||
class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
const DmTopBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.trailing,
|
||||
this.progress,
|
||||
this.steps,
|
||||
this.stepLabel,
|
||||
this.onBack,
|
||||
this.background = DmColors.canvas,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Widget? trailing;
|
||||
|
||||
/// 0..1 — omit to hide the step indicator.
|
||||
final double? progress;
|
||||
|
||||
/// When given, the indicator is drawn as [steps] separate segments rather
|
||||
/// than one continuous rail. Discrete steps are countable; a filled bar is
|
||||
/// only a proportion, and "how many screens until I am done" is the question
|
||||
/// a customer is actually asking.
|
||||
final int? steps;
|
||||
|
||||
/// The line above the segments — "Step 2 of 4 · Destination".
|
||||
final String? stepLabel;
|
||||
|
||||
final VoidCallback? onBack;
|
||||
final Color background;
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = progress == null || steps == null
|
||||
? 0
|
||||
: (progress! * steps!).round().clamp(0, steps!);
|
||||
|
||||
return Material(
|
||||
color: background,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: DmSpace.pad - 12),
|
||||
DmIconButton(
|
||||
icon: LucideIcons.arrowLeft,
|
||||
tooltip: 'Back',
|
||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: title == null
|
||||
? const SizedBox.shrink()
|
||||
: Text(
|
||||
title!,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 15.5,
|
||||
letterSpacing: -0.31,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
const SizedBox(width: DmSpace.pad),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (progress != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
|
||||
child: DmStepBar(
|
||||
progress: progress!,
|
||||
steps: steps,
|
||||
current: current,
|
||||
label: stepLabel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Booking progress.
|
||||
///
|
||||
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
|
||||
/// without, it falls back to a single filled rail for callers that only know a
|
||||
/// fraction.
|
||||
class DmStepBar extends StatelessWidget {
|
||||
const DmStepBar({
|
||||
super.key,
|
||||
required this.progress,
|
||||
this.steps,
|
||||
this.current = 0,
|
||||
this.label,
|
||||
this.onFill = false,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final int? steps;
|
||||
final int current;
|
||||
final String? label;
|
||||
|
||||
/// Drawn on a crimson field (the live-tracking card), where the track is a
|
||||
/// lightened white and the fill is solid white.
|
||||
final bool onFill;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
|
||||
final fill = onFill ? Colors.white : DmColors.brand;
|
||||
|
||||
final bar = steps == null
|
||||
? ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
child: SizedBox(
|
||||
height: 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ColoredBox(color: track)),
|
||||
FractionallySizedBox(
|
||||
widthFactor: progress.clamp(0, 1),
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.ease,
|
||||
decoration: BoxDecoration(
|
||||
color: fill,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
for (var i = 0; i < steps!; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: i < current ? fill : track,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (label == null && steps == null) return bar;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (label != null || steps != null) ...[
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (steps != null)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: DmText.monoSm.copyWith(
|
||||
color: onFill ? DmColors.onBrand3 : DmColors.ink3,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: 'Step '),
|
||||
TextSpan(
|
||||
text: '$current',
|
||||
style: TextStyle(color: onFill ? Colors.white : DmColors.brand),
|
||||
),
|
||||
TextSpan(text: ' of ${steps!}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (label != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
label!,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: onFill ? DmColors.onBrand3 : DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
],
|
||||
bar,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sticky bottom action area.
|
||||
///
|
||||
/// `edge: true` lifts it onto white with a hairline above — used when the
|
||||
/// content behind it scrolls, so the action never looks like the end of the
|
||||
/// list. On a short screen the canvas variant keeps the page quiet.
|
||||
class DmFooter extends StatelessWidget {
|
||||
const DmFooter({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.edge = false,
|
||||
this.background,
|
||||
this.divider = false,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
|
||||
/// White bed, hairline above.
|
||||
final bool edge;
|
||||
|
||||
final Color? background;
|
||||
|
||||
/// Retained for call-site compatibility; superseded by [edge].
|
||||
final bool divider;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lifted = edge || divider;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: background ?? (lifted ? DmColors.surface : DmColors.canvas),
|
||||
border: lifted
|
||||
? const Border(top: BorderSide(color: DmColors.border))
|
||||
: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 4),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The crimson field at the top of a tab root.
|
||||
///
|
||||
/// Home, Orders and Account all open with one. It does three jobs at once —
|
||||
/// it states where you are (eyebrow + title), it holds that destination's one
|
||||
/// control (a segmented filter, an address switcher, a phone number), and it
|
||||
/// gives the app a recognisable brand surface that the white canvas below can
|
||||
/// then stay completely quiet.
|
||||
///
|
||||
/// The arcs are drawn, not an asset: they are struck from the same geometry as
|
||||
/// the mark, they scale to any header height, and they cost no bytes.
|
||||
class DmBrandHeader extends StatelessWidget {
|
||||
const DmBrandHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.eyebrow,
|
||||
this.leading,
|
||||
this.action,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? eyebrow;
|
||||
|
||||
/// Sits at the top-left of the header — the avatar on Account.
|
||||
final Widget? leading;
|
||||
|
||||
/// Top-right — refresh, help.
|
||||
final Widget? action;
|
||||
|
||||
/// The control that belongs to this destination, under the title.
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(DmRadius.header),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: DmColors.brand,
|
||||
child: CustomPaint(
|
||||
painter: const DmArcField(),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.headerPad,
|
||||
2,
|
||||
DmSpace.headerPad,
|
||||
18,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
?leading,
|
||||
const Spacer(),
|
||||
// ── A build on dev data says so, on every screen ──
|
||||
//
|
||||
// Twice now a booking made offline has been hunted for
|
||||
// in the admin console. The Account screen named the
|
||||
// mode, but nobody goes to Account before booking. It
|
||||
// belongs where the booking is made.
|
||||
if (AppConfig.useDevData) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0x80FFFFFF)),
|
||||
borderRadius: DmRadius.all(5),
|
||||
),
|
||||
child: Text(
|
||||
'OFFLINE DATA',
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
?action,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (eyebrow != null) ...[
|
||||
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
Text(title, style: DmText.headerTitle),
|
||||
if (child != null) ...[const SizedBox(height: 18), child!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
|
||||
/// uses. Decorative only — it never carries meaning, so it is not announced.
|
||||
class DmArcField extends CustomPainter {
|
||||
const DmArcField({this.opacity = 0.15});
|
||||
|
||||
final double opacity;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// The arcs are authored on a 340-square field anchored to the top-right,
|
||||
// then scaled — so the same geometry reads identically on a 210pt Orders
|
||||
// header and a 260pt Account one.
|
||||
const field = 340.0;
|
||||
final scale = size.width / 390 * 1.0;
|
||||
canvas.save();
|
||||
canvas.translate(size.width - field * scale + 62 * scale, -72 * scale);
|
||||
canvas.scale(scale);
|
||||
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5 / scale
|
||||
..color = Colors.white.withValues(alpha: opacity);
|
||||
|
||||
canvas.drawCircle(const Offset(250, 90), 120, paint);
|
||||
canvas.drawCircle(const Offset(250, 90), 170, paint);
|
||||
|
||||
for (final (y1, y2, cx, cy) in const [
|
||||
(300.0, 40.0, 60.0, 300.0),
|
||||
(330.0, 60.0, 70.0, 330.0),
|
||||
]) {
|
||||
final path = Path()
|
||||
..moveTo(-20, y1)
|
||||
..cubicTo(cx, cy, field - 40, y2 + 140, field, y2);
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant DmArcField oldDelegate) =>
|
||||
oldDelegate.opacity != opacity;
|
||||
}
|
||||
|
||||
/// The segmented filter recessed into the brand header — Orders' Active /
|
||||
/// Completed / Cancelled, with each tab's count.
|
||||
///
|
||||
/// The selected tab lifts out as a white pill and keeps its figure; the others
|
||||
/// stay in the recess. One control, three states, no separate count row.
|
||||
class DmSegmented extends StatelessWidget {
|
||||
const DmSegmented({
|
||||
super.key,
|
||||
required this.labels,
|
||||
required this.counts,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
|
||||
/// Null where a tab has no meaningful figure yet (still loading).
|
||||
final List<int?> counts;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.onBrandSunken,
|
||||
borderRadius: DmRadius.all(DmRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: _Segment(
|
||||
label: labels[i],
|
||||
count: i < counts.length ? counts[i] : null,
|
||||
selected: i == index,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
const _Segment({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int? count;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? DmColors.brand : DmColors.onBrand3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? DmColors.brand : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recessed strip under a header title that carries one live fact — the
|
||||
/// address a pickup is coming from, or the customer's phone number.
|
||||
class DmHeaderTrack extends StatelessWidget {
|
||||
const DmHeaderTrack({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: onTap != null,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.onBrandSunken,
|
||||
borderRadius: DmRadius.all(DmRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 10)],
|
||||
Expanded(child: child),
|
||||
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Large title for a screen that has no brand header.
|
||||
class DmPageTitle extends StatelessWidget {
|
||||
const DmPageTitle(this.text, {super.key, this.subtitle});
|
||||
|
||||
final String text;
|
||||
final String? subtitle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(text, style: DmText.pageTitle),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: DmText.lede),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sentence-case heading above a group of fields or rows.
|
||||
class DmGroupHead extends StatelessWidget {
|
||||
const DmGroupHead(this.text, {super.key, this.top = 24});
|
||||
|
||||
final String text;
|
||||
final double top;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: top, bottom: 10),
|
||||
child: Text(text, style: DmText.sectionHead),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Screen heading and its supporting line — shared by every booking step.
|
||||
class DmScreenHeader extends StatelessWidget {
|
||||
const DmScreenHeader({super.key, required this.title, this.lede, this.leding});
|
||||
|
||||
final String title;
|
||||
final String? lede;
|
||||
|
||||
/// Rich alternative to [lede].
|
||||
final Widget? leding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(title, style: DmText.title),
|
||||
if (lede != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(lede!, style: DmText.lede),
|
||||
],
|
||||
if (leding != null) ...[const SizedBox(height: 10), leding!],
|
||||
const SizedBox(height: 22),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user