Replace the customer app with Doormile CX

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>
This commit is contained in:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

375
lib/ui/widgets/buttons.dart Normal file
View File

@@ -0,0 +1,375 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../tokens.dart';
enum DmButtonKind {
/// Crimson, with the glow. The one committing action on a screen.
primary,
/// White with a hairline edge. A real action that is not *the* action —
/// "Share live tracking".
outline,
/// White with a crimson edge and crimson label. Brand-weighted, but not a
/// commitment — used where an outline would read as too neutral.
quiet,
/// A crimson wash. Rare; secondary actions inside a wash card.
soft,
/// No surface at all. The line under a primary action.
ghost,
}
/// The app's only button.
///
/// `block: true` fills the width (the default in footers). The disabled primary
/// does not fade the crimson — it goes to the sunken neutral, because a
/// half-opacity red still reads as "press me" and this design uses exactly one
/// red for exactly one meaning.
class DmButton extends StatefulWidget {
const DmButton({
super.key,
required this.label,
this.onPressed,
this.kind = DmButtonKind.primary,
this.block = true,
this.danger = false,
this.busy = false,
this.busyLabel,
this.icon,
this.iconLeading = false,
this.height,
});
final String label;
final VoidCallback? onPressed;
final DmButtonKind kind;
final bool block;
/// Renders the label in the danger colour (Cancel pickup).
final bool danger;
final bool busy;
final String? busyLabel;
/// Optional icon, e.g. an arrow on a forward action.
final IconData? icon;
/// Puts [icon] before the label. True for a pair of short actions — "Call",
/// "Message" — where the glyph is what the eye lands on first.
final bool iconLeading;
/// Overrides [DmSpace.button] — for the half-width pair inside a card.
final double? height;
@override
State<DmButton> createState() => _DmButtonState();
}
class _DmButtonState extends State<DmButton> {
bool _down = false;
bool get _enabled => widget.onPressed != null && !widget.busy;
@override
Widget build(BuildContext context) {
final Color background;
final Color foreground;
BoxBorder? border;
List<BoxShadow>? shadow;
double height = widget.height ?? DmSpace.button;
switch (widget.kind) {
case DmButtonKind.primary:
background = !_enabled
? DmColors.surfaceAlt
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
shadow = _enabled && !_down ? DmShadow.lift : null;
case DmButtonKind.outline:
background = _down ? DmColors.surfaceAlt : DmColors.surface;
foreground = widget.danger ? DmColors.danger : DmColors.ink;
border = Border.all(color: DmColors.border);
case DmButtonKind.quiet:
background = _down ? DmColors.brandSoft : DmColors.surface;
foreground = DmColors.brand;
border = Border.all(color: DmColors.brandLine);
case DmButtonKind.soft:
background = _down ? DmColors.brandLine : DmColors.brandSoft;
foreground = DmColors.brand;
case DmButtonKind.ghost:
background = Colors.transparent;
foreground = widget.danger ? DmColors.brand : DmColors.ink2;
height = widget.height ?? DmSpace.tap;
}
final content = Row(
mainAxisSize: widget.block ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.icon != null && widget.iconLeading && !widget.busy) ...[
Icon(widget.icon, size: 18, color: foreground),
const SizedBox(width: 9),
],
if (widget.busy) ...[
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation(foreground),
),
),
const SizedBox(width: 10),
],
Flexible(
child: Text(
widget.busy ? (widget.busyLabel ?? widget.label) : widget.label,
style: (widget.kind == DmButtonKind.ghost
? DmText.textButton
: DmText.button)
.copyWith(color: foreground),
textAlign: TextAlign.center,
),
),
if (widget.icon != null && !widget.iconLeading && !widget.busy) ...[
const SizedBox(width: 9),
Icon(widget.icon, size: 18, color: foreground),
],
],
);
// Presses translate rather than scale: the design's buttons sit on a
// hairline grid, and scaling a 1px border makes it shimmer.
final button = AnimatedSlide(
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: AnimatedContainer(
duration: DmMotion.fast,
constraints: BoxConstraints(minHeight: height),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: background,
border: border,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: shadow,
),
alignment: Alignment.center,
child: content,
),
);
return Semantics(
button: true,
enabled: _enabled,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _enabled ? (_) => setState(() => _down = true) : null,
onTapUp: _enabled ? (_) => setState(() => _down = false) : null,
onTapCancel: _enabled ? () => setState(() => _down = false) : null,
onTap: _enabled
? () {
HapticFeedback.selectionClick();
widget.onPressed!();
}
: null,
child: widget.block ? SizedBox(width: double.infinity, child: button) : button,
),
);
}
}
/// Half-width action inside a card — Call / Message on the rider row.
class DmMiniButton extends StatelessWidget {
const DmMiniButton({
super.key,
required this.label,
required this.icon,
this.onPressed,
this.filled = false,
});
final String label;
final IconData icon;
final VoidCallback? onPressed;
/// The one of the pair that is the expected action.
final bool filled;
@override
Widget build(BuildContext context) {
return Expanded(
child: DmButton(
label: label,
icon: icon,
iconLeading: true,
height: 42,
kind: filled ? DmButtonKind.primary : DmButtonKind.outline,
onPressed: onPressed,
),
);
}
}
/// Small quiet pill inside a card ("Change", when it needs a surface).
class DmChipButton extends StatelessWidget {
const DmChipButton({super.key, required this.label, this.onPressed, this.icon});
final String label;
final VoidCallback? onPressed;
final IconData? icon;
@override
Widget build(BuildContext context) {
return Material(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
child: InkWell(
onTap: onPressed,
borderRadius: DmRadius.all(DmRadius.xs),
child: Container(
constraints: const BoxConstraints(minHeight: 34),
padding: EdgeInsets.only(
left: icon == null ? 12 : 9,
right: 12,
top: 6,
bottom: 6,
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: DmColors.ink2),
const SizedBox(width: 5),
],
Text(label, style: DmText.label),
],
),
),
),
);
}
}
/// Text-only action — the "Change" / "Edit" / "Copy" at the end of a card row.
///
/// Its padding is negative-margined back out, so the label sits flush with the
/// card's right edge while the tap target still clears 40pt.
class DmTextAction extends StatelessWidget {
const DmTextAction({
super.key,
required this.label,
this.onPressed,
this.color = DmColors.brand,
this.minHeight = 40,
this.icon,
});
final String label;
final VoidCallback? onPressed;
final Color color;
final double minHeight;
final IconData? icon;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onPressed == null
? null
: () {
HapticFeedback.selectionClick();
onPressed!();
},
child: Container(
constraints: BoxConstraints(minHeight: minHeight),
padding: const EdgeInsets.symmetric(horizontal: 6),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
],
// Flexible, because this sits at the end of rows whose other half
// is an address or a window that has first claim on the width.
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: color),
),
),
],
),
),
),
);
}
}
/// The chrome affordance: a rounded square, not a disc.
class DmIconButton extends StatelessWidget {
const DmIconButton({
super.key,
required this.icon,
this.onPressed,
this.background = DmColors.surface,
this.foreground = DmColors.ink,
this.border = DmColors.border,
this.size = 40,
this.radius = DmRadius.sm,
this.tooltip,
});
final IconData icon;
final VoidCallback? onPressed;
final Color background;
final Color foreground;
/// Transparent on the crimson header, where the fill carries the shape.
final Color border;
final double size;
final double radius;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: tooltip,
child: SizedBox(
width: DmSpace.tap,
height: DmSpace.tap,
child: Center(
child: Material(
color: background,
borderRadius: DmRadius.all(radius),
child: InkWell(
borderRadius: DmRadius.all(radius),
onTap: onPressed,
child: Ink(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: DmRadius.all(radius),
border: Border.all(color: border),
),
child: Icon(icon, size: 19, color: foreground),
),
),
),
),
),
);
}
}

470
lib/ui/widgets/cards.dart Normal file
View File

@@ -0,0 +1,470 @@
import 'package:flutter/material.dart';
import '../tokens.dart';
/// How loudly a card speaks.
enum DmCardTone {
/// White on the canvas, hairline edge. Almost everything.
plain,
/// Filled crimson. **One per screen, at most** — it is the screen's answer
/// to "what is happening right now": the live ETA, the booked confirmation,
/// the delivered summary. A second one on the same screen and neither wins.
fill,
/// A crimson wash. Supporting brand information that must not shout: the
/// address being collected from, the rider on the way, a reassurance.
wash,
/// The canvas itself, edged. For content that is deliberately inert — the
/// build panel on Account.
sunken,
}
/// A bordered panel that stacks rows and rules them apart.
///
/// This replaces the shadowed card the app used to draw everywhere. The rule
/// between rows belongs to the card, not to the rows: a row does not know
/// whether it is first, and a card that is handed one child draws no rule at
/// all, so the same [DmCardCell] works alone or in a stack of five.
class DmCard extends StatelessWidget {
const DmCard({
super.key,
required this.children,
this.tone = DmCardTone.plain,
this.rail = false,
this.onTap,
});
final List<Widget> children;
final DmCardTone tone;
/// A 3pt crimson spine down the leading edge — marks the one row in a list
/// that needs the customer's attention now.
final bool rail;
final VoidCallback? onTap;
Color get _background => switch (tone) {
DmCardTone.plain => DmColors.surface,
DmCardTone.fill => DmColors.brand,
DmCardTone.wash => DmColors.brandSoft,
DmCardTone.sunken => DmColors.canvas,
};
Color get _edge => switch (tone) {
DmCardTone.plain => DmColors.border,
DmCardTone.fill => DmColors.brand,
DmCardTone.wash => DmColors.brandLine,
DmCardTone.sunken => DmColors.border,
};
/// The rule between two rows of the same card. On crimson it is a lightened
/// white rather than a darker line: a dark rule on a saturated field reads as
/// a crack, a light one as a seam.
Color get _rule => switch (tone) {
DmCardTone.plain => DmColors.hairline,
DmCardTone.fill => const Color(0x2EFFFFFF),
DmCardTone.wash => DmColors.brandLine,
DmCardTone.sunken => DmColors.border,
};
@override
Widget build(BuildContext context) {
// The edge is drawn in the FOREGROUND, over the children.
//
// As a background border it was painted first and then covered: a cell with
// its own tint — the washed status strip at the head of an order card, the
// crimson rows on the booked screen — filled the whole box, border included,
// and the card lost its outline exactly where it needed one most. A
// foreground decoration paints last, so every cell is clipped inside an
// edge that is always visible.
final card = DecoratedBox(
decoration: BoxDecoration(
color: _background,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: _edge),
),
child: ClipRRect(
borderRadius: DmRadius.all(DmRadius.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Container(height: 1, color: _rule),
children[i],
],
],
),
),
),
);
final railed = rail
? Container(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.md),
),
padding: const EdgeInsets.only(left: 3),
child: card,
)
: card;
if (onTap == null) return railed;
return Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: railed,
),
);
}
}
/// One padded cell inside a [DmCard] — the design's `.p`.
class DmCardCell extends StatelessWidget {
const DmCardCell({
super.key,
required this.child,
this.padding = const EdgeInsets.fromLTRB(16, 15, 16, 15),
this.background,
this.onTap,
});
final Widget child;
final EdgeInsets padding;
/// Tints this cell alone — a wash strip at the head of a plain card.
final Color? background;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final body = Padding(padding: padding, child: child);
final painted = background == null
? body
: ColoredBox(color: background!, child: body);
if (onTap == null) return painted;
return Material(
color: background ?? Colors.transparent,
child: InkWell(
onTap: onTap,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.6),
child: body,
),
);
}
}
/// The common cell: a glyph or tile, a title with an optional second line, and
/// something on the right — a value, a chevron, an inline action.
class DmCardRow extends StatelessWidget {
const DmCardRow({
super.key,
required this.title,
this.eyebrow,
this.subtitle,
this.leading,
this.trailing,
this.tone = DmCardTone.plain,
this.onTap,
this.titleStyle,
this.subtitleStyle,
this.background,
this.crossAlign = CrossAxisAlignment.center,
});
final String title;
/// A micro-label above the title — "COLLECTING FROM".
final String? eyebrow;
final String? subtitle;
final Widget? leading;
final Widget? trailing;
/// The tone of the *card this row sits in*, so the row can pick text colours
/// that survive a crimson field.
final DmCardTone tone;
final VoidCallback? onTap;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final Color? background;
final CrossAxisAlignment crossAlign;
@override
Widget build(BuildContext context) {
final onFill = tone == DmCardTone.fill;
final titleColor = onFill ? Colors.white : DmColors.ink;
final subColor = switch (tone) {
DmCardTone.fill => DmColors.onBrand2,
DmCardTone.wash => DmColors.brandPress,
_ => DmColors.ink2,
};
final eyebrowColor = switch (tone) {
DmCardTone.fill => DmColors.onBrand3,
DmCardTone.wash => DmColors.brand,
_ => DmColors.ink3,
};
return DmCardCell(
background: background,
onTap: onTap,
child: Row(
crossAxisAlignment: crossAlign,
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow != null) ...[
Text(
eyebrow!.toUpperCase(),
style: DmText.eyebrow.copyWith(color: eyebrowColor),
),
const SizedBox(height: 3),
],
Text(
title,
style: (titleStyle ?? DmText.cardTitle).copyWith(
color: titleStyle?.color ?? titleColor,
),
),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(
subtitle!,
style: (subtitleStyle ?? DmText.small).copyWith(
color: subtitleStyle?.color ?? subColor,
),
),
],
],
),
),
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
],
),
);
}
}
/// How a [DmTile] is filled.
enum DmTileTone { sunken, wash, brand, onFill }
/// The 38pt rounded square that holds a glyph or a step number.
///
/// Square, not a circle: the cards, fields and buttons around it are all cut
/// with the same small radius, and a disc in the middle of that is the one
/// shape that has to be explained.
class DmTile extends StatelessWidget {
const DmTile({
super.key,
this.icon,
this.label,
this.tone = DmTileTone.sunken,
this.size = 38,
}) : assert(icon != null || label != null, 'a tile shows a glyph or a number');
final IconData? icon;
/// A step number, for the "how it works" sequence.
final String? label;
final DmTileTone tone;
final double size;
@override
Widget build(BuildContext context) {
final (bg, fg) = switch (tone) {
DmTileTone.sunken => (DmColors.surfaceAlt, DmColors.ink),
DmTileTone.wash => (DmColors.brandSoft, DmColors.brand),
DmTileTone.brand => (DmColors.brand, Colors.white),
DmTileTone.onFill => (DmColors.onBrandRaise, Colors.white),
};
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: icon != null
? Icon(icon, size: size * 0.47, color: fg)
: Text(
label!,
style: DmText.label.copyWith(
fontSize: 12.5,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
color: fg,
),
),
);
}
}
/// A figure and its caption, sat in its own bordered box. Used in pairs.
class DmMetric extends StatelessWidget {
const DmMetric({
super.key,
required this.figure,
required this.caption,
this.tone = DmCardTone.plain,
});
final String figure;
final String caption;
final DmCardTone tone;
@override
Widget build(BuildContext context) {
final (bg, edge, figureColor, captionColor) = switch (tone) {
DmCardTone.fill => (
DmColors.brand,
DmColors.brand,
Colors.white,
DmColors.onBrand3,
),
DmCardTone.wash => (
DmColors.brandSoft,
DmColors.brandLine,
DmColors.brand,
DmColors.brandPress,
),
_ => (DmColors.surface, DmColors.border, DmColors.ink, DmColors.ink3),
};
return Container(
padding: const EdgeInsets.fromLTRB(15, 14, 15, 14),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: edge),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
figure,
style: DmText.display.copyWith(fontSize: 25, height: 1, color: figureColor),
),
const SizedBox(height: 5),
Text(
caption,
style: DmText.value.copyWith(fontSize: 12.5, color: captionColor),
),
],
),
);
}
}
/// A label and its figure on one baseline — the fare breakdown, the build
/// panel. The label may wrap; the figure never does.
class DmFareLine extends StatelessWidget {
const DmFareLine({
super.key,
required this.label,
required this.value,
this.strong = false,
this.mono = true,
});
final String label;
final String value;
/// The total, rather than one of the lines that make it up.
final bool strong;
/// Figures are mono by default — they are meant to be compared down a column.
final bool mono;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
label,
style: DmText.small.copyWith(
fontWeight: strong ? FontWeight.w600 : FontWeight.w500,
),
),
),
const SizedBox(width: 12),
Text(
value,
maxLines: 1,
style: mono
? DmText.mono.copyWith(
fontSize: strong ? 15 : 13.5,
color: strong ? DmColors.brand : DmColors.ink,
)
: DmText.value.copyWith(
color: strong ? DmColors.brand : DmColors.ink,
),
),
],
),
);
}
}
/// The uppercase micro-label that heads a group of cards.
class DmMicroHead extends StatelessWidget {
const DmMicroHead(
this.text, {
super.key,
this.brand = true,
this.top = 20,
this.bottom = 10,
this.trailing,
});
final String text;
/// Crimson heads brand content; neutral heads everything else.
final bool brand;
final double top;
final double bottom;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: bottom),
child: Row(
children: [
Expanded(
child: Text(
text.toUpperCase(),
style: DmText.eyebrow.copyWith(
color: brand ? DmColors.brand : DmColors.ink3,
),
),
),
?trailing,
],
),
);
}
}

655
lib/ui/widgets/chrome.dart Normal file
View File

@@ -0,0 +1,655 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.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(),
// ── A build on dev data says so, on every screen ──
//
// Twice now a booking made offline has been hunted for
// in the admin console. The Account screen named the
// mode, but nobody goes to Account before booking. It
// belongs where the booking is made.
if (AppConfig.useDevData) ...[
Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0x80FFFFFF)),
borderRadius: DmRadius.all(5),
),
child: Text(
'OFFLINE DATA',
style: DmText.eyebrow.copyWith(
fontSize: 10,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(width: 10),
],
?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),
],
);
}
}

View File

@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import '../tokens.dart';
/// Dark pill toast, matching the prototype's transient confirmations.
class DmToast {
DmToast._();
static OverlayEntry? _entry;
static void show(BuildContext context, String message) {
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
_entry?.remove();
final entry = OverlayEntry(
builder: (context) => _ToastBody(message: message),
);
_entry = entry;
overlay.insert(entry);
Future<void>.delayed(const Duration(milliseconds: 2400), () {
if (_entry == entry) {
entry.remove();
_entry = null;
}
});
}
/// Called when the app is torn down in tests.
static void dismiss() {
_entry?.remove();
_entry = null;
}
}
class _ToastBody extends StatefulWidget {
const _ToastBody({required this.message});
final String message;
@override
State<_ToastBody> createState() => _ToastBodyState();
}
class _ToastBodyState extends State<_ToastBody>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 240),
)..forward();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Positioned(
left: 20,
right: 20,
bottom: 96 + bottom,
child: FadeTransition(
opacity: _c,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)),
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 11,
),
decoration: BoxDecoration(
color: const Color(0xF0191716),
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Text(
widget.message,
textAlign: TextAlign.center,
style: DmText.label.copyWith(
fontSize: 13.5,
color: Colors.white,
),
),
),
),
),
),
),
);
}
}
/// Bottom sheet with the prototype's grip, radius and padding.
Future<T?> showDmSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
bool isScrollControlled = true,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
backgroundColor: DmColors.canvas,
barrierColor: DmColors.scrim,
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(DmRadius.xl)),
),
clipBehavior: Clip.antiAlias,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.86,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
10,
DmSpace.pad,
20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _SheetGrip(),
Flexible(child: builder(context)),
],
),
),
),
),
),
);
}
class _SheetGrip extends StatelessWidget {
const _SheetGrip();
@override
Widget build(BuildContext context) {
return Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(top: 2, bottom: 18),
decoration: BoxDecoration(
color: DmColors.border,
borderRadius: DmRadius.all(2),
),
);
}
}
/// Sheet heading + supporting line.
class DmSheetHeader extends StatelessWidget {
const DmSheetHeader({super.key, required this.title, this.subtitle});
final String title;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: DmText.sheetTitle),
if (subtitle != null) ...[
const SizedBox(height: 3),
Text(subtitle!, style: DmText.tiny),
],
const SizedBox(height: 18),
],
);
}
}

323
lib/ui/widgets/inputs.dart Normal file
View File

@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'cards.dart';
/// Labelled field.
///
/// White with a hairline at rest, and on focus the edge goes crimson with a
/// 3pt wash ring around it. The ring is the whole reason this is a container
/// rather than an `InputDecoration`: it is a spread shadow in the brand's
/// lightest tint, which no `InputBorder` can draw, and it is what makes the
/// focused field read as lit rather than merely outlined.
///
/// [optional] appends the grey marker that keeps skippable destination and
/// recipient fields honestly signposted.
class DmTextField extends StatefulWidget {
const DmTextField({
super.key,
required this.label,
required this.controller,
this.hint,
this.optional = false,
this.keyboardType,
this.maxLength,
this.maxLines = 1,
this.prefix,
this.textInputAction,
this.onSubmitted,
this.autofocus = false,
this.digitsOnly = false,
this.mono = false,
this.onClear,
});
final String label;
final TextEditingController controller;
final String? hint;
final bool optional;
final TextInputType? keyboardType;
final int? maxLength;
final int maxLines;
final String? prefix;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
final bool digitsOnly;
/// Sets the value in mono — a pincode or a phone number, where the customer
/// checks the digits back.
final bool mono;
/// Shows a clear button on the right when non-null.
final VoidCallback? onClear;
@override
State<DmTextField> createState() => _DmTextFieldState();
}
class _DmTextFieldState extends State<DmTextField> {
final FocusNode _node = FocusNode();
@override
void initState() {
super.initState();
_node.addListener(() => setState(() {}));
}
@override
void dispose() {
_node.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final focused = _node.hasFocus;
final textStyle = widget.mono
? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink)
: DmText.field;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 8),
AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
constraints: BoxConstraints(
minHeight: widget.maxLines > 1 ? 0 : DmSpace.field,
),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(
color: focused ? DmColors.brand : DmColors.border,
),
boxShadow: focused
? const [
BoxShadow(
color: DmColors.brandSoft,
spreadRadius: 3,
blurRadius: 0,
),
]
: null,
),
child: Row(
crossAxisAlignment: widget.maxLines > 1
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
children: [
if (widget.prefix != null) ...[
Padding(
padding: const EdgeInsets.fromLTRB(15, 0, 12, 0),
child: Text(
widget.prefix!,
style: DmText.field.copyWith(
color: DmColors.ink2,
fontWeight: FontWeight.w700,
),
),
),
// A hairline between the country code and the number: they
// are two different things and reading them as one string is
// how a digit gets lost.
Container(width: 1, height: 22, color: DmColors.border),
],
Expanded(
child: TextField(
controller: widget.controller,
focusNode: _node,
keyboardType: widget.keyboardType,
maxLines: widget.maxLines,
autofocus: widget.autofocus,
textInputAction: widget.textInputAction,
onSubmitted: widget.onSubmitted,
cursorColor: DmColors.brand,
cursorRadius: const Radius.circular(2),
style: textStyle,
inputFormatters: [
if (widget.maxLength != null)
LengthLimitingTextInputFormatter(widget.maxLength),
if (widget.digitsOnly)
FilteringTextInputFormatter.digitsOnly,
],
decoration: InputDecoration(
isDense: true,
hintText: widget.hint,
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
filled: false,
counterText: '',
contentPadding: EdgeInsets.fromLTRB(
widget.prefix == null ? 15 : 12,
widget.maxLines > 1 ? 15 : 14,
8,
widget.maxLines > 1 ? 15 : 14,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
if (widget.onClear != null)
Padding(
padding: const EdgeInsets.only(right: 6),
child: InkWell(
onTap: widget.onClear,
borderRadius: DmRadius.all(DmRadius.xs),
child: const SizedBox(
width: 34,
height: 34,
child: Icon(LucideIcons.x, size: 17, color: DmColors.ink3),
),
),
),
],
),
),
],
),
);
}
}
/// A settings row: label left, value or control right.
class DmRow extends StatelessWidget {
const DmRow({
super.key,
required this.label,
this.value,
this.trailing,
this.onTap,
this.showChevron = false,
this.icon,
});
final String label;
final String? value;
final Widget? trailing;
final VoidCallback? onTap;
final bool showChevron;
/// The glyph in the row's leading tile. Account's rows all carry one, so the
/// list can be scanned by shape before it is read.
final IconData? icon;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
child: Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
child: Row(
children: [
if (icon != null) ...[
DmTile(icon: icon, tone: DmTileTone.wash),
const SizedBox(width: 12),
],
// The label is what the row IS, so it never wraps and never
// gives up width; the value is context and ellipsizes. A saved
// address as a value would otherwise push "Saved addresses" onto
// two lines.
Expanded(
flex: 3,
child: Text(
label,
style: DmText.cardTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (value != null) ...[
const SizedBox(width: 10),
Flexible(
flex: 2,
child: Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
?trailing,
if (showChevron) ...[
const SizedBox(width: 8),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.ink4,
),
],
],
),
),
),
);
}
}
/// Card that rules a group of [DmRow]s together.
class DmRowGroup extends StatelessWidget {
const DmRowGroup({super.key, required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) => DmCard(children: children);
}
/// Switch in brand colours.
class DmSwitch extends StatelessWidget {
const DmSwitch({super.key, required this.value, required this.onChanged});
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Switch.adaptive(
value: value,
onChanged: (v) {
HapticFeedback.selectionClick();
onChanged(v);
},
activeThumbColor: Colors.white,
activeTrackColor: DmColors.brand,
inactiveThumbColor: Colors.white,
inactiveTrackColor: DmColors.border,
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
);
}
}

View File

@@ -0,0 +1,454 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../tokens.dart';
import 'map_tiles.dart';
/// The pickup map: a real, pannable map with a pin fixed to the centre.
///
/// The pin never moves — the map moves under it, the way every ride-hailing
/// app does it. When the map settles, [onPinMoved] fires with the coordinates
/// under the pin so the caller can reverse-geocode them.
class DmMapPanel extends StatefulWidget {
const DmMapPanel({
super.key,
required this.lat,
required this.lng,
this.height,
this.loading = false,
this.interactive = true,
this.zoom = 16.5,
this.deviceLat,
this.deviceLng,
this.deviceAccuracy,
this.onLocate,
this.onPinMoveStart,
this.onPinMoved,
this.borderRadius = const BorderRadius.all(Radius.circular(DmRadius.lg)),
});
/// Where the pin sits. Changing these recentres the map, unless the customer
/// is mid-gesture — their drag always wins.
final double lat;
final double lng;
/// Fixed height, or null to fill whatever the parent gives it — which is how
/// it sits inside the pickup card.
final double? height;
/// True while we are still resolving a position; dims the pin.
final bool loading;
/// False makes the map a picture: no dragging, no pin lift, no callbacks.
final bool interactive;
final double zoom;
/// Where the customer is, drawn as the blue dot. Null hides it — a refused
/// permission should show no dot rather than a dot in the wrong place.
///
/// Distinct from [lat]/[lng], which is where the *pickup pin* is: after a
/// drag the two separate, which is exactly the information the dot carries.
final double? deviceLat;
final double? deviceLng;
/// Horizontal accuracy in metres. Drawn as a halo at true map scale, so it
/// shrinks as the customer zooms in. Null draws the dot alone.
final double? deviceAccuracy;
/// Shows the "my location" control when set.
final VoidCallback? onLocate;
/// Fires the moment a drag begins, so the address can go stale-quiet.
final VoidCallback? onPinMoveStart;
/// Fires once the map has come to rest under the pin.
final void Function(double lat, double lng)? onPinMoved;
final BorderRadius borderRadius;
@override
State<DmMapPanel> createState() => _DmMapPanelState();
}
class _DmMapPanelState extends State<DmMapPanel> with TickerProviderStateMixin {
final MapController _map = MapController();
/// Raises the pin off the map while the customer drags.
late final AnimationController _lift = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 180),
);
late final AnimationController _glide = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 420),
);
Timer? _settle;
bool _ready = false;
bool _dragging = false;
/// The last position the parent knows about — either what it handed us or
/// what we last reported. Used to tell an external recentre from an echo of
/// our own callback.
late LatLng _known = LatLng(widget.lat, widget.lng);
@override
void dispose() {
_settle?.cancel();
_lift.dispose();
_glide.dispose();
_map.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant DmMapPanel old) {
super.didUpdateWidget(old);
if (widget.lat == old.lat && widget.lng == old.lng) return;
final target = LatLng(widget.lat, widget.lng);
// Our own reverse-geocode coming back, or a genuinely new place?
if (_metres(target, _known) < 20) {
_known = target;
return;
}
_known = target;
if (!_dragging) _glideTo(target);
}
/// flutter_map moves instantly; a short eased glide keeps "locate me" and a
/// searched address from teleporting.
void _glideTo(LatLng target) {
if (!_ready) return;
final from = _map.camera.center;
final fromZoom = _map.camera.zoom;
_glide
..stop()
..reset();
void tick() {
final t = Curves.easeInOutCubic.transform(_glide.value);
_map.move(
LatLng(
from.latitude + (target.latitude - from.latitude) * t,
from.longitude + (target.longitude - from.longitude) * t,
),
fromZoom + (widget.zoom - fromZoom) * t,
);
}
_glide.addListener(tick);
_glide.forward().whenComplete(() => _glide.removeListener(tick));
}
void _onEvent(MapEvent event) {
if (!widget.interactive) return;
if (event is MapEventMoveStart ||
event is MapEventFlingAnimationStart ||
event is MapEventDoubleTapZoomStart) {
_settle?.cancel();
if (!_dragging) {
_dragging = true;
_lift.forward();
widget.onPinMoveStart?.call();
}
return;
}
if (event is MapEventMoveEnd ||
event is MapEventFlingAnimationEnd ||
event is MapEventDoubleTapZoomEnd) {
_dragging = false;
_lift.reverse();
final centre = event.camera.center;
// A flick that ends and restarts should resolve once, not twice.
_settle?.cancel();
_settle = Timer(const Duration(milliseconds: 320), () {
if (!mounted) return;
_known = centre;
widget.onPinMoved?.call(centre.latitude, centre.longitude);
});
}
}
static double _metres(LatLng a, LatLng b) {
const earth = 6371000.0;
double rad(double d) => d * math.pi / 180;
final dLat = rad(b.latitude - a.latitude);
final dLng = rad(b.longitude - a.longitude);
final h = math.sin(dLat / 2) * math.sin(dLat / 2) +
math.cos(rad(a.latitude)) *
math.cos(rad(b.latitude)) *
math.sin(dLng / 2) *
math.sin(dLng / 2);
return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h));
}
/// The customer's own position: an accuracy halo at map scale with the dot
/// on top. Returns null when there is no fix to draw.
Widget? get _deviceLayers {
final lat = widget.deviceLat;
final lng = widget.deviceLng;
if (lat == null || lng == null) return null;
final at = LatLng(lat, lng);
final accuracy = widget.deviceAccuracy;
return Stack(
children: [
if (accuracy != null && accuracy > 0)
CircleLayer(
circles: [
CircleMarker(
point: at,
// True metres, so the halo tells the truth at every zoom.
radius: accuracy,
useRadiusInMeter: true,
color: DmColors.locationHalo,
borderColor: DmColors.locationDot.withValues(alpha: 0.28),
borderStrokeWidth: 1,
),
],
),
MarkerLayer(
markers: [
Marker(
key: const ValueKey('deviceLocationDot'),
point: at,
width: 22,
height: 22,
child: const _DeviceDot(),
),
],
),
],
);
}
@override
Widget build(BuildContext context) {
final map = ClipRRect(
borderRadius: widget.borderRadius,
child: ColoredBox(
color: DmMapTiles.ground,
child: Stack(
children: [
Positioned.fill(
child: FlutterMap(
mapController: _map,
options: MapOptions(
initialCenter: LatLng(widget.lat, widget.lng),
initialZoom: widget.zoom,
minZoom: 4,
maxZoom: 19,
backgroundColor: DmMapTiles.ground,
onMapReady: () => _ready = true,
onMapEvent: _onEvent,
interactionOptions: InteractionOptions(
// North stays up: rotation buys nothing here and makes
// the fixed pin read as broken.
flags: widget.interactive
? InteractiveFlag.drag |
InteractiveFlag.flingAnimation |
InteractiveFlag.pinchMove |
InteractiveFlag.pinchZoom |
InteractiveFlag.doubleTapZoom
: InteractiveFlag.none,
),
),
children: [
DmMapTiles.layer(context),
?_deviceLayers,
],
),
),
// The pin is an overlay, not a marker: it belongs to the screen,
// not to a coordinate.
Center(
child: IgnorePointer(
child: AnimatedOpacity(
opacity: widget.loading ? 0.45 : 1,
duration: DmMotion.base,
child: AnimatedBuilder(
animation: _lift,
builder: (context, child) => _CentrePin(lift: _lift.value),
),
),
),
),
if (widget.onLocate != null)
Positioned(
right: 12,
bottom: 12,
child: _LocateButton(
busy: widget.loading,
onTap: widget.onLocate!,
),
),
const Positioned(left: 10, bottom: 10, child: DmMapAttribution()),
],
),
),
);
return widget.height == null
? map
: SizedBox(height: widget.height, child: map);
}
}
/// The fixed centre pin. [lift] 0..1 raises it and shrinks its shadow, so a
/// drag feels like picking the pin up rather than dragging the ground.
class _CentrePin extends StatelessWidget {
const _CentrePin({required this.lift});
final double lift;
@override
Widget build(BuildContext context) {
final eased = Curves.easeOutCubic.transform(lift);
return SizedBox(
width: 60,
height: 74,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
// Ground shadow — the anchor point the pin actually refers to.
Positioned(
bottom: 8,
child: Transform.scale(
scale: 1 - eased * 0.35,
child: Container(
width: 18,
height: 6,
decoration: BoxDecoration(
color: DmColors.ink.withValues(alpha: 0.22 - eased * 0.1),
borderRadius: BorderRadius.circular(3),
),
),
),
),
Positioned(
bottom: 10 + eased * 12,
child: const _PinHead(),
),
],
),
);
}
}
/// "You are here". White ring so it stays legible over any cartography, and a
/// soft shadow so it sits on the map rather than in it.
class _DeviceDot extends StatelessWidget {
const _DeviceDot();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: DmColors.locationDot,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: const [
BoxShadow(
color: Color(0x3D14100F),
blurRadius: 6,
offset: Offset(0, 2),
),
],
),
),
);
}
}
class _PinHead extends StatelessWidget {
const _PinHead();
@override
Widget build(BuildContext context) {
return const Stack(
alignment: Alignment.topCenter,
children: [
Icon(
LucideIcons.mapPin,
size: 46,
color: DmColors.brand,
shadows: [
Shadow(
color: Color(0x408F0F06),
blurRadius: 12,
offset: Offset(0, 5),
),
],
),
Positioned(
top: 12,
child: CircleAvatar(radius: 5.5, backgroundColor: Colors.white),
),
],
);
}
}
class _LocateButton extends StatelessWidget {
const _LocateButton({required this.onTap, this.busy = false});
final VoidCallback onTap;
final bool busy;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
boxShadow: DmShadow.float,
),
child: Material(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
child: InkWell(
onTap: busy ? null : onTap,
borderRadius: DmRadius.all(DmRadius.md),
child: Container(
width: DmSpace.tap,
height: DmSpace.tap,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: DmColors.line),
),
child: busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: DmColors.brand,
),
)
: const Icon(
LucideIcons.locateFixed,
size: 22,
color: DmColors.ink,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../data/map_config.dart';
import '../tokens.dart';
/// Turns the configured tile provider ([DmMapConfig]) into a flutter_map layer.
///
/// The one bridge between map configuration and map rendering: no widget in
/// this app knows a tile URL, a subdomain list or an API key, and changing
/// provider never reaches a screen.
class DmMapTiles {
DmMapTiles._();
/// What shows through before a tile paints, or when one never arrives.
static const ground = Color(0xFFF0EDE9);
static DmMapConfig get config => DmMapConfig.instance;
static TileLayer layer(
BuildContext context, {
DmMapStyle style = DmMapStyle.streets,
}) {
final map = config;
return TileLayer(
urlTemplate: map.urlFor(style),
subdomains: map.subdomains,
// Identifies the app to the provider so our traffic is attributable and
// can be rate-limited or allow-listed properly.
userAgentPackageName: DmMapConfig.packageName,
tileProvider: NetworkTileProvider(
headers: {'User-Agent': map.userAgent},
// A failed tile is a cosmetic event — the ground colour covers it.
silenceExceptions: true,
),
retinaMode: map.retina && RetinaMode.isHighDensity(context),
maxNativeZoom: map.maxNativeZoom,
// Defaults, stated so they are not "tuned" upward later: the app fetches
// only the tiles it is about to draw plus the standard render margin.
// Bulk prefetching or scraping a provider's tiles is out of bounds.
keepBuffer: 2,
panBuffer: 1,
tileDisplay: const TileDisplay.fadeIn(
duration: Duration(milliseconds: 160),
),
// Offline, or under `flutter test`, tiles simply never arrive.
errorTileCallback: (_, _, _) {},
);
}
}
/// The credit line the tile licence requires. Small, quiet, and on every map —
/// its text comes from the provider, so it stays correct after a swap.
class DmMapAttribution extends StatelessWidget {
const DmMapAttribution({super.key, this.dark = false});
/// Slightly stronger contrast for maps with busy cartography behind it.
final bool dark;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: DmColors.surface.withValues(alpha: dark ? 0.92 : 0.82),
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
DmMapTiles.config.attribution,
style: DmText.small.copyWith(fontSize: 9.5, color: DmColors.ink3),
),
),
);
}
}

View File

@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../../data/models.dart';
import '../format.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'cards.dart';
/// The customer's journey: seven milestones, not nine backend states.
///
/// Operational detail — "Miler on the way", "Order created" — is shown as
/// context beneath the milestone that is currently happening, so a transition
/// informs without becoming another permanent row.
///
/// ── Newest first, and only as many as matter ──
///
/// The list used to run oldest-to-newest, full height, every time: seven rows
/// of which four were grey placeholders for things that have not happened. On
/// a screen whose job is "where is my parcel right now", the answer was at the
/// bottom, below the scroll. It now leads with what just happened and, when
/// [maxRows] is set, keeps the rest behind one line — the same compression the
/// design applies to the live tracking card.
class DmMilestones extends StatelessWidget {
const DmMilestones({
super.key,
required this.booking,
this.stage,
this.title = 'Recent updates',
this.maxRows,
this.onExpand,
});
final Booking booking;
/// Journey to show. Defaults to the pickup's own stage; a destination order
/// passes its own.
final JourneyStage? stage;
final String title;
/// Show at most this many rows, newest first. Null shows the whole journey.
final int? maxRows;
/// Tapped from the "All n stages" action. Null hides the action.
final VoidCallback? onExpand;
@override
Widget build(BuildContext context) {
final current = stage ?? booking.stage;
final currentMilestone = CustomerMilestone.of(current);
final milestones = CustomerMilestone.values;
final currentIndex = currentMilestone.index;
// Newest first: the current milestone, then everything already done, then
// — only if there is room — what is still to come.
final ordered = [
for (var i = currentIndex; i >= 0; i--) i,
for (var i = currentIndex + 1; i < milestones.length; i++) i,
];
final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList();
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title.toUpperCase(),
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
label: 'All ${milestones.length} stages',
onPressed: onExpand,
minHeight: 24,
)
else
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.monoSm,
),
],
),
const SizedBox(height: 14),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
),
],
),
),
],
);
}
/// One line of operational context for the stage actually in progress.
String? _detailFor(JourneyStage current) => switch (current) {
JourneyStage.booked => 'Matching the nearest available Miler',
JourneyStage.assigned =>
booking.miler == null
? null
: '${booking.miler!.name} accepted this pickup',
JourneyStage.onTheWay =>
booking.milerDistanceKm == null
? 'Miler on the way'
: 'Miler on the way · ${booking.milerDistanceKm} km away, '
'about ${booking.milerEtaMinutes} min',
JourneyStage.arrived => 'Miler arrived at your door · verification OTP ready',
JourneyStage.pickedUp => 'Weighing and sealing your packages',
JourneyStage.orderCreated =>
booking.isMultiDestination
? 'Order created for each destination · tracking issued'
: 'Order created · tracking number issued',
JourneyStage.inTransit => 'Moving through our network',
JourneyStage.outForDelivery => 'Out with the delivery Miler',
JourneyStage.delivered => null,
};
}
class _MilestoneRow extends StatelessWidget {
const _MilestoneRow({
required this.milestone,
required this.done,
required this.isCurrent,
required this.last,
required this.time,
this.detail,
});
final CustomerMilestone milestone;
final bool done;
final bool isCurrent;
final bool last;
final String time;
final String? detail;
@override
Widget build(BuildContext context) {
final upcoming = !done && !isCurrent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 12,
child: Column(
children: [
const SizedBox(height: 3),
_Marker(done: done, isCurrent: isCurrent),
if (!last)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Container(width: 1.5, color: DmColors.border),
),
),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
milestone.label,
style: upcoming
? DmText.small.copyWith(color: DmColors.ink3)
: DmText.cardTitle.copyWith(fontSize: 14.5),
),
),
if (time.isNotEmpty) ...[
const SizedBox(width: 10),
Text(time, style: DmText.monoSm),
],
],
),
if (detail != null) ...[
const SizedBox(height: 4),
Text(detail!, style: DmText.small),
],
],
),
),
),
],
),
);
}
}
class _Marker extends StatelessWidget {
const _Marker({required this.done, required this.isCurrent});
final bool done;
final bool isCurrent;
@override
Widget build(BuildContext context) {
if (isCurrent) {
return Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 4),
],
),
);
}
return Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: done ? DmColors.brand : DmColors.border,
shape: BoxShape.circle,
),
);
}
}

260
lib/ui/widgets/misc.dart Normal file
View File

@@ -0,0 +1,260 @@
import 'package:flutter/material.dart';
import '../tokens.dart';
/// One accent, three intensities — the design's `.st`.
enum DmPillTone {
/// Something is happening right now and nobody has to do anything: searching
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
live,
/// Moving, but between beats — assigned, in transit. The crimson is held in
/// an outline so a list of six orders is not six red blocks.
neutral,
/// Finished. Grey, with a filled dot: the journey happened.
done,
/// Never happened. Grey, hollow dot.
off,
}
/// Status pill used on order rows and at the head of a tracking card.
class DmPill extends StatelessWidget {
const DmPill(this.label, {super.key, this.tone = DmPillTone.neutral});
final String label;
final DmPillTone tone;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
DmPillTone.live => (DmColors.brand, Colors.white, null),
DmPillTone.neutral => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
),
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
};
// The dot is the tone's tell at a glance, before the word is read: filled
// for anything that happened or is happening, hollow for what never did.
final hollow = tone == DmPillTone.off;
return Container(
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
decoration: BoxDecoration(
color: bg,
border: edge,
borderRadius: DmRadius.all(5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 7),
decoration: BoxDecoration(
color: hollow ? Colors.transparent : fg,
shape: BoxShape.circle,
border: hollow ? Border.all(color: fg, width: 1.5) : null,
),
),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12.5, color: fg),
),
),
],
),
);
}
}
/// The pulsing dot that marks a live booking.
class DmLiveDot extends StatefulWidget {
const DmLiveDot({super.key, this.size = 9, this.color = DmColors.brand});
final double size;
final Color color;
@override
State<DmLiveDot> createState() => _DmLiveDotState();
}
class _DmLiveDotState extends State<DmLiveDot>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1900),
);
@override
void initState() {
super.initState();
// A ring that never stops is a battery cost and, for anyone who asked the
// system not to animate, a distraction. Honour the setting: the dot still
// reads as live because it is a filled brand dot on a quiet row.
if (!WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations) {
_c.repeat();
}
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final span = widget.size + 10;
return SizedBox(
width: span,
height: span,
child: AnimatedBuilder(
animation: _c,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
if (_c.isAnimating)
Transform.scale(
scale: 1 + _c.value * 1.6,
child: Opacity(
opacity: (1 - _c.value) * 0.5,
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
),
child!,
],
);
},
child: Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
),
);
}
}
/// Initials avatar. Crimson-filled on a light surface, lightened white when it
/// sits on the brand header.
class DmAvatar extends StatelessWidget {
const DmAvatar({
super.key,
required this.initials,
this.size = 42,
this.onBrand = false,
});
final String initials;
final double size;
final bool onBrand;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
shape: BoxShape.circle,
),
child: Text(
initials,
style: DmText.label.copyWith(
fontSize: size < 40 ? 12 : 13,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
letterSpacing: 0.13,
color: Colors.white,
),
),
);
}
}
/// The Doormile logomark: a door inside a house silhouette.
class DoormileMark extends StatelessWidget {
const DoormileMark({super.key, this.size = 52, this.inverted = false});
final double size;
/// White strokes on nothing, for use on the crimson field.
final bool inverted;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: inverted ? Colors.transparent : DmColors.brand,
borderRadius: DmRadius.all(size * 0.24),
),
child: CustomPaint(
painter: const _MarkPainter(),
size: Size.square(size),
child: SizedBox.square(dimension: size),
),
);
}
}
class _MarkPainter extends CustomPainter {
const _MarkPainter();
@override
void paint(Canvas canvas, Size size) {
final s = size.width;
final paint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = s * 0.072
..strokeJoin = StrokeJoin.round
..strokeCap = StrokeCap.round;
// Roof + walls, drawn on a 24x24 grid scaled to the mark.
double u(double v) => v / 24 * s * 0.62 + s * 0.19;
final house = Path()
..moveTo(u(3.6), u(9.2))
..lineTo(u(12), u(4))
..lineTo(u(20.4), u(9.2))
..lineTo(u(20.4), u(20))
..lineTo(u(3.6), u(20))
..close();
final door = Path()
..moveTo(u(9.4), u(20))
..lineTo(u(9.4), u(13))
..lineTo(u(14.6), u(13))
..lineTo(u(14.6), u(20));
canvas.drawPath(house, paint);
canvas.drawPath(door, paint);
}
@override
bool shouldRepaint(covariant _MarkPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
/// The selectable row used for serviceable states, districts and pickup slots.
///
/// Selection **fills**: the chosen slot becomes a crimson card with a glow
/// under it, and every other option stays a white card with a hairline. The
/// earlier design tinted the selected tile instead, which on a list of five
/// slots left the customer hunting for which one they had picked. A filled row
/// is unmistakable from across the room, and the radio on the right keeps the
/// control legible for anyone who does not read the colour.
///
/// Unavailable options stay visible but recede, with the backend's reason
/// attached — coverage should be legible, not hidden.
class DmOptionTile extends StatefulWidget {
const DmOptionTile({
super.key,
required this.title,
this.subtitle,
this.selected = false,
this.enabled = true,
this.onTap,
this.leading,
this.tags,
this.trailing,
this.titleStyle,
});
final String title;
final String? subtitle;
final bool selected;
final bool enabled;
final VoidCallback? onTap;
/// Optional leading glyph.
final IconData? leading;
/// Chips above the title — "Fastest", "4 Milers nearby".
final List<Widget>? tags;
/// Replaces the radio — "Notify me" on a full slot.
final Widget? trailing;
/// Slots set their time in the display face; states and districts do not.
final TextStyle? titleStyle;
@override
State<DmOptionTile> createState() => _DmOptionTileState();
}
class _DmOptionTileState extends State<DmOptionTile> {
bool _down = false;
@override
Widget build(BuildContext context) {
final selected = widget.selected;
final enabled = widget.enabled;
final Color background = selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
final Color edge = selected
? DmColors.brand
: (enabled ? DmColors.border : DmColors.border);
final titleColor = selected
? Colors.white
: (enabled ? DmColors.ink : DmColors.ink3);
final subColor = selected
? DmColors.onBrand2
: (enabled ? DmColors.ink2 : DmColors.ink3);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: enabled ? (_) => setState(() => _down = true) : null,
onTapUp: enabled ? (_) => setState(() => _down = false) : null,
onTapCancel: enabled ? () => setState(() => _down = false) : null,
onTap: enabled
? () {
HapticFeedback.selectionClick();
widget.onTap?.call();
}
: null,
child: AnimatedSlide(
offset: _down ? const Offset(0, 0.012) : Offset.zero,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 62),
padding: EdgeInsets.fromLTRB(
widget.leading == null ? 16 : 14,
14,
16,
14,
),
decoration: BoxDecoration(
color: background,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: edge),
boxShadow: selected ? DmShadow.lift : null,
),
child: Row(
children: [
if (widget.leading != null) ...[
Icon(
widget.leading,
size: 20,
color: selected ? Colors.white : DmColors.ink3,
),
const SizedBox(width: 13),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.tags != null && widget.tags!.isNotEmpty) ...[
Wrap(
spacing: 7,
runSpacing: 6,
children: widget.tags!,
),
const SizedBox(height: 9),
],
Text(
widget.title,
style: (widget.titleStyle ?? DmText.cardTitle)
.copyWith(color: titleColor),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 3),
Text(
widget.subtitle!,
style: DmText.small.copyWith(color: subColor),
),
],
],
),
),
const SizedBox(width: 12),
widget.trailing ??
_Radio(selected: selected, enabled: enabled),
],
),
),
),
),
);
}
}
/// A radio, not a check: these lists are single-choice, and a tick reads as
/// "done" where a radio reads as "this one, of these".
class _Radio extends StatelessWidget {
const _Radio({required this.selected, required this.enabled});
final bool selected;
final bool enabled;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected
? Colors.white
: (enabled ? DmColors.ink4 : DmColors.border),
width: selected ? 2 : 1.5,
),
),
child: selected
? Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
)
: null,
);
}
}
/// A tappable row with a leading tile — "Drop a map pin", "Saved addresses".
class DmActionRow extends StatelessWidget {
const DmActionRow({
super.key,
required this.icon,
required this.title,
required this.subtitle,
this.onTap,
this.subtitleColor,
this.active = false,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback? onTap;
final Color? subtitleColor;
/// Tints the tile once the action has been completed.
final bool active;
@override
Widget build(BuildContext context) {
return Material(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: DmRadius.all(DmRadius.md),
child: Ink(
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Container(
constraints: const BoxConstraints(minHeight: 64),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? DmColors.okSoft : DmColors.brandSoft,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(
icon,
size: 18,
color: active ? DmColors.ok : DmColors.brand,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: DmText.cardTitle),
const SizedBox(height: 2),
Text(
subtitle,
style: DmText.small.copyWith(
color: subtitleColor ?? DmColors.ink2,
),
),
],
),
),
const Icon(
LucideIcons.chevronRight,
size: 20,
color: DmColors.ink4,
),
],
),
),
),
),
);
}
}

559
lib/ui/widgets/pieces.dart Normal file
View File

@@ -0,0 +1,559 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'chrome.dart';
enum DmTagTone {
/// The default: a quiet sunken chip carrying a fact — "2 Milers nearby".
neutral,
/// Outlined crimson. The one tag in a group that is a recommendation
/// — "Fastest".
brand,
/// Kept for the delivered state.
ok,
warn,
/// On a crimson field.
onFill,
}
/// Small badge: "Fastest pickup", "Transit", "4 Milers nearby".
class DmTag extends StatelessWidget {
const DmTag(
this.label, {
super.key,
this.tone = DmTagTone.neutral,
this.icon,
this.mono = false,
});
final String label;
final DmTagTone tone;
final IconData? icon;
/// For a vehicle plate or a reference carried as a tag.
final bool mono;
@override
Widget build(BuildContext context) {
final (Color bg, Color fg, BoxBorder? edge) = switch (tone) {
DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null),
DmTagTone.brand => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
),
DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null),
DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null),
DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null),
};
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5),
decoration: BoxDecoration(
color: bg,
border: edge,
borderRadius: DmRadius.all(5),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 13, color: fg),
const SizedBox(width: 5),
],
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: mono
? DmText.monoSm.copyWith(fontSize: 12, color: fg)
: DmText.label.copyWith(fontSize: 12, color: fg),
),
),
],
),
);
}
}
/// A selection chip — the design's `.filt`.
///
/// This is the app's one selection control: it is the sign-in method switch,
/// the address label picker, the slot day filter and the rating tags. Selected
/// is a filled crimson chip with a small glow; unselected is a white chip with
/// a hairline. There is no third state to learn.
class DmChoiceChip extends StatelessWidget {
const DmChoiceChip({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.onTap,
this.count,
this.expand = false,
});
final String label;
final bool selected;
final bool enabled;
final VoidCallback? onTap;
/// A figure carried inside the chip, as on the Orders filters.
final int? count;
/// Fills the available width — for a row of chips that splits evenly.
final bool expand;
@override
Widget build(BuildContext context) {
final fg = selected
? Colors.white
: (enabled ? DmColors.ink2 : DmColors.ink4);
final chip = AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 38),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.xs),
boxShadow: selected ? DmShadow.liftSm : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: fg),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? Colors.white : DmColors.ink3,
),
),
),
],
],
),
);
final tappable = Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: chip,
),
);
return expand ? Expanded(child: tappable) : tappable;
}
}
/// A destination card in a horizontal strip — the design's `.ccard`.
///
/// Selection is a wash and a crimson edge rather than a fill: a strip of these
/// is scanned, and a solid crimson block in a scrollable row pulls the eye off
/// the neighbours the customer is still comparing.
class DmGridTile extends StatelessWidget {
const DmGridTile({
super.key,
required this.label,
required this.selected,
this.enabled = true,
this.note,
this.onTap,
this.icon,
});
final String label;
final bool selected;
final bool enabled;
final String? note;
final VoidCallback? onTap;
final IconData? icon;
@override
Widget build(BuildContext context) {
final titleColor = enabled
? (selected ? DmColors.brand : DmColors.ink)
: DmColors.ink4;
final noteColor = selected
? DmColors.brandPress
: (enabled ? DmColors.ink3 : DmColors.ink4);
return Semantics(
button: true,
selected: selected,
enabled: enabled,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap?.call();
}
: null,
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.fromLTRB(13, 12, 13, 12),
decoration: BoxDecoration(
color: selected
? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 18,
color: selected ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 10),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(color: titleColor),
),
if (note != null) ...[
const SizedBox(height: 2),
Text(
// Secondary: the promise may ellipsize, the place name
// never does.
note!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor),
),
],
],
),
),
if (selected)
const Padding(
padding: EdgeInsets.only(left: 6),
child: Icon(LucideIcons.check, size: 17, color: DmColors.brand),
),
],
),
),
),
);
}
}
/// "Step 2 of 4 · Pickup" with its segmented rail.
class DmStepHeader extends StatelessWidget {
const DmStepHeader({
super.key,
required this.step,
required this.total,
required this.label,
});
final int step;
final int total;
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
child: DmStepBar(
progress: step / total,
steps: total,
current: step,
label: label,
),
);
}
}
/// A washed strip carrying one piece of supporting information.
class DmInfoBanner extends StatelessWidget {
const DmInfoBanner({
super.key,
required this.icon,
required this.title,
this.message,
this.tone = DmTagTone.brand,
});
final IconData icon;
final String title;
final String? message;
final DmTagTone tone;
@override
Widget build(BuildContext context) {
final (bg, edge, fg, sub) = switch (tone) {
DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok),
DmTagTone.warn => (
const Color(0xFFFFF3DF),
const Color(0xFFF6E3C2),
const Color(0xFF9C5303),
const Color(0xFF9C5303),
),
DmTagTone.neutral => (
DmColors.surfaceAlt,
DmColors.border,
DmColors.ink,
DmColors.ink2,
),
_ => (
DmColors.brandSoft,
DmColors.brandLine,
DmColors.ink,
DmColors.brandPress,
),
};
return Container(
padding: const EdgeInsets.fromLTRB(13, 13, 14, 13),
decoration: BoxDecoration(
color: bg,
border: Border.all(color: edge),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(
icon,
size: 18,
color: tone == DmTagTone.brand ? DmColors.brand : fg,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)),
if (message != null) ...[
const SizedBox(height: 2),
Text(message!, style: DmText.small.copyWith(color: sub)),
],
],
),
),
],
),
);
}
}
/// A reference number with a copy affordance. The number is mono and large
/// enough to read back over a phone.
class DmCopyRow extends StatefulWidget {
const DmCopyRow({
super.key,
required this.label,
required this.value,
this.trailing,
this.onWash = false,
});
final String label;
final String value;
final Widget? trailing;
/// Sitting inside a wash card, where the label goes crimson.
final bool onWash;
@override
State<DmCopyRow> createState() => _DmCopyRowState();
}
class _DmCopyRowState extends State<DmCopyRow> {
bool _copied = false;
Future<void> _copy() async {
await Clipboard.setData(ClipboardData(text: widget.value));
await HapticFeedback.mediumImpact();
if (!mounted) return;
// The confirmation lands on the control that was pressed rather than in a
// snackbar over the footer: it is the row the customer is looking at.
setState(() => _copied = true);
await Future<void>.delayed(const Duration(milliseconds: 1600));
if (mounted) setState(() => _copied = false);
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.label.toUpperCase(),
style: DmText.eyebrow.copyWith(
color: widget.onWash ? DmColors.brand : DmColors.ink3,
),
),
const SizedBox(height: 4),
Text(
widget.value,
style: DmText.monoLg.copyWith(
color: widget.onWash ? DmColors.brandPress : DmColors.ink,
),
),
],
),
),
?widget.trailing,
DmTextAction(
label: _copied ? 'Copied' : 'Copy',
icon: _copied ? LucideIcons.check : LucideIcons.copy,
onPressed: _copy,
),
],
);
}
}
/// − n + quantity control. Used for how many packages go to one place.
class DmStepper extends StatelessWidget {
const DmStepper({
super.key,
required this.value,
required this.onChanged,
this.min = 1,
this.canIncrease = true,
});
final int value;
final ValueChanged<int> onChanged;
final int min;
/// False when a cap has been reached; the + greys out rather than vanishing.
final bool canIncrease;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_Step(
icon: LucideIcons.minus,
onTap: value > min ? () => onChanged(value - 1) : null,
),
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 30),
child: Text(
'$value',
textAlign: TextAlign.center,
style: DmText.monoLg.copyWith(fontSize: 17),
),
),
_Step(
icon: LucideIcons.plus,
onTap: canIncrease ? () => onChanged(value + 1) : null,
),
],
),
);
}
}
class _Step extends StatelessWidget {
const _Step({required this.icon, this.onTap});
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Semantics(
button: true,
enabled: enabled,
child: InkResponse(
onTap: enabled
? () {
HapticFeedback.selectionClick();
onTap!();
}
: null,
radius: 20,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
icon,
size: 18,
color: enabled ? DmColors.ink : DmColors.ink4,
),
),
),
);
}
}

View File

@@ -0,0 +1,460 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../data/location_service.dart';
import '../../data/map_config.dart';
import '../../data/models.dart';
import '../tokens.dart';
import 'map_tiles.dart';
/// A real map of the journey: pickup and destination joined by the transit
/// path, with the Miler's live position when there is one.
///
/// Read-only by design — it summarises a booking, and a non-interactive map
/// never steals a scroll gesture from the list it sits in.
class DmRouteMap extends StatefulWidget {
const DmRouteMap({
super.key,
this.height = 190,
this.pickup,
this.destination,
this.progress = 0,
this.showMiler = false,
this.pickupLabel,
this.destinationLabel,
this.badge,
this.overlay,
this.radius = DmRadius.lg,
});
final double height;
/// Where the Miler collects. Without coordinates the map falls back to the
/// service-area centre rather than showing an empty ocean.
final Place? pickup;
/// The destination hub. Null draws pickup only — which is exactly right
/// before a district has been chosen.
final Place? destination;
/// How far along the route the Miler is, 0..1.
final double progress;
final bool showMiler;
final String? pickupLabel;
final String? destinationLabel;
/// Pill shown top-left, e.g. "En route · 1.4 km away".
final Widget? badge;
/// Anything centred over the map, e.g. the "searching" state.
final Widget? overlay;
final double radius;
@override
State<DmRouteMap> createState() => _DmRouteMapState();
}
class _DmRouteMapState extends State<DmRouteMap>
with TickerProviderStateMixin {
late final AnimationController _pulse = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
)..repeat();
late final AnimationController _draw = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..forward();
@override
void dispose() {
_pulse.dispose();
_draw.dispose();
super.dispose();
}
LatLng? get _from => widget.pickup != null && widget.pickup!.hasLocation
? LatLng(widget.pickup!.lat!, widget.pickup!.lng!)
: null;
LatLng? get _to =>
widget.destination != null && widget.destination!.hasLocation
? LatLng(widget.destination!.lat!, widget.destination!.lng!)
: null;
/// A gentle arc rather than a ruler-straight line — it reads as a journey,
/// and it separates the two ends when they are nearly aligned.
List<LatLng> _arc(LatLng a, LatLng b) {
final dLat = b.latitude - a.latitude;
final dLng = b.longitude - a.longitude;
// Perpendicular offset, proportional to the span.
final bowLat = -dLng * 0.12;
final bowLng = dLat * 0.12;
final control = LatLng(
(a.latitude + b.latitude) / 2 + bowLat,
(a.longitude + b.longitude) / 2 + bowLng,
);
return [
for (var i = 0; i <= 48; i++)
_quadratic(a, control, b, i / 48),
];
}
static LatLng _quadratic(LatLng a, LatLng c, LatLng b, double t) {
final u = 1 - t;
return LatLng(
u * u * a.latitude + 2 * u * t * c.latitude + t * t * b.latitude,
u * u * a.longitude + 2 * u * t * c.longitude + t * t * b.longitude,
);
}
@override
Widget build(BuildContext context) {
final from = _from;
final to = _to;
final route = (from != null && to != null) ? _arc(from, to) : null;
return ClipRRect(
borderRadius: DmRadius.all(widget.radius),
child: SizedBox(
height: widget.height,
child: ColoredBox(
color: DmMapTiles.ground,
child: Stack(
children: [
Positioned.fill(
child: _map(context, from: from, to: to, route: route),
),
if (widget.badge != null)
Positioned(
top: 12,
left: 12,
// Held to half the map's width so it cannot run under the
// attribution chip opposite it, whatever the badge says.
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.sizeOf(context).width * 0.5,
),
child: widget.badge!,
),
),
if (widget.pickupLabel != null)
Positioned(
left: 12,
bottom: 12,
child: _MapLabel(
icon: LucideIcons.circleDot,
color: DmColors.ink,
label: widget.pickupLabel!,
),
),
if (widget.destinationLabel != null)
Positioned(
right: 12,
bottom: 12,
child: _MapLabel(
icon: LucideIcons.mapPin,
color: DmColors.brand,
label: widget.destinationLabel!,
),
),
// Attribution is required on every map, so it sits top-right,
// clear of the badge (top-left) and the labels (bottom).
const Positioned(top: 10, right: 10, child: DmMapAttribution()),
if (widget.overlay != null) Center(child: widget.overlay!),
],
),
),
),
);
}
Widget _map(
BuildContext context, {
required LatLng? from,
required LatLng? to,
required List<LatLng>? route,
}) {
final centre =
from ??
to ??
const LatLng(
LocationService.fallbackLat,
LocationService.fallbackLng,
);
// The map itself is built once. Only the layers that animate sit inside an
// AnimatedBuilder, so a pulsing Miler never re-creates the camera.
return FlutterMap(
options: MapOptions(
initialCenter: centre,
initialZoom: to == null ? 13.5 : 11,
initialCameraFit: route == null
? null
: CameraFit.coordinates(
coordinates: route,
padding: const EdgeInsets.fromLTRB(46, 34, 46, 52),
maxZoom: 14,
),
backgroundColor: DmMapTiles.ground,
interactionOptions: const InteractionOptions(
flags: InteractiveFlag.none,
),
),
children: [
// The quieter basemap: this map is a summary, the route is the subject.
DmMapTiles.layer(context, style: DmMapStyle.muted),
if (route != null)
AnimatedBuilder(
animation: _draw,
builder: (context, _) {
final drawn = Curves.easeOutCubic.transform(_draw.value);
final upto = (route.length * drawn).ceil().clamp(2, route.length);
return PolylineLayer(
polylines: [
Polyline(
points: route.sublist(0, upto),
color: DmColors.brand,
strokeWidth: 4,
borderColor: Colors.white,
borderStrokeWidth: 3,
),
],
);
},
),
AnimatedBuilder(
animation: widget.showMiler
? Listenable.merge([_pulse, _draw])
: _draw,
builder: (context, _) {
final drawn = Curves.easeOutCubic.transform(_draw.value);
return MarkerLayer(
markers: [
if (from != null)
Marker(
point: from,
width: 26,
height: 26,
child: const _OriginDot(),
),
if (to != null)
Marker(
point: to,
width: 34,
height: 40,
alignment: Alignment.topCenter,
child: const _DestinationPin(),
),
if (widget.showMiler && route != null)
Marker(
point: route[(
(route.length - 1) *
widget.progress.clamp(0.02, 0.98) *
drawn
).round()],
width: 58,
height: 58,
child: _MilerDot(pulse: _pulse.value),
),
],
);
},
),
],
);
}
}
class _OriginDot extends StatelessWidget {
const _OriginDot();
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: DmColors.ink, width: 3.5),
boxShadow: DmShadow.float,
),
),
);
}
}
class _DestinationPin extends StatelessWidget {
const _DestinationPin();
@override
Widget build(BuildContext context) {
return const Stack(
alignment: Alignment.topCenter,
children: [
Icon(
LucideIcons.mapPin,
size: 34,
color: DmColors.brand,
shadows: [
Shadow(color: Color(0x338F0F06), blurRadius: 8, offset: Offset(0, 4)),
],
),
Positioned(
top: 9,
child: CircleAvatar(radius: 4, backgroundColor: Colors.white),
),
],
);
}
}
/// The Miler's live position. The expanding ring is what makes it read as live.
class _MilerDot extends StatelessWidget {
const _MilerDot({required this.pulse});
final double pulse;
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 24 + pulse * 30,
height: 24 + pulse * 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: DmColors.brand.withValues(alpha: (1 - pulse) * 0.22),
),
),
Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: DmShadow.float,
),
child: const Icon(
LucideIcons.bike,
size: 15,
color: DmColors.brand,
),
),
],
),
);
}
}
class _MapLabel extends StatelessWidget {
const _MapLabel({required this.icon, required this.color, required this.label});
final IconData icon;
final Color color;
final String label;
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxWidth: 168),
padding: const EdgeInsets.fromLTRB(9, 6, 12, 6),
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(6),
boxShadow: DmShadow.float,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12),
),
),
],
),
);
}
}
/// Radar sweep shown while the backend is matching a Miler.
class DmRadarPulse extends StatefulWidget {
const DmRadarPulse({super.key, this.size = 150, this.child});
final double size;
final Widget? child;
@override
State<DmRadarPulse> createState() => _DmRadarPulseState();
}
class _DmRadarPulseState extends State<DmRadarPulse>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _c,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
for (var i = 0; i < 3; i++) _ring(i),
?child,
],
);
},
child: widget.child,
),
);
}
Widget _ring(int index) {
final t = (_c.value + index / 3) % 1;
return Opacity(
opacity: (1 - t) * 0.5,
child: Container(
width: widget.size * (0.35 + t * 0.65),
height: widget.size * (0.35 + t * 0.65),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: DmColors.brand.withValues(alpha: 0.45),
width: 1.5,
),
),
),
);
}
}

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import '../tokens.dart';
/// One stop on a rail: where it goes, plus anything worth saying about it.
class DmRailStop {
const DmRailStop({required this.label, this.note, this.trailing});
final String label;
/// Second line under the label.
final String? note;
/// Right-aligned text, e.g. "2 packages".
final String? trailing;
}
/// What a node on the thread stands for.
enum DmNodeKind {
/// Where the parcel starts. A hollow circle: a door, not a decision.
origin,
/// A sorting hub in between. An outlined diamond — the parcel passes
/// through, it does not stop here.
hub,
/// The destination. A filled crimson diamond, the only solid node on the
/// thread, so the eye lands on where the parcel is going.
drop,
/// A leg that has not been arranged yet — dashed outline.
pending,
}
/// Pickup → drop, drawn as a vertical thread.
///
/// One component for a journey wherever it appears: an order card, the review
/// summary, tracking. It takes a single [drop] or, when a pickup serves several
/// places, a list of [drops] hanging off the same origin.
class DmRouteRail extends StatelessWidget {
const DmRouteRail({
super.key,
required this.pickup,
this.pickupNote,
this.pickupEyebrow,
this.pickupTrailing,
this.drop,
this.dropNote,
this.dropsEyebrow,
this.drops,
this.compact = false,
this.hub,
this.hubNote,
this.hubTrailing,
}) : assert(drop != null || drops != null, 'give the rail somewhere to go');
final String pickup;
final String? pickupNote;
/// Uppercase micro-label above the origin, e.g. "PICKUP".
final String? pickupEyebrow;
/// Action rendered at the right of the origin row.
final Widget? pickupTrailing;
/// The single-destination case.
final String? drop;
final String? dropNote;
/// Uppercase micro-label above the first destination.
final String? dropsEyebrow;
/// Several destinations hanging off one pickup.
final List<DmRailStop>? drops;
final bool compact;
/// The sorting hub between pickup and drop, when the customer has been told
/// about it. Omitted on a hyperlocal run, which never sees a hub.
final String? hub;
final String? hubNote;
final Widget? hubTrailing;
@override
Widget build(BuildContext context) {
final stops = drops ?? [DmRailStop(label: drop!, note: dropNote)];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Leg(
eyebrow: pickupEyebrow,
label: pickup,
note: compact ? null : pickupNote,
trailing: pickupTrailing,
kind: DmNodeKind.origin,
connector: true,
dashedConnector: hub != null,
compact: compact,
),
if (hub != null)
_Leg(
label: hub!,
note: compact ? null : hubNote,
trailing: hubTrailing,
kind: DmNodeKind.hub,
labelColor: DmColors.brand,
connector: true,
dashedConnector: true,
compact: compact,
),
for (var i = 0; i < stops.length; i++)
_Leg(
eyebrow: i == 0 ? dropsEyebrow : null,
label: stops[i].label,
note: compact ? null : stops[i].note,
trailingText: stops[i].trailing,
kind: DmNodeKind.drop,
connector: i != stops.length - 1,
compact: compact,
),
],
);
}
}
class _Leg extends StatelessWidget {
const _Leg({
required this.label,
required this.note,
required this.kind,
required this.connector,
required this.compact,
this.dashedConnector = false,
this.eyebrow,
this.trailing,
this.trailingText,
this.labelColor,
});
final String label;
final String? note;
final DmNodeKind kind;
final bool connector;
final bool dashedConnector;
final bool compact;
final String? eyebrow;
final Widget? trailing;
final String? trailingText;
final Color? labelColor;
@override
Widget build(BuildContext context) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 15,
child: Column(
children: [
SizedBox(height: eyebrow == null ? 3 : 19),
_Node(kind: kind),
if (connector)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: CustomPaint(
painter: _Thread(dashed: dashedConnector),
size: const Size(1.5, double.infinity),
),
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: connector ? (compact ? 13 : 16) : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
const SizedBox(height: 4),
],
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
label,
style: DmText.cardTitle.copyWith(color: labelColor),
),
),
if (trailingText != null) ...[
const SizedBox(width: 10),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
trailingText!,
textAlign: TextAlign.right,
style: DmText.small,
),
),
],
?trailing,
],
),
if (note != null) ...[
const SizedBox(height: 3),
Text(note!, style: DmText.small),
],
],
),
),
),
],
),
);
}
}
class _Node extends StatelessWidget {
const _Node({required this.kind});
final DmNodeKind kind;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 15,
height: 15,
child: Center(
child: switch (kind) {
DmNodeKind.origin => Container(
width: 11,
height: 11,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: DmColors.ink4, width: 2),
),
),
// Rotated squares rather than a second circle: on a thread of three
// identical dots nothing tells you which end matters, and at 11pt a
// diamond is distinguishable from a circle at a glance where two
// shades of the same dot are not.
DmNodeKind.hub => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.brand, width: 2),
),
),
),
DmNodeKind.drop => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: BorderRadius.circular(2),
),
),
),
DmNodeKind.pending => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.ink4, width: 1.5),
),
),
),
},
),
);
}
}
/// The line between two nodes. Solid where the parcel is handled, dashed
/// across a leg it merely travels — the distinction the design draws between
/// a doorstep and a trunk route.
class _Thread extends CustomPainter {
const _Thread({required this.dashed});
final bool dashed;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = DmColors.border
..strokeWidth = 1.5
..strokeCap = StrokeCap.round;
final x = size.width / 2;
if (!dashed) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
return;
}
const dash = 2.0;
const gap = 4.0;
var y = 0.0;
while (y < size.height) {
canvas.drawLine(
Offset(x, y),
Offset(x, (y + dash).clamp(0, size.height)),
paint,
);
y += dash + gap;
}
}
@override
bool shouldRepaint(covariant _Thread oldDelegate) =>
oldDelegate.dashed != dashed;
}

230
lib/ui/widgets/states.dart Normal file
View File

@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
/// Shimmering placeholder rows shown while a remote list loads.
class DmSkeleton extends StatefulWidget {
const DmSkeleton({super.key, this.rows = 4, this.height = 68});
final int rows;
final double height;
@override
State<DmSkeleton> createState() => _DmSkeletonState();
}
class _DmSkeletonState extends State<DmSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1300),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Loading',
child: AnimatedBuilder(
animation: _c,
builder: (context, _) {
return Column(
children: [
for (var i = 0; i < widget.rows; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == widget.rows - 1 ? 0 : 10,
),
child: Container(
height: widget.height,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
gradient: LinearGradient(
begin: Alignment(-1 - 2 * (1 - _c.value), 0),
end: Alignment(1 + 2 * (1 - _c.value), 0),
colors: const [
DmColors.surfaceAlt,
Color(0xFFFBFAF8),
DmColors.surfaceAlt,
],
stops: const [0.25, 0.4, 0.6],
),
),
),
),
],
);
},
),
);
}
}
/// Empty / error state with an optional retry action.
class DmEmptyState extends StatelessWidget {
const DmEmptyState({
super.key,
required this.title,
required this.message,
this.icon = LucideIcons.ban,
this.actionLabel,
this.onAction,
});
final String title;
final String message;
final IconData icon;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 16),
child: Column(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: DmColors.border),
),
child: Icon(icon, size: 24, color: DmColors.ink3),
),
const SizedBox(height: 16),
Text(
title,
style: DmText.heading,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
message,
style: DmText.small,
textAlign: TextAlign.center,
),
if (actionLabel != null) ...[
const SizedBox(height: 20),
DmButton(
label: actionLabel!,
kind: DmButtonKind.outline,
block: false,
onPressed: onAction,
),
],
],
),
);
}
}
/// Wraps every backend-driven list so loading, empty, unavailable and error +
/// retry are handled identically wherever data comes from.
///
/// Adding an endpoint needs no extra UI work: return a
/// different [Future] and these states come along.
class DmAsyncList<T> extends StatefulWidget {
const DmAsyncList({
super.key,
required this.load,
required this.builder,
required this.emptyTitle,
required this.emptyMessage,
this.emptyIcon = LucideIcons.ban,
this.emptyActionLabel = 'Retry',
this.onEmptyAction,
this.skeletonRows = 4,
this.reloadToken,
});
/// Called on first build and on every retry.
final Future<List<T>> Function({bool refresh}) load;
final Widget Function(BuildContext context, List<T> items) builder;
final String emptyTitle;
final String emptyMessage;
final IconData emptyIcon;
final String emptyActionLabel;
/// Defaults to reloading; override to send the customer somewhere else.
final VoidCallback? onEmptyAction;
final int skeletonRows;
/// Changes when the caller has invalidated what this list is showing.
///
/// The future is held in state, so a screen the customer returns to would
/// otherwise keep rendering the answer it got the first time — which is how
/// a pickup slot from yesterday stays on screen after the server has
/// rejected it.
final Object? reloadToken;
@override
State<DmAsyncList<T>> createState() => DmAsyncListState<T>();
}
class DmAsyncListState<T> extends State<DmAsyncList<T>> {
late Future<List<T>> _future = widget.load();
void reload() {
// Note the block body: setState rejects a callback that returns a value,
// and an expression-bodied closure would hand it the Future.
setState(() {
_future = widget.load(refresh: true);
});
}
@override
void didUpdateWidget(covariant DmAsyncList<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.reloadToken != oldWidget.reloadToken) reload();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<List<T>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return DmSkeleton(rows: widget.skeletonRows);
}
if (snap.hasError) {
return DmEmptyState(
icon: LucideIcons.wifiOff,
title: "Couldn't load this",
message: _messageFor(snap.error),
actionLabel: 'Retry',
onAction: reload,
);
}
final items = snap.data ?? const [];
if (items.isEmpty) {
return DmEmptyState(
icon: widget.emptyIcon,
title: widget.emptyTitle,
message: widget.emptyMessage,
actionLabel: widget.emptyActionLabel,
onAction: widget.onEmptyAction ?? reload,
);
}
return widget.builder(context, items);
},
);
}
String _messageFor(Object? error) {
final text = error?.toString() ?? '';
final match = RegExp(r'\): (.+)$').firstMatch(text);
return match?.group(1) ?? 'Check your connection and try again.';
}
}

173
lib/ui/widgets/summary.dart Normal file
View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'cards.dart';
/// One labelled fact, optionally with an inline edit action.
class DmSummaryRow extends StatelessWidget {
const DmSummaryRow({
super.key,
required this.label,
required this.value,
this.secondary,
this.muted = false,
this.mono = false,
this.actionLabel,
this.onAction,
});
final String label;
final String value;
/// Smaller grey line under the value (street, postcode…).
final String? secondary;
/// Renders the value as placeholder text ("Not added — …").
final bool muted;
/// Sets the value in mono — references, phone numbers, pincodes.
final bool mono;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return DmCardCell(
padding: EdgeInsets.fromLTRB(16, 14, actionLabel == null ? 16 : 10, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label.toUpperCase(), style: DmText.eyebrow),
const SizedBox(height: 5),
Text(
value,
style: muted
? DmText.small.copyWith(color: DmColors.ink3)
: mono
? DmText.mono.copyWith(fontSize: 15, color: DmColors.ink)
: DmText.cardTitle,
),
if (secondary != null) ...[
const SizedBox(height: 3),
Text(secondary!, style: DmText.small),
],
],
),
),
if (actionLabel != null)
DmTextAction(label: actionLabel!, onPressed: onAction),
],
),
);
}
}
/// White card that rules its children apart — Review, the confirmation summary,
/// and the tracking facts card.
///
/// A thin wrapper over [DmCard] so the many call sites that say "summary card"
/// keep saying it; new code can use [DmCard] directly and pick a tone.
class DmSummaryCard extends StatelessWidget {
const DmSummaryCard({super.key, required this.children, this.flat = false});
final List<Widget> children;
/// Sits on the canvas rather than lifting off it.
final bool flat;
@override
Widget build(BuildContext context) {
return DmCard(
tone: flat ? DmCardTone.sunken : DmCardTone.plain,
children: children,
);
}
}
/// Quiet explanatory note — the "this creates a pickup request" block.
class DmNote extends StatelessWidget {
const DmNote({super.key, required this.child, this.icon = LucideIcons.info});
final Widget child;
final IconData icon;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 14, 15, 14),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 17, color: DmColors.ink3),
const SizedBox(width: 11),
Expanded(child: child),
],
),
);
}
}
/// Confirmation strip — "Delivering to Coimbatore, Tamil Nadu".
///
/// Crimson, not green. The app has one accent, and a confirmation inside the
/// booking flow is a brand moment, not a system status: green here would be
/// the only green in the product and would read as a validation message.
class DmConfirmedStrip extends StatelessWidget {
const DmConfirmedStrip({super.key, required this.prefix, required this.value});
final String prefix;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(13, 12, 15, 12),
decoration: BoxDecoration(
color: DmColors.brandSoft,
border: Border.all(color: DmColors.brandLine),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.check, size: 13, color: Colors.white),
),
const SizedBox(width: 11),
Expanded(
child: Text.rich(
TextSpan(
style: DmText.small.copyWith(color: DmColors.brandPress),
children: [
TextSpan(text: prefix),
TextSpan(
text: value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
),
),
],
),
);
}
}