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>
376 lines
11 KiB
Dart
376 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../tokens.dart';
|
|
|
|
enum DmButtonKind {
|
|
/// Crimson, with the glow. The one committing action on a screen.
|
|
primary,
|
|
|
|
/// White with a hairline edge. A real action that is not *the* action —
|
|
/// "Share live tracking".
|
|
outline,
|
|
|
|
/// White with a crimson edge and crimson label. Brand-weighted, but not a
|
|
/// commitment — used where an outline would read as too neutral.
|
|
quiet,
|
|
|
|
/// A crimson wash. Rare; secondary actions inside a wash card.
|
|
soft,
|
|
|
|
/// No surface at all. The line under a primary action.
|
|
ghost,
|
|
}
|
|
|
|
/// The app's only button.
|
|
///
|
|
/// `block: true` fills the width (the default in footers). The disabled primary
|
|
/// does not fade the crimson — it goes to the sunken neutral, because a
|
|
/// half-opacity red still reads as "press me" and this design uses exactly one
|
|
/// red for exactly one meaning.
|
|
class DmButton extends StatefulWidget {
|
|
const DmButton({
|
|
super.key,
|
|
required this.label,
|
|
this.onPressed,
|
|
this.kind = DmButtonKind.primary,
|
|
this.block = true,
|
|
this.danger = false,
|
|
this.busy = false,
|
|
this.busyLabel,
|
|
this.icon,
|
|
this.iconLeading = false,
|
|
this.height,
|
|
});
|
|
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
final DmButtonKind kind;
|
|
final bool block;
|
|
|
|
/// Renders the label in the danger colour (Cancel pickup).
|
|
final bool danger;
|
|
final bool busy;
|
|
final String? busyLabel;
|
|
|
|
/// Optional icon, e.g. an arrow on a forward action.
|
|
final IconData? icon;
|
|
|
|
/// Puts [icon] before the label. True for a pair of short actions — "Call",
|
|
/// "Message" — where the glyph is what the eye lands on first.
|
|
final bool iconLeading;
|
|
|
|
/// Overrides [DmSpace.button] — for the half-width pair inside a card.
|
|
final double? height;
|
|
|
|
@override
|
|
State<DmButton> createState() => _DmButtonState();
|
|
}
|
|
|
|
class _DmButtonState extends State<DmButton> {
|
|
bool _down = false;
|
|
|
|
bool get _enabled => widget.onPressed != null && !widget.busy;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Color background;
|
|
final Color foreground;
|
|
BoxBorder? border;
|
|
List<BoxShadow>? shadow;
|
|
double height = widget.height ?? DmSpace.button;
|
|
|
|
switch (widget.kind) {
|
|
case DmButtonKind.primary:
|
|
background = !_enabled
|
|
? DmColors.surfaceAlt
|
|
: (_down ? DmColors.brandPress : DmColors.brand);
|
|
foreground = _enabled ? Colors.white : DmColors.ink3;
|
|
shadow = _enabled && !_down ? DmShadow.lift : null;
|
|
case DmButtonKind.outline:
|
|
background = _down ? DmColors.surfaceAlt : DmColors.surface;
|
|
foreground = widget.danger ? DmColors.danger : DmColors.ink;
|
|
border = Border.all(color: DmColors.border);
|
|
case DmButtonKind.quiet:
|
|
background = _down ? DmColors.brandSoft : DmColors.surface;
|
|
foreground = DmColors.brand;
|
|
border = Border.all(color: DmColors.brandLine);
|
|
case DmButtonKind.soft:
|
|
background = _down ? DmColors.brandLine : DmColors.brandSoft;
|
|
foreground = DmColors.brand;
|
|
case DmButtonKind.ghost:
|
|
background = Colors.transparent;
|
|
foreground = widget.danger ? DmColors.brand : DmColors.ink2;
|
|
height = widget.height ?? DmSpace.tap;
|
|
}
|
|
|
|
final content = Row(
|
|
mainAxisSize: widget.block ? MainAxisSize.max : MainAxisSize.min,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (widget.icon != null && widget.iconLeading && !widget.busy) ...[
|
|
Icon(widget.icon, size: 18, color: foreground),
|
|
const SizedBox(width: 9),
|
|
],
|
|
if (widget.busy) ...[
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
strokeCap: StrokeCap.round,
|
|
valueColor: AlwaysStoppedAnimation(foreground),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
],
|
|
Flexible(
|
|
child: Text(
|
|
widget.busy ? (widget.busyLabel ?? widget.label) : widget.label,
|
|
style: (widget.kind == DmButtonKind.ghost
|
|
? DmText.textButton
|
|
: DmText.button)
|
|
.copyWith(color: foreground),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
if (widget.icon != null && !widget.iconLeading && !widget.busy) ...[
|
|
const SizedBox(width: 9),
|
|
Icon(widget.icon, size: 18, color: foreground),
|
|
],
|
|
],
|
|
);
|
|
|
|
// Presses translate rather than scale: the design's buttons sit on a
|
|
// hairline grid, and scaling a 1px border makes it shimmer.
|
|
final button = AnimatedSlide(
|
|
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
|
|
duration: DmMotion.fast,
|
|
curve: DmMotion.ease,
|
|
child: AnimatedContainer(
|
|
duration: DmMotion.fast,
|
|
constraints: BoxConstraints(minHeight: height),
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: background,
|
|
border: border,
|
|
borderRadius: DmRadius.all(DmRadius.sm),
|
|
boxShadow: shadow,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: content,
|
|
),
|
|
);
|
|
|
|
return Semantics(
|
|
button: true,
|
|
enabled: _enabled,
|
|
label: widget.label,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: _enabled ? (_) => setState(() => _down = true) : null,
|
|
onTapUp: _enabled ? (_) => setState(() => _down = false) : null,
|
|
onTapCancel: _enabled ? () => setState(() => _down = false) : null,
|
|
onTap: _enabled
|
|
? () {
|
|
HapticFeedback.selectionClick();
|
|
widget.onPressed!();
|
|
}
|
|
: null,
|
|
child: widget.block ? SizedBox(width: double.infinity, child: button) : button,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Half-width action inside a card — Call / Message on the rider row.
|
|
class DmMiniButton extends StatelessWidget {
|
|
const DmMiniButton({
|
|
super.key,
|
|
required this.label,
|
|
required this.icon,
|
|
this.onPressed,
|
|
this.filled = false,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final VoidCallback? onPressed;
|
|
|
|
/// The one of the pair that is the expected action.
|
|
final bool filled;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: DmButton(
|
|
label: label,
|
|
icon: icon,
|
|
iconLeading: true,
|
|
height: 42,
|
|
kind: filled ? DmButtonKind.primary : DmButtonKind.outline,
|
|
onPressed: onPressed,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Small quiet pill inside a card ("Change", when it needs a surface).
|
|
class DmChipButton extends StatelessWidget {
|
|
const DmChipButton({super.key, required this.label, this.onPressed, this.icon});
|
|
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
final IconData? icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Material(
|
|
color: DmColors.surfaceAlt,
|
|
borderRadius: DmRadius.all(DmRadius.xs),
|
|
child: InkWell(
|
|
onTap: onPressed,
|
|
borderRadius: DmRadius.all(DmRadius.xs),
|
|
child: Container(
|
|
constraints: const BoxConstraints(minHeight: 34),
|
|
padding: EdgeInsets.only(
|
|
left: icon == null ? 12 : 9,
|
|
right: 12,
|
|
top: 6,
|
|
bottom: 6,
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (icon != null) ...[
|
|
Icon(icon, size: 14, color: DmColors.ink2),
|
|
const SizedBox(width: 5),
|
|
],
|
|
Text(label, style: DmText.label),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Text-only action — the "Change" / "Edit" / "Copy" at the end of a card row.
|
|
///
|
|
/// Its padding is negative-margined back out, so the label sits flush with the
|
|
/// card's right edge while the tap target still clears 40pt.
|
|
class DmTextAction extends StatelessWidget {
|
|
const DmTextAction({
|
|
super.key,
|
|
required this.label,
|
|
this.onPressed,
|
|
this.color = DmColors.brand,
|
|
this.minHeight = 40,
|
|
this.icon,
|
|
});
|
|
|
|
final String label;
|
|
final VoidCallback? onPressed;
|
|
final Color color;
|
|
final double minHeight;
|
|
final IconData? icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onPressed == null
|
|
? null
|
|
: () {
|
|
HapticFeedback.selectionClick();
|
|
onPressed!();
|
|
},
|
|
child: Container(
|
|
constraints: BoxConstraints(minHeight: minHeight),
|
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
|
alignment: Alignment.center,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (icon != null) ...[
|
|
Icon(icon, size: 14, color: color),
|
|
const SizedBox(width: 6),
|
|
],
|
|
// Flexible, because this sits at the end of rows whose other half
|
|
// is an address or a window that has first claim on the width.
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: DmText.label.copyWith(fontSize: 13, color: color),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The chrome affordance: a rounded square, not a disc.
|
|
class DmIconButton extends StatelessWidget {
|
|
const DmIconButton({
|
|
super.key,
|
|
required this.icon,
|
|
this.onPressed,
|
|
this.background = DmColors.surface,
|
|
this.foreground = DmColors.ink,
|
|
this.border = DmColors.border,
|
|
this.size = 40,
|
|
this.radius = DmRadius.sm,
|
|
this.tooltip,
|
|
});
|
|
|
|
final IconData icon;
|
|
final VoidCallback? onPressed;
|
|
final Color background;
|
|
final Color foreground;
|
|
|
|
/// Transparent on the crimson header, where the fill carries the shape.
|
|
final Color border;
|
|
final double size;
|
|
final double radius;
|
|
final String? tooltip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
button: true,
|
|
label: tooltip,
|
|
child: SizedBox(
|
|
width: DmSpace.tap,
|
|
height: DmSpace.tap,
|
|
child: Center(
|
|
child: Material(
|
|
color: background,
|
|
borderRadius: DmRadius.all(radius),
|
|
child: InkWell(
|
|
borderRadius: DmRadius.all(radius),
|
|
onTap: onPressed,
|
|
child: Ink(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
borderRadius: DmRadius.all(radius),
|
|
border: Border.all(color: border),
|
|
),
|
|
child: Icon(icon, size: 19, color: foreground),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|