Miler rider app: surface system, visible design language, backend lifecycle

Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -75,7 +75,12 @@ class ColorConstants {
/// app washed out in exactly the daylight this app is used in.
///
/// Now 5.48:1 behind white, and ≥4.5:1 on all four surfaces the app draws on.
static const Color acceptGreen = Color(0xFF047857);
// #047857 → #047354 (2026-08-21). A two-percent darkening, and the only
// reason for it is arithmetic: with the canvas moved to #DEE3EA the old
// value measured 4.25:1 on it — "AA large only", which a 13.5sp status word
// is not. This clears 4.54 on the canvas and 5.86 on white, and at this
// distance the hue is indistinguishable from what it replaced.
static const Color acceptGreen = Color(0xFF047354);
/// Kept as a name for the cash figure, now that [acceptGreen] is dark enough
/// to be read as text. Same value; the two greens collapsed into one.
@@ -95,7 +100,10 @@ class ColorConstants {
// `cardSurface`** — and the card fill is where warnings actually appear, on
// the skip sheet and the short-payment notice. A token that passes on the
// surface nobody uses it on has not passed.
static const Color warning = Color(0xFFAE5008);
// #AE5008 → #A44B08, for the same arithmetic reason as [acceptGreen]: 4.12
// on the new canvas was below AA for the size this is actually set at.
// Now 4.54 on the canvas, 5.85 on white.
static const Color warning = Color(0xFFA44B08);
static const Color onWarning = Color(0xFFFFFFFF);
static const Color warningContainer = Color(0xFFFEF3C7);
@@ -163,6 +171,21 @@ class ColorConstants {
static const Color deliveryChip = Color(0xFF1E40AF);
static const Color combinedChip = Color(0xFF4C1D95);
// ── The service line ─────────────────────────────────────────────────
//
// A tenant whose riders run subscription meal routes sees one work type all
// day — collect a crate from a kitchen, drop it to subscribers — so its stops
// wear a single `SERVICE` mark instead of the pickup/delivery pair. See
// `ServiceProfile.workTypeLabel`.
//
// Deliberately the delivery blue rather than a fourth hue: a service day is
// delivery-shaped from the rider's side, the blue is already tuned to 5.17:1,
// and nothing in that day sits next to a Doormile stop for the two to be
// confused. Named separately so it can diverge later without a hunt through
// call sites for which blue meant which thing.
static const Color serviceAccent = deliveryAccent;
static const Color serviceChip = deliveryChip;
/// Text drawn *on* a chip fill.
static const Color onChip = Color(0xFFFFFFFF);
@@ -235,10 +258,60 @@ class ColorConstants {
/// rather than as a translucent rectangle.
static const Color glassRim = Color(0x8CFFFFFF);
/// ── The pane 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**.
///
/// 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.
///
/// 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);
/// The same pane, 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);
// ── Daylight surfaces ────────────────────────────────────────────────
// Tuned for a phone clamped to a handlebar in direct sun. Slate-tinted grey
// separates from white far more strongly outdoors than the old warm surface.
static const Color daylightSurface = Color(0xFFF8FAFC);
//
// #F8FAFC → #EEF2F7 (2026-08-21). The old value was a four-percent step off
// the cards' white — honest on a calibrated display and INVISIBLE on the
// budget LCD this app actually ships to: verified on-device, the record's
// panels and the queue's cards read as printed on the same sheet. A ground
// has one job — make the surfaces on it read as surfaces — and a step nobody
// can see is a step not taken. Secondary text keeps ≥4.2:1 here.
// ── #EEF2F7 → #DEE3EA (2026-08-21, measured) ──
//
// The same correction, made once more and this time with a number attached.
// The previous move went from a 4% step to a 12.4% one, which was better and
// still not enough: **white on this ground measured 1.124:1**. A tonal step
// does not begin to read as a *different surface* until roughly 1.25:1, and
// outdoors on a cheap LCD it needs the top of that band — which is why every
// screen that wanted its content to separate had quietly grown a border or a
// shadow to do the ground's job for it.
//
// #DEE3EA puts white at **1.290:1**. Surfaces now separate on their own, so
// the borders and shadows those screens added can come off rather than being
// piled higher. Body text still clears 13.8:1 here and secondary text 4.4:1,
// so nothing was traded for it.
//
// This is [MilerSurface.canvas]. Read that ladder, not this constant, when
// choosing a ground — it is what makes four layers four layers.
static const Color daylightSurface = Color(0xFFDEE3EA);
/// The app's near-black body colour.
static const Color slateText = Color(0xFF0F172A);

View File

@@ -1,11 +1,43 @@
import 'package:flutter/material.dart';
class FontConstants {
// Miler Design System: Manrope typography (variable font) — a free,
// OFL-licensed geometric sans chosen as the closest legal stand-in for
// Uber Move (which is proprietary and cannot be bundled).
static const String fontFamily = 'Manrope';
static String fontFamilyFallback = 'PlusJakartaSans';
/// ── The app's typeface ──
///
/// Poppins: geometric, OFL-licensed, and the one every screen now reads from.
/// It replaced Manrope, which was itself a stand-in for Uber Move.
///
/// **Read this before changing it.** The family was already declared in
/// `pubspec.yaml` as `poppins`, with a lowercase path on every one of its
/// seven assets, while the files on disk are `assets/fonts/Poppins/Poppins-*`.
/// Asset bundling is case-sensitive on both platforms, so not one of them
/// resolved: anything asking for the family fell back to Roboto and no error
/// was raised anywhere. The declaration is fixed and all seven static weights
/// are registered — Poppins ships as separate files rather than a variable
/// font, and without explicit 800/900 entries Flutter synthesises them by
/// smearing SemiBold, which is what makes a heading look muddy at exactly the
/// size it is meant to be read from.
/// ── The weight scale is calibrated for THIS family ──
///
/// Poppins runs optically heavier than Manrope at every grade: it is
/// geometric, its bowls are wide and its stems thick, so `w800` here is far
/// darker on the page than `w800` was in a variable Manrope. The whole scale
/// was tuned against Manrope and came across a grade too heavy — headings
/// read as shouting and body copy as bold.
///
/// Every weight in `lib/` (outside `lib/xpress`, which is Proxima Nova) was
/// stepped down one grade: 900→800, 800→700, 700→600, 600→500. The *relative*
/// hierarchy is untouched — each level is still one step from its neighbour —
/// so nothing lost its rank; the whole run just sits where it should for this
/// typeface.
///
/// **If the family changes again, re-tune this.** A weight is a number, not a
/// design: what it looks like belongs to the font.
static const String fontFamily = 'Poppins';
/// Only ever reached for a glyph Poppins does not carry. Manrope covers the
/// Latin and currency sets the app uses, so this is a safety net rather than
/// a second typeface in the design.
static String fontFamilyFallback = 'Manrope';
// Typography scale
static const double displayLg = 32.0;
@@ -18,21 +50,21 @@ class FontConstants {
static TextStyle displayLgStyle = const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
letterSpacing: -0.64,
fontFamily: fontFamily,
);
static TextStyle headlineMdStyle = const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
letterSpacing: -0.24,
fontFamily: fontFamily,
);
static TextStyle headlineSmStyle = const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
letterSpacing: -0.2,
fontFamily: fontFamily,
);
@@ -51,7 +83,7 @@ class FontConstants {
static TextStyle labelBoldStyle = const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
fontFamily: fontFamily,
);

View File

@@ -36,7 +36,13 @@ class AppTheme {
},
),
fontFamily: FontConstants.fontFamily,
fontFamilyFallback: const ['Roboto', 'sans-serif'],
// Manrope first: it is bundled, so its coverage is guaranteed on every
// device, and it is what backs Poppins up for anything Poppins does not
// carry. The platform families behind it are the last resort. Poppins is
// missing U+2192 among others — a glyph the app no longer renders as a
// character, but the net belongs here rather than in a rule nobody
// remembers.
fontFamilyFallback: const ['Manrope', 'Roboto', 'sans-serif'],
textTheme: ThemeData.light().textTheme.apply(
fontFamily: FontConstants.fontFamily,
),
@@ -110,7 +116,7 @@ class AppTheme {
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
@@ -147,7 +153,7 @@ class AppTheme {
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),
@@ -156,7 +162,7 @@ class AppTheme {
style: TextButton.styleFrom(
foregroundColor: _primary,
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
fontFamily: FontConstants.fontFamily,
),
),

View File

@@ -97,6 +97,31 @@ class DesignConstants {
static const List<BoxShadow> shadowLg = [
BoxShadow(color: Color(0x2A000000), blurRadius: 12, offset: Offset(0, 8)),
];
/// A surface that floats **over** the list rather than in it: the selection
/// bar, a snackbar. Wider and deeper than [shadowGlass] because it has to
/// read as being on a different plane from the cards it covers — but still
/// slate-tinted and still soft, because a hard drop shadow under a white bar
/// reads as a grey stripe, which is exactly what it looked like.
static const List<BoxShadow> shadowFloat = [
BoxShadow(color: Color(0x1F0F172A), blurRadius: 28, offset: Offset(0, 12)),
BoxShadow(color: Color(0x140F172A), blurRadius: 4, offset: Offset(0, 2)),
];
/// ── What holds a borderless card off the page ──
///
/// Two shadows, not one, and that is the whole trick: a wide soft pool gives
/// the card its lift, and a tight near-opaque line under the bottom edge
/// gives it a *defined* edge — without it a single blurred shadow reads as a
/// smudge and the card looks unfinished rather than raised.
///
/// Tinted with the page's slate rather than pure black: a neutral-black
/// shadow on a blue-grey ground goes muddy, which is what makes a card look
/// cheap next to the same card in a design file.
static const List<BoxShadow> shadowGlass = [
BoxShadow(color: Color(0x0F0F172A), blurRadius: 18, offset: Offset(0, 6)),
BoxShadow(color: Color(0x0A0F172A), blurRadius: 2, offset: Offset(0, 1)),
];
}
/// ─────────────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,150 @@
import 'package:flutter/widgets.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/helpers/constants/design_constants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// FOUR LAYERS, AND WHAT EACH ONE MEANS
///
/// The app had **five** near-identical light surfaces — `pureSurface`,
/// `surface`, `neutralLight`, `cardSurface`, `daylightSurface` — and measured
/// against the page ground, three of them were invisible:
///
/// ```
/// neutralLight #F2F4F8 1.021 : 1 ← indistinguishable
/// cardSurface #EDEFF4 1.023 : 1 ← indistinguishable
/// surface #FCF9F8 1.073 : 1 ← and warm, unlike everything else
/// pureSurface #FFFFFF 1.124 : 1 ← the one that mattered, and too close
/// ```
///
/// Five tokens doing the work of two is why the app looked flat *and* why
/// individual screens grew their own borders and shadows: a surface that will
/// not separate on its own has to be outlined, and once one screen outlines
/// something every screen does, in its own way.
///
/// The fix was not more containers. It was giving the ground somewhere to be:
/// the canvas moved down to #DEE3EA, white now separates at 1.290:1, and the
/// layers below are named for **what a surface means** rather than for what
/// colour it happens to be.
///
/// ── The ladder ──
///
/// ```
/// 0 canvas the page itself. Never white.
/// 1 working a region the rider reads or works in. White, no border.
/// 2 raised something that acts: a command bar, a live card. Shadow.
/// 3 floating over everything, with a scrim: sheets, dialogs.
/// ```
///
/// A container is justified when it is one of these layers. It is not
/// justified because the content inside it is a group — grouping is done by
/// whitespace, type and the timeline, which is why a route reads as a route
/// without a box around each stop.
/// ─────────────────────────────────────────────────────────────────────────
abstract final class MilerSurface {
/// **Layer 0.** The page. Every scaffold in the app sits on this.
///
/// 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;
/// **Layer 1.** A region the rider reads or works in — a sheet's body, a
/// details page, a grouped operational block.
///
/// No border. At 1.290:1 against [canvas] it separates on its own, and an
/// outline on top of a step that already works is a line to read past.
static const Color working = ColorConstants.pureSurface;
/// **Layer 2.** Something that *acts*: the floating command bar, the live
/// card, a control that owns the bottom of the screen.
///
/// Same fill as [working] — the difference is [raisedShadow], because what
/// separates an action surface from a reading surface is that it appears to
/// sit above the page rather than in it.
static const Color raised = ColorConstants.pureSurface;
/// **Layer 3.** Over everything, with a scrim beneath: modal sheets and
/// dialogs. Painted by `milerGlassSheet`; this names the layer.
static const Color floating = ColorConstants.glassCard;
/// A quiet tonal block *inside* layer 1 — a fact grid, a folded group.
///
/// The only nesting the system allows, and only when the block is a
/// different **kind** of thing from what surrounds it. Never for grouping.
static const Color inset = ColorConstants.neutralLight;
/// Layer 2's lift. One shadow, used everywhere something is raised, so
/// "floating" looks the same on every screen.
static const List<BoxShadow> raisedShadow = DesignConstants.shadowSm;
/// Layer 3's lift.
static const List<BoxShadow> floatingShadow = DesignConstants.shadowFloat;
/// The dim behind a layer-3 surface. Separation comes from the scrim first
/// and the shadow second — a sheet that needs a heavy shadow to be legible
/// is a sheet whose scrim is too weak.
static const Color scrim = Color(0x66000000);
/// 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.
static const double panelGutter = 12;
/// The breathing room *inside* a layer-1 panel.
static const double panelPad = 14;
}
/// ── Layer 1, as a widget ──
///
/// The ladder above says a working region is *white, standing on the canvas,
/// with no border*. Home was the screen that proved why that has to be a
/// widget rather than a paragraph: its sheet was painted with the canvas and
/// its content placed straight onto it, so the whole page came out one flat
/// grey with nothing white anywhere on it. Every rule was followed except the
/// one that does the work — something has to actually stand on the ground.
///
/// No border and no shadow, deliberately. White separates from the canvas at
/// 1.290 : 1, which is above the ~1.25 : 1 where two tones start reading as
/// two surfaces, so an outline here would be a line to read past rather than
/// a line that says anything.
class MilerPanel extends StatelessWidget {
final Widget child;
/// Space below this panel before the next one. The canvas showing through
/// the gap is what makes them read as separate regions.
final double gap;
/// Set false where the child already owns its own horizontal padding — a
/// list whose rows need to bleed to the panel edge, for instance.
final bool padded;
const MilerPanel({
super.key,
required this.child,
this.gap = 10,
this.padded = true,
});
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.fromLTRB(
MilerSurface.panelGutter,
0,
MilerSurface.panelGutter,
gap,
),
padding: padded
? EdgeInsets.symmetric(
horizontal: MilerSurface.panelPad,
vertical: MilerSurface.panelPad,
)
: EdgeInsets.zero,
decoration: BoxDecoration(
color: MilerSurface.working,
borderRadius: BorderRadius.circular(DesignConstants.radiusXl),
),
child: child,
);
}
}

View File

@@ -64,7 +64,7 @@ class MilerType {
/// The one thing a screen is about. One per screen, or none.
static TextStyle get hero => TextStyle(
fontSize: 28.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.9,
height: 1.08,
color: ColorConstants.slateText,
@@ -74,7 +74,7 @@ class MilerType {
/// App bar titles and sheet headings.
static TextStyle get pageTitle => TextStyle(
fontSize: 22.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
height: 1.15,
color: ColorConstants.slateText,
@@ -84,7 +84,7 @@ class MilerType {
/// A stop name in a list, a section heading inside a screen.
static TextStyle get cardTitle => TextStyle(
fontSize: 19.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
height: 1.2,
color: ColorConstants.slateText,
@@ -94,7 +94,7 @@ class MilerType {
/// Addresses, running copy, the title line of a compact row.
static TextStyle get body => TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
letterSpacing: -0.2,
height: 1.35,
color: ColorConstants.slateText,
@@ -114,7 +114,7 @@ class MilerType {
/// Metadata, timestamps, chip text, the second line of a row.
static TextStyle get label => TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
height: 1.35,
color: ColorConstants.secondaryText,
fontFamily: _family,
@@ -124,7 +124,7 @@ class MilerType {
/// expected to be read outdoors.
static TextStyle get micro => TextStyle(
fontSize: 12.5.sp,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w500,
height: 1.3,
color: ColorConstants.secondaryText,
fontFamily: _family,
@@ -134,7 +134,7 @@ class MilerType {
/// the reason most groups in this app need no outline.
static TextStyle get eyebrow => TextStyle(
fontSize: 11.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
height: 1.3,
color: ColorConstants.secondaryText,
@@ -150,7 +150,7 @@ class MilerType {
/// number, resolved without `.sp`.
static const TextStyle pageTitleFixed = TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.6,
height: 1.15,
color: ColorConstants.slateText,
@@ -165,7 +165,7 @@ class MilerType {
/// them lines up.
static TextStyle figure(double size, {Color? color}) => TextStyle(
fontSize: size.sp,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
height: 1.1,
fontFeatures: const [FontFeature.tabularFigures()],
@@ -176,8 +176,14 @@ class MilerType {
/// The label inside a button. Sized against the button's own height, so a
/// compact button does not shout like a primary one.
static TextStyle buttonLabel(double height) => TextStyle(
fontSize: (height >= 56 ? 16 : height >= 48 ? 15 : 14).sp,
fontWeight: FontWeight.w700,
fontSize:
(height >= 56
? 16
: height >= 48
? 15
: 14)
.sp,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
color: ColorConstants.onAccent,
fontFamily: _family,
@@ -192,7 +198,7 @@ class MilerType {
extension MilerTypeTint on TextStyle {
TextStyle on(Color color) => copyWith(color: color);
TextStyle get bold => copyWith(fontWeight: FontWeight.w800);
TextStyle get bold => copyWith(fontWeight: FontWeight.w700);
TextStyle get semibold => copyWith(fontWeight: FontWeight.w700);
TextStyle get semibold => copyWith(fontWeight: FontWeight.w600);
}