production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -4,12 +4,14 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:shimmer/shimmer.dart';
import 'miler_app_bar.dart' show milerGlassSurface;
import 'package:miler/Models/stop_status.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/widgets/miler_app_bar.dart'
show milerBrandSurface;
import '../constants/Colorconstants.dart';
import '../constants/design_constants.dart';
@@ -24,6 +26,96 @@ import '../constants/Font_constant.dart';
/// every screen stays visually consistent.
/// ───────────────────────────────────────────────────────────────────────────
/// ─────────────────────────────────────────────────────────────────────────
/// A BAND — a section that runs the full width of the phone
///
/// The alternative, and what most of this app still does, is a card: a
/// surface inset from both edges by a page margin, closed with a radius and
/// usually an outline. That shape is right when a screen shows a *list of
/// objects* — a stop, a job, a booking — because the margin is what says
/// "these are separate things".
///
/// It is wrong for a screen that is one long thing: a form, a review, a
/// receipt. There the margin buys nothing and costs twice. It costs width —
/// 32 to 40 points off a 390pt phone, spent on two strips of empty ground
/// beside text that is mostly addresses — and it costs a second boundary,
/// because the gap between two cards already separates them and the outline
/// says it again.
///
/// A band takes the other trade: it runs edge to edge, the page's ground
/// showing between one band and the next is the only separator, and the
/// margin moves *inside* as padding, where it is air around words rather than
/// air around a box. Same breathing room at the left of a line, ~40pt more
/// line to breathe in.
///
/// ── The rail ──
///
/// A band cannot carry state in an outline it does not have, so state that has
/// to read at arm's length goes in the fill, and a finished band adds [rail] —
/// a short accent stripe down its leading edge. It is the one mark that stays
/// legible when a phone is at arm's length in sun, and it is cheap: three
/// points of colour rather than a whole tinted border.
/// ─────────────────────────────────────────────────────────────────────────
class MilerBand extends StatelessWidget {
final Widget child;
/// The band's ground. White unless the band's state says otherwise, and an
/// **opaque** colour either way — see [ColorConstants.tint] for why a
/// translucent wash is the wrong tool for a surface.
final Color? color;
/// An accent stripe down the leading edge, for a band whose state has to
/// read without being examined. Null draws no stripe and no inset.
final Color? rail;
/// Overrides the standard inset. Pass only where the child owns its own
/// horizontal padding — a row that has to bleed to the band's edge.
final EdgeInsets? padding;
const MilerBand({
super.key,
required this.child,
this.color,
this.rail,
this.padding,
});
/// The inset a band keeps around its content.
///
/// One number for the whole app. With the bands running edge to edge, their
/// padding is the only thing left aligning the page's left margin — two
/// bands a couple of points apart is exactly how a column of text stops
/// looking like a column.
static double get pad => 20.w;
/// The gap of page ground between one band and the next. This is the only
/// separator a band has, so it is a real gap rather than a hairline.
static double get gap => 10.h;
@override
Widget build(BuildContext context) {
// Animated because [color] and [rail] carry state: a section going from
// outstanding to finished should be seen to change, and a hard cut on a
// full-width fill reads as the screen having been replaced. Bands whose
// state never moves pay nothing for this.
return AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOut,
width: double.infinity,
padding: padding ?? EdgeInsets.symmetric(horizontal: pad, vertical: 16.h),
decoration: BoxDecoration(
color: color ?? ColorConstants.pureSurface,
border: rail == null
? null
: Border(
left: BorderSide(color: rail!, width: 3.w),
),
),
child: child,
);
}
}
/// White rounded card with the design-system shadow.
class AppCard extends StatelessWidget {
final Widget child;
@@ -792,6 +884,12 @@ PreferredSizeWidget milerPageBar(
/// True for a screen that genuinely *dismisses* rather than pops — a sheet
/// presented modally. Only this gets the cross.
bool isModal = false,
/// The colour of the surface directly under the bar, so the curve at its
/// bottom corners opens onto the page rather than onto more brand.
/// Transparent reveals the Scaffold's own background, which is right for
/// every pushed page here. See [milerBrandSurface].
Color pageColor = Colors.transparent,
}) {
final IconData backIcon = isModal ? LucideIcons.x : LucideIcons.arrowLeft;
final bool hasControlRow = onBack != null || (actions?.isNotEmpty ?? false);
@@ -805,10 +903,15 @@ PreferredSizeWidget milerPageBar(
// the phone AND the colour of the status-bar glyphs, which reads as leaving
// the app rather than opening a page inside it.
//
// Solid [ColorConstants.primary], and everything on it inverts. The wash
// survives as `milerGlassSurface()` for the sheets, which sit over content
// and still want it.
backgroundColor: ColorConstants.primary,
// One ground, and everything on it inverts. The wash and the blur it was
// painted behind are both gone; what the bar wears now is
// [kBrandGradient], with its bottom corners taken off.
//
// **Transparent**, and the surface does all the painting: an opaque fill
// behind a rounded surface fills in the two corners the curve exists to
// open. See [milerBrandSurface].
backgroundColor: Colors.transparent,
flexibleSpace: milerBrandSurface(pageColor: pageColor),
surfaceTintColor: Colors.transparent,
foregroundColor: Colors.white,
iconTheme: const IconThemeData(color: Colors.white),
@@ -937,17 +1040,28 @@ Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) =>
/// ─────────────────────────────────────────────────────────────────────────
/// GLASS CARD — the one surface every list card in the app is drawn on.
///
/// A translucent pane, a soft wide shadow, and **no border**.
/// An opaque surface, a hairline, and a soft wide shadow.
///
/// ── Why no border ──
/// ── The name is older than the material ──
///
/// A border, a fill and a shadow are three devices doing one job, and the
/// border is the one that competes with the content: six bordered rectangles
/// down a phone screen read as a form to be filled in, not a route to be
/// ridden. Every large logistics app the rider already uses — Uber, Swiggy,
/// Amazon Shopper — separates list cards with elevation alone, and it survives
/// what a hairline does not: a scratched screen in direct sun, where a 1px
/// #E2E8F0 line is simply not there.
/// It was a translucent pane with no border at all, and both halves of that
/// have been undone. The pane went opaque because a translucent card takes its
/// contrast from whatever happens to be behind it — the same address is crisp
/// over the canvas and grey over a map — and the hairline came back because on
/// the budget LCD this app ships to, a shadow crushes to nothing and a
/// borderless card reads as printed onto the page.
///
/// The name is kept because seven call sites use it and a rename buys nothing.
///
/// ── What the border is and is not ──
///
/// The risk a border carries is that it competes with the content: six heavily
/// bordered rectangles down a phone screen read as a form to be filled in, not
/// a route to be ridden. So it is a *hairline* and it is nearly the card's own
/// colour — it is not asked to separate the card from the page, which is the
/// shadow's job and the canvas step's. It is asked to finish an edge that the
/// shadow has already softened, and to be the thing still standing when the
/// shadow is crushed away by sunlight.
///
/// ── Why one widget rather than a decoration constant ──
///
@@ -969,8 +1083,8 @@ class GlassCard extends StatelessWidget {
final String? semanticLabel;
final String? semanticHint;
/// A wash over the pane, for the one card on a screen that is *live*.
/// Deliberately a whisper — see [ColorConstants.glassCardLive].
/// The card's fill, for the one card on a screen that is *live*. Deliberately
/// a whisper — see [ColorConstants.glassCardLive].
final Color? tint;
/// Dims the whole card without changing a single colour inside it, for a
@@ -998,31 +1112,26 @@ class GlassCard extends StatelessWidget {
margin: margin,
padding: padding,
decoration: BoxDecoration(
color: ColorConstants.glassCard,
borderRadius: BorderRadius.circular(radius),
// ── The rim is what a budget LCD can actually render ──
// ── The tint replaces the fill, it does not sit over it ──
//
// On a calibrated panel the slate shadow separates the pane from the
// It used to be a translucent wash laid *inside* the card, on the
// reasoning that a live card should be the same glass as everything
// else, warmed. With opaque surfaces that overlay would cover the
// content rather than warm it, so the live card is simply a card with a
// different fill — which is what it always looked like anyway.
color: tint ?? ColorConstants.glassCard,
borderRadius: BorderRadius.circular(radius),
// ── The hairline is what a budget LCD can actually render ──
//
// On a calibrated panel the shadow alone separates the card from the
// ground; on the LCD this app ships to, shadows crush to nothing and
// the cards read as printed on the page — reported twice from the
// device. The one-pixel rim is the same trick the frosted sheets use
// ([milerGlassSheet]'s glassRim): a line of light on the curve costs
// nothing at any brightness and says "edge" where blur cannot.
// device. A one-pixel edge costs nothing at any brightness and says
// "edge" where a blur cannot.
border: Border.all(color: ColorConstants.borderSubtle),
boxShadow: DesignConstants.shadowGlass,
),
// The tint rides *inside* the pane rather than replacing it, so a live
// card is the same glass everything else is, warmed — not a different
// material.
child: tint == null
? child
: DecoratedBox(
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(radius),
),
child: child,
),
child: child,
);
final tappable = onTap == null

View File

@@ -97,7 +97,7 @@ class LoadStateView<T> extends StatelessWidget {
// The server's own sentence where it gave one — it is more useful than
// anything this app can invent about a failure it did not cause.
LoadFailureKind.server =>
message.isEmpty ? 'The hub could not be reached.' : message,
message.isEmpty ? 'Your office could not be reached.' : message,
},
onRetry: kind.isRetryable ? onRetry : null,
),

View File

@@ -1,6 +1,4 @@
import 'dart:io';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
@@ -8,65 +6,131 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:miler/controllers/profile_controller.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/constants/miler_surface.dart';
import 'package:miler/views/helpers/constants/miler_type.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// The frosted brand surface every app bar in the app sits on.
/// The brand, as a surface with light falling across it.
///
/// ── Why a filter and not just a tinted box ──
/// Two stops about eight per cent apart, top-left to bottom-right. Enough that
/// a header reads as an object rather than as a rectangle filled with one hex
/// value; not enough to be seen *as* a gradient, which on a brand ground is the
/// difference between premium and 2014. The app's own red stays the darker
/// stop, so nothing about the colour the app is known by changes.
///
/// A flat 7% red over white is a colour; glass is a *relationship* — it takes
/// whatever is underneath and softens it. Content scrolls under these bars, so
/// the blur has something real to work on: a card edge passing beneath the bar
/// blurs out rather than sliding under a solid lid, which is the whole visual
/// difference between "the page continues up there" and "the page stops here".
/// Declared once here because three screens wear it — Home's own header builds
/// it inline for the same values — and a gradient copied per screen is how two
/// pages end up a shade apart.
const LinearGradient kBrandGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA51C1C), Color(0xFF8B1A1A)],
);
/// ── The bar curves UP, and draws the curve itself ──
///
/// Pass as `flexibleSpace` with a transparent `backgroundColor`, so the wash
/// covers the status-bar inset too — a bar tinted only below the notch reads as
/// a stripe rather than a surface.
/// Every page in this app used to do the opposite: the body was a [MilerSheet]
/// with rounded *top* corners over a brand-red Scaffold, so the maroon filled
/// in behind the curve and reached furthest down at the two outside edges. The
/// header's bottom edge therefore read as sagging at the corners — a page
/// hanging off a bar rather than a bar sitting over a page.
///
/// ── And no line under it ──
/// Home was inverted first and it is the right way round. This is the same
/// shape, made available to every other bar.
/// ── Logical points, not `.r` ──
///
/// The bar closed with a hairline for as long as it was white, because a white
/// bar over a near-white page has no edge of its own and would otherwise bleed
/// into the content. The wash *is* an edge — a tinted surface against an
/// untinted one — so the rule underneath became a second boundary drawn on top
/// of the first, which is exactly the doubled-up border this app has been
/// pulling out of its cards. Glass separates by being a different surface, not
/// by being fenced.
Widget milerGlassSurface() {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: Container(color: ColorConstants.glassRed),
/// Everything else in this app sizes with ScreenUtil, and this deliberately
/// does not. `milerBrandSurface()` is called while the `AppBar` is being
/// *constructed*, which is outside any `ScreenUtilInit` — so reading `.r` there
/// throws `LateInitializationError` for every caller that builds a bar without
/// one, which includes several tests and any future widget that composes a bar
/// eagerly.
///
/// A corner radius is also the one measurement that gains nothing from scaling:
/// it is device-independent already, and 22 logical points is the same shape on
/// every phone.
const double kBarBottomRadius = 22;
/// The brand surface a bar sits on, with its bottom corners taken off.
///
/// ── Why it paints [pageColor] itself ──
///
/// A rounded bar only reads as rounded if something *else* shows through the
/// corners, and the obvious way to arrange that — round the bar and let the
/// Scaffold behind it show — does not work here: every page that carries this
/// bar paints its Scaffold **brand red**, left over from the old arrangement.
/// The corners would open onto more maroon and the curve would be invisible.
///
/// The alternative was to flip five pages' backgrounds, which is five chances
/// to get a page's own ground wrong for a change that is about the bar. So the
/// bar carries its own backdrop instead: the page's colour behind, the gradient
/// in front with the corners taken off. Self-contained, and correct whatever a
/// Scaffold happens to be painted.
///
/// [pageColor] must match the surface directly under the bar — that is the
/// whole illusion.
///
/// It defaults to **transparent**, which lets the Scaffold's own background
/// show through: on the dozen pushed pages whose body sits straight on the
/// Scaffold that is exactly right and costs no wiring at all. Only a page that
/// paints its Scaffold brand-red — a hangover from the old top-rounded-sheet
/// arrangement — has to name its ground, because there the corners would open
/// onto more maroon.
///
/// Pass as `flexibleSpace` with a **transparent** `backgroundColor`, so the
/// AppBar's own fill does not paint a square maroon rectangle behind all of
/// this — and so the surface covers the status-bar inset, since a bar tinted
/// only below the notch reads as a stripe rather than a surface.
Widget milerBrandSurface({Color pageColor = Colors.transparent}) {
return ColoredBox(
color: pageColor,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: kBrandGradient,
borderRadius: BorderRadius.vertical(
bottom: Radius.circular(kBarBottomRadius),
),
),
// ── The `SizedBox.expand` is load-bearing ──
//
// A `DecoratedBox` with no child shrink-wraps to nothing. On its own as a
// `flexibleSpace` that did not matter — the AppBar's stack stretched it —
// but inside the [ColoredBox] the outer box takes the space and the inner
// one collapses to zero, painting no gradient at all. The bar then showed
// the Scaffold's own maroon through a fully transparent AppBar, which
// looks exactly like a flat brand bar and is why this read as "the curve
// did not apply".
child: const SizedBox.expand(),
),
);
}
/// ── A bottom sheet as frosted glass ──
/// ── The sheet is a solid surface again ──
///
/// The two sheets a rider spends the most time in — the stop's map sheet and
/// Update Status — were opaque white slabs. On the map screen that is the
/// whole problem: the sheet opens over the one thing the screen exists to
/// show, and takes 45% of it away permanently.
/// It was frosted: an 88%-white fill behind an 18-sigma blur, so a map stayed
/// legible as context under the sheet covering it. The name has been kept
/// because forty call sites use it; the material has not.
///
/// Frosted instead. The map stays visible as context underneath, softened
/// enough that an address printed on top of it is still the sharpest thing in
/// the frame. It is the same material the app bar already uses
/// ([milerGlassSurface]) — the sheets now belong to the same app as the bars
/// above them, which is the thing that made them look borrowed from somewhere
/// else.
/// Two reasons, and the second is the one that decided it.
///
/// ── What it costs ──
/// **It was asked for.** The design brief these screens were rebuilt against
/// carries a glass layer, and the instruction that came with it was to drop
/// the translucency. A sheet a rider reads an address off at a doorstep, in
/// sun, is the wrong surface to spend contrast on: every point of transparency
/// is taken off the words.
///
/// A `BackdropFilter` re-samples everything behind it every frame, and this one
/// is full-width over a map that repaints as it pans. Sigma is held at 18 —
/// the same as the app bar, tuned on a mid-range Android — rather than the 30+
/// that reads as heavier frost, because on this phone the difference between
/// them is frames, and a sheet that stutters when dragged is worse-looking than
/// one that is merely less blurry.
/// **It cost frames.** A `BackdropFilter` re-samples everything behind it every
/// frame, and this one is full-width over a map that repaints as it pans, on a
/// mid-range Android. The blur was already held down to sigma 18 for exactly
/// that reason — which is the tell that the effect was being rationed rather
/// than enjoyed.
///
/// What replaces it is the brief's Level 1: white, a hairline along the top
/// edge, and a soft lift. The map underneath is no longer visible *through* the
/// sheet, and it does not need to be — the sheet is 45% of the screen and the
/// other 55% is the map.
Widget milerGlassSheet({required Widget child, double radius = 28}) {
final shape = BorderRadius.vertical(top: Radius.circular(radius));
@@ -75,28 +139,19 @@ Widget milerGlassSheet({required Widget child, double radius = 28}) {
// that has been clipped away.
decoration: BoxDecoration(
borderRadius: shape,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.13),
blurRadius: 26,
offset: const Offset(0, -6),
),
],
boxShadow: Narrative.floatShadow,
),
child: ClipRRect(
borderRadius: shape,
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
color: ColorConstants.glassSheet,
borderRadius: shape,
// The rim, not a border: it is one pixel of light on the curve, and
// it is what separates "frosted glass" from "white box at 88%".
border: Border.all(color: ColorConstants.glassRim, width: 1),
child: DecoratedBox(
decoration: BoxDecoration(
color: Narrative.card,
borderRadius: shape,
border: Border(
top: BorderSide(color: Narrative.cardBorder, width: 1),
),
child: child,
),
child: child,
),
),
);
@@ -310,6 +365,14 @@ class MilerAvatar extends StatelessWidget {
/// icon).
class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
/// The colour of the surface directly under the bar, so the curve at its
/// bottom corners opens onto the page rather than onto more brand.
///
/// Transparent by default, which reveals the Scaffold's own background — the
/// right answer everywhere except a page that paints its Scaffold brand-red.
/// See [milerBrandSurface].
final Color pageColor;
final Widget? trailing;
/// Draws a back arrow before the title.
@@ -326,6 +389,7 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
required this.title,
this.trailing,
this.showBack = false,
this.pageColor = Colors.transparent,
});
/// Fixed logical bar height. 64 → 76 to absorb [_titleTopInset] without
@@ -350,10 +414,16 @@ class MilerAppBar extends StatelessWidget implements PreferredSizeWidget {
// and the colour of the status-bar glyphs with it, which reads as
// arriving in a different application rather than a different page.
//
// Solid [ColorConstants.primary] everywhere, and everything on it
// inverts — title, back arrow, trailing controls, status-bar glyphs.
// White on #960019 clears AA at every size used here.
backgroundColor: ColorConstants.primary,
// One ground everywhere, and everything on it inverts — title, back
// arrow, trailing controls, status-bar glyphs. White clears AA at every
// size used here against both stops of [kBrandGradient].
//
// **Transparent**, and the surface does all the painting. The fill used
// to be the brand as a safety net, and it cannot be one now: an opaque
// rectangle behind a rounded surface fills in the two corners the curve
// exists to open. See [milerBrandSurface].
backgroundColor: Colors.transparent,
flexibleSpace: milerBrandSurface(pageColor: pageColor),
// The status bar belongs to the bar under it. Dark glyphs on maroon is
// the one contrast failure a rider cannot work around by tilting the
// phone. Matches the value Home's own header sets.
@@ -482,7 +552,17 @@ class MilerSheet extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.vertical(top: Radius.circular(radius.r)),
// ── Flat, because the curve moved onto the bar ──
//
// This clipped its own **top** corners so the brand-red Scaffold behind
// could show through them — the page hanging off the bar. The bar rounds
// its own bottom now and paints the page colour into those corners
// itself (see [milerBrandSurface]), so rounding here as well would draw
// the same curve twice, in opposite directions, one inside the other.
//
// Kept as a zero-radius clip rather than deleted: every page names this
// widget as *the page's working surface*, and that is still what it is.
borderRadius: BorderRadius.zero,
child: ColoredBox(
// ── Layer 1: this IS the working surface ──
//

View File

@@ -332,20 +332,20 @@ class SettingsLabel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
// ── Aligned to the titles, not to the icons ──
// ── Aligned to the card, not to the titles inside it ──
//
// The label used to start on the page gutter, level with the row icons
// below it. Text-to-glyph alignment is optical, never exact: an `Icon`
// does not paint flush to the left of its box, so an 11sp heading and a
// 21sp glyph starting at the same x read as eight points out of true —
// and a column of three headings all eight points out is what makes a
// settings page look assembled rather than laid out.
// It was inset by the row's icon box plus its gap, so that the heading
// sat exactly over the *words* it heads and the glyphs formed a margin
// rail to its left — text-to-text alignment, which is exact where
// text-to-glyph is only optical.
//
// Text-to-text is exact. The heading sits over the words it heads, and
// the icons become a clean margin rail to its left — the arrangement iOS
// Settings has used since it had groups. The inset is the row's own icon
// box plus its gap, read from the same numbers [SettingsRow] uses.
padding: EdgeInsetsDirectional.only(start: 21.sp + 14.w, bottom: 6.h),
// The argument is sound and the result was wrong on the page: the label
// ended up ~65 points in from the screen edge, floating in the middle of
// nothing with the card's own left edge 35 points to its left. A section
// heading names the block under it, and the block's edge is the line the
// eye actually reads down. It sits on that now, and the caller supplies
// the page gutter.
padding: EdgeInsetsDirectional.only(start: 2.w, bottom: 6.h),
child: Text(text.toUpperCase(), style: MilerType.eyebrow),
);
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
import 'package:miler/views/helpers/constants/Font_constant.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// SLIDE TO COMMIT
///
/// The delivery leg's one control, at both of its moments: setting off, and
/// handing over. Both are writes the rider cannot take back — one puts the load
/// on the road under his name, the other closes the order — and this screen is
/// held while walking, so a tap target at the foot of it is how either gets
/// recorded by a knuckle.
///
/// ```
/// ┌──────────────────────────────────────────────┐
/// │ (▸) Slide to start ride │ solid accent
/// └──────────────────────────────────────────────┘
/// ```
///
/// ── A drag-only control is a control some riders cannot use ──
///
/// A bare `GestureDetector` announces nothing to TalkBack, and a glove on a
/// cold morning will not register a drag. So the same act is reachable by
/// double tap through [Semantics] — the deliberateness is what the gesture is
/// for, and a screen reader's double tap is every bit as deliberate as a slide.
/// ─────────────────────────────────────────────────────────────────────────
class MilerSlideAction extends StatefulWidget {
final String label;
final IconData icon;
final Color color;
final Future<void> Function() onCommit;
const MilerSlideAction({
super.key,
required this.label,
required this.icon,
required this.color,
required this.onCommit,
});
/// The control's height.
///
/// ── 56 → 72 ──
///
/// It was [ButtonSizes.primary], the app's ordinary button height, and this
/// is not an ordinary button. It is the only control on a screen held against
/// a handlebar, operated with a thumb that is often gloved and always moving
/// between glances at traffic — and it is a *drag*, which needs a target deep
/// enough that a thumb travelling horizontally does not slide off the top of
/// it. 72 gives a 64pt thumb, which is a third larger than the 48pt
/// accessibility floor for a tap and about right for a drag.
///
/// It costs 16pt of a sheet whose other content just got shorter — the ETA
/// lost a line and the rail lost its captions — so the sheet is no taller
/// than it was.
static double get height => 72.h;
@override
State<MilerSlideAction> createState() => _MilerSlideActionState();
}
class _MilerSlideActionState extends State<MilerSlideAction>
with TickerProviderStateMixin {
/// How far along the track the thumb has been dragged, 0..1.
double _progress = 0;
/// True while the thumb is under a finger. The spring back is animated; the
/// drag itself must not be, or the thumb lags the thumb-print.
bool _dragging = false;
/// True once [onCommit] has fired, until it returns. The track holds at the
/// far end rather than springing back, so the rider can see that the gesture
/// took before anything else happens.
bool _committed = false;
/// ── The hint ──
///
/// A drag-only control has one problem a button does not: nothing about a
/// still image says it slides. This is the answer every app that ships one
/// uses — a shimmer travelling left to right along the track, and three
/// chevrons brightening in turn ahead of the thumb. It runs only while the
/// control is idle: once a finger is on it the rider has worked out what it
/// is, and an animation under his own thumb is noise.
late final AnimationController _hint = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
);
/// How many laps the hint runs before it stops.
///
/// ── Why it is not `repeat()` ──
///
/// It was, and an endless one is wrong twice. It is a permanent repaint on a
/// sheet the rider is reading — the whole track re-shaded every frame, for as
/// long as it is on screen — and its job is done long before that: three
/// passes is enough for anyone to see that the thing moves. After that it is
/// motion in the corner of the eye of someone about to make a decision.
///
/// It also makes the control **settle**. An animation that never ends is an
/// animation `pumpAndSettle` waits for forever, so every widget test that
/// opened a sheet carrying one hung — which is the framework saying out loud
/// that nothing on screen is ever at rest.
static const int _hintLaps = 3;
int _lapsRun = 0;
void _runHint() {
_lapsRun = 0;
_hint
..reset()
..forward();
}
/// The fraction of the track that counts as slid. Short of the end on
/// purpose: a track that has to be driven into its own wall reads as stuck.
static const double _commitAt = 0.72;
double get _thumb => MilerSlideAction.height - 8.h;
@override
void initState() {
super.initState();
_hint.addStatusListener((status) {
if (status != AnimationStatus.completed) return;
if (++_lapsRun >= _hintLaps || !mounted) return;
_hint
..reset()
..forward();
});
_runHint();
}
Future<void> _commit() async {
HapticFeedback.mediumImpact();
setState(() {
_progress = 1;
_committed = true;
});
_hint.stop();
await widget.onCommit();
if (mounted) {
setState(() {
_progress = 0;
_committed = false;
});
// The control is back at the start, so the hint has something to say
// again — a rider who slid once and came back to a second stop meets the
// same affordance he did the first time.
_runHint();
}
}
void _settle() {
setState(() => _dragging = false);
if (_progress >= _commitAt) {
_commit();
} else {
setState(() => _progress = 0);
if (!_hint.isAnimating) _runHint();
}
}
@override
void dispose() {
_hint.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: widget.label.replaceFirst('Slide to ', ''),
hint: 'Slide, or double tap, to confirm',
excludeSemantics: true,
onTap: _commit,
child: LayoutBuilder(
builder: (context, box) {
final travel = (box.maxWidth - _thumb - 8.w).clamp(
1.0,
double.infinity,
);
return GestureDetector(
onHorizontalDragStart: (_) {
HapticFeedback.selectionClick();
_hint.stop();
},
onHorizontalDragUpdate: (d) => setState(() {
_dragging = true;
_progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0);
}),
onHorizontalDragEnd: (_) => _settle(),
onHorizontalDragCancel: _settle,
child: AnimatedBuilder(
animation: _hint,
builder: (context, _) => Container(
height: MilerSlideAction.height,
padding: EdgeInsets.all(4.w),
decoration: BoxDecoration(
// ── The track is the action, not a slot the action sits in ──
//
// It was the accent at 14% — a pale wash carrying a bright
// thumb. That reads as a *disabled* button on the one screen
// where the control has to be found without being looked for:
// this sheet is glanced at over a handlebar, and the shape the
// rider is scanning for is the same solid slab of colour that
// every other primary action in the app is.
//
// So the track carries the accent solid and the thumb inverts
// to white — the shape and the weight of a primary button,
// with a disc on it that says the button travels.
color: widget.color,
// Fully round, and the thumb with it: a rounded-rectangle
// thumb inside a rounded-rectangle track reads as a button
// that has been pushed to one side. A disc in a capsule reads
// as something that travels, which is the one thing this
// control has to say before it is touched.
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
// A hairline of the accent's own light along the rim. It is
// what makes the track read as a *groove* the thumb sits in
// rather than as a flat slab with a disc drawn on it — the
// one cue that says the two parts are different objects.
border: Border.all(
color: Colors.white.withValues(alpha: 0.16),
),
boxShadow: [
BoxShadow(
color: widget.color.withValues(alpha: 0.30),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
child: Stack(
alignment: Alignment.center,
children: [
// ── The shimmer ──
//
// A band of lighter colour sweeping the length of the
// track. It is the only thing on the sheet that moves
// horizontally, which is precisely the gesture being
// asked for.
if (!_dragging && !_committed)
Positioned.fill(child: _Shimmer(t: _hint.value)),
// ── The chevrons and the label are one object ──
//
// They were laid out independently — chevrons pinned
// after the thumb, label centred on what was left — and
// on a long label the two overlapped: `› Slide to confirm
// pickup` with two of the three arrows underneath the
// word.
//
// One row, sized to itself and centred in the room to the
// right of the thumb. The arrows lead into the word
// instead of colliding with it, the pair stays balanced
// whatever the label says, and both fade together as the
// thumb arrives — rather than one of them hanging on
// while the other has gone.
Padding(
padding: EdgeInsets.only(left: _thumb, right: 12.w),
child: Center(
child: Opacity(
opacity: (1 - _progress * 2.0).clamp(0.0, 1.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Nobody has to be told what an arrow pointing
// right means, and three of them brightening in
// turn is one arrow travelling.
for (var i = 0; i < 3; i++)
Icon(
LucideIcons.chevronRight,
size: 19.sp,
color: ColorConstants.onAccent.withValues(
alpha: _dragging || _committed
? 0.30
: _chevron(i),
),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
color: ColorConstants.onAccent,
fontFamily: FontConstants.fontFamily,
),
),
),
],
),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
// Instant under the finger, eased on the way back —
// and a spring on the way home, which is what makes a
// committed slide feel like it landed rather than
// like it was reset.
duration: _dragging
? Duration.zero
: DesignConstants.motionState,
curve: Curves.easeOutCubic,
margin: EdgeInsets.only(left: travel * _progress),
width: _thumb,
height: _thumb,
decoration: BoxDecoration(
color: ColorConstants.onAccent,
shape: BoxShape.circle,
boxShadow: const [
BoxShadow(
color: Color(0x2E000000),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: AnimatedSwitcher(
duration: DesignConstants.motionPress,
child: Icon(
// The tick is the receipt. A thumb that arrives
// still wearing the "go" glyph looks like it has
// not finished.
_committed ? LucideIcons.check : widget.icon,
key: ValueKey(_committed),
color: widget.color,
size: 26.sp,
),
),
),
),
],
),
),
),
),
);
},
),
);
}
/// How lit chevron [i] is on this frame — a wave running left to right, so
/// the three read as one arrow travelling rather than three blinking.
double _chevron(int i) {
final phase = (_hint.value * 3 - i) % 3;
final lit = phase < 1 ? 1 - phase : 0.0;
return 0.28 + 0.62 * lit;
}
}
/// A band of light sweeping the length of the track.
///
/// Painted rather than composed from widgets: it is a gradient whose stops
/// move, and a `LinearGradient` rebuilt per frame is one object where a stack
/// of animated boxes would be several.
class _Shimmer extends StatelessWidget {
/// 0 → 1, the sweep's position over one cycle.
final double t;
const _Shimmer({required this.t});
@override
Widget build(BuildContext context) {
// Runs from off the left edge to off the right, so the band is never
// parked at either end waiting for the cycle to restart.
final centre = -0.4 + t * 1.8;
return DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [
(centre - 0.22).clamp(0.0, 1.0),
centre.clamp(0.0, 1.0),
(centre + 0.22).clamp(0.0, 1.0),
],
colors: [
Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.16),
Colors.white.withValues(alpha: 0),
],
),
),
);
}
}