Book a pickup, track it to delivery — the rebuilt customer app. - Design language from doormile-screens.html: brand #8F0F06, Manrope + Geist Mono (variable fonts), bordered cards instead of shadows, crimson brand headers, sliding tab indicator, mono for anything read digit by digit. - lib/data (one live API implementation, plus a debug-only offline fake), lib/state, lib/ui (tokens, widgets, screens). - 84 tests, plus a design snapshot harness that renders every screen with the real fonts: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens This replaces the previous app (pubspec 'doormile', app id com.doormile.customer). That tree remains in history at 6c7d656; note its android/app/google-services.json is not carried over, and the application id here is in.doormile.customer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
396 lines
14 KiB
Dart
396 lines
14 KiB
Dart
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.
|
|
///
|
|
/// **Manrope 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.
|
|
static const brand = Color(0xFF8F0F06);
|
|
|
|
/// Pressed state, and crimson text sitting on a [brandSoft] wash where the
|
|
/// full brand would vibrate against the tint.
|
|
static const brandPress = Color(0xFF730C05);
|
|
static const brandDeep = brandPress;
|
|
|
|
/// 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.
|
|
static const canvas = Color(0xFFF7F6F4);
|
|
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);
|
|
|
|
/// 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._();
|
|
|
|
/// Tiles, chips, the small square that holds a glyph.
|
|
static const xs = 8.0;
|
|
|
|
/// Buttons, fields, icon buttons. The design's most-used corner.
|
|
static const sm = 9.0;
|
|
|
|
/// Cards and every panel that holds rows.
|
|
static const md = 10.0;
|
|
|
|
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
|
|
static const lg = 12.0;
|
|
|
|
/// Bottom sheets and the auth sheet that overlaps the hero.
|
|
static const xl = 16.0;
|
|
|
|
/// The brand header's bottom corners.
|
|
static const header = 18.0;
|
|
|
|
static const pill = 999.0;
|
|
|
|
/// Kept for call sites that mean "a primary bar's corner".
|
|
static const book = sm;
|
|
|
|
static BorderRadius all(double r) => BorderRadius.circular(r);
|
|
}
|
|
|
|
class DmSpace {
|
|
DmSpace._();
|
|
|
|
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas
|
|
/// content — the header's crimson field reads as tighter than it measures,
|
|
/// so it takes the smaller inset.
|
|
static const pad = 24.0;
|
|
static const headerPad = 22.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),
|
|
),
|
|
];
|
|
|
|
/// 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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/// Manrope for voice, Geist Mono for data.
|
|
///
|
|
/// Both ship as single variable-font files, which is why every style below sets
|
|
/// `fontVariations` as well as `fontWeight` — see the note inside the class.
|
|
class DmText {
|
|
DmText._();
|
|
|
|
static const _f = 'Manrope';
|
|
static const _mono = 'GeistMono';
|
|
|
|
// The weight axis. Manrope and Geist Mono both ship as variable fonts, 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.
|
|
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.12,
|
|
fontWeight: FontWeight.w800, fontVariations: _w800,
|
|
letterSpacing: -1.09, color: DmColors.ink,
|
|
);
|
|
|
|
/// The brand header's own title, sitting on crimson.
|
|
static const headerTitle = TextStyle(
|
|
fontFamily: _f, fontSize: 34, height: 1.0,
|
|
fontWeight: FontWeight.w800, fontVariations: _w800,
|
|
letterSpacing: -1.36, color: Colors.white,
|
|
);
|
|
|
|
/// Screen headings on a canvas.
|
|
static const title = TextStyle(
|
|
fontFamily: _f, fontSize: 25, height: 1.18,
|
|
fontWeight: FontWeight.w800, fontVariations: _w800,
|
|
letterSpacing: -0.75, 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: 19, height: 1.2,
|
|
fontWeight: FontWeight.w800, fontVariations: _w800,
|
|
letterSpacing: -0.57, 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.w700, fontVariations: _w700,
|
|
letterSpacing: -0.18, 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.w700, fontVariations: _w700,
|
|
color: DmColors.ink2,
|
|
);
|
|
|
|
static const button = TextStyle(
|
|
fontFamily: _f, fontSize: 15.5, height: 1.2,
|
|
fontWeight: FontWeight.w700, fontVariations: _w700,
|
|
letterSpacing: -0.23,
|
|
);
|
|
|
|
/// 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.w700, fontVariations: _w700,
|
|
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.w700, fontVariations: _w700,
|
|
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.w800, fontVariations: _w800,
|
|
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.w700, fontVariations: _w700,
|
|
color: DmColors.ink,
|
|
);
|
|
}
|