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

@@ -232,57 +232,59 @@ class ColorConstants {
/// snackbar.
static const Color onAccent = Color(0xFFFFFFFF);
/// ── The frosted brand wash on every app bar ───────────────────────────
/// ── A tint, resolved rather than laid over ──
///
/// A translucent maroon laid over the page rather than a solid slab of it.
/// Painted behind a blur (see `milerGlassSurface`), so what shows through is
/// the page's own colour softened and tinted.
/// `accent.withValues(alpha: 0.10)` is how nearly every tinted chip, pill and
/// panel in this app was drawn, and it is a *translucent* surface: what the
/// rider actually sees is the accent composited against whatever happens to
/// be behind it. That is fine on a white page and wrong the moment the same
/// chip appears on a tinted panel, a photo or a map — the same "10% green"
/// comes out four different colours across four screens, which is the drift
/// the surface system exists to stop.
///
/// Deliberately weak. At 7% the surface stays light enough for near-black
/// type, which is what keeps a title readable through a windscreen mount in
/// sun.
static const Color glassRed = Color(0x12960019);
/// This resolves the composite once, against the surface the caller names, so
/// what comes back is an ordinary opaque colour. Same appearance where the
/// old code was already over white; predictable everywhere else.
static Color tint(Color accent, double amount, {Color on = pureSurface}) =>
Color.alphaBlend(accent.withValues(alpha: amount), on);
/// ── The fill on a frosted sheet ──
/// ── The fill on a bottom sheet ──
///
/// A sheet over a map has something worth seeing underneath it, and a solid
/// slab throws all of it away. Frosted, the map stays legible as context while
/// the sheet stays legible as content.
///
/// Deliberately high at 88%. This is not decoration: the text over it is an
/// address and an ETA, read one-handed at a doorstep in daylight, and every
/// point of transparency spent on the effect is contrast taken off the words.
static const Color glassSheet = Color(0xE0FDFDFE);
/// It was 88% white — a frosted pane that let the map read through it. It is
/// opaque now, because every point of transparency spent on the effect was
/// contrast taken off the words, and the words here are an address and an ETA
/// read one-handed at a doorstep in daylight. The map is not lost: the sheet
/// covers 45% of the screen and the other 55% is still the map.
static const Color glassSheet = Color(0xFFFFFFFF);
/// The specular rim along a glass edge — what makes frost read as a pane
/// rather than as a translucent rectangle.
static const Color glassRim = Color(0x8CFFFFFF);
/// The hairline along a sheet's or a card's edge. It was a specular white
/// highlight that only read as an edge because the surface under it was
/// translucent; on an opaque surface the same job needs a real line.
static const Color glassRim = Color(0xFFE5E7EB);
/// ── The pane a list card is made of ──
/// ── The surface a list card is made of ──
///
/// Every card the rider scrolls — a stop on Home, a job on Deliveries — is
/// this fill with a soft, wide shadow under it ([DesignConstants.shadowGlass])
/// and **no border at all**.
/// this fill, a hairline and a soft, wide shadow.
///
/// Not a solid white: at 92% the page's slate tint comes through just enough
/// that the card reads as a pane laid over the screen rather than a white
/// rectangle cut into it, which is the whole difference between "glass" and
/// "box". It stays high enough for near-black type at any brightness — a
/// rider reads addresses off this surface in direct sun.
/// ── 0xEB → 0xFF ──
///
/// No `BackdropFilter`. A real blur re-samples everything behind it every
/// frame, and a scrolling list of twenty of them is the single most reliable
/// way to drop a mid-range Android phone to 30fps. The blur is reserved for
/// the two surfaces that genuinely sit over live content — the app bar and
/// the bottom sheets ([milerGlassSheet]).
static const Color glassCard = Color(0xEBFFFFFF);
/// It was 92% white, so the page's slate tint came through and the card read
/// as a pane laid *over* the screen. That is a nice effect on a design file
/// and a cost on a handlebar: a translucent card takes its contrast from
/// whatever happens to be behind it, so the same address is crisp over the
/// canvas and grey over a map. Opaque, it reads the same everywhere, which is
/// the only property that matters on a surface a rider takes an address off
/// in direct sun. Depth comes from the hairline and the lift instead.
static const Color glassCard = Color(0xFFFFFFFF);
/// The same pane, warmed by the brand, for the one stop that is under way.
/// The same surface, warmed by the brand, for the one stop that is under way.
///
/// A whisper — 5% — because a card the rider has to *read* is the wrong place
/// for a saturated fill: what marks the live stop is the LIVE chip and the
/// button on it, not a red panel behind its address.
static const Color glassCardLive = Color(0x0D960019);
/// A whisper — the brand at 5%, flattened onto white rather than laid over
/// the card as a translucent wash — because a card the rider has to *read* is
/// the wrong place for a saturated fill: what marks the live stop is the LIVE
/// chip and the button on it, not a red panel behind its address.
static const Color glassCardLive = Color(0xFFFAF2F3);
// ── Daylight surfaces ────────────────────────────────────────────────
// Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey

View File

@@ -71,12 +71,27 @@ class DesignConstants {
// `radius2xl` (20) is retired: it was the third-most-used value and had no
// job the container radius could not do.
// ── 12/16 → 14/20 ──
//
// Both steps went up one notch, together, after four screens were rebuilt
// against reference designs and every one of them came back reading harder
// than the picture it was drawn from. The shapes were right and the corners
// were not: at 16 a full-width card on a phone is a rectangle with the edges
// taken off, and the difference between that and something that reads as a
// *soft object* is about four points.
//
// Moved as a pair so the ladder holds — an inner tile has to stay visibly
// tighter than the surface it sits on, or the two corners fight — and moved
// in the tokens rather than at call sites, because a radius that is softer
// on the screens somebody redesigned last is exactly how an app ends up with
// 26 of them again.
/// **Inner** — inputs, chips, small tiles, panels *inside* a container.
static const double radiusLg = 12.0;
static const double radiusLg = 16.0;
/// **Container** — cards, sheets, and anything that is a surface in its own
/// right. Buttons use this too, so a button and the card it sits on agree.
static const double radiusXl = 16.0;
static const double radiusXl = 24.0;
/// **Pill** — fully rounded: pills, circular tracks, avatars.
static const double radiusFull = 999.0;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/narrative.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
@@ -46,7 +47,15 @@ abstract final class MilerSurface {
///
/// Deliberately not white: a canvas that is white leaves nothing for a white
/// surface to be distinguishable *from*, which is the state this app was in.
static const Color canvas = ColorConstants.daylightSurface;
///
/// ── #DEE3EA → #F8F9FA ──
///
/// It was a heavy slate, chosen when a card was a bare white fill and the
/// tonal step was the *only* thing separating the two. Cards carry a hairline
/// and a lift now (see [MilerPanel]), so the ground no longer has to do that
/// work alone — and a page this dark under bordered cards reads as heavy
/// rather than as layered. The brief's canvas is the value.
static const Color canvas = Narrative.canvas;
/// **Layer 1.** A region the rider reads or works in — a sheet's body, a
/// details page, a grouped operational block.
@@ -88,10 +97,18 @@ abstract final class MilerSurface {
/// The gutter a layer-1 panel leaves around itself, so the canvas reads as
/// ground rather than as a hairline. One number, because two panels a few
/// points apart is how a page stops looking composed.
///
/// 12 → 20. The brief keeps a generous margin off the screen's edge so
/// nothing interactive touches it and the panels read as floating; its own
/// figure is 24, and 20 is where that lands once the panel's internal padding
/// (below, also raised) is added to it — 40 of inset before a word on a
/// 390pt phone is as far as this can go and still hold a full address.
static const double panelGutter = 12;
/// The breathing room *inside* a layer-1 panel.
static const double panelPad = 14;
///
/// 14 → 20, the brief's figure for the padding inside a card.
static const double panelPad = 16;
}
/// ── Layer 1, as a widget ──
@@ -140,10 +157,17 @@ class MilerPanel extends StatelessWidget {
vertical: MilerSurface.panelPad,
)
: EdgeInsets.zero,
decoration: BoxDecoration(
color: MilerSurface.working,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
// ── Level 1, as the Narrative brief draws it ──
//
// It was a bare white fill on a dark canvas: separation bought with a
// 1.29:1 tonal step, no border and no shadow, on the rule that an
// outline over a step that already works is a line to read past.
//
// The brief inverts that trade — a *light* canvas, and cards that carry
// a hairline and a soft lift. Both work; what does not is half of each,
// so the panel takes the brief's whole recipe and the ground goes light
// with it. See [Narrative].
decoration: Narrative.cardBox(),
child: child,
);
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// PREMIUM LOGISTICS NARRATIVE — the design system the rebuilt screens follow
///
/// A supplied spec, transcribed rather than interpreted: the values below are
/// the ones in the brief. It is a different recipe from [MilerSurface]'s, and
/// the difference is the whole point, so it is worth stating plainly:
///
/// ```
/// MilerSurface dark canvas, white cards, NO border and NO shadow
/// — separation bought with a 1.29:1 tonal step
/// Narrative light canvas, white cards, 1px border AND a soft shadow
/// — separation bought with an edge and a lift
/// ```
///
/// Both work; what does not work is half of each. So a screen belongs to one
/// system or the other, and the ones rebuilt against this brief take this one
/// whole — canvas, card, border, shadow, radius and rhythm together.
///
/// ── No glass ──
///
/// The brief's own Level 2 is a frosted, 70%-white overlay. It is deliberately
/// **not** transcribed here: translucency was cut from these screens on the
/// instruction that came with the spec. A blurred surface is also the single
/// most reliable way to drop a mid-range Android below 60fps, which is the
/// phone this app ships to — so nothing here is see-through, and depth comes
/// from tone, edge and shadow instead.
/// ─────────────────────────────────────────────────────────────────────────
class Narrative {
Narrative._();
// ── Surfaces ──────────────────────────────────────────────────────────
/// **Level 0.** The page. A soft, cool grey — light enough that a white card
/// on it needs its edge and its shadow to read, which is exactly what this
/// system gives them.
static const Color canvas = Color(0xFFF8F9FA);
/// **Level 1.** A card: pure white, over [cardBorder] and [cardShadow].
static const Color card = Color(0xFFFFFFFF);
/// The hairline around a card. 1.13:1 against white on its own — it is not
/// asked to separate anything by itself, it is asked to *finish* an edge the
/// shadow has already softened.
static const Color cardBorder = Color(0xFFE5E7EB);
/// A tonal block *inside* a card — an address, a fact tile, a task row.
static const Color inset = Color(0xFFF3F4F5);
/// One step down from [inset], for a control's resting state.
static const Color insetDeep = Color(0xFFEDEEEF);
// ── Ink ───────────────────────────────────────────────────────────────
/// Headings and any figure the screen is about. 15.9:1 on [card].
static const Color ink = Color(0xFF191C1D);
/// Supporting copy, captions, the quiet half of a pair. 8.0:1 on [card].
static const Color inkSoft = Color(0xFF584141);
/// Labels in data-dense places — timestamps, counts, eyebrows. 4.9:1.
static const Color inkMuted = Color(0xFF6B6B6B);
// ── Accents ───────────────────────────────────────────────────────────
/// **Deep burgundy.** The brand, spent sparingly: primary actions, the
/// selected state, and the one figure a screen is answering with.
///
/// The brief names #800020 in prose and #570013 in its token table. The
/// prose value is the one used, because it is the one the brief calls "the
/// palette is centred around" — and it is the value the mock-ups were drawn
/// with.
static const Color burgundy = Color(0xFF800020);
/// The burgundy at the weight a tint reads on white without becoming a
/// surface of its own — the brief's "10% opacity burgundy" ghost fill.
static const Color burgundyWash = Color(0x1A800020);
/// **Refined emerald.** Completion, and only completion.
static const Color emerald = Color(0xFF00472C);
/// The emerald as a chip fill, with [emerald] as its text — the brief's
/// "light tint of the status colour, darker text of the same hue".
static const Color emeraldWash = Color(0x1A00472C);
/// Amber, for the one row that still owes somebody something.
static const Color amber = Color(0xFF8E5A00);
static const Color amberWash = Color(0x1A8E5A00);
// ── Shape ─────────────────────────────────────────────────────────────
//
// "Architectural curvature": large, intentional radii, nested so a child is
// always visibly tighter than the surface holding it.
/// A card, a sheet, a panel — anything that is a surface in its own right.
static const double radiusCard = 28;
/// A block inside a card: a tonal well, a task row, a fact tile.
static const double radiusInner = 18;
/// A chip, an icon container, a small tile.
static const double radiusChip = 14;
/// Fully rounded — pills, segmented tracks, avatars.
static const double radiusPill = 999;
// ── Elevation ─────────────────────────────────────────────────────────
/// A card's lift: wide, diffuse and almost invisible. Offset 0,4 · blur 20 ·
/// 4% black. It is not there to be seen; it is there so the edge above it
/// does not have to carry the separation alone.
static const List<BoxShadow> cardShadow = [
BoxShadow(color: Color(0x0A000000), blurRadius: 20, offset: Offset(0, 4)),
];
/// A surface that floats over the page rather than sitting in it.
static const List<BoxShadow> floatShadow = [
BoxShadow(color: Color(0x14000000), blurRadius: 28, offset: Offset(0, 8)),
];
/// The glow under a primary action — the brand's own colour, so the button
/// reads as lit rather than as merely raised.
static const List<BoxShadow> burgundyGlow = [
BoxShadow(color: Color(0x33800020), blurRadius: 20, offset: Offset(0, 8)),
];
// ── Rhythm ────────────────────────────────────────────────────────────
//
// Base-8. Two sections are 32 apart, two blocks 16, two lines 8.
/// The margin the page keeps off the screen's edge, so nothing interactive
/// touches it and the cards read as floating.
///
/// ── 24 → 14 ──
///
/// The brief's figure is 24, and it is written for a page of prose. These
/// pages are lists of stops read at arm's length: at 24 of margin plus 20 of
/// card padding, a customer's name started 44pt in on a 390pt phone and the
/// screen held two fewer rows for it. The reference mock-ups the brief came
/// with are drawn at about half its own number, which is the answer — the
/// margin exists so the cards read as floating, and 14 does that.
///
/// ── 14 → 12 ──
///
/// One more point off each side, measured on device rather than reasoned:
/// with a 1px border and a 20-blur shadow the card's *visual* edge already
/// sits a point or two inside its box, so 14 of margin reads as 16 and the
/// page looked like it had a frame. 12 is where the gap stops being noticed
/// and starts just being air.
static const double gutter = 12;
/// The padding inside a card. The brief says 20; 16 is what leaves the
/// content room once [gutter] is added to it on a phone.
static const double cardPad = 16;
/// Between two logical sections.
static const double gapSection = 32;
/// Between two blocks in a section.
static const double gapBlock = 16;
/// Between two lines of one block.
static const double gapLine = 8;
/// Decoration for a Level-1 card, so no screen has to re-declare the three
/// values that make one.
static BoxDecoration cardBox({double? radius}) => BoxDecoration(
color: card,
borderRadius: BorderRadius.circular(radius ?? radiusCard),
border: Border.all(color: cardBorder, width: 1),
boxShadow: cardShadow,
);
}

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),
],
),
),
);
}
}