Files
2026-08-11 13:16:33 +05:30

1688 lines
57 KiB
Dart
Raw Permalink 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:get/get.dart';
import 'package:shimmer/shimmer.dart';
import 'miler_app_bar.dart' show milerGlassSurface;
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,
style: ElevatedButton.styleFrom(
backgroundColor: color,
foregroundColor: foreground,
disabledBackgroundColor: color.withValues(alpha: 0.5),
disabledForegroundColor: foreground.withValues(alpha: 0.8),
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.w700,
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.w700,
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.w600,
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.w600,
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.w600,
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 = Icons.info_outline_rounded,
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;
/// 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.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: 180, height: 180, 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,
child: Icon(icon, size: 24, color: ColorConstants.slateText),
),
),
),
);
}
}
/// ── 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
? Icons.close_rounded
: Icons.arrow_back_rounded;
final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false);
return AppBar(
// The frosted brand wash every bar in the app shares — see
// [milerGlassSurface]. Replaces the flat white, so a pushed page and a tab
// read as the same surface at the top.
backgroundColor: Colors.transparent,
flexibleSpace: milerGlassSurface(),
surfaceTintColor: Colors.transparent,
foregroundColor: ColorConstants.slateText,
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,
),
],
),
),
);
}
/// ───────────────────────────────────────────────────────────────────────────
/// 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));
/// 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(
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(
Icons.place_rounded,
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.w800,
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: Icons.error_outline_rounded,
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: Icons.check_circle_rounded,
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: Icons.info_outline_rounded,
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: Icons.wifi_off_rounded,
title: title,
message: message,
actionLabel: 'Try again',
onAction: () => onRetry(),
);
}
}