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
436 lines
14 KiB
Dart
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),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|