import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../tokens.dart'; /// Top bar for pushed screens: a square back affordance, a quiet title, an /// optional trailing slot (usually the booking reference in mono), and the /// booking step indicator. /// /// ── It knows whether anything is under it ── /// /// The bar used to be a flat slab of canvas, the same colour as the page, with /// no line under it. Content scrolled beneath it and simply disappeared — an /// address row would slide up and vanish behind a back arrow with nothing to /// say it had gone anywhere. A bar that never changes is not a layer; it is /// paint. /// /// It listens to the [ScrollNotificationObserver] that [Scaffold] installs /// around its body — the same mechanism Material's own `AppBar` uses for /// `scrolledUnderElevation` — and the moment a list has scrolled under it, the /// canvas turns to surface and a hairline fades in along the bottom. At the top /// of a page it is invisible again. Nothing is announced, nothing moves; the /// screen just stops looking like one plane. class DmTopBar extends StatefulWidget implements PreferredSizeWidget { const DmTopBar({ super.key, this.title, this.trailing, this.progress, this.steps, this.stepLabel, this.onBack, this.background = DmColors.canvas, }); final String? title; final Widget? trailing; /// 0..1 — omit to hide the step indicator. final double? progress; /// When given, the indicator is drawn as [steps] separate segments rather /// than one continuous rail. Discrete steps are countable; a filled bar is /// only a proportion, and "how many screens until I am done" is the question /// a customer is actually asking. final int? steps; /// The line above the segments — "Step 2 of 4 · Destination". final String? stepLabel; final VoidCallback? onBack; final Color background; /// The +1 is the hairline. A transparent [BorderSide] still measures 1pt, so /// the bar is the same height scrolled or not — but it is a point taller than /// the row inside it, and without this the Column overflows by exactly that /// on every screen with a top bar. @override Size get preferredSize => Size.fromHeight(57 + (progress == null ? 0 : 42)); @override State createState() => _DmTopBarState(); } class _DmTopBarState extends State { ScrollNotificationObserverState? _observer; bool _under = false; @override void didChangeDependencies() { super.didChangeDependencies(); _observer?.removeListener(_onScroll); _observer = ScrollNotificationObserver.maybeOf(context); _observer?.addListener(_onScroll); } @override void dispose() { _observer?.removeListener(_onScroll); _observer = null; super.dispose(); } void _onScroll(ScrollNotification notification) { // The predicate is what keeps a horizontal strip inside the page — or a // list inside a sheet over it — from driving the bar behind them. if (!defaultScrollNotificationPredicate(notification)) return; if (notification is! ScrollUpdateNotification && notification is! ScrollMetricsNotification) { return; } // A few points of slack: a list that rests one pixel off the top, or a // bounce at the end of an overscroll, should not flicker the line on. final under = notification.metrics.extentBefore > 4; if (under != _under && mounted) setState(() => _under = under); } @override Widget build(BuildContext context) { final current = widget.progress == null || widget.steps == null ? 0 : (widget.progress! * widget.steps!).round().clamp(0, widget.steps!); return AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, decoration: BoxDecoration( color: _under ? DmColors.surface : widget.background, border: Border( bottom: BorderSide( color: _under ? DmColors.border : Colors.transparent, ), ), ), child: SafeArea( bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ const SizedBox(width: DmSpace.pad - 14), DmBackButton(onPressed: widget.onBack), const SizedBox(width: 4), Expanded( child: widget.title == null ? const SizedBox.shrink() : Text( widget.title!, style: DmText.heading.copyWith(fontSize: 17), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ?widget.trailing, const SizedBox(width: DmSpace.pad), ], ), ), if (widget.progress != null) Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 0, DmSpace.pad, 16, ), child: DmStepBar( progress: widget.progress!, steps: widget.steps, current: current, label: widget.stepLabel, ), ), ], ), ), ); } } /// Back. /// /// ── Why it lost its box ── /// /// It was a 40pt white square with a hairline border and a 12pt corner, which /// on a canvas of white bordered cards read as *a card containing an arrow* /// rather than as a way out of the screen. On the send screen, where the title /// is now the route underneath it, that left a small empty card alone in the /// top-left corner of an otherwise open page. /// /// A back affordance is the one control in an app that needs no explaining and /// no container. It is the glyph, a 48pt target around it, and a circular /// ripple in the brand wash when it is pressed. class DmBackButton extends StatelessWidget { const DmBackButton({super.key, this.onPressed}); final VoidCallback? onPressed; @override Widget build(BuildContext context) { return Semantics( button: true, label: 'Back', child: SizedBox( width: DmSpace.tap, height: DmSpace.tap, child: InkResponse( onTap: onPressed ?? () => Navigator.of(context).maybePop(), radius: 24, highlightShape: BoxShape.circle, splashColor: DmColors.brandSoft, highlightColor: DmColors.brandSoft, child: const Icon( LucideIcons.arrowLeft, size: 22, color: DmColors.ink, ), ), ), ); } } /// Booking progress. /// /// With [steps] it draws that many segments, captioned "Step n of m" in mono; /// without, it falls back to a single filled rail for callers that only know a /// fraction. class DmStepBar extends StatelessWidget { const DmStepBar({ super.key, required this.progress, this.steps, this.current = 0, this.label, this.onFill = false, this.caption = true, }); final double progress; final int? steps; final int current; final String? label; /// Drawn on a crimson field (the live-tracking card), where the track is a /// lightened white and the fill is solid white. final bool onFill; /// Whether to print "Step n of m" above the segments. /// /// True inside the booking flow, where the customer is answering questions /// and "how many left" is the thing they want. False on tracking, where the /// same segments describe a journey nobody is working through — there, /// counting the stages turns a parcel into a progress bar with homework. final bool caption; @override Widget build(BuildContext context) { final track = onFill ? const Color(0x52FFFFFF) : DmColors.border; final fill = onFill ? Colors.white : DmColors.brand; final bar = steps == null ? ClipRRect( borderRadius: DmRadius.all(DmRadius.pill), child: SizedBox( height: 3, child: Stack( children: [ Positioned.fill(child: ColoredBox(color: track)), FractionallySizedBox( widthFactor: progress.clamp(0, 1), child: AnimatedContainer( duration: DmMotion.slow, curve: DmMotion.ease, decoration: BoxDecoration( color: fill, borderRadius: DmRadius.all(DmRadius.pill), ), ), ), ], ), ), ) : Row( children: [ for (var i = 0; i < steps!; i++) ...[ if (i > 0) const SizedBox(width: 4), Expanded( child: AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, height: 3, decoration: BoxDecoration( color: i < current ? fill : track, borderRadius: DmRadius.all(DmRadius.pill), ), ), ), ], ], ); if (!caption || (label == null && steps == null)) return bar; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (label != null || steps != null) ...[ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ if (steps != null) Text.rich( TextSpan( style: DmText.monoSm.copyWith( color: onFill ? DmColors.onBrand3 : DmColors.ink3, ), children: [ const TextSpan(text: 'Step '), TextSpan( text: '$current', style: TextStyle(color: onFill ? Colors.white : DmColors.brand), ), TextSpan(text: ' of ${steps!}'), ], ), ), const Spacer(), if (label != null) Flexible( child: Text( label!, textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith( fontSize: 12.5, color: onFill ? DmColors.onBrand3 : DmColors.ink, ), ), ), ], ), const SizedBox(height: 9), ], bar, ], ); } } /// Sticky bottom action area. /// /// `edge: true` lifts it onto white with a hairline above — used when the /// content behind it scrolls, so the action never looks like the end of the /// list. On a short screen the canvas variant keeps the page quiet. class DmFooter extends StatelessWidget { const DmFooter({ super.key, required this.children, this.edge = false, this.background, this.divider = false, }); final List children; /// White bed, hairline above. final bool edge; final Color? background; /// Retained for call-site compatibility; superseded by [edge]. final bool divider; @override Widget build(BuildContext context) { final lifted = edge || divider; return DecoratedBox( decoration: BoxDecoration( color: background ?? (lifted ? DmColors.surface : DmColors.canvas), border: lifted ? const Border(top: BorderSide(color: DmColors.border)) : null, ), child: SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < children.length; i++) ...[ if (i > 0) const SizedBox(height: 4), children[i], ], ], ), ), ), ); } } /// The crimson field at the top of a tab root. /// /// Home, Orders and Account all open with one. It does three jobs at once — /// it states where you are (eyebrow + title), it holds that destination's one /// control (a segmented filter, an address switcher, a phone number), and it /// gives the app a recognisable brand surface that the white canvas below can /// then stay completely quiet. /// /// The arcs are drawn, not an asset: they are struck from the same geometry as /// the mark, they scale to any header height, and they cost no bytes. class DmBrandHeader extends StatelessWidget { const DmBrandHeader({ super.key, required this.title, this.eyebrow, this.leading, this.action, this.child, }); final String title; final String? eyebrow; /// Sits at the top-left of the header — the avatar on Account. final Widget? leading; /// Top-right — refresh, help. final Widget? action; /// The control that belongs to this destination, under the title. final Widget? child; @override Widget build(BuildContext context) { return ClipRRect( borderRadius: const BorderRadius.vertical( bottom: Radius.circular(DmRadius.header), ), child: DecoratedBox( // Lit from the top-left like every other brand object in the app, so // the crimson field has a direction instead of sitting flat. The spread // is deliberately narrow — a header that reads as a gradient rather // than as a colour has stopped being the brand. decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)], stops: [0.0, 0.52, 1.0], ), ), child: CustomPaint( painter: const DmArcField(), child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB( DmSpace.headerPad, 2, DmSpace.headerPad, 18, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 48, child: Row( children: [ ?leading, const Spacer(), ?action, ], ), ), const SizedBox(height: 10), if (eyebrow != null) ...[ Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow), const SizedBox(height: 8), ], Text(title, style: DmText.headerTitle), if (child != null) ...[const SizedBox(height: 18), child!], ], ), ), ), ), ), ); } } /// Thin arc strokes struck from the mark's geometry, at the opacity the design /// uses. Decorative only — it never carries meaning, so it is not announced. class DmArcField extends CustomPainter { const DmArcField({this.opacity = 0.15}); final double opacity; @override void paint(Canvas canvas, Size size) { // The arcs are authored on a 340-square field anchored to the top-right, // then scaled — so the same geometry reads identically on a 210pt Orders // header and a 260pt Account one. const field = 340.0; final scale = size.width / 390 * 1.0; canvas.save(); canvas.translate(size.width - field * scale + 62 * scale, -72 * scale); canvas.scale(scale); final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.5 / scale ..color = Colors.white.withValues(alpha: opacity); canvas.drawCircle(const Offset(250, 90), 120, paint); canvas.drawCircle(const Offset(250, 90), 170, paint); for (final (y1, y2, cx, cy) in const [ (300.0, 40.0, 60.0, 300.0), (330.0, 60.0, 70.0, 330.0), ]) { final path = Path() ..moveTo(-20, y1) ..cubicTo(cx, cy, field - 40, y2 + 140, field, y2); canvas.drawPath(path, paint); } canvas.restore(); } @override bool shouldRepaint(covariant DmArcField oldDelegate) => oldDelegate.opacity != opacity; } /// The segmented filter recessed into the brand header — Orders' Active / /// Completed / Cancelled, with each tab's count. /// /// The selected tab lifts out as a white pill and keeps its figure; the others /// stay in the recess. One control, three states, no separate count row. class DmSegmented extends StatelessWidget { const DmSegmented({ super.key, required this.labels, required this.counts, required this.index, required this.onChanged, }); final List labels; /// Null where a tab has no meaningful figure yet (still loading). final List counts; final int index; final ValueChanged onChanged; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( color: DmColors.onBrandSunken, borderRadius: DmRadius.all(DmRadius.lg), ), child: Row( children: [ for (var i = 0; i < labels.length; i++) ...[ if (i > 0) const SizedBox(width: 4), Expanded( child: _Segment( label: labels[i], count: i < counts.length ? counts[i] : null, selected: i == index, onTap: () => onChanged(i), ), ), ], ], ), ); } } class _Segment extends StatelessWidget { const _Segment({ required this.label, required this.count, required this.selected, required this.onTap, }); final String label; final int? count; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { return Semantics( button: true, selected: selected, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { HapticFeedback.selectionClick(); onTap(); }, child: AnimatedContainer( duration: DmMotion.base, curve: DmMotion.ease, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11), decoration: BoxDecoration( color: selected ? Colors.white : Colors.transparent, borderRadius: DmRadius.all(DmRadius.sm), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith( fontSize: 13, color: selected ? DmColors.brand : DmColors.onBrand3, ), ), ), if (count != null) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: selected ? DmColors.brandSoft : DmColors.onBrandRaise, borderRadius: DmRadius.all(DmRadius.pill), ), child: Text( '$count', style: DmText.monoSm.copyWith( fontSize: 11, color: selected ? DmColors.brand : Colors.white, ), ), ), ], ], ), ), ), ); } } /// The recessed strip under a header title that carries one live fact — the /// address a pickup is coming from, or the customer's phone number. class DmHeaderTrack extends StatelessWidget { const DmHeaderTrack({ super.key, required this.child, this.leading, this.trailing, this.onTap, }); final Widget child; final Widget? leading; final Widget? trailing; final VoidCallback? onTap; @override Widget build(BuildContext context) { return Semantics( button: onTap != null, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), decoration: BoxDecoration( color: DmColors.onBrandSunken, borderRadius: DmRadius.all(DmRadius.lg), ), child: Row( children: [ if (leading != null) ...[leading!, const SizedBox(width: 10)], Expanded(child: child), if (trailing != null) ...[const SizedBox(width: 10), trailing!], ], ), ), ), ); } } /// Large title for a screen that has no brand header. class DmPageTitle extends StatelessWidget { const DmPageTitle(this.text, {super.key, this.subtitle}); final String text; final String? subtitle; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(text, style: DmText.pageTitle), if (subtitle != null) ...[ const SizedBox(height: 6), Text(subtitle!, style: DmText.lede), ], ], ), ); } } /// Sentence-case heading above a group of fields or rows. class DmGroupHead extends StatelessWidget { const DmGroupHead(this.text, {super.key, this.top = 24}); final String text; final double top; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only(top: top, bottom: 10), child: Text(text, style: DmText.sectionHead), ); } } /// Screen heading and its supporting line — shared by every booking step. class DmScreenHeader extends StatelessWidget { const DmScreenHeader({super.key, required this.title, this.lede, this.leding}); final String title; final String? lede; /// Rich alternative to [lede]. final Widget? leding; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), Text(title, style: DmText.title), if (lede != null) ...[ const SizedBox(height: 8), Text(lede!, style: DmText.lede), ], if (leding != null) ...[const SizedBox(height: 10), leding!], const SizedBox(height: 22), ], ); } }