Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
839 lines
26 KiB
Dart
839 lines
26 KiB
Dart
import 'package:flutter/material.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 head of a tab root: an eyebrow, a title, an optional action, and the
|
|
/// control that belongs to this destination.
|
|
///
|
|
/// ── It stopped being a crimson field ──
|
|
///
|
|
/// This was 260pt of solid brand on Home, Orders and Account — three of the
|
|
/// app's five screens opening on a wall of it, with white type, a tinted rose
|
|
/// for the secondary line, a recessed track for the address and a lightened
|
|
/// control on top. Every one of those tokens existed only to survive the
|
|
/// crimson, and the accent that is supposed to mean *do this* was the
|
|
/// background of half the product.
|
|
///
|
|
/// It is the canvas now, with ink type. What is left of the brand on these
|
|
/// screens is the sphere, the primary button, and a live dot — which is what
|
|
/// an accent is for.
|
|
///
|
|
/// The arcs survive at a fraction of their old opacity: they were struck from
|
|
/// the mark's geometry and they are the one thing here that was never the
|
|
/// colour doing the work.
|
|
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 DecoratedBox(
|
|
decoration: const BoxDecoration(color: DmColors.canvas),
|
|
child: CustomPaint(
|
|
painter: const DmArcField(opacity: 0.035, colour: DmColors.brand),
|
|
child: SafeArea(
|
|
bottom: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
DmSpace.pad,
|
|
2,
|
|
DmSpace.pad,
|
|
14,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SizedBox(
|
|
height: 48,
|
|
child: Row(children: [?leading, const Spacer(), ?action]),
|
|
),
|
|
const SizedBox(height: 6),
|
|
if (eyebrow != null) ...[
|
|
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
|
|
const SizedBox(height: 6),
|
|
],
|
|
Text(title, style: DmText.headerTitle),
|
|
if (child != null) ...[const SizedBox(height: 16), 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, this.colour = Colors.white});
|
|
|
|
final double opacity;
|
|
|
|
/// White on a crimson field; crimson, very faint, on the canvas.
|
|
final Color colour;
|
|
|
|
@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 = colour.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 || oldDelegate.colour != colour;
|
|
}
|
|
|
|
/// 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),
|
|
// A bordered white field, not a recess. `onBrandSunken` is 14%
|
|
// black, which on the canvas reads as a grey smudge rather than as
|
|
// something cut into the surface — a recess only works against a
|
|
// colour dark enough to be cut.
|
|
decoration: BoxDecoration(
|
|
color: DmColors.surface,
|
|
borderRadius: DmRadius.all(DmRadius.lg),
|
|
border: Border.all(color: DmColors.border),
|
|
),
|
|
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),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The wordmark bar that sits at the top of every tab root.
|
|
///
|
|
/// ── Why the app grew a permanent header ──
|
|
///
|
|
/// The three roots each began with their own arrangement: Home with a name,
|
|
/// Orders with a title, Account with another name. Nothing on any of them
|
|
/// said which app this was, and the two things a customer reaches for from
|
|
/// anywhere — find an order, see what changed — had no home at all.
|
|
///
|
|
/// One bar, on all three. The mark on the left with its dot, a search and a
|
|
/// bell on the right. It is 52pt of chrome that makes the three roots read as
|
|
/// one product rather than as three screens that happen to share a tab bar.
|
|
class DmAppBar extends StatelessWidget {
|
|
const DmAppBar({super.key, this.onSearch, this.onNotifications, this.unread = false});
|
|
|
|
final VoidCallback? onSearch;
|
|
final VoidCallback? onNotifications;
|
|
|
|
/// Puts the brand dot on the bell.
|
|
final bool unread;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad - 4, 2),
|
|
child: SizedBox(
|
|
height: 46,
|
|
child: Row(
|
|
children: [
|
|
const DmWordmark(),
|
|
const Spacer(),
|
|
_BarIcon(
|
|
icon: LucideIcons.search,
|
|
tooltip: 'Search orders',
|
|
onPressed: onSearch,
|
|
),
|
|
_BarIcon(
|
|
icon: LucideIcons.bell,
|
|
tooltip: 'Notifications',
|
|
dot: unread,
|
|
onPressed: onNotifications,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// "Doormile ·" — the name, then the mark's dot.
|
|
///
|
|
/// The dot is the whole logo at this size. A 100pt lockup in a 46pt bar would
|
|
/// be a picture of a logo; a word and a full stop in the brand colour is a
|
|
/// wordmark, which is what a bar wants.
|
|
class DmWordmark extends StatelessWidget {
|
|
const DmWordmark({super.key, this.size = 17});
|
|
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Semantics(
|
|
label: 'Doormile',
|
|
header: true,
|
|
excludeSemantics: true,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Doormile',
|
|
style: DmText.cardTitle.copyWith(
|
|
fontSize: size,
|
|
letterSpacing: -0.45,
|
|
fontWeight: FontWeight.w700,
|
|
fontVariations: const [FontVariation('wght', 700)],
|
|
),
|
|
),
|
|
SizedBox(width: size * 0.22),
|
|
Container(
|
|
width: size * 0.26,
|
|
height: size * 0.26,
|
|
decoration: const BoxDecoration(
|
|
color: DmColors.brand,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BarIcon extends StatelessWidget {
|
|
const _BarIcon({
|
|
required this.icon,
|
|
required this.tooltip,
|
|
this.onPressed,
|
|
this.dot = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String tooltip;
|
|
final VoidCallback? onPressed;
|
|
final bool dot;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: InkResponse(
|
|
onTap: onPressed,
|
|
radius: 22,
|
|
child: SizedBox(
|
|
width: 44,
|
|
height: 44,
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Icon(icon, size: 20, color: DmColors.ink),
|
|
if (dot)
|
|
Positioned(
|
|
top: 11,
|
|
right: 11,
|
|
child: Container(
|
|
width: 7,
|
|
height: 7,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.brand,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: DmColors.canvas, width: 1.5),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A status mark: a dot, a word, and a tint the colour of the state.
|
|
///
|
|
/// ── The shape the whole app now says "what is happening" in ──
|
|
///
|
|
/// It replaces three different objects: the bordered `DmPill`, the bare dot
|
|
/// and word on the flat order rows, and the hand-built chip on the live card.
|
|
/// One shape, three intensities of the same idea — a coloured dot, uppercase
|
|
/// micro-type, and six percent of the dot's colour behind them.
|
|
class DmStatusChip extends StatelessWidget {
|
|
const DmStatusChip({
|
|
super.key,
|
|
required this.label,
|
|
this.tone,
|
|
this.icon,
|
|
this.quiet = false,
|
|
});
|
|
|
|
final String label;
|
|
|
|
/// Defaults to the brand. `ok` for delivered, `ink4` for cancelled.
|
|
final Color? tone;
|
|
|
|
/// Replaces the dot — the lightning bolt on "EXPRESS PRIORITY".
|
|
final IconData? icon;
|
|
|
|
/// A finished state. Same shape, less of it.
|
|
final bool quiet;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colour = tone ?? DmColors.brand;
|
|
return Container(
|
|
padding: EdgeInsets.fromLTRB(icon == null ? 8 : 7, 4.5, 9, 5),
|
|
decoration: BoxDecoration(
|
|
color: colour.withValues(alpha: quiet ? 0.055 : 0.085),
|
|
borderRadius: DmRadius.all(DmRadius.chip),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (icon != null)
|
|
Icon(icon, size: 11, color: colour)
|
|
else
|
|
Container(
|
|
width: 5.5,
|
|
height: 5.5,
|
|
decoration: BoxDecoration(color: colour, shape: BoxShape.circle),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
label.toUpperCase(),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: DmText.eyebrow.copyWith(
|
|
fontSize: 9.5,
|
|
letterSpacing: 0.85,
|
|
color: quiet ? DmColors.ink3 : DmColors.ink,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|