Files
doormile_customer_app/lib/ui/widgets/chrome.dart
Thiru-tenext 8207e27a97 Booking and sign-in flow, and the offline build back under guard
Picks up where 0d66627 left off. Three things.

SIGN-IN, CUT TO THE QUESTION IT ASKS
It opened with a quarter-screen crimson hero carrying a lockup, a CUSTOMER
badge, a display-size promise and a sub-line, then a Phone/Email toggle, then a
labelled field, then a card explaining what a verification code is. Six things
to read before the one thing to do.

Nobody arrives at a sign-in screen needing to be sold the product. It is a
heading, a field and a button now, which is where Uber, Bolt and Porter put
them. The toggle became one quiet line under the field — choosing a method was
the first decision on the screen, before the customer had seen what was being
asked, and almost everyone uses the phone. The privacy card went: it explained
that a code would be sent, which the next screen demonstrates a second later.

`DmTextField.label` is nullable for this — "Enter your mobile number" above a
field captioned "Phone number" is one sentence printed twice.

BOOKING, DOWN TO ONE SCREENFUL
Landmark, recipient name and recipient phone are all optional and were all
drawn at the weight of the two fields that are not, putting six rows of "you
may skip this" between the address and the button. They fold behind one row
that counts what is filled in rather than just saying "optional".

Four crimson section heads became one. An accent used five times on a screen is
not an accent; crimson now marks the destination, which is the only choice that
changes the price.

Together those put the window, the package count and the CTA above the fold.

THE OFFLINE BUILD, BACK, UNDER TWO RULES
Deleted on 15 Sep after it cost two rounds of hunting for bookings in the admin
console that had never left the phone. That was not caused by the fake
existing — it was caused by a fake that did not announce itself and that
nothing stopped from shipping. Both are closed:

  * `useDevData` is false in a release whatever the defines say;
  * `describe` leads with DEV DATA (offline) and shows "no network" rather than
    a host the build never contacts.

It is opt-in — `flutter run` still talks to the real API — which is the
property whose absence caused the original mess. `devAutoLogin` is deliberately
false under FLUTTER_TEST so the widget tests keep driving the real entrance.

    flutter run --dart-define=DM_MOCK=true

86 tests green, analyze clean.

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

631 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.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.
class DmTopBar extends StatelessWidget 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;
@override
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
@override
Widget build(BuildContext context) {
final current = progress == null || steps == null
? 0
: (progress! * steps!).round().clamp(0, steps!);
return Material(
color: background,
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 12),
DmIconButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 6),
Expanded(
child: title == null
? const SizedBox.shrink()
: Text(
title!,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
letterSpacing: -0.31,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
child: DmStepBar(
progress: progress!,
steps: steps,
current: current,
label: stepLabel,
),
),
],
),
),
);
}
}
/// 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,
});
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;
@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 (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: ColoredBox(
color: DmColors.brand,
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),
],
);
}
}