import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../tokens.dart'; import 'buttons.dart'; import 'chrome.dart'; enum DmTagTone { /// The default: a quiet sunken chip carrying a fact — "2 Milers nearby". neutral, /// Outlined crimson. The one tag in a group that is a recommendation /// — "Fastest". brand, /// Kept for the delivered state. ok, warn, /// On a crimson field. onFill, } /// Small badge: "Fastest pickup", "Transit", "4 Milers nearby". class DmTag extends StatelessWidget { const DmTag( this.label, { super.key, this.tone = DmTagTone.neutral, this.icon, this.mono = false, }); final String label; final DmTagTone tone; final IconData? icon; /// For a vehicle plate or a reference carried as a tag. final bool mono; @override Widget build(BuildContext context) { final (Color bg, Color fg, BoxBorder? edge) = switch (tone) { DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null), DmTagTone.brand => ( DmColors.surface, DmColors.brand, Border.all(color: DmColors.brandLine), ), DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null), DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null), DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null), }; return Container( padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5), decoration: BoxDecoration( color: bg, border: edge, borderRadius: DmRadius.all(5), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 13, color: fg), const SizedBox(width: 5), ], Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: mono ? DmText.monoSm.copyWith(fontSize: 12, color: fg) : DmText.label.copyWith(fontSize: 12, color: fg), ), ), ], ), ); } } /// A selection chip — the design's `.filt`. /// /// This is the app's one selection control: it is the sign-in method switch, /// the address label picker, the slot day filter and the rating tags. Selected /// is a filled crimson chip with a small glow; unselected is a white chip with /// a hairline. There is no third state to learn. class DmChoiceChip extends StatelessWidget { const DmChoiceChip({ super.key, required this.label, required this.selected, this.enabled = true, this.onTap, this.count, this.expand = false, }); final String label; final bool selected; final bool enabled; final VoidCallback? onTap; /// A figure carried inside the chip, as on the Orders filters. final int? count; /// Fills the available width — for a row of chips that splits evenly. final bool expand; @override Widget build(BuildContext context) { // ── A segmented control, not a crimson block ── // // "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting // eight points above a list of windows and two hundred above the crimson // button that confirms one. Three crimson objects on a sheet that asks a // single question, and the loudest of them was the day — which the // customer changes once, if ever. // // It is a lifted white chip on a sunken track now: the same convention // iOS and every banking app use for a segment, where *selected* means // raised rather than coloured. final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4); final chip = AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, constraints: const BoxConstraints(minHeight: 40), // 9, not 12. Three of these split a 390pt phone between them on the // Orders filter, and at 12 the widest label truncated to "Cancell…". // The pickup window's day switcher has two chips and acres of room, so // it loses nothing. padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 9), alignment: Alignment.center, decoration: BoxDecoration( color: selected ? DmColors.surface : Colors.transparent, borderRadius: DmRadius.all(DmRadius.sm), boxShadow: selected ? DmShadow.card : null, ), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith( fontSize: 13.5, color: fg, fontWeight: selected ? FontWeight.w700 : FontWeight.w600, ), ), ), if (count != null) ...[ const SizedBox(width: 5), Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), decoration: BoxDecoration( color: selected ? DmColors.surfaceAlt : DmColors.surface, borderRadius: DmRadius.all(DmRadius.pill), ), child: Text( '$count', style: DmText.monoSm.copyWith( fontSize: 11, color: DmColors.ink3, ), ), ), ], ], ), ); final tappable = Semantics( button: true, selected: selected, enabled: enabled, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: enabled ? () { HapticFeedback.selectionClick(); onTap?.call(); } : null, child: chip, ), ); return expand ? Expanded(child: tappable) : tappable; } } /// A destination card in a horizontal strip — the design's `.ccard`. /// /// Selection is a wash and a crimson edge rather than a fill: a strip of these /// is scanned, and a solid crimson block in a scrollable row pulls the eye off /// the neighbours the customer is still comparing. class DmGridTile extends StatelessWidget { const DmGridTile({ super.key, required this.label, required this.selected, this.enabled = true, this.note, this.onTap, this.icon, }); final String label; final bool selected; final bool enabled; final String? note; final VoidCallback? onTap; final IconData? icon; @override Widget build(BuildContext context) { final titleColor = enabled ? (selected ? DmColors.brand : DmColors.ink) : DmColors.ink4; final noteColor = selected ? DmColors.brandPress : (enabled ? DmColors.ink3 : DmColors.ink4); return Semantics( button: true, selected: selected, enabled: enabled, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: enabled ? () { HapticFeedback.selectionClick(); onTap?.call(); } : null, child: AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, padding: const EdgeInsets.fromLTRB(13, 12, 13, 12), decoration: BoxDecoration( color: selected ? DmColors.brandSoft : (enabled ? DmColors.surface : DmColors.surfaceAlt), border: Border.all( color: selected ? DmColors.brand : DmColors.border, ), borderRadius: DmRadius.all(DmRadius.md), ), child: Row( children: [ if (icon != null) ...[ Icon( icon, size: 18, color: selected ? DmColors.brand : DmColors.ink3, ), const SizedBox(width: 10), ], Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( label, maxLines: 2, overflow: TextOverflow.ellipsis, style: DmText.cardTitle.copyWith(color: titleColor), ), if (note != null) ...[ const SizedBox(height: 2), Text( // Secondary: the promise may ellipsize, the place name // never does. note!, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor), ), ], ], ), ), if (selected) const Padding( padding: EdgeInsets.only(left: 6), child: Icon(LucideIcons.check, size: 17, color: DmColors.brand), ), ], ), ), ), ); } } /// "Step 2 of 4 · Pickup" with its segmented rail. class DmStepHeader extends StatelessWidget { const DmStepHeader({ super.key, required this.step, required this.total, required this.label, }); final int step; final int total; final String label; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16), child: DmStepBar( progress: step / total, steps: total, current: step, label: label, ), ); } } /// A washed strip carrying one piece of supporting information. class DmInfoBanner extends StatelessWidget { const DmInfoBanner({ super.key, required this.icon, required this.title, this.message, this.tone = DmTagTone.brand, }); final IconData icon; final String title; final String? message; final DmTagTone tone; @override Widget build(BuildContext context) { final (bg, edge, fg, sub) = switch (tone) { DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok), DmTagTone.warn => ( const Color(0xFFFFF3DF), const Color(0xFFF6E3C2), const Color(0xFF9C5303), const Color(0xFF9C5303), ), DmTagTone.neutral => ( DmColors.surfaceAlt, DmColors.border, DmColors.ink, DmColors.ink2, ), _ => ( DmColors.brandSoft, DmColors.brandLine, DmColors.ink, DmColors.brandPress, ), }; return Container( padding: const EdgeInsets.fromLTRB(13, 13, 14, 13), decoration: BoxDecoration( color: bg, border: Border.all(color: edge), borderRadius: DmRadius.all(DmRadius.md), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 38, height: 38, alignment: Alignment.center, decoration: BoxDecoration( color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent, borderRadius: DmRadius.all(DmRadius.xs), ), child: Icon( icon, size: 18, color: tone == DmTagTone.brand ? DmColors.brand : fg, ), ), const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)), if (message != null) ...[ const SizedBox(height: 2), Text(message!, style: DmText.small.copyWith(color: sub)), ], ], ), ), ], ), ); } } /// A reference number with a copy affordance. The number is mono and large /// enough to read back over a phone. class DmCopyRow extends StatefulWidget { const DmCopyRow({ super.key, required this.label, required this.value, this.trailing, this.onWash = false, }); final String label; final String value; final Widget? trailing; /// Sitting inside a wash card, where the label goes crimson. final bool onWash; @override State createState() => _DmCopyRowState(); } class _DmCopyRowState extends State { bool _copied = false; Future _copy() async { await Clipboard.setData(ClipboardData(text: widget.value)); await HapticFeedback.mediumImpact(); if (!mounted) return; // The confirmation lands on the control that was pressed rather than in a // snackbar over the footer: it is the row the customer is looking at. setState(() => _copied = true); await Future.delayed(const Duration(milliseconds: 1600)); if (mounted) setState(() => _copied = false); } @override Widget build(BuildContext context) { return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( widget.label.toUpperCase(), style: DmText.eyebrow.copyWith( color: widget.onWash ? DmColors.brand : DmColors.ink3, ), ), const SizedBox(height: 4), Text( widget.value, style: DmText.monoLg.copyWith( color: widget.onWash ? DmColors.brandPress : DmColors.ink, ), ), ], ), ), ?widget.trailing, DmTextAction( label: _copied ? 'Copied' : 'Copy', icon: _copied ? LucideIcons.check : LucideIcons.copy, onPressed: _copy, ), ], ); } } /// − n + quantity control. Used for how many packages go to one place. class DmStepper extends StatelessWidget { const DmStepper({ super.key, required this.value, required this.onChanged, this.min = 1, this.canIncrease = true, }); final int value; final ValueChanged onChanged; final int min; /// False when a cap has been reached; the + greys out rather than vanishing. final bool canIncrease; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: DmColors.surface, border: Border.all(color: DmColors.border), borderRadius: DmRadius.all(DmRadius.sm), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ _Step( icon: LucideIcons.minus, onTap: value > min ? () => onChanged(value - 1) : null, ), ConstrainedBox( constraints: const BoxConstraints(minWidth: 30), child: Text( '$value', textAlign: TextAlign.center, style: DmText.monoLg.copyWith(fontSize: 17), ), ), _Step( icon: LucideIcons.plus, onTap: canIncrease ? () => onChanged(value + 1) : null, ), ], ), ); } } class _Step extends StatelessWidget { const _Step({required this.icon, this.onTap}); final IconData icon; final VoidCallback? onTap; @override Widget build(BuildContext context) { final enabled = onTap != null; return Semantics( button: true, enabled: enabled, child: InkResponse( onTap: enabled ? () { HapticFeedback.selectionClick(); onTap!(); } : null, radius: 20, child: SizedBox( width: 44, height: 44, child: Icon( icon, size: 18, color: enabled ? DmColors.ink : DmColors.ink4, ), ), ), ); } }