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:
375
lib/ui/widgets/buttons.dart
Normal file
375
lib/ui/widgets/buttons.dart
Normal 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
470
lib/ui/widgets/cards.dart
Normal 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
655
lib/ui/widgets/chrome.dart
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
187
lib/ui/widgets/feedback.dart
Normal file
187
lib/ui/widgets/feedback.dart
Normal 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
323
lib/ui/widgets/inputs.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
454
lib/ui/widgets/map_panel.dart
Normal file
454
lib/ui/widgets/map_panel.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/ui/widgets/map_tiles.dart
Normal file
76
lib/ui/widgets/map_tiles.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
lib/ui/widgets/milestones.dart
Normal file
243
lib/ui/widgets/milestones.dart
Normal 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
260
lib/ui/widgets/misc.dart
Normal 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;
|
||||
}
|
||||
285
lib/ui/widgets/option_tile.dart
Normal file
285
lib/ui/widgets/option_tile.dart
Normal 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
559
lib/ui/widgets/pieces.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
460
lib/ui/widgets/route_map.dart
Normal file
460
lib/ui/widgets/route_map.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
328
lib/ui/widgets/route_rail.dart
Normal file
328
lib/ui/widgets/route_rail.dart
Normal 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
230
lib/ui/widgets/states.dart
Normal 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
173
lib/ui/widgets/summary.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user