1688 lines
57 KiB
Dart
1688 lines
57 KiB
Dart
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(),
|
||
);
|
||
}
|
||
}
|