Files
doormile_customer_app/lib/ui/tokens.dart
Thiru-tenext 8427824951 A launch that opens in brand, and a sheet that stops resizing
── The splash, in three beats ──

Crimson edge to edge with the truck running across it in white; the invert;
then the mark.

The red starts before Flutter does. Four surfaces painted white before any
Dart runs — launch_background at both API levels, windowSplashScreenBackground
on Android 12+ in light and dark, and the iOS launch storyboard — and leaving
any one of them white makes the launch a white flash followed by a red one.
That flash is the only part of a launch a customer consciously notices.

The invert is one gesture rather than a fade. A white truck on a background
turning white is an invisible truck, so the ground lightens as the truck
darkens, off one controller, and the screen turns itself inside out with the
truck still on it. Fading it would have left the screen empty for the moment
before the mark.

The truck's colour is no longer its own: it is painted through srcIn, so the
file's palette is discarded and only alpha survives. A replacement Lottie now
needs no preparation, and tool/lottie_brand.py is off this path.

`splash.json` is a seamless 3.9s loop — frame 0 is frame 60, the truck never
arrives or departs — so there is no completion to hand over on. _truckBeat is
a decision about how long a launch may hold somebody, not a property of the
file. The comment claiming six seconds was wrong on both counts.

── Three things the splash was getting wrong quietly ──

It showed the wrong logo: doormile-icon.png, the previous mark, to a customer
who had just tapped the new one. tool/icons.py now cuts doormile-mark.png from
the same master alpha as the launcher icon, so they cannot drift again.

The fallback loader was invisible on red. _RoadLoader painted in DmColors.brand
on what used to be a white screen; on crimson that is crimson on crimson, and
it drew nothing at all on exactly the devices that had fallen back to it.

The mark appeared and left in the same frame — _minimum was the sum of the
beats exactly, so the clock ran out as the entrance finished. Hence _markHold.

── The truck was not in the middle ──

Not a layout bug: both beats sit in a Center and always did. The artwork is
drawn low and to the right inside its own 500x500 composition, so a centred
widget rendered an off-centre picture — 30pt right, 36pt down.

splash_centring_test.dart renders a frame at phone size and density, finds the
ink and fails if either beat drifts. It is the only form of test that could
have caught this, and the one that will catch it again when the Lottie is
replaced, which is when the correction goes stale. Two things it taught:
one enormous pump does not let the splash's async start-up chain advance, and
capturing at pixelRatio 1 rasterizes the speed lines too faintly to detect,
which truncates the bounding box and moves the measured centre by 12pt.

── The destination sheet stops resizing ──

Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames later.
DmAsyncList renders four skeleton rows while it loads — 302pt — and the states
that replace them are nearer 200; the sheet was Flexible, so it was as tall as
whichever state its content happened to be in, and the modal is still
animating up while that swap happens.

The list now lives in a box of one height. That also removes a second resize:
the sheet grew from 48% of the screen to 74% when a state was picked. Both
steps now measure 64% and it never changes size again.

And DmAsyncList takes initialItems, fed by AppState.cachedCities: FutureBuilder
reports `waiting` on its first build even for an already-complete future, so a
warm cache still flashed a skeleton over data it already had.

Three skeleton rows here rather than four — sheet_stability_test caught that
302pt overflows the smallest box the clamp can produce.
2026-09-28 12:54:03 +05:30

578 lines
23 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
/// Design tokens. Every colour, radius, shadow and text style in the app comes
/// from here — screens never hardcode a value.
///
/// Direction (2026-09-15 rebuild, from the 14-screen design):
///
/// **Edges, not elevation.** Surfaces are separated by a 1px warm hairline and
/// a 10pt radius, not by a shadow. The only shadows left in the system are the
/// crimson glow under the one committing action on a screen, the float under a
/// control that genuinely hovers over a map, and the sheet lift. A screen that
/// drops shadows everywhere has no way left to say "this one matters".
///
/// **One accent, three intensities.** Crimson fills the header and the primary
/// action, washes (`brandSoft` over `brandLine`) carry supporting information,
/// and everything else is warm neutral. There is no second hue competing for
/// meaning — `ok` survives only for the delivered state, `danger` only for
/// destructive text.
///
/// **Poppins for voice, Geist Mono for data.** Headings run at w800 with tight
/// negative tracking; anything a customer might read back aloud or check
/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a
/// countdown — is set in mono so the glyphs line up and a transposed character
/// is visible.
class DmColors {
DmColors._();
// ── Brand ────────────────────────────────────────────────────────────────
/// The mark's single colour.
///
/// Briefly brightened to a vermilion on 2026-09-23 to match a reference
/// design and put straight back. The palette is not up for redesign — the
/// reference's *layout* is what the app is being drawn against, and the
/// oxblood carries it perfectly well.
static const brand = Color(0xFF8F0F06);
/// Pressed state, and brand text sitting on a [brandSoft] wash where the
/// full brand would vibrate against the tint.
static const brandPress = Color(0xFF730C05);
static const brandDeep = brandPress;
/// The lit edge of the sphere and of the primary button's gradient. Named
/// rather than repeated as a literal in three files.
static const brandLit = Color(0xFFA6180B);
/// What the launch fills the screen with, before anything else exists.
///
/// ── Why this is not [brand] ──
///
/// It is the launcher icon's own crimson, and it matches on purpose: the
/// customer taps a red tile and the screen fills with that same red, which
/// reads as one continuous gesture rather than as an app starting up. [brand]
/// is the UI's red and is four shades deeper; using it here would make the
/// tap and the screen after it two different colours for no reason a customer
/// could name.
///
/// The two never appear together — the splash has inverted to white before a
/// single piece of UI is drawn.
///
/// Mirrored in `android/app/src/main/res/values/colors.xml` as `dm_splash_bg`
/// and in `ios/Runner/Base.lproj/LaunchScreen.storyboard`, because the window
/// is painted this colour from process start — long before Flutter can.
static const splash = Color(0xFFBD0921);
/// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2);
/// The wash's own edge, and the outline on quiet brand buttons.
static const brandLine = Color(0xFFF0DEDC);
// ── Ink — warm neutrals, never pure black ────────────────────────────────
static const ink = Color(0xFF191716);
static const ink2 = Color(0xFF5C5654);
static const ink3 = Color(0xFF6E6864);
static const ink4 = Color(0xFFB4AEAA);
// ── Surfaces ─────────────────────────────────────────────────────────────
/// The page canvas.
///
/// Deepened on 2026-09-23. At `#F7F6F4` a white card on it was a 3% step,
/// so the 1px border was doing all the separating and every screen read as
/// a list of outlined boxes. A canvas the cards can sit *on* is what lets
/// the edge go quiet.
static const canvas = Color(0xFFF1F0EC);
static const surface = Color(0xFFFFFFFF);
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
/// treatment for a disabled primary action.
static const surfaceAlt = Color(0xFFF1EFEC);
/// The bed a segmented control's chosen segment is raised out of.
///
/// A step below [surfaceAlt], which is within a point of the canvas and so
/// drew no groove at all on a sheet. A segment only reads as *raised* if
/// there is something visible for it to be raised out of.
static const groove = Color(0xFFE7E4E0);
/// Barely-there divider between rows inside one card.
static const hairline = Color(0xFFF1EFEC);
/// A card's own edge — the workhorse of this design.
static const border = Color(0xFFE8E5E2);
// ── On-brand text ────────────────────────────────────────────────────────
/// Secondary text on a crimson field. Not white-at-opacity: a tinted rose
/// keeps its warmth where translucent white goes grey and dirty.
static const onBrand2 = Color(0xFFFFD9D4);
/// Tertiary text on a crimson field.
static const onBrand3 = Color(0xFFFFD1CB);
/// Recessed track inside the header (the segmented control's bed).
static const onBrandSunken = Color(0x24000000);
/// A control resting on the crimson field.
static const onBrandRaise = Color(0x2EFFFFFF);
// ── Semantic ─────────────────────────────────────────────────────────────
static const ok = Color(0xFF0A6B56);
static const okSoft = Color(0xFFE2F4EF);
static const danger = Color(0xFFB3231F);
/// "You are here" on a map. Deliberately not the brand crimson — on a map
/// crimson means the pickup pin, and blue is the convention every customer
/// already reads as their own position.
static const locationDot = Color(0xFF1B6EF3);
static const locationHalo = Color(0x241B6EF3);
static const scrim = Color(0x80191716);
// Backwards-compatible aliases used across the widget layer.
static const bg = canvas;
static const surface2 = surfaceAlt;
static const line = hairline;
static const lineStrong = border;
}
class DmRadius {
DmRadius._();
// Every corner was opened up on 2026-09-22. At 8–12 the app measured
// correct and looked like a form: a screen of tight rectangles, which is
// exactly the "boxy" the redesign was called in to fix. Four points across
// the scale is the difference between a panel and a card, and it costs
// nothing but the token.
/// Tiles, chips, the small square that holds a glyph.
static const xs = 10.0;
/// Buttons, fields, icon buttons. The design's most-used corner.
static const sm = 12.0;
/// Cards and every panel that holds rows.
static const md = 18.0;
/// A card that stands alone on the canvas rather than in a stack of rows.
/// Wider than [md] because a shadowed card with no edge needs the corner to
/// carry the shape the border used to.
static const card = 20.0;
/// A chip: a status pill, a tier badge, a stat tile's glyph well.
static const chip = 8.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 16.0;
/// Bottom sheets and the auth sheet that overlaps the hero. Moved with the
/// scale — at 16 it had collided with [lg], and a sheet that shares a corner
/// with the cards inside it has stopped being a separate surface.
static const xl = 20.0;
/// The brand header's bottom corners.
static const header = 26.0;
static const pill = 999.0;
/// A primary bar's corner. Softer than a field's, because the committing
/// action is the one element on a screen allowed to look like an object
/// rather than part of the form.
static const book = 16.0;
static BorderRadius all(double r) => BorderRadius.circular(r);
}
class DmSpace {
DmSpace._();
/// Horizontal page padding.
///
/// 24 once, when the header was a crimson field and the canvas held loose
/// rows. On a page of white cards it left a broad margin down both sides and
/// the cards read as narrower than the screen — the reference designs this
/// was drawn against run their cards to within about a finger of the edge.
static const pad = 16.0;
static const headerPad = 16.0;
/// Minimum touch target.
static const tap = 48.0;
/// Primary button height.
static const button = 54.0;
/// Field height — matched to [button] so a form and its action share a rhythm.
static const field = 54.0;
}
class DmShadow {
DmShadow._();
/// Under the one committing action on a screen, and under a selected slot.
static const lift = [
BoxShadow(
color: Color(0x808F0F06),
blurRadius: 24,
spreadRadius: -12,
offset: Offset(0, 10),
),
];
/// A smaller version of [lift] for chips and half-width controls.
static const liftSm = [
BoxShadow(
color: Color(0xA68F0F06),
blurRadius: 16,
spreadRadius: -8,
offset: Offset(0, 7),
),
];
/// A card resting on the canvas.
///
/// ── Why the cards stopped having edges ──
///
/// Every surface in this app was separated by a 1px hairline, which is
/// honest and cheap and, repeated eight times down a screen, reads as a
/// wireframe: the outline is the loudest thing about an object whose whole
/// job is to be quiet and hold something else.
///
/// Two very soft layers do it instead — a wide, faint ambient wash and a
/// short contact shadow just under the shape. Neither is visible as a
/// shadow; together they lift the white off the warm canvas so the card is
/// read as *nearer* rather than as *outlined*. That is the difference
/// between a premium consumer surface and a bordered panel, and it costs
/// nothing but two `BoxShadow`s.
static const card = [
BoxShadow(
color: Color(0x0D1F1A17),
blurRadius: 22,
spreadRadius: -10,
offset: Offset(0, 8),
),
BoxShadow(
color: Color(0x081F1A17),
blurRadius: 3,
spreadRadius: -1,
offset: Offset(0, 1),
),
];
/// A card the customer has to act on, or the one live thing in a list.
/// The same two layers, a third again as deep.
static const cardRaised = [
BoxShadow(
color: Color(0x141F1A17),
blurRadius: 30,
spreadRadius: -12,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x0B1F1A17),
blurRadius: 4,
spreadRadius: -1,
offset: Offset(0, 2),
),
];
/// Controls that genuinely float over content — map buttons, map chips.
static const float = [
BoxShadow(
color: Color(0x0D191716),
blurRadius: 2,
offset: Offset(0, 1),
),
];
static const sheet = [
BoxShadow(
color: Color(0x2E191716),
blurRadius: 40,
spreadRadius: -20,
offset: Offset(0, -8),
),
];
// Aliases kept for existing call sites.
static const two = float;
static const three = sheet;
}
/// Clearance for the floating tab bar.
///
/// ── It was being counted twice ──
///
/// Every root padded its foot by `MediaQuery.paddingOf(context).bottom + 74`,
/// on the reasoning that the inset is the device's home indicator and 74 is
/// the bar floating over it. That is not what the body is handed. `Scaffold`
/// with `extendBody: true` **replaces** the body's bottom padding with the
/// laid-out height of the `bottomNavigationBar` — the bar's own SafeArea, its
/// 10 of padding and its 62 of pill, all of it. Measured: a 34pt device inset
/// and this bar gives the body `padding.bottom = 106`, which is the bar
/// exactly.
///
/// So the roots were clearing the bar, and then clearing it again. It cost 74
/// points of empty canvas under the last card on Home, Orders and Account —
/// enough that Home's caption had nowhere to go while a hole sat under the
/// form.
///
/// The inset is the clearance. [gap] is the air between the last card and the
/// top of the pill, which is a design decision rather than a measurement.
double dmTabBarClear(BuildContext context, {double gap = 12}) =>
MediaQuery.paddingOf(context).bottom + gap;
class DmMotion {
DmMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 250);
static const slow = Duration(milliseconds: 460);
static const ease = Curves.easeOutCubic;
/// The bottom nav's travelling pill — a touch of overshoot, which reads as
/// the indicator catching up rather than teleporting.
static const glide = Cubic(0.34, 1.06, 0.36, 1);
/// Screen transitions. An emphasised decelerate: it leaves quickly and
/// settles slowly, so a push feels driven at the start and arrived-at by the
/// end. `easeOutCubic` is too even across a distance this short — it reads
/// as a constant-speed slide.
static const enter = Cubic(0.05, 0.7, 0.1, 1);
}
/// Poppins for voice, Geist Mono for data.
///
/// ── Why the sans styles look different from the mono ones ──
///
/// Poppins has no variable release. Google Fonts ships it as static cuts only,
/// so the sans styles below set `fontWeight` and nothing else: there is no
/// `wght` axis to move, and `fontVariations` on a static font is ignored in
/// silence, which is worse than useless — it reads as if it were doing the
/// work. `pubspec.yaml` declares one file per weight and Flutter matches on the
/// enum. Geist Mono is still variable and its three styles still carry the axis.
///
/// Only the four weights the design uses are bundled — 500, 600, 700, 800 —
/// because each one is a separate ~155 KB file. Asking for w900 anywhere would
/// not fail; Flutter would quietly synthesise it from Bold, and it would look
/// like it.
///
/// ── Every weight is one step lighter than it was ──
///
/// Poppins is a geometric face built on near-circles, and it carries far more
/// ink at the same nominal weight than the humanist faces before it. Styles
/// that were w900 are w800, w800 are w700, w700 are w600. Set at the old
/// numbers the headings looked inflated rather than strong.
///
/// ── And the tracking is deeper again ──
///
/// The opposite correction from the last face. Poppins is wide, round and has
/// generous default sidebearings, so display sizes need pulling in harder than
/// anything this app has used — the header runs at −1.2. Body sizes stay near
/// zero, because the same roundness is what makes it legible small and tracking
/// it in takes that back. Tracking belongs to a typeface, not to a design.
class DmText {
DmText._();
/// ── Switzer → Plus Jakarta Sans (2026-09-24) ──
///
/// Both are variable neo-grotesques and the swap is a one-line change, but
/// two things come with it. Plus Jakarta's humanist curves and taller
/// x-height give the place names the editorial weight the reference design
/// has, and — the practical part — **it carries the rupee glyph**, which
/// Switzer does not. Every amount in this app was set in Geist Mono for that
/// reason alone; the ones that are prices rather than data can now be type.
///
/// Its axis is wght 200–800, so every weight the design uses is real rather
/// than synthesised.
static const _f = 'PlusJakartaSans';
static const _mono = 'GeistMono';
// The weight axis. Switzer and Geist Mono both ship as one variable file, so
// a style has to move `wght` to change weight — `fontWeight` alone picks the
// nearest named instance and, on a single-file VF, that is always the
// default. Both are set on every style: the axis does the work, the enum
// keeps the semantics (and the system-font fallback) correct.
//
// This came back when Poppins left. Poppins has no variable release, so for
// one release the sans styles carried `fontWeight` alone and this block was
// the mono's private business.
static const _w500 = [FontVariation('wght', 500)];
static const _w600 = [FontVariation('wght', 600)];
static const _w700 = [FontVariation('wght', 700)];
static const _w800 = [FontVariation('wght', 800)];
// Letter spacing is quoted in em by the design and converted here at each
// size, because -0.035em is a different number of pixels on a 31pt display
// head than on a 15pt row title, and splitting the difference would soften
// exactly the heads that are supposed to feel tight.
// ── Display ──────────────────────────────────────────────────────────────
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.1,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.1, color: DmColors.ink,
);
/// The largest line on a screen: the name on Home, the title of a tab root.
///
/// It was white, because it was written for a crimson header. That header is
/// a canvas now and every caller was overriding the colour back to ink, so
/// the white was a trap waiting for the first caller who did not.
static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.4, color: DmColors.ink,
);
/// Screen headings on a canvas.
static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.16,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.8, color: DmColors.ink,
);
/// Tab-root page titles where no brand header is used.
static const pageTitle = title;
/// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.45, color: DmColors.ink,
);
static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 21, height: 1.2,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.65, color: DmColors.ink,
);
// ── Rows ─────────────────────────────────────────────────────────────────
/// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: -0.2, color: DmColors.ink,
);
/// Alias: the same weight of statement, used mid-form.
static const bodyStrong = cardTitle;
static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.12, color: DmColors.ink,
);
/// Supporting line under a row title — `t2`.
static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink3,
);
/// Paragraph under a screen heading.
static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The right-hand value in a settings row.
static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Field labels, tags, chips, inline actions.
static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
static const button = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.25,
);
/// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Uppercase micro-label above a group — the design's `micro`. Crimson when
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: 1.16, color: DmColors.ink3,
);
/// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: 1.84, color: DmColors.onBrand3,
);
/// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.3, color: DmColors.ink,
);
/// Alias kept for existing call sites.
static const groupHead = sectionHead;
/// Field input text.
static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
// ── Mono: anything checkable digit by digit ──────────────────────────────
/// References, plates and phone numbers sitting inline in a row.
static const mono = TextStyle(
fontFamily: _mono, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.26, color: DmColors.ink3,
);
/// Step counters, timers, the smallest data.
static const monoSm = TextStyle(
fontFamily: _mono, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.23, color: DmColors.ink3,
);
/// A booking reference given its own line, or a fare.
static const monoLg = TextStyle(
fontFamily: _mono, fontSize: 19, height: 1.2,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.38, color: DmColors.ink,
);
/// The OTP digits.
static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
}