Files
doormile_customer_app/lib/ui/widgets/chrome.dart
Thiru-tenext 06fa6b797a Redesign: Poppins, a two-step destination, and a splash that says what the app does
The effort pass, end to end. Every screen was run through one test — if I
remove this sentence, does the customer make a worse decision? — and the parts
that failed it are gone.

The flow

  Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked

BOOK opens a sheet, not a form. The destination is browsed state-then-district
because a flat list of every serviceable district survives twelve and not
sixty, and search cuts across states because somebody who knows they are
sending to Chennai should not have to know which state it is in. Districts
multi-select, but only where the server allows it: BookingLimits advertises
maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet
that ignored that would sell a booking the network cannot complete.

The pickup window is now a step the customer answers rather than a slot chosen
for them. A pickup window is a promise about somebody's afternoon.

What the screens stopped saying

Home lost the orb caption for returning customers and a four-cell live card.
Send lost the city strip, both address fields, the optional disclosure and
three sentences about charging — the route, the packages and the button are
what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone
count, a "Step 2 of 7" and a sentence describing the screen you were looking
at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed
evening handover".

Type

Poppins, which has no variable release — four static cuts, and the sans styles
set fontWeight alone because fontVariations on a static font is ignored in
silence. Every weight dropped a step and the tracking went deeper: Poppins is
built on near-circles and carries more ink than the humanist faces before it.

Objects

One lit sphere on Home, and the primary button now takes its gradient and rim
because a committing action that is not lit like the hero reads as a different
material. The tracking rail's connector is crimson as far as the parcel has
come, so the line is the progress bar. Confirmation is a white tick on green:
crimson is this app's action colour and that screen has nothing left to do.

Bugs found on the way

The OTP screen dropped digits. Four fields passing focus along lose a keystroke
that arrives mid-transition, so "1234" became "124" and the screen answered
"That code did not match" — blaming the customer for its own race. One field
now, four boxes that only draw.

Nothing ever asked for the customer's location: detectPickupLocation was the
OTP screen's job, so a restored session or an auto-login never triggered the
permission prompt and the pickup map had nothing to centre on.

The launcher icon and both splash screens pointed at a house drawn as two
vector paths — a placeholder that shipped.

The splash clock started when the widget was built rather than when it was
visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash.
It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a
binding that never reports one cannot strand the app.

Also: design/screens/ holds all 19 screens under readable names, tool/ has the
scripts that refresh them and rebrand the Lottie, and DESIGN.md is current.

flutter analyze clean. 88 tests, 1 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-22 17:45:54 +05:30

756 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
/// Top bar for pushed screens: a square back affordance, a quiet title, an
/// optional trailing slot (usually the booking reference in mono), and the
/// booking step indicator.
///
/// ── It knows whether anything is under it ──
///
/// The bar used to be a flat slab of canvas, the same colour as the page, with
/// no line under it. Content scrolled beneath it and simply disappeared — an
/// address row would slide up and vanish behind a back arrow with nothing to
/// say it had gone anywhere. A bar that never changes is not a layer; it is
/// paint.
///
/// It listens to the [ScrollNotificationObserver] that [Scaffold] installs
/// around its body — the same mechanism Material's own `AppBar` uses for
/// `scrolledUnderElevation` — and the moment a list has scrolled under it, the
/// canvas turns to surface and a hairline fades in along the bottom. At the top
/// of a page it is invisible again. Nothing is announced, nothing moves; the
/// screen just stops looking like one plane.
class DmTopBar extends StatefulWidget implements PreferredSizeWidget {
const DmTopBar({
super.key,
this.title,
this.trailing,
this.progress,
this.steps,
this.stepLabel,
this.onBack,
this.background = DmColors.canvas,
});
final String? title;
final Widget? trailing;
/// 0..1 — omit to hide the step indicator.
final double? progress;
/// When given, the indicator is drawn as [steps] separate segments rather
/// than one continuous rail. Discrete steps are countable; a filled bar is
/// only a proportion, and "how many screens until I am done" is the question
/// a customer is actually asking.
final int? steps;
/// The line above the segments — "Step 2 of 4 · Destination".
final String? stepLabel;
final VoidCallback? onBack;
final Color background;
/// The +1 is the hairline. A transparent [BorderSide] still measures 1pt, so
/// the bar is the same height scrolled or not — but it is a point taller than
/// the row inside it, and without this the Column overflows by exactly that
/// on every screen with a top bar.
@override
Size get preferredSize => Size.fromHeight(57 + (progress == null ? 0 : 42));
@override
State<DmTopBar> createState() => _DmTopBarState();
}
class _DmTopBarState extends State<DmTopBar> {
ScrollNotificationObserverState? _observer;
bool _under = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_observer?.removeListener(_onScroll);
_observer = ScrollNotificationObserver.maybeOf(context);
_observer?.addListener(_onScroll);
}
@override
void dispose() {
_observer?.removeListener(_onScroll);
_observer = null;
super.dispose();
}
void _onScroll(ScrollNotification notification) {
// The predicate is what keeps a horizontal strip inside the page — or a
// list inside a sheet over it — from driving the bar behind them.
if (!defaultScrollNotificationPredicate(notification)) return;
if (notification is! ScrollUpdateNotification &&
notification is! ScrollMetricsNotification) {
return;
}
// A few points of slack: a list that rests one pixel off the top, or a
// bounce at the end of an overscroll, should not flicker the line on.
final under = notification.metrics.extentBefore > 4;
if (under != _under && mounted) setState(() => _under = under);
}
@override
Widget build(BuildContext context) {
final current = widget.progress == null || widget.steps == null
? 0
: (widget.progress! * widget.steps!).round().clamp(0, widget.steps!);
return AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: _under ? DmColors.surface : widget.background,
border: Border(
bottom: BorderSide(
color: _under ? DmColors.border : Colors.transparent,
),
),
),
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 14),
DmBackButton(onPressed: widget.onBack),
const SizedBox(width: 4),
Expanded(
child: widget.title == null
? const SizedBox.shrink()
: Text(
widget.title!,
style: DmText.heading.copyWith(fontSize: 17),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?widget.trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (widget.progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
16,
),
child: DmStepBar(
progress: widget.progress!,
steps: widget.steps,
current: current,
label: widget.stepLabel,
),
),
],
),
),
);
}
}
/// Back.
///
/// ── Why it lost its box ──
///
/// It was a 40pt white square with a hairline border and a 12pt corner, which
/// on a canvas of white bordered cards read as *a card containing an arrow*
/// rather than as a way out of the screen. On the send screen, where the title
/// is now the route underneath it, that left a small empty card alone in the
/// top-left corner of an otherwise open page.
///
/// A back affordance is the one control in an app that needs no explaining and
/// no container. It is the glyph, a 48pt target around it, and a circular
/// ripple in the brand wash when it is pressed.
class DmBackButton extends StatelessWidget {
const DmBackButton({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Back',
child: SizedBox(
width: DmSpace.tap,
height: DmSpace.tap,
child: InkResponse(
onTap: onPressed ?? () => Navigator.of(context).maybePop(),
radius: 24,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const Icon(
LucideIcons.arrowLeft,
size: 22,
color: DmColors.ink,
),
),
),
);
}
}
/// Booking progress.
///
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
/// without, it falls back to a single filled rail for callers that only know a
/// fraction.
class DmStepBar extends StatelessWidget {
const DmStepBar({
super.key,
required this.progress,
this.steps,
this.current = 0,
this.label,
this.onFill = false,
this.caption = true,
});
final double progress;
final int? steps;
final int current;
final String? label;
/// Drawn on a crimson field (the live-tracking card), where the track is a
/// lightened white and the fill is solid white.
final bool onFill;
/// Whether to print "Step n of m" above the segments.
///
/// True inside the booking flow, where the customer is answering questions
/// and "how many left" is the thing they want. False on tracking, where the
/// same segments describe a journey nobody is working through — there,
/// counting the stages turns a parcel into a progress bar with homework.
final bool caption;
@override
Widget build(BuildContext context) {
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
final fill = onFill ? Colors.white : DmColors.brand;
final bar = steps == null
? ClipRRect(
borderRadius: DmRadius.all(DmRadius.pill),
child: SizedBox(
height: 3,
child: Stack(
children: [
Positioned.fill(child: ColoredBox(color: track)),
FractionallySizedBox(
widthFactor: progress.clamp(0, 1),
child: AnimatedContainer(
duration: DmMotion.slow,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: fill,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
),
),
)
: Row(
children: [
for (var i = 0; i < steps!; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
height: 3,
decoration: BoxDecoration(
color: i < current ? fill : track,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
],
);
if (!caption || (label == null && steps == null)) return bar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null || steps != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (steps != null)
Text.rich(
TextSpan(
style: DmText.monoSm.copyWith(
color: onFill ? DmColors.onBrand3 : DmColors.ink3,
),
children: [
const TextSpan(text: 'Step '),
TextSpan(
text: '$current',
style: TextStyle(color: onFill ? Colors.white : DmColors.brand),
),
TextSpan(text: ' of ${steps!}'),
],
),
),
const Spacer(),
if (label != null)
Flexible(
child: Text(
label!,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 12.5,
color: onFill ? DmColors.onBrand3 : DmColors.ink,
),
),
),
],
),
const SizedBox(height: 9),
],
bar,
],
);
}
}
/// Sticky bottom action area.
///
/// `edge: true` lifts it onto white with a hairline above — used when the
/// content behind it scrolls, so the action never looks like the end of the
/// list. On a short screen the canvas variant keeps the page quiet.
class DmFooter extends StatelessWidget {
const DmFooter({
super.key,
required this.children,
this.edge = false,
this.background,
this.divider = false,
});
final List<Widget> children;
/// White bed, hairline above.
final bool edge;
final Color? background;
/// Retained for call-site compatibility; superseded by [edge].
final bool divider;
@override
Widget build(BuildContext context) {
final lifted = edge || divider;
return DecoratedBox(
decoration: BoxDecoration(
color: background ?? (lifted ? DmColors.surface : DmColors.canvas),
border: lifted
? const Border(top: BorderSide(color: DmColors.border))
: null,
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(height: 4),
children[i],
],
],
),
),
),
);
}
}
/// The crimson field at the top of a tab root.
///
/// Home, Orders and Account all open with one. It does three jobs at once —
/// it states where you are (eyebrow + title), it holds that destination's one
/// control (a segmented filter, an address switcher, a phone number), and it
/// gives the app a recognisable brand surface that the white canvas below can
/// then stay completely quiet.
///
/// The arcs are drawn, not an asset: they are struck from the same geometry as
/// the mark, they scale to any header height, and they cost no bytes.
class DmBrandHeader extends StatelessWidget {
const DmBrandHeader({
super.key,
required this.title,
this.eyebrow,
this.leading,
this.action,
this.child,
});
final String title;
final String? eyebrow;
/// Sits at the top-left of the header — the avatar on Account.
final Widget? leading;
/// Top-right — refresh, help.
final Widget? action;
/// The control that belongs to this destination, under the title.
final Widget? child;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
child: DecoratedBox(
// Lit from the top-left like every other brand object in the app, so
// the crimson field has a direction instead of sitting flat. The spread
// is deliberately narrow — a header that reads as a gradient rather
// than as a colour has stopped being the brand.
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.52, 1.0],
),
),
child: CustomPaint(
painter: const DmArcField(),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.headerPad,
2,
DmSpace.headerPad,
18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(
children: [
?leading,
const Spacer(),
?action,
],
),
),
const SizedBox(height: 10),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
const SizedBox(height: 8),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 18), child!],
],
),
),
),
),
),
);
}
}
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
/// uses. Decorative only — it never carries meaning, so it is not announced.
class DmArcField extends CustomPainter {
const DmArcField({this.opacity = 0.15});
final double opacity;
@override
void paint(Canvas canvas, Size size) {
// The arcs are authored on a 340-square field anchored to the top-right,
// then scaled — so the same geometry reads identically on a 210pt Orders
// header and a 260pt Account one.
const field = 340.0;
final scale = size.width / 390 * 1.0;
canvas.save();
canvas.translate(size.width - field * scale + 62 * scale, -72 * scale);
canvas.scale(scale);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale
..color = Colors.white.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint);
for (final (y1, y2, cx, cy) in const [
(300.0, 40.0, 60.0, 300.0),
(330.0, 60.0, 70.0, 330.0),
]) {
final path = Path()
..moveTo(-20, y1)
..cubicTo(cx, cy, field - 40, y2 + 140, field, y2);
canvas.drawPath(path, paint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity;
}
/// The segmented filter recessed into the brand header — Orders' Active /
/// Completed / Cancelled, with each tab's count.
///
/// The selected tab lifts out as a white pill and keeps its figure; the others
/// stay in the recess. One control, three states, no separate count row.
class DmSegmented extends StatelessWidget {
const DmSegmented({
super.key,
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
/// Null where a tab has no meaningful figure yet (still loading).
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
for (var i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: _Segment(
label: labels[i],
count: i < counts.length ? counts[i] : null,
selected: i == index,
onTap: () => onChanged(i),
),
),
],
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.brand : DmColors.onBrand3,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? DmColors.brand : Colors.white,
),
),
),
],
],
),
),
),
);
}
}
/// The recessed strip under a header title that carries one live fact — the
/// address a pickup is coming from, or the customer's phone number.
class DmHeaderTrack extends StatelessWidget {
const DmHeaderTrack({
super.key,
required this.child,
this.leading,
this.trailing,
this.onTap,
});
final Widget child;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 10)],
Expanded(child: child),
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
],
),
),
),
);
}
}
/// Large title for a screen that has no brand header.
class DmPageTitle extends StatelessWidget {
const DmPageTitle(this.text, {super.key, this.subtitle});
final String text;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(text, style: DmText.pageTitle),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: DmText.lede),
],
],
),
);
}
}
/// Sentence-case heading above a group of fields or rows.
class DmGroupHead extends StatelessWidget {
const DmGroupHead(this.text, {super.key, this.top = 24});
final String text;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: 10),
child: Text(text, style: DmText.sectionHead),
);
}
}
/// Screen heading and its supporting line — shared by every booking step.
class DmScreenHeader extends StatelessWidget {
const DmScreenHeader({super.key, required this.title, this.lede, this.leding});
final String title;
final String? lede;
/// Rich alternative to [lede].
final Widget? leding;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(title, style: DmText.title),
if (lede != null) ...[
const SizedBox(height: 8),
Text(lede!, style: DmText.lede),
],
if (leding != null) ...[const SizedBox(height: 10), leding!],
const SizedBox(height: 22),
],
);
}
}