Files
doormile_customer_app/lib/ui/widgets/buttons.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

436 lines
14 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.
///
/// ── Why the primary is lit (2026-09-22) ──
///
/// It was a flat crimson rectangle at the field radius, which is what a button
/// looks like when nobody has designed it. The BOOK sphere on Home is lit from
/// the upper left; a committing action that is not lit the same way reads as a
/// different material, and the app ends up with one crafted object and a row of
/// plain boxes underneath it.
///
/// So the primary now takes the sphere's treatment, dialled right down: the
/// same directional gradient, a white rim along the top edge where the light
/// would catch, and the crimson glow it already had. It also finally uses
/// [DmRadius.book] — the token that exists for exactly this and was never
/// wired up, leaving every CTA at the 12pt field corner.
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;
Gradient? gradient;
double height = widget.height ?? DmSpace.button;
double radius = DmRadius.book;
switch (widget.kind) {
case DmButtonKind.primary:
background = !_enabled
? DmColors.surfaceAlt
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
// A disabled primary on the deepened canvas is a 1% step from it, so
// the screen's one action read as a grey smear rather than as a
// button waiting for the form to be filled. The edge is what keeps it
// a button.
if (!_enabled) border = Border.all(color: DmColors.border);
shadow = _enabled && !_down ? DmShadow.lift : null;
// Lit like the sphere, and only while it can be pressed: a disabled
// control that still catches the light is a control that is lying.
gradient = _enabled && !_down
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
stops: [0.0, 0.55, 1.0],
)
: 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;
radius = DmRadius.sm;
}
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),
],
],
);
final filled = widget.kind == DmButtonKind.primary;
// Presses translate rather than scale for anything with an edge: these
// buttons sit on a hairline grid, and scaling a 1px border makes it
// shimmer. The primary has no border, so it also takes a touch of scale —
// which is what makes a filled surface feel pressed rather than nudged.
final button = AnimatedScale(
scale: _down && _enabled && filled ? 0.985 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: 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: gradient == null ? background : null,
gradient: gradient,
border: border,
borderRadius: DmRadius.all(radius),
boxShadow: shadow,
),
// The rim the light catches. Drawn in the foreground so the gradient
// cannot cover it, and only along the top — a full outline would
// read as a border and flatten the fill back out.
foregroundDecoration: gradient == null
? null
: BoxDecoration(
borderRadius: DmRadius.all(radius),
border: const Border(
top: BorderSide(color: Color(0x33FFFFFF), width: 1.2),
),
),
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
? () {
// A primary action commits to something. Everything else is
// navigation or a change of mind, and a medium impact for
// those makes the one that matters feel ordinary.
if (filled) {
HapticFeedback.mediumImpact();
} else {
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),
),
),
),
),
),
);
}
}