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:
375
lib/ui/widgets/buttons.dart
Normal file
375
lib/ui/widgets/buttons.dart
Normal file
@@ -0,0 +1,375 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user