import 'package:flutter/material.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 head of a tab root: an eyebrow, a title, an optional action, and the /// control that belongs to this destination. /// /// ── It stopped being a crimson field ── /// /// This was 260pt of solid brand on Home, Orders and Account — three of the /// app's five screens opening on a wall of it, with white type, a tinted rose /// for the secondary line, a recessed track for the address and a lightened /// control on top. Every one of those tokens existed only to survive the /// crimson, and the accent that is supposed to mean *do this* was the /// background of half the product. /// /// It is the canvas now, with ink type. What is left of the brand on these /// screens is the sphere, the primary button, and a live dot — which is what /// an accent is for. /// /// The arcs survive at a fraction of their old opacity: they were struck from /// the mark's geometry and they are the one thing here that was never the /// colour doing the work. 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 DecoratedBox( decoration: const BoxDecoration(color: DmColors.canvas), child: CustomPaint( painter: const DmArcField(opacity: 0.035, colour: DmColors.brand), child: SafeArea( bottom: false, child: Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 2, DmSpace.pad, 14, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 48, child: Row(children: [?leading, const Spacer(), ?action]), ), const SizedBox(height: 6), if (eyebrow != null) ...[ Text(eyebrow!.toUpperCase(), style: DmText.eyebrow), const SizedBox(height: 6), ], Text(title, style: DmText.headerTitle), if (child != null) ...[const SizedBox(height: 16), 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, this.colour = Colors.white}); final double opacity; /// White on a crimson field; crimson, very faint, on the canvas. final Color colour; @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 = colour.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 || oldDelegate.colour != colour; } /// 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), // A bordered white field, not a recess. `onBrandSunken` is 14% // black, which on the canvas reads as a grey smudge rather than as // something cut into the surface — a recess only works against a // colour dark enough to be cut. decoration: BoxDecoration( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.lg), border: Border.all(color: DmColors.border), ), 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), ], ); } } /// The wordmark bar that sits at the top of every tab root. /// /// ── Why the app grew a permanent header ── /// /// The three roots each began with their own arrangement: Home with a name, /// Orders with a title, Account with another name. Nothing on any of them /// said which app this was, and the two things a customer reaches for from /// anywhere — find an order, see what changed — had no home at all. /// /// One bar, on all three. The mark on the left with its dot, a search and a /// bell on the right. It is 52pt of chrome that makes the three roots read as /// one product rather than as three screens that happen to share a tab bar. class DmAppBar extends StatelessWidget { const DmAppBar({super.key, this.onSearch, this.onNotifications, this.unread = false}); final VoidCallback? onSearch; final VoidCallback? onNotifications; /// Puts the brand dot on the bell. final bool unread; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad - 4, 2), child: SizedBox( height: 46, child: Row( children: [ const DmWordmark(), const Spacer(), _BarIcon( icon: LucideIcons.search, tooltip: 'Search orders', onPressed: onSearch, ), _BarIcon( icon: LucideIcons.bell, tooltip: 'Notifications', dot: unread, onPressed: onNotifications, ), ], ), ), ); } } /// "Doormile ·" — the name, then the mark's dot. /// /// The dot is the whole logo at this size. A 100pt lockup in a 46pt bar would /// be a picture of a logo; a word and a full stop in the brand colour is a /// wordmark, which is what a bar wants. class DmWordmark extends StatelessWidget { const DmWordmark({super.key, this.size = 17}); final double size; @override Widget build(BuildContext context) { return Semantics( label: 'Doormile', header: true, excludeSemantics: true, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( 'Doormile', style: DmText.cardTitle.copyWith( fontSize: size, letterSpacing: -0.45, fontWeight: FontWeight.w700, fontVariations: const [FontVariation('wght', 700)], ), ), SizedBox(width: size * 0.22), Container( width: size * 0.26, height: size * 0.26, decoration: const BoxDecoration( color: DmColors.brand, shape: BoxShape.circle, ), ), ], ), ); } } class _BarIcon extends StatelessWidget { const _BarIcon({ required this.icon, required this.tooltip, this.onPressed, this.dot = false, }); final IconData icon; final String tooltip; final VoidCallback? onPressed; final bool dot; @override Widget build(BuildContext context) { return Tooltip( message: tooltip, child: InkResponse( onTap: onPressed, radius: 22, child: SizedBox( width: 44, height: 44, child: Stack( alignment: Alignment.center, children: [ Icon(icon, size: 20, color: DmColors.ink), if (dot) Positioned( top: 11, right: 11, child: Container( width: 7, height: 7, decoration: BoxDecoration( color: DmColors.brand, shape: BoxShape.circle, border: Border.all(color: DmColors.canvas, width: 1.5), ), ), ), ], ), ), ), ); } } /// A status mark: a dot, a word, and a tint the colour of the state. /// /// ── The shape the whole app now says "what is happening" in ── /// /// It replaces three different objects: the bordered `DmPill`, the bare dot /// and word on the flat order rows, and the hand-built chip on the live card. /// One shape, three intensities of the same idea — a coloured dot, uppercase /// micro-type, and six percent of the dot's colour behind them. class DmStatusChip extends StatelessWidget { const DmStatusChip({ super.key, required this.label, this.tone, this.icon, this.quiet = false, }); final String label; /// Defaults to the brand. `ok` for delivered, `ink4` for cancelled. final Color? tone; /// Replaces the dot — the lightning bolt on "EXPRESS PRIORITY". final IconData? icon; /// A finished state. Same shape, less of it. final bool quiet; @override Widget build(BuildContext context) { final colour = tone ?? DmColors.brand; return Container( padding: EdgeInsets.fromLTRB(icon == null ? 8 : 7, 4.5, 9, 5), decoration: BoxDecoration( color: colour.withValues(alpha: quiet ? 0.055 : 0.085), borderRadius: DmRadius.all(DmRadius.chip), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) Icon(icon, size: 11, color: colour) else Container( width: 5.5, height: 5.5, decoration: BoxDecoration(color: colour, shape: BoxShape.circle), ), const SizedBox(width: 6), Flexible( child: Text( label.toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.eyebrow.copyWith( fontSize: 9.5, letterSpacing: 0.85, color: quiet ? DmColors.ink3 : DmColors.ink, ), ), ), ], ), ); } }