import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:shimmer/shimmer.dart'; import 'miler_app_bar.dart' show milerGlassSurface; import 'package:miler/Models/stop_status.dart'; import 'package:miler/views/helpers/constants/miler_type.dart'; import '../constants/Colorconstants.dart'; import '../constants/design_constants.dart'; import '../constants/Font_constant.dart'; /// ─────────────────────────────────────────────────────────────────────────── /// Miler shared design kit /// /// Reusable building blocks that mirror the Doormile customer app's clean /// Material-3 look, backed by [ColorConstants], [DesignConstants] and /// [FontConstants]. Use these instead of hand-rolling cards/buttons/chips so /// every screen stays visually consistent. /// ─────────────────────────────────────────────────────────────────────────── /// White rounded card with the design-system shadow. class AppCard extends StatelessWidget { final Widget child; final EdgeInsetsGeometry padding; final VoidCallback? onTap; final Color? _colorOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get color => _colorOverride ?? ColorConstants.surfaceContainerLowest; final BorderRadius? radius; final Border? border; final bool elevated; const AppCard({ super.key, required this.child, this.padding = const EdgeInsets.all(16), this.onTap, Color? color, this.radius, this.border, this.elevated = true, }) : _colorOverride = color; @override Widget build(BuildContext context) { final br = radius ?? BorderRadius.circular(DesignConstants.radiusXl); return Material( color: color, borderRadius: br, child: InkWell( onTap: onTap, borderRadius: br, child: Ink( decoration: BoxDecoration( color: color, borderRadius: br, border: border ?? Border.all(color: ColorConstants.dividerColor, width: 1), boxShadow: elevated ? DesignConstants.shadowSm : null, ), child: Padding(padding: padding, child: child), ), ), ); } } /// Primary filled action button (brand red), [ButtonSizes.primary] tall, /// [ButtonSizes.radius] corners — the same metrics as [MilerButton], which is /// the newer widget and the one to reach for in new code. /// /// These two disagreed until now: this one rounded at `radiusLg` (12) while /// [MilerButton] rounded at `ButtonSizes.radius` (14), so the app's two /// "official" primary buttons were visibly different objects — and both are /// live, this one in Help Center, Edit Profile and Saved Addresses. class PrimaryButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final IconData? icon; final bool loading; final Color? _colorOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get color => _colorOverride ?? ColorConstants.primary; final Color foreground; final double height; const PrimaryButton({ super.key, required this.label, required this.onPressed, this.icon, this.loading = false, Color? color, this.foreground = Colors.white, this.height = ButtonSizes.primary, }) : _colorOverride = color; @override Widget build(BuildContext context) { return SizedBox( width: double.infinity, height: height, child: ElevatedButton( onPressed: loading ? null : onPressed, // ── A blocked primary goes neutral, it does not go pale ── // // This drew the brand at 50% with its label at 80% white: still a // saturated pill, still shaped like the thing you press, just dimmer. // A rider reads that as the button and taps it, and nothing happens — // which is the exact failure `stop_verify_chrome_test` pins on the // other screen that has a blocked step, where the fix was to draw no // coloured button at all. // // Neutral fill with the page's secondary ink says *not yet* instead of // *try me*, and it clears AA at 5.45:1 where white-on-washed-red did // not come close. `loading` keeps the brand, because a button mid-work // is disabled for a different reason and must not look unavailable. style: ElevatedButton.styleFrom( backgroundColor: color, foregroundColor: foreground, disabledBackgroundColor: loading ? color : ColorConstants.neutralLight, disabledForegroundColor: loading ? foreground : ColorConstants.secondaryText, elevation: 0, minimumSize: Size(double.infinity, height), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ButtonSizes.radius), ), ), child: loading ? SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.4, color: foreground, ), ) : Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 20), const SizedBox(width: 8), ], Flexible( child: Text( label, overflow: TextOverflow.ellipsis, maxLines: 1, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ); } } /// Tonal secondary button for minor actions. class SecondaryButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final IconData? icon; final Color? color; final double height; const SecondaryButton({ super.key, required this.label, this.onPressed, this.icon, this.color, this.height = ButtonSizes.secondary, }); @override Widget build(BuildContext context) { final c = color ?? ColorConstants.primary; return SizedBox( height: height, child: OutlinedButton( onPressed: onPressed, style: OutlinedButton.styleFrom( backgroundColor: c.withValues(alpha: 0.08), foregroundColor: c, side: BorderSide.none, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ButtonSizes.radius), ), padding: const EdgeInsets.symmetric(horizontal: 18), // The theme floors every OutlinedButton at [ButtonSizes.primary] so // dialog pairs line up. This one is deliberately a secondary, lives // in a 48dp box, and would be clipped by that floor — so it opts out // and lets the enclosing SizedBox be the only thing setting height. minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 18, color: c), const SizedBox(width: 8), ], Text( label, style: TextStyle( color: c, fontSize: 14, fontWeight: FontWeight.w600, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ); } } /// Pill-shaped status / category chip. class StatusChip extends StatelessWidget { final String label; final Color color; final Color bg; final IconData? icon; const StatusChip({ super.key, required this.label, required this.color, required this.bg, this.icon, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: color), const SizedBox(width: 4), ], Text( label, style: TextStyle( color: color, fontSize: 12, fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, ), ), ], ), ); } } /// A square/circular icon avatar with a tinted background. class IconBadge extends StatelessWidget { final IconData icon; final Color bg; final Color color; final double size; final double iconSize; final bool circle; const IconBadge({ super.key, required this.icon, required this.bg, required this.color, this.size = 44, this.iconSize = 22, this.circle = false, }); @override Widget build(BuildContext context) { return Container( width: size, height: size, decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular( circle ? size : DesignConstants.radiusLg, ), ), child: Icon(icon, color: color, size: iconSize), ); } } /// Title row with an optional trailing text action. class SectionHeader extends StatelessWidget { final String title; final String? action; final VoidCallback? onAction; const SectionHeader({ super.key, required this.title, this.action, this.onAction, }); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Flexible( child: Text( title, overflow: TextOverflow.ellipsis, maxLines: 1, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w500, letterSpacing: -0.2, color: ColorConstants.onSurface, fontFamily: FontConstants.fontFamily, ), ), ), if (action != null) GestureDetector( onTap: onAction, child: Padding( padding: const EdgeInsets.only(left: 8), child: Text( action!, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), ), ), ], ); } } /// Thin rounded progress bar (0..1). class AppProgressBar extends StatelessWidget { final double value; final Color? color; final double height; const AppProgressBar({ super.key, required this.value, this.color, this.height = 8, }); @override Widget build(BuildContext context) { return Container( height: height, decoration: BoxDecoration( color: ColorConstants.surfaceContainerHigh, borderRadius: BorderRadius.circular(height / 2), ), child: ClipRRect( borderRadius: BorderRadius.circular(height / 2), child: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: value.clamp(0.0, 1.0), child: Container(color: color ?? ColorConstants.primary), ), ), ); } } /// Soft info/reassurance banner (tinted background + leading icon). class InfoBanner extends StatelessWidget { final String text; final IconData icon; final Color? _colorOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get color => _colorOverride ?? ColorConstants.tertiary; const InfoBanner({ super.key, required this.text, this.icon = LucideIcons.info, Color? color, }) : _colorOverride = color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all(color: color.withValues(alpha: 0.16)), ), child: Row( children: [ Icon(icon, color: color, size: 22), const SizedBox(width: 12), Expanded( child: Text( text, style: TextStyle( color: color, fontSize: 13, height: 1.35, fontWeight: FontWeight.w500, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } } /// Full-screen empty / zero-state placeholder. /// /// Matches the app's gold-standard "You're all caught up" look: a soft layered /// brand-tinted circle behind an icon, a bold heading and a helpful subtitle, /// with an optional call-to-action. Use this instead of hand-rolling a grey /// icon + text so every empty screen reads as one system. class MilerEmptyState extends StatelessWidget { final IconData icon; final String title; final String? message; /// A Lottie animation shown *instead* of the tinted icon halo. /// /// Bookings and Activity each hand-rolled a Lottie empty state — different /// headline size (18 vs 17), different body size (14 vs 12.5), different /// animation size (160 vs 190) and different horizontal padding (48 vs 44). /// Empty is the state a new rider sees most in his first week; three /// near-identical-but-not treatments make the app feel unfinished at exactly /// the moment he is deciding whether to trust it. /// /// The copy stays screen-specific — an empty day and an unworked trip are /// different sentences — but the chrome is one thing now. final Widget? illustration; /// Edge of the square the [illustration] is drawn in. /// /// 180 is right for a screen whose empty state *is* the screen — an empty /// booking queue on a fresh install. It is too much for an empty state that /// is a label on one branch of a populated tab: on Activity the animation was /// the largest object in the app on the one screen with nothing to say, and /// it pushed the sentence answering the rider's question toward the fold. final double illustrationSize; /// Anything that belongs under the CTA: a live-listening indicator, a debug /// line. Kept as a slot so a screen does not have to abandon the component to /// add one row. final Widget? footer; /// Accent used for the icon and its tinted backdrop. Defaults to the brand. final Color? _accentOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get accent => _accentOverride ?? ColorConstants.primary; /// Optional call-to-action button rendered under the message. final String? actionLabel; final VoidCallback? onAction; const MilerEmptyState({ super.key, required this.icon, required this.title, this.message, Color? accent, this.actionLabel, this.onAction, this.illustration, this.illustrationSize = 180, this.footer, }) : _accentOverride = accent; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: Column( mainAxisSize: MainAxisSize.min, children: [ // An illustration replaces the halo when one is supplied; the // sizing and the spacing below it stay the component's, so two // screens with different animations still read as one system. if (illustration != null) SizedBox( width: illustrationSize, height: illustrationSize, child: illustration, ) else // Layered halo: a faint outer ring + a slightly stronger inner // disc holding the icon. Stack( alignment: Alignment.center, children: [ Container( width: 120, height: 120, decoration: BoxDecoration( color: accent.withValues(alpha: 0.05), shape: BoxShape.circle, ), ), Container( width: 84, height: 84, decoration: BoxDecoration( color: accent.withValues(alpha: 0.10), shape: BoxShape.circle, ), child: Icon( icon, size: 40, color: accent.withValues(alpha: 0.75), ), ), ], ), const SizedBox(height: 24), Text( title, textAlign: TextAlign.center, style: MilerType.cardTitle, ), if (message != null) ...[ const SizedBox(height: 8), Text( message!, textAlign: TextAlign.center, style: MilerType.caption, ), ], if (actionLabel != null) ...[ const SizedBox(height: 22), // Filled, not outlined. On an empty state the one action present // should look like one — outlined-on-white read as a disabled // control on an already-pale screen. MilerButton( label: actionLabel!, color: accent, height: ButtonSizes.compact, expand: false, onPressed: onAction, ), ], if (footer != null) ...[const SizedBox(height: 16), footer!], ], ), ), ); } } /// Clean, minimal loader — a thin brand-red spinner, optionally with a label. /// Mirrors Doormile's understated `CircularProgressIndicator(strokeWidth: 2.4)`. class AppLoader extends StatelessWidget { final double size; final Color? color; final String? label; const AppLoader({super.key, this.size = 26, this.color, this.label}); @override Widget build(BuildContext context) { final c = color ?? ColorConstants.primary; return Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: size, height: size, child: CircularProgressIndicator(strokeWidth: 2.6, color: c), ), if (label != null) ...[ const SizedBox(height: 14), Text( label!, style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: ColorConstants.onSurfaceVariant, fontFamily: FontConstants.fontFamily, ), ), ], ], ); } } /// Centered loader inside a white rounded card — for blocking dialogs. class AppLoaderCard extends StatelessWidget { final String? label; const AppLoaderCard({super.key, this.label}); @override Widget build(BuildContext context) { return Center( child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), boxShadow: DesignConstants.shadowLg, ), child: AppLoader(label: label), ), ); } } /// Clean back-arrow app bar shared across detail screens. /// ───────────────────────────────────────────────────────────────────────── /// THE APP BAR FOR EVERY PUSHED PAGE /// /// One bar, used by every screen you can navigate *into*: Saved Addresses, /// Notifications, Alert Sound, FAQ, Terms, Help, Support, Edit Profile. /// /// ── What it replaced ── /// /// Eight pushed pages had six different bars between them. Four were solid brand /// red with white text; two were white with black text; one was grey; one was /// white with a red title. The back arrow was `arrow_back`, `arrow_back_ios`, /// `arrow_back_ios_new_rounded` or `arrow_back_rounded`, at 16, 20, 24 or 28, /// in white, black or slate. Two shared helpers already existed and between them /// were used by three pages. /// /// That is not a style question — it is a rider being unable to tell, from the /// top of the screen, whether he is one level deep or somewhere else entirely. /// /// ── Why white with a red title, and not the reverse ── /// /// A saturated brand-red slab across the top of every page spends the loudest /// colour available on the one element that never changes and never needs /// acting on, and it puts white text on a mid-tone red, which is the weakest /// contrast pairing in the palette. Inverting it gives the title the brand /// colour where it costs nothing, keeps the surface neutral so the page's own /// content leads, and matches the tab-level [MilerAppBar] — so moving from a tab /// into a sub-page now reads as going deeper into one app rather than arriving /// somewhere new. /// ───────────────────────────────────────────────────────────────────────── /// ── The × goes on its own line, above the title ── /// /// It used to share a row: `[×] Edit Profile`. Two problems with that, and the /// second is the one that mattered. /// /// The obvious one is that the × ate the start of the line, so every title on /// every pushed page began 52pt in from the margin — out of line with the page /// content underneath it, which starts at the gutter like everything else in /// the app. /// /// The real one is what a leading control *says*. Sitting to the left of a /// title, at the same optical weight, it reads as part of the title's lockup — /// the thing you look at, rather than a thing you press. Lifted onto its own /// row it becomes unambiguously a control with the whole row to itself, and the /// title below is left free to be the page's heading, aligned to the same /// margin as the content it heads. It is the shape Uber, Revolut and iOS large /// titles all settle on for a leaf page, for the same reason. /// /// Heights are generous rather than tight because this bar has no /// `BuildContext` to read a text scaler from, and `toolbarHeight` is fixed — /// the Account pages are covered at 1.0×–2.0× by `profile_pages_test.dart`. const double _kTopBarHeight = 104; /// The × on the control row. /// /// Hand-rolled rather than an `IconButton`, for one reason: alignment. The /// whole point of stacking the control above the heading is that the two share /// a left margin, and `IconButton` will not do that. It ignores a tight /// `BoxConstraints` on the minor axis (it came out 48 wide against a requested /// 44) and insets its glyph a further 2pt inside whatever box it settles on, so /// the × landed 2pt right of the title no matter what `alignment` was passed. /// /// A `SizedBox` + `Align` is exact, gives a true 44pt target rather than an /// approximate one, and is less code than the overrides it replaces. class _TopBarClose extends StatelessWidget { final IconData icon; final VoidCallback onTap; final String semanticLabel; const _TopBarClose({ required this.icon, required this.onTap, this.semanticLabel = 'Back', }); @override Widget build(BuildContext context) { return Semantics( button: true, label: semanticLabel, child: InkResponse( onTap: onTap, radius: ButtonSizes.minTapTarget / 2 + 4, // 44 → 48. The 44pt floor is the accessibility *minimum*; 48 is the // Material target and what a thumb on a moving bike actually needs. // The glyph stays 24 — the hit area grows, the icon does not. child: SizedBox( width: 48, height: 48, child: Align( alignment: Alignment.centerLeft, // On the brand ground — see [milerPageBar]. child: Icon(icon, size: 24, color: Colors.white), ), ), ), ); } } /// ── One bar for every pushed screen ── /// /// An audit found **five** app-bar implementations across the app: this one at /// 104pt with a maroon 22sp title, [MilerAppBar] at 76pt with a slate 23sp /// title, Home's hand-rolled header, a bespoke 88pt bar on the verify and /// payment screens, and raw `AppBar` on two more. Three title sizes, three back /// icons, two title colours. The heading physically jumped and changed colour /// as the rider moved between a tab and a pushed page. /// /// `MilerPageBar` is now the only bar for a pushed screen. [MilerAppBar] stays /// for the four tabs, and Home keeps its hand-rolled header because it holds /// the avatar and the duty switch — but all three now share one title size, one /// title colour and one inset, so nothing moves vertically between them. /// /// ── What changed from `appTopBar` ── /// /// * **Title colour: maroon → slate.** It was the only heading in the app in /// the brand colour, which read as a link rather than a title. /// * **Back icon: × → `arrow_back_rounded`.** A cross promises dismissal; nine /// of these pages are pushed screens you go *back* from. The cross survives /// behind `isModal: true`, for sheets that genuinely dismiss. /// * **Type from [MilerType.pageTitle]**, so it cannot drift again. PreferredSizeWidget milerPageBar( String title, { List? actions, VoidCallback? onBack, /// True for a screen that genuinely *dismisses* rather than pops — a sheet /// presented modally. Only this gets the cross. bool isModal = false, }) { final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft; final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false); return AppBar( // ── One ground, every bar in the app ── // // This wore the 7% frosted brand wash flattened against white while Home // and the four tabs are solid brand. So pushing Edit Profile, Saved // Addresses, Alert Sound or Notifications changed the colour of the top of // the phone AND the colour of the status-bar glyphs, which reads as leaving // the app rather than opening a page inside it. // // Solid [ColorConstants.primary], and everything on it inverts. The wash // survives as `milerGlassSurface()` for the sheets, which sit over content // and still want it. backgroundColor: ColorConstants.primary, surfaceTintColor: Colors.transparent, foregroundColor: Colors.white, iconTheme: const IconThemeData(color: Colors.white), actionsIconTheme: const IconThemeData(color: Colors.white), // Dark glyphs on maroon is the one contrast failure a rider cannot work // around by tilting the phone. Matches [MilerAppBar] and Home's header. systemOverlayStyle: const SystemUiOverlayStyle( statusBarColor: Colors.transparent, statusBarIconBrightness: Brightness.light, statusBarBrightness: Brightness.dark, ), elevation: 0, scrolledUnderElevation: 0, // Everything lives in `title` so the control row and the heading can be // stacked. Leaving `leading`/`actions` to the AppBar would centre them // against the full bar height, i.e. beside the title again. automaticallyImplyLeading: false, toolbarHeight: _kTopBarHeight, // The page gutter. Both the × and the heading start here, so they share one // margin with the content below. titleSpacing: 20, title: Padding( padding: const EdgeInsets.only(right: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ if (hasControlRow) SizedBox( height: 48, child: Row( children: [ if (onBack != null) _TopBarClose( icon: backIcon, onTap: onBack, semanticLabel: isModal ? 'Close' : 'Back', ), const Spacer(), if (actions != null) ...actions, ], ), ), Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: MilerType.pageTitleFixed.copyWith(color: Colors.white), ), ], ), ), ); } /// ─────────────────────────────────────────────────────────────────────────── /// Motion + loading primitives (the "Uber-smooth" layer) /// ─────────────────────────────────────────────────────────────────────────── /// Fades + gently rises its [child] once, when first mounted. Pass a [delay] /// (e.g. `index * 40ms`) to cascade a list. Because the animation only runs in /// initState, a keyed [Reveal] plays exactly once per logical item — a rebuild /// (poll refresh) does NOT replay it, so lists don't flicker under the finger. class Reveal extends StatefulWidget { final Widget child; final Duration delay; final Duration duration; final double offsetY; const Reveal({ super.key, required this.child, this.delay = Duration.zero, this.duration = DesignConstants.motionPage, this.offsetY = 16, }); @override State createState() => _RevealState(); } class _RevealState extends State with SingleTickerProviderStateMixin { late final AnimationController _c; late final Animation _a; @override void initState() { super.initState(); _c = AnimationController(vsync: this, duration: widget.duration); _a = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic); if (widget.delay == Duration.zero) { _c.forward(); } else { Future.delayed(widget.delay, () { if (mounted) _c.forward(); }); } } @override void dispose() { _c.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _a, builder: (_, child) => Opacity( opacity: _a.value, child: Transform.translate( offset: Offset(0, widget.offsetY * (1 - _a.value)), child: child, ), ), child: widget.child, ); } } /// Convenience: cap the stagger delay so long lists don't wait forever. Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) => Duration(milliseconds: (index * stepMs).clamp(0, maxMs)); /// ───────────────────────────────────────────────────────────────────────── /// GLASS CARD — the one surface every list card in the app is drawn on. /// /// A translucent pane, a soft wide shadow, and **no border**. /// /// ── Why no border ── /// /// A border, a fill and a shadow are three devices doing one job, and the /// border is the one that competes with the content: six bordered rectangles /// down a phone screen read as a form to be filled in, not a route to be /// ridden. Every large logistics app the rider already uses — Uber, Swiggy, /// Amazon Shopper — separates list cards with elevation alone, and it survives /// what a hairline does not: a scratched screen in direct sun, where a 1px /// #E2E8F0 line is simply not there. /// /// ── Why one widget rather than a decoration constant ── /// /// Two screens draw these cards (Home's route and the work tab's jobs) and they /// drifted apart every single time the recipe was a constant each copied. The /// press feedback belongs here too: a card that opens a sheet ~340ms later /// needs to acknowledge the tap, and that acknowledgement should not be /// something each caller remembers to add. /// ───────────────────────────────────────────────────────────────────────── class GlassCard extends StatelessWidget { final Widget child; final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? padding; /// Opens whatever this card is about. Null makes it a plain surface. final VoidCallback? onTap; /// What a screen reader calls the card. final String? semanticLabel; final String? semanticHint; /// A wash over the pane, for the one card on a screen that is *live*. /// Deliberately a whisper — see [ColorConstants.glassCardLive]. final Color? tint; /// Dims the whole card without changing a single colour inside it, for a /// card that is no longer actionable. final double opacity; final double radius; const GlassCard({ super.key, required this.child, this.margin, this.padding, this.onTap, this.semanticLabel, this.semanticHint, this.tint, this.opacity = 1, this.radius = 22, }); @override Widget build(BuildContext context) { final surface = Container( margin: margin, padding: padding, decoration: BoxDecoration( color: ColorConstants.glassCard, borderRadius: BorderRadius.circular(radius), // ── The rim is what a budget LCD can actually render ── // // On a calibrated panel the slate shadow separates the pane from the // ground; on the LCD this app ships to, shadows crush to nothing and // the cards read as printed on the page — reported twice from the // device. The one-pixel rim is the same trick the frosted sheets use // ([milerGlassSheet]'s glassRim): a line of light on the curve costs // nothing at any brightness and says "edge" where blur cannot. border: Border.all(color: ColorConstants.borderSubtle), boxShadow: DesignConstants.shadowGlass, ), // The tint rides *inside* the pane rather than replacing it, so a live // card is the same glass everything else is, warmed — not a different // material. child: tint == null ? child : DecoratedBox( decoration: BoxDecoration( color: tint, borderRadius: BorderRadius.circular(radius), ), child: child, ), ); final tappable = onTap == null ? surface : PressScale( onTap: onTap, // No `semanticLabel` unless the caller asks for one: PressScale // *excludes* its descendants' semantics when it labels itself, and // these cards carry buttons a reader has to be able to reach. semanticLabel: semanticLabel, semanticHint: semanticHint, child: surface, ); return opacity == 1 ? tappable : Opacity(opacity: opacity, child: tappable); } } /// Subtle press feedback — scales the [child] down slightly while held, then /// springs back. Use it to make cards and custom buttons feel tappable. class PressScale extends StatefulWidget { final Widget child; final VoidCallback? onTap; final VoidCallback? onLongPress; final double scale; /// What a screen reader announces, and what it calls this control. /// /// PressScale wraps a `GestureDetector`, which is invisible to TalkBack — it /// has no role, no label and no hint, so every card and custom row in the app /// was announced as unlabelled text that happens to be there. Passing a /// [semanticLabel] turns it into a button the reader can find, name and /// activate. Leave it null only where the child is already fully labelled /// text that says the same thing. final String? semanticLabel; /// The consequence, in the reader's words — "opens the stop", "plays the /// sound". Optional; the label alone is usually enough. final String? semanticHint; /// Guarantees the control is at least [ButtonSizes.minTapTarget] *48* in /// both axes without changing what is drawn. /// /// An audit found 18 bare `GestureDetector`s in the app with no tap-target /// floor at all — a 32pt icon tapped one-handed on a moving bike is a miss. /// The visible glyph keeps its size; only the hit area grows around it. /// /// Off by default so existing full-width rows are unaffected; turn it on for /// icon-only controls. final bool minTarget; const PressScale({ super.key, required this.child, this.onTap, this.onLongPress, this.scale = 0.97, this.semanticLabel, this.semanticHint, this.minTarget = false, }); @override State createState() => _PressScaleState(); } class _PressScaleState extends State { bool _pressed = false; void _set(bool v) { if (mounted) setState(() => _pressed = v); } @override Widget build(BuildContext context) { final enabled = widget.onTap != null || widget.onLongPress != null; final gesture = GestureDetector( // Make the whole child area tappable, not just its painted pixels. // Without this the default `deferToChild` leaves the gaps/whitespace in a // row un-hittable, so taps land on dead space and nothing happens. behavior: HitTestBehavior.opaque, onTapDown: enabled ? (_) => _set(true) : null, onTapUp: enabled ? (_) => _set(false) : null, onTapCancel: enabled ? () => _set(false) : null, onTap: widget.onTap, onLongPress: widget.onLongPress, child: AnimatedScale( scale: _pressed ? widget.scale : 1.0, duration: DesignConstants.motionPress, curve: Curves.easeOut, child: widget.child, ), ); final sized = widget.minTarget ? ConstrainedBox( constraints: const BoxConstraints(minWidth: 48, minHeight: 48), child: Center(widthFactor: 1, heightFactor: 1, child: gesture), ) : gesture; if (widget.semanticLabel == null) return sized; return Semantics( button: enabled, enabled: enabled, label: widget.semanticLabel, hint: widget.semanticHint, // The children already read as text; without this the reader would // announce the label and then every line inside it again. excludeSemantics: true, child: sized, ); } } /// A single shimmering placeholder block. Compose these inside [MilerShimmer]. class SkeletonBone extends StatelessWidget { final double? width; final double height; final double radius; const SkeletonBone({ super.key, this.width, this.height = 14, this.radius = 8, }); @override Widget build(BuildContext context) { return Container( width: width, height: height, decoration: BoxDecoration( color: ColorConstants.pureSurface, borderRadius: BorderRadius.circular(radius), ), ); } } /// Wraps skeleton bones in the brand shimmer sweep. Put a column of /// [SkeletonBone]s (or [skeletonCard]s) inside. class MilerShimmer extends StatelessWidget { final Widget child; const MilerShimmer({super.key, required this.child}); @override Widget build(BuildContext context) { return Shimmer.fromColors( baseColor: ColorConstants.surfaceContainerHigh, highlightColor: ColorConstants.surfaceContainerLow, period: const Duration(milliseconds: 1300), child: child, ); } } /// One placeholder card shaped like a Miler list item (icon + two lines + chip). Widget skeletonCard() { return Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 7), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: ColorConstants.surfaceContainerLowest, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: const [ Row( children: [ SkeletonBone(width: 40, height: 40, radius: 12), SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SkeletonBone(width: 140, height: 15), SizedBox(height: 8), SkeletonBone(width: 200, height: 12), ], ), ), SizedBox(width: 12), SkeletonBone(width: 52, height: 24, radius: 12), ], ), SizedBox(height: 18), SkeletonBone(height: 44, radius: 12), ], ), ); } /// A full-list loading placeholder: [count] shimmering [skeletonCard]s. class SkeletonList extends StatelessWidget { final int count; const SkeletonList({super.key, this.count = 4}); @override Widget build(BuildContext context) { return MilerShimmer( // ── Clipped, not overflowed ── // // A bare `Column` of five cards is ~820pt tall, and the body of a pushed // page on the shortest phone the app supports is ~768. So the one screen // whose whole job is to say "this is coming" greeted the rider with a // black-and-yellow overflow band. A skeleton is a placeholder: it should // fill what it is given and stop, never demand a height. child: SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( children: [ const SizedBox(height: 8), for (int i = 0; i < count; i++) skeletonCard(), ], ), ), ); } } /// Cross-fades between whatever it is given — a skeleton and the real content, /// an empty state and a list, one filtered list and the next. /// /// Every loading screen in this app did the same thing: held a shimmer, then /// replaced it with content in a single frame. The shimmer exists to say "this /// is coming", and then the arrival — the one moment it was preparing the rider /// for — was a cut. This fades the old out and lifts the new in over 280ms, so /// the wait resolves instead of ending. /// /// The children must differ by [Key] (or runtime type) for the switch to be /// seen; a `ValueKey('loading')` / `ValueKey('content')` pair is enough. class SmoothSwap extends StatelessWidget { final Widget child; final Duration duration; const SmoothSwap({ super.key, required this.child, this.duration = DesignConstants.motionState, }); @override Widget build(BuildContext context) { return AnimatedSwitcher( duration: duration, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeIn, // Top-aligned rather than the framework's centre default: mid-fade the // two children are on screen together, and if a short skeleton is centred // against a tall list every row appears to slide upward as it lands. // // Deliberately no `SizedBox.expand` around the incoming child — this is // used inside slivers too, where forcing an infinite height would throw. layoutBuilder: (currentChild, previousChildren) => Stack( alignment: Alignment.topCenter, children: [...previousChildren, if (currentChild != null) currentChild], ), transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.02), end: Offset.zero, ).animate(animation), child: child, ), ), child: child, ); } } /// Holds an expensive child back until the route or sheet containing it has /// finished animating in, showing [placeholder] in its place meanwhile. /// /// This exists for one specific offender: [GoogleMap]. A Google Map is an /// Android *platform view* — creating it spins up a native surface, and that /// work lands on the UI thread. Build it in the same frame the sheet starts /// rising and it eats a couple of hundred milliseconds right in the middle of /// the entrance, so the animation is technically running while the screen shows /// nothing but its first and last frames. No curve fixes that; the frames are /// simply never drawn. The tap looked broken because the most expensive widget /// in the app was being constructed inside the animation. /// /// So: cheap widgets during the motion, the map a beat later, faded up over the /// placeholder rather than swapped for it — a platform view under an /// `AnimatedSwitcher` blends badly on Android, so the placeholder fades out on /// top of it instead. /// /// [fallbackDelay] covers the case where there is no route animation to wait on /// (already-settled route, or a widget used outside a route). class AfterEntrance extends StatefulWidget { final WidgetBuilder builder; final Widget placeholder; final Duration fallbackDelay; final Duration fadeOut; const AfterEntrance({ super.key, required this.builder, required this.placeholder, this.fallbackDelay = const Duration(milliseconds: 450), this.fadeOut = const Duration(milliseconds: 300), }); @override State createState() => _AfterEntranceState(); } class _AfterEntranceState extends State { bool _ready = false; Animation? _watched; Timer? _fallback; @override void didChangeDependencies() { super.didChangeDependencies(); if (_ready) return; final Animation? anim = ModalRoute.of(context)?.animation; if (anim == null || anim.isCompleted) { // Nothing to wait for — but still leave the current frame alone, so a // rebuild during someone else's animation doesn't drag it down. WidgetsBinding.instance.addPostFrameCallback((_) => _markReady()); return; } if (!identical(anim, _watched)) { _watched?.removeStatusListener(_onStatus); _watched = anim..addStatusListener(_onStatus); } _fallback ??= Timer(widget.fallbackDelay, _markReady); } void _onStatus(AnimationStatus status) { if (status == AnimationStatus.completed) _markReady(); } void _markReady() { if (_ready || !mounted) return; setState(() => _ready = true); } @override void dispose() { _watched?.removeStatusListener(_onStatus); _fallback?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( fit: StackFit.expand, children: [ if (_ready) widget.builder(context), IgnorePointer( ignoring: _ready, child: AnimatedOpacity( opacity: _ready ? 0 : 1, duration: widget.fadeOut, curve: Curves.easeOut, child: widget.placeholder, ), ), ], ); } } /// The stand-in a map wears while [AfterEntrance] holds it back: the right /// shape and colour, with a pin, and deliberately no spinner — something that /// spins for 300ms and vanishes reads as a stutter, not as progress. class MapPlaceholder extends StatelessWidget { final double? radius; const MapPlaceholder({super.key, this.radius}); @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: ColorConstants.surfaceContainerHigh, borderRadius: radius == null ? null : BorderRadius.circular(radius!), ), alignment: Alignment.center, child: Icon( LucideIcons.mapPin, size: 30, color: ColorConstants.onSurfaceVariant.withValues(alpha: 0.35), ), ); } } /// ───────────────────────────────────────────────────────────────────────── /// MilerButton — the one button widget for the whole app. /// /// Use this instead of hand-rolling `ElevatedButton` / `OutlinedButton` with /// a bespoke `SizedBox(height: …)`. It fixes the height to one of the three /// [ButtonSizes], keeps the radius and typography identical everywhere, and /// guarantees the 44dp tap-target floor. /// /// Three visual variants, mapped to meaning rather than looks: /// • [MilerButtonVariant.filled] — the primary action. Solid, high contrast. /// • [MilerButtonVariant.outlined] — a real alternative to the primary. /// • [MilerButtonVariant.tonal] — a quieter action that is still safe. /// ───────────────────────────────────────────────────────────────────────── enum MilerButtonVariant { filled, outlined, tonal } class MilerButton extends StatelessWidget { final String label; final VoidCallback? onPressed; final IconData? icon; final bool loading; /// Semantic colour: brand maroon for pickup, green for go, red for danger. /// Never pass a one-off hex — use a `ColorConstants` token. final Color? _colorOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get color => _colorOverride ?? ColorConstants.primary; final MilerButtonVariant variant; /// One of [ButtonSizes]. Defaults to [ButtonSizes.primary]. final double height; /// Fills the available width. Off for buttons sharing a row. final bool expand; const MilerButton({ super.key, required this.label, required this.onPressed, this.icon, this.loading = false, Color? color, this.variant = MilerButtonVariant.filled, this.height = ButtonSizes.primary, this.expand = true, }) : _colorOverride = color; bool get _enabled => onPressed != null && !loading; @override Widget build(BuildContext context) { final radius = BorderRadius.circular(ButtonSizes.radius); final Color bg = switch (variant) { MilerButtonVariant.filled => color, // The surface token, not a literal white — an outlined button is a // *surface* with an edge drawn on it. MilerButtonVariant.outlined => ColorConstants.pureSurface, MilerButtonVariant.tonal => color.withValues(alpha: 0.12), }; // The fill under it is the brand colour, which is picked to carry white. final Color fg = variant == MilerButtonVariant.filled ? ColorConstants.onAccent : color; final Color? borderColor = switch (variant) { MilerButtonVariant.filled => null, MilerButtonVariant.outlined => color, MilerButtonVariant.tonal => color.withValues(alpha: 0.35), }; // Font tracks the height so a compact button doesn't shout like a primary. final double fontSize = height >= ButtonSizes.primary ? 15.5 : height >= ButtonSizes.secondary ? 14.0 : 13.0; final double iconSize = height >= ButtonSizes.primary ? 20 : 17; final child = loading ? SizedBox( width: iconSize, height: iconSize, child: CircularProgressIndicator(strokeWidth: 2.4, color: fg), ) : Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: iconSize, color: fg), const SizedBox(width: 7), ], Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: fontSize, fontWeight: FontWeight.w700, letterSpacing: -0.1, color: fg, fontFamily: FontConstants.fontFamily, ), ), ), ], ); final button = Opacity( // Disabled state is a uniform fade rather than three different greys, // so "I can't press this" looks the same everywhere. opacity: _enabled ? 1.0 : 0.45, child: Material( color: bg, borderRadius: radius, child: InkWell( onTap: _enabled ? onPressed : null, borderRadius: radius, child: Container( height: math.max(height, ButtonSizes.minTapTarget), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( borderRadius: radius, border: borderColor == null ? null : Border.all(color: borderColor, width: 1.5), ), child: child, ), ), ), ); return expand ? SizedBox(width: double.infinity, child: button) : button; } } /// Square icon-only button, sized from the same scale as [MilerButton] so it /// lines up with whatever CTA it sits beside. class MilerIconButton extends StatelessWidget { final IconData icon; final VoidCallback? onPressed; final Color? _colorOverride; /// Resolved rather than defaulted. /// /// The override is stored and the token is read at paint time, so an /// un-overridden widget always draws the current token. Color get color => _colorOverride ?? ColorConstants.primary; final double size; final String semanticLabel; final bool tonal; const MilerIconButton({ super.key, required this.icon, required this.onPressed, required this.semanticLabel, Color? color, this.size = ButtonSizes.icon, this.tonal = true, }) : _colorOverride = color; @override Widget build(BuildContext context) { final radius = BorderRadius.circular(ButtonSizes.radius); return Semantics( button: true, label: semanticLabel, child: Opacity( opacity: onPressed == null ? 0.45 : 1.0, child: Material( color: tonal ? color.withValues(alpha: 0.12) : Colors.white, borderRadius: radius, child: InkWell( onTap: onPressed, borderRadius: radius, child: Container( width: math.max(size, ButtonSizes.minTapTarget), height: math.max(size, ButtonSizes.minTapTarget), alignment: Alignment.center, decoration: BoxDecoration( borderRadius: radius, border: Border.all( color: color.withValues(alpha: 0.35), width: 1.5, ), ), child: Icon(icon, color: color, size: size >= 52 ? 22 : 19), ), ), ), ), ); } } /// ─────────────────────────────────────────────────────────────────────────── /// ONE LOOK FOR EVERY CODE BOX /// /// The rider types a code twice in his working life with this app: once at /// login, and once at every customer's door. Those were two different controls /// — different box size, radius, border weight, focus treatment, fill and type /// size — because they are built on two different input models: the auth screen /// uses one controller per box (which is what makes SMS autofill work), and the /// delivery OTP draws boxes over a single hidden field (which is what makes /// paste work). /// /// Both models are correct for their job, so this shares the *look* rather than /// the widget. The rider meets one control; the two screens keep the input /// behaviour each needs. /// ─────────────────────────────────────────────────────────────────────────── class MilerCodeBox { MilerCodeBox._(); /// Gap between boxes. static double gap(BuildContext _) => 8; /// The box itself, in whichever state it is in. /// /// [accent] lets the delivery OTP tint with its leg colour while login stays /// on the brand — the one thing the two are allowed to differ on, because on /// a verification screen the accent is what says which job is running. static BoxDecoration decoration({ required bool focused, required bool filled, required bool hasError, required Color accent, }) { final Color border = hasError ? ColorConstants.errorRed : focused ? accent : filled ? accent.withValues(alpha: 0.40) : ColorConstants.borderStrong; return BoxDecoration( color: filled && !hasError ? accent.withValues(alpha: 0.08) : ColorConstants.pureSurface, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), border: Border.all(color: border, width: focused || hasError ? 2 : 1.4), ); } /// The digit. static TextStyle digit(Color accent, {bool hasError = false}) => MilerType.figure(20, color: hasError ? ColorConstants.errorRed : accent); } /// ─────────────────────────────────────────────────────────────────────────── /// FEEDBACK — what the app says when something goes wrong /// /// The app had 84 `catch (_) {}` blocks. Every one of them is the same moment /// for the rider: he taps, the request fails, and *nothing happens*. No error, /// no spinner, no retry — the screen simply sits there. He taps again. Then he /// decides the app is frozen, force-closes it, and calls the hub. Meanwhile the /// failure was swallowed on his phone and nobody upstream ever hears about it. /// /// Silence is the worst possible response to a failure, because it is /// indistinguishable from success that has not rendered yet. These are the two /// things to say instead, and they are deliberately blunt: what failed, and /// what he can do about it. /// ─────────────────────────────────────────────────────────────────────────── class AppFeedback { AppFeedback._(); /// ── One shape, three tones ── /// /// An audit found **three** feedback systems in the app: 25 hand-rolled /// `showSnackBar` calls with their own colours, radii, margins and durations, /// 9 `Get.snackbar` overlays with a completely different visual language and /// animation, and this. Error feedback is the app's contract with the rider /// at the one moment something has gone wrong — three shapes meant "did that /// work?" had three different answers depending on which screen he was on. /// /// Everything routes through here now. The tone changes the fill and the /// glyph; nothing else varies. /// /// ── Clear of the floating nav bar ── /// /// The bottom nav is `Positioned` outside the `Scaffold` body, so a snackbar /// at the default margin lands *underneath* it — the message the rider most /// needs, hidden behind the tab bar. [_bottomInset] lifts every one of them /// clear. static void error( BuildContext context, String message, { VoidCallback? onRetry, Duration duration = const Duration(seconds: 4), }) => _show( context, message, icon: LucideIcons.circleAlert, fill: ColorConstants.errorRed, duration: duration, onRetry: onRetry, ); /// It worked. Used sparingly — a confirmation for every successful tap is /// noise, and noise is how a rider learns to ignore the one that matters. static void success(BuildContext context, String message) => _show( context, message, icon: LucideIcons.circleCheck, fill: ColorConstants.acceptGreen, duration: const Duration(seconds: 2), ); /// Neutral news: something changed that the rider did not ask for, or a /// background action finished. Never used for failures. static void info(BuildContext context, String message) => _show( context, message, icon: LucideIcons.info, fill: ColorConstants.slateText, duration: const Duration(seconds: 3), ); /// The floating nav bar's height plus the safe area, so nothing lands behind /// it. Falls back to the plain inset on screens that have no nav bar. static double _bottomInset(BuildContext context) { final safe = MediaQuery.of(context).padding.bottom; return safe + 88; } /// For code with no `BuildContext` — controllers, background handlers. /// /// The nine `Get.snackbar` calls in the app existed because a controller has /// no context to hand a `ScaffoldMessenger`. They arrived with GetX's own /// visual language and its own animation, on a different overlay layer, and /// could be occluded by the floating nav bar. This routes them through the /// same shape as everything else. static void errorGlobal(String message) { final ctx = Get.context; if (ctx != null) error(ctx, message); } static void successGlobal(String message) { final ctx = Get.context; if (ctx != null) success(ctx, message); } static void infoGlobal(String message) { final ctx = Get.context; if (ctx != null) info(ctx, message); } static void _show( BuildContext context, String message, { required IconData icon, required Color fill, required Duration duration, VoidCallback? onRetry, }) { final messenger = ScaffoldMessenger.maybeOf(context); if (messenger == null) return; messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( content: Row( children: [ Icon(icon, color: ColorConstants.onAccent, size: 20), const SizedBox(width: 10), Expanded( child: Text( message, style: MilerType.micro.on(ColorConstants.onAccent).semibold, ), ), ], ), backgroundColor: fill, behavior: SnackBarBehavior.floating, margin: EdgeInsets.fromLTRB(16, 0, 16, _bottomInset(context)), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), duration: duration, action: onRetry == null ? null : SnackBarAction( label: 'Retry', textColor: ColorConstants.onAccent, onPressed: onRetry, ), ), ); } } /// The screen-level version of [AppFeedback.error]: shown where the content /// would have been when a load fails and there is nothing to fall back on. /// /// This is the state Home and Bookings were missing entirely. A failed fetch /// left `trips` empty, and empty renders as "Trip 1 not assigned yet" or "You're /// all caught up" — so a network failure was reported to the rider as *good /// news about his workload*. He would sit waiting for work that the app had /// simply failed to ask for. class ErrorRetry extends StatelessWidget { final String title; final String message; final Future Function() onRetry; const ErrorRetry({ super.key, this.title = "Couldn't load", this.message = 'Check your connection — your bookings are safe on the server.', required this.onRetry, }); @override Widget build(BuildContext context) { return MilerEmptyState( icon: LucideIcons.wifiOff, title: title, message: message, actionLabel: 'Try again', onAction: () => onRetry(), ); } } /// ───────────────────────────────────────────────────────────────────────── /// WHERE THIS PARCEL CAME FROM, AND WHICH ONE IT IS /// /// `[ Kitchen 1 ] Bag DG-1042` /// /// Driven by the data, not by which line the rider is on: it renders whatever /// of the two facts the stop actually carries, and collapses to nothing when it /// carries neither. A parcel booking has no kitchen and no bag label to send — /// everything on that route came from the one hub the rider started at — so a /// parcel card is unchanged without the widget having to ask which tenant it is /// on. If a parcel payload ever does name a source, showing it is the right /// answer anyway. /// /// On a meal run both facts earn their space. A rider part-way through a /// two-kitchen day is carrying two loads that look identical in the box, and /// the failure that costs the client money is not a missed stop — it is the /// right door with the wrong bag, which no OTP, photo or signature catches, /// because the rider is confident and the customer has no way to know. A /// printed label on the card he is already reading is the cheapest thing that /// does. /// /// The source is a pill and the bag is plain text, deliberately: the pill is /// scannable down a list, which is what grouping needs, while the label is /// checked once against something physically in his hand. /// /// Sized in raw logical pixels like everything else in this file — see the note /// on [MilerEmptyState]; the two stop cards that host it are already /// ScreenUtil-scaled around it. /// ───────────────────────────────────────────────────────────────────────── class StopSourceLine extends StatelessWidget { final String source; final String bagLabel; /// Dims both when the stop is not actionable, matching the card around it. final bool muted; /// Space above the line. Zero when a caller has already placed it in a row of /// its own and is spacing it itself. final double topGap; const StopSourceLine({ super.key, required this.source, required this.bagLabel, this.muted = false, this.topGap = 6, }); bool get _isEmpty => source.isEmpty && bagLabel.isEmpty; @override Widget build(BuildContext context) { if (_isEmpty) return const SizedBox.shrink(); final accent = muted ? ColorConstants.secondaryText : ColorConstants.serviceAccent; return Padding( padding: EdgeInsets.only(top: topGap), child: Row( children: [ if (source.isNotEmpty) Flexible( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: accent.withValues(alpha: 0.09), borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(LucideIcons.store, size: 12, color: accent), const SizedBox(width: 5), Flexible( child: Text( source, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w700, letterSpacing: -0.1, color: accent, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ), ), if (source.isNotEmpty && bagLabel.isNotEmpty) const SizedBox(width: 8), if (bagLabel.isNotEmpty) Flexible( child: Text( 'Bag $bagLabel', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w600, // Tabular, because these are read as codes and a column of // them down a list should line up. fontFeatures: const [FontFeature.tabularFigures()], color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } } /// ───────────────────────────────────────────────────────────────────────── /// WHERE THIS ORDER HAS GOT TO /// /// `⏱ Assigned` · `✓ Accepted` · `🚲 In progress` · `✔ Completed` /// /// One tag, on every card that shows an order, on every screen. The status /// vocabulary already lives in one place — [StopStatusX.label] — and this is /// the matching one place for how it looks, so Home, Bookings and Activity /// cannot end up drawing the same backend state three different ways, which is /// exactly what happened to the words before they were centralised. /// /// Icon **and** colour, never colour alone: these are read outdoors, in /// sunlight, on a scratched screen, by somebody who is in a hurry. /// ───────────────────────────────────────────────────────────────────────── class OrderStatusTag extends StatelessWidget { final StopStatus status; /// Dims it to match a card that is not actionable. final bool muted; /// Sentence-case label. Sizing is raw logical pixels, like the rest of this /// file — the cards hosting it are ScreenUtil-scaled around it. const OrderStatusTag({super.key, required this.status, this.muted = false}); @override Widget build(BuildContext context) { final color = muted ? ColorConstants.secondaryText : status.color; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(status.icon, size: 12, color: color), const SizedBox(width: 5), Flexible( child: Text( status.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w700, letterSpacing: -0.1, color: color, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } }