production
This commit is contained in:
@@ -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