Files
doormile_milderapp/lib/views/helpers/widgets/app_widgets.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

2030 lines
71 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Widget>? 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<Reveal> createState() => _RevealState();
}
class _RevealState extends State<Reveal> with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final Animation<double> _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<PressScale> createState() => _PressScaleState();
}
class _PressScaleState extends State<PressScale> {
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<Offset>(
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<AfterEntrance> createState() => _AfterEntranceState();
}
class _AfterEntranceState extends State<AfterEntrance> {
bool _ready = false;
Animation<double>? _watched;
Timer? _fallback;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_ready) return;
final Animation<double>? 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<void> 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,
),
),
),
],
),
);
}
}