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
756 lines
23 KiB
Dart
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),
|
|
],
|
|
);
|
|
}
|
|
}
|