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>
656 lines
20 KiB
Dart
656 lines
20 KiB
Dart
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),
|
|
],
|
|
);
|
|
}
|
|
}
|