production
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
174
lib/views/helpers/constants/narrative.dart
Normal file
174
lib/views/helpers/constants/narrative.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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 ──
|
||||
//
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
404
lib/views/helpers/widgets/slide_action.dart
Normal file
404
lib/views/helpers/widgets/slide_action.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user