import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../data/app_config.dart'; import '../tokens.dart'; import 'buttons.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. class DmTopBar extends StatelessWidget 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; @override Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42)); @override Widget build(BuildContext context) { final current = progress == null || steps == null ? 0 : (progress! * steps!).round().clamp(0, steps!); return Material( color: background, child: SafeArea( bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 56, child: Row( children: [ const SizedBox(width: DmSpace.pad - 12), DmIconButton( icon: LucideIcons.arrowLeft, tooltip: 'Back', onPressed: onBack ?? () => Navigator.of(context).maybePop(), ), const SizedBox(width: 6), Expanded( child: title == null ? const SizedBox.shrink() : Text( title!, style: DmText.cardTitle.copyWith( fontSize: 15.5, letterSpacing: -0.31, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ?trailing, const SizedBox(width: DmSpace.pad), ], ), ), if (progress != null) Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16), child: DmStepBar( progress: progress!, steps: steps, current: current, label: stepLabel, ), ), ], ), ), ); } } /// 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, }); 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; @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 (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: ColoredBox( color: DmColors.brand, 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(), // ── A build on dev data says so, on every screen ── // // Twice now a booking made offline has been hunted for // in the admin console. The Account screen named the // mode, but nobody goes to Account before booking. It // belongs where the booking is made. if (AppConfig.useDevData) ...[ Container( padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), decoration: BoxDecoration( border: Border.all(color: const Color(0x80FFFFFF)), borderRadius: DmRadius.all(5), ), child: Text( 'OFFLINE DATA', style: DmText.eyebrow.copyWith( fontSize: 10, color: Colors.white, letterSpacing: 1.2, ), ), ), const SizedBox(width: 10), ], ?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), ], ); } }