Replace the customer app with Doormile CX

Book a pickup, track it to delivery — the rebuilt customer app.

- Design language from doormile-screens.html: brand #8F0F06, Manrope +
  Geist Mono (variable fonts), bordered cards instead of shadows, crimson
  brand headers, sliding tab indicator, mono for anything read digit by digit.
- lib/data (one live API implementation, plus a debug-only offline fake),
  lib/state, lib/ui (tokens, widgets, screens).
- 84 tests, plus a design snapshot harness that renders every screen with the
  real fonts: flutter test test/design_snapshot_test.dart --run-skipped
  --update-goldens

This replaces the previous app (pubspec 'doormile', app id
com.doormile.customer). That tree remains in history at 6c7d656; note its
android/app/google-services.json is not carried over, and the application id
here is in.doormile.customer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 16:07:33 +05:30
parent 6c7d656de5
commit 0d66627c3c
305 changed files with 21930 additions and 11056 deletions

80
lib/ui/format.dart Normal file
View File

@@ -0,0 +1,80 @@
/// Small formatting helpers shared across screens.
library;
String greetingForNow([DateTime? now]) {
final h = (now ?? DateTime.now()).hour;
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
/// "Just now" / "18 min ago" / "3 hr ago" / "5 d ago"
String relativeTime(DateTime? at, [DateTime? now]) {
if (at == null) return '';
final mins = (now ?? DateTime.now()).difference(at).inMinutes;
if (mins < 1) return 'Just now';
if (mins < 60) return '$mins min ago';
final hrs = (mins / 60).round();
if (hrs < 24) return '$hrs hr ago';
return '${(hrs / 24).round()} d ago';
}
const _months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
/// "Today" / "Yesterday" / "29 Aug"
String dateLabel(DateTime date, [DateTime? now]) {
final today = now ?? DateTime.now();
bool sameDay(DateTime a, DateTime b) =>
a.year == b.year && a.month == b.month && a.day == b.day;
if (sameDay(date, today)) return 'Today';
final yesterday = today.subtract(const Duration(days: 1));
if (sameDay(date, yesterday)) return 'Yesterday';
return '${date.day} ${_months[date.month - 1]}';
}
/// "SEPTEMBER 2026" — the eyebrow over a tab root's title. Spelled in full
/// rather than abbreviated: it is read once, at the top of the screen, and
/// "SEP 2026" saves four characters nobody needed saved.
String monthLabel(DateTime date) => '${_monthsFull[date.month - 1]} ${date.year}';
const _monthsFull = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/// Initials for an avatar, e.g. "Joe Oommen" -> "JO".
String initialsOf(String? name) {
final n = (name ?? '').trim();
if (n.isEmpty) return 'DM';
final letters = n
.split(RegExp(r'\s+'))
.map((p) => p.isEmpty ? '' : p[0])
.join();
return letters
.substring(0, letters.length < 2 ? letters.length : 2)
.toUpperCase();
}

View File

@@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/inputs.dart';
import '../widgets/misc.dart';
import 'auth/login_screen.dart';
import 'sheets/place_search_sheet.dart';
/// Account — profile, preferences, and the build's environment line.
///
/// The profile folds into the brand header: the avatar takes the slot the other
/// tab roots give an action, the customer's name takes the slot the title holds
/// elsewhere, and the phone number sits in the recessed track. That removes the
/// profile *card* the screen used to open with — a card whose entire content
/// was three facts about the person already looking at the screen.
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final customer = app.customer;
// Empty rather than a stand-in name: this screen is only ever built for a
// signed-in customer, and a placeholder here would read as somebody's
// actual account.
final name = customer?.name ?? '';
final phone = customer?.phone ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmBrandHeader(
eyebrow: 'Account',
title: name.isEmpty ? 'Your account' : name,
leading: DmAvatar(initials: initialsOf(name), onBrand: true),
action: DmIconButton(
icon: LucideIcons.circleHelp,
tooltip: 'Help',
background: DmColors.onBrandRaise,
foreground: Colors.white,
border: Colors.transparent,
radius: DmRadius.lg,
size: 42,
onPressed: () => DmToast.show(context, 'Support is on the way'),
),
child: phone.isEmpty
? null
: DmHeaderTrack(
trailing: DmTextAction(
label: 'Edit',
color: Colors.white,
minHeight: 24,
onPressed: () => DmToast.show(context, 'Editing is coming soon'),
),
child: Text(
phone,
style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white),
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
children: [
const DmMicroHead('Preferences', top: 16),
DmRowGroup(
children: [
DmRow(
icon: LucideIcons.mapPin,
label: 'Saved addresses',
value: app.pickup?.title ?? 'None',
showChevron: true,
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) {
app.setPickup(place);
DmToast.show(context, 'Pickup location updated');
}
},
),
const DmRow(
icon: LucideIcons.bell,
label: 'Notifications',
value: 'SMS and push',
showChevron: true,
),
const DmRow(
icon: LucideIcons.wallet,
label: 'Payment methods',
value: 'UPI',
showChevron: true,
),
const DmRow(
icon: LucideIcons.circleHelp,
label: 'Help and support',
showChevron: true,
),
],
),
// The environment line is not a control — it is what a bug
// report needs in order to be actionable. Always shown when the
// session came from a dev token rather than a sign-in.
if (!AppConfig.isProd || AppConfig.authBypassed) ...[
const DmMicroHead('Build', brand: false),
DmCard(
tone: DmCardTone.sunken,
children: [
DmCardCell(
// Not a DmRow: that lays a short label against a short
// value on one line, and this value is a URL plus every
// flag the build carries. It overflowed by 59px the
// moment a bypass was on — which is exactly when someone
// needs to read it.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('API', style: DmText.eyebrow),
const SizedBox(height: 6),
Text(
AppConfig.describe,
style: DmText.mono.copyWith(
height: 1.5,
color: DmColors.ink2,
),
),
],
),
),
],
),
],
const SizedBox(height: 24),
DmButton(
label: 'Sign out',
kind: DmButtonKind.ghost,
danger: true,
onPressed: () {
app.signOut();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(builder: (_) => const LoginScreen()),
(route) => false,
);
},
),
const SizedBox(height: 6),
Text.rich(
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'Doormile Customer '),
TextSpan(
text: 'v1.0.0',
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
),
textAlign: TextAlign.center,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,440 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../tokens.dart';
import '../../widgets/chrome.dart';
/// Shared chrome for the auth screens: a brand panel at the top, a white sheet
/// below holding the form.
///
/// Auth is the only place the app goes full-brand — it sets the tone once, and
/// the product itself stays quiet after that.
/// Shared chrome for the auth screens: a full-bleed brand block, and a rounded
/// sheet lifted over it holding the form.
///
/// The frame matches the Miler app's onboarding, deliberately — the two apps
/// are the same company arriving from two directions, and a customer who has
/// seen a Miler at the door should recognise the doorway. One block across sign
/// in, sign up and verify, so the three read as one entrance rather than three
/// forms that happen to follow each other.
///
/// Content is **top-anchored, not centred**: the question and its input sit
/// high, where the keyboard cannot shove them around. The banner gives back
/// half its height the moment a field takes focus, which is what fills the
/// screen at rest without stealing room while someone is typing.
class AuthScaffold extends StatelessWidget {
const AuthScaffold({
super.key,
this.title,
this.subtitle,
required this.children,
this.footer,
this.onBack,
this.heroTitle,
this.heroSub,
this.compactBanner = false,
this.badge = true,
});
/// Heading inside the white sheet, above the form. Null on the entry screen,
/// where the headline is [heroTitle] and sits on the crimson instead.
final String? title;
final String? subtitle;
final List<Widget> children;
/// Pinned below the form, above the safe area.
final Widget? footer;
final VoidCallback? onBack;
/// The promise, set on the brand field itself — "Book a pickup from your
/// door." The app's first screen should say what the app is for in the app's
/// own voice, at display size, before it asks for anything. Only the entry
/// screen carries one; the screens after it have already made the promise.
final String? heroTitle;
/// One supporting line under [heroTitle].
final String? heroSub;
/// Forces the short banner. For screens that raise the keyboard themselves on
/// the first frame — otherwise they render the tall block for one frame and
/// then animate it away, which is a splash screen wearing a different hat.
final bool compactBanner;
/// The CUSTOMER label beside the mark. It belongs on the way in, where a
/// customer might wonder which Doormile app they opened; two screens later
/// they know, and it is just a word taking up the mark's room.
final bool badge;
/// How far the sheet is pulled up over the banner.
static const double _lift = 18;
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
child: Scaffold(
backgroundColor: DmColors.canvas,
body: Column(
children: [
Stack(
children: [
// `Scaffold` consumes the bottom view inset, so a child inside
// `body` sees zero whether the keyboard is up or not. This
// context sits above the Scaffold and still carries the real
// value — handing it back lets the banner react to the keyboard
// without disturbing the Scaffold's own resizing.
MediaQuery(
data: MediaQuery.of(context),
child: AuthBrandBanner(
title: heroTitle,
sub: heroSub,
badge: badge,
compact: compactBanner,
back: onBack == null
? null
: _BackButton(onPressed: onBack!),
),
),
],
),
Expanded(
child: Transform.translate(
offset: const Offset(0, -_lift),
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: DmColors.canvas,
borderRadius: BorderRadius.vertical(
top: Radius.circular(DmRadius.xl),
),
),
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false,
child: Column(
// Stretch, not the default centre: the heading block is a
// Padding sized to its own text, so a centred column was
// pushing "Verify your number" into the middle of the
// sheet while the form under it ranged left.
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── The question is pinned, only the form scrolls ──
//
// Focusing the field made Flutter scroll it into view,
// which carried "Verify your number" off the top of the
// sheet: the screen asked a question and then hid it
// the moment anyone went to answer. The heading is not
// part of the scrollable content any more, so it cannot
// be scrolled away by the keyboard.
if (title != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
26,
DmSpace.pad,
0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title!, style: DmText.title),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(subtitle!, style: DmText.lede),
],
],
),
),
Expanded(
child: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
title == null ? 26 : 24,
DmSpace.pad,
12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
if (footer != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
4,
DmSpace.pad,
10 + _lift,
),
child: footer!,
),
],
),
),
),
),
),
],
),
),
);
}
}
/// The masthead every auth screen wears.
///
/// ── Left-aligned, and it speaks ──
///
/// This was a centred lockup floating in a fixed 210pt block: correct, and
/// completely silent. A customer opening the app for the first time was shown a
/// logo and then asked for their phone number, with nothing in between saying
/// what they were signing up to.
///
/// The block now reads top-down like the rest of the app — lockup, then the
/// promise at display size, then one supporting line — all ranged left on the
/// same 24pt margin the sheet below uses, so the crimson field and the white
/// sheet share one column rather than looking like two different screens.
///
/// The wordmark is **knocked out, not re-exported**: `doormile-logo.png` is
/// RGBA, so `BlendMode.srcIn` fills every non-transparent pixel white and gives
/// a clean reverse of the red original. Shipping a second white asset would
/// mean two logo files that drift; this one cannot.
class AuthBrandBanner extends StatelessWidget {
const AuthBrandBanner({
super.key,
this.title,
this.sub,
this.back,
this.compact = false,
this.badge = true,
});
/// See [AuthScaffold.heroTitle].
final String? title;
final String? sub;
/// The back affordance, drawn above the lockup rather than floated over it.
final Widget? back;
/// See [AuthScaffold.compactBanner].
final bool compact;
/// See [AuthScaffold.badge].
final bool badge;
/// The source wordmark is 748×100. Held explicitly so a layout change cannot
/// squash the mark.
static const double _markAspect = 748 / 100;
@override
Widget build(BuildContext context) {
// While the keyboard is up the promise gives way: the field the customer is
// typing into matters more than the sentence they have already read. The
// lockup stays, so the screen never loses its identity mid-form.
final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0;
// Without the badge beside it the mark is the only thing on the field, so
// it is given the room the badge was taking rather than sitting small in a
// corner of an otherwise empty block.
final markWidth = badge ? (typing ? 132.0 : 150.0) : 196.0;
return ColoredBox(
color: DmColors.brand,
child: CustomPaint(
painter: const DmArcField(opacity: 0.16),
child: SafeArea(
bottom: false,
child: AnimatedSize(
duration: DmMotion.base,
curve: DmMotion.ease,
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.fromLTRB(
DmSpace.pad,
back == null ? 14 : 6,
DmSpace.pad,
typing ? 22 : 34,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (back != null) ...[back!, const SizedBox(height: 14)],
Row(
children: [
Flexible(
child: Semantics(
label: 'Doormile',
image: true,
child: SizedBox(
width: markWidth,
height: markWidth / _markAspect,
child: ColorFiltered(
colorFilter: const ColorFilter.mode(
Colors.white,
BlendMode.srcIn,
),
child: Image.asset(
'assets/images/doormile-logo.png',
fit: BoxFit.contain,
alignment: Alignment.centerLeft,
filterQuality: FilterQuality.medium,
),
),
),
),
),
// Which Doormile app this is. Outlined rather than
// filled: it is a label on the mark, not a status.
if (badge) ...[
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0x80FFFFFF)),
borderRadius: DmRadius.all(5),
),
child: Text(
'CUSTOMER',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.eyebrow.copyWith(
fontSize: 10,
color: Colors.white,
letterSpacing: 1.4,
),
),
),
],
],
),
if (title != null && !typing) ...[
const SizedBox(height: 26),
// Held to a narrow measure so the headline breaks where the
// design breaks it — two short lines, not one long one that
// runs the full width of a phone.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Text(
title!,
// Display is inked for the canvas; on the brand field
// it knocks out white.
style: DmText.display.copyWith(color: Colors.white),
),
),
if (sub != null) ...[
const SizedBox(height: 10),
Text(
sub!,
style: DmText.lede.copyWith(color: DmColors.onBrand2),
),
],
],
],
),
),
),
),
),
);
}
}
class _BackButton extends StatelessWidget {
const _BackButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Material(
color: DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.sm),
child: InkWell(
borderRadius: DmRadius.all(DmRadius.sm),
onTap: onPressed,
child: Ink(
width: 40,
height: 40,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(color: const Color(0x4DFFFFFF)),
),
child: const Icon(
LucideIcons.arrowLeft,
size: 19,
color: Colors.white,
),
),
),
);
}
}
/// The fine print shared by every auth screen.
class AuthLegal extends StatelessWidget {
const AuthLegal({super.key});
@override
Widget build(BuildContext context) {
return Text(
"By continuing you agree to Doormile's Terms and Privacy Policy.",
textAlign: TextAlign.center,
style: DmText.tiny.copyWith(fontSize: 12, height: 1.55),
);
}
}
/// The cross-link between sign in and sign up.
class AuthSwitchLink extends StatelessWidget {
const AuthSwitchLink({
super.key,
required this.question,
required this.action,
required this.onTap,
});
final String question;
final String action;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
question,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(fontSize: 13.5),
),
),
InkWell(
onTap: onTap,
borderRadius: DmRadius.all(DmRadius.xs),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8),
child: Text(
action,
style: DmText.label.copyWith(
fontSize: 13.5,
color: DmColors.brand,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
import 'signup_screen.dart';
enum LoginMode { phone, email }
/// Sign in — phone or email, then a 4-digit code.
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _identifier = TextEditingController();
LoginMode _mode = LoginMode.phone;
bool _sending = false;
@override
void initState() {
super.initState();
_identifier.addListener(() => setState(() {}));
}
@override
void dispose() {
_identifier.dispose();
super.dispose();
}
bool get _isPhone => _mode == LoginMode.phone;
bool get _valid => _isPhone
? _identifier.text.trim().length >= 10
: _identifier.text.contains('@');
Future<void> _continue() async {
final raw = _identifier.text.trim();
setState(() => _sending = true);
final target = _isPhone ? '+91 $raw' : raw;
try {
final challenge = await AppScope.read(context).sendOtp(target);
if (!mounted) return;
setState(() => _sending = false);
// ── No code sent, no code screen ──
//
// `sent: false` is the server saying it accepted the request but did not
// deliver anything — the SMS gateway or the mail relay refused it. The
// resend path on the next screen has always honoured this flag; this one
// did not, and walked the customer to four empty boxes for a code that
// was never issued. Everything they can type there is wrong, and the
// failure reads to them as "I entered it wrong" rather than "nothing
// arrived". Stay here, say what happened, let them try again.
if (!challenge.sent) {
DmToast.show(
context,
'We could not send a code to that ${_isPhone ? 'number' : 'address'}. '
'Try again in a moment.',
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
OtpScreen(identifier: target, challenge: challenge),
),
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, e.message);
} catch (_) {
// Anything the API layer did not turn into an ApiException still has to
// clear the spinner. Without this the button stays mid-send forever and
// the screen offers no way forward at all — a worse failure than the one
// being reported.
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, 'Something went wrong. Please try again.');
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
// The promise sits on the crimson, at display size, and the sheet gets
// on with the form. A "Welcome back" heading in the sheet said nothing
// the screen did not already say, and pushed the first control down.
heroTitle: 'Book a pickup from your door.',
heroSub: 'Track every parcel through to delivery.',
// The CTA is pinned, not scrolled. In the scroll area it was clipped by
// the footer the moment the keyboard came up — the one control the screen
// exists for, hidden exactly when it is needed.
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
const SizedBox(height: 10),
AuthSwitchLink(
question: 'New to Doormile?',
action: 'Create account',
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SignUpScreen()),
),
),
const SizedBox(height: 6),
const AuthLegal(),
],
),
children: [
// The same chip the Orders filter and the address labels use. One
// selection control across the app means the customer learns it once,
// on the screen they meet first.
Row(
children: [
for (final mode in LoginMode.values) ...[
if (mode != LoginMode.values.first) const SizedBox(width: 8),
DmChoiceChip(
label: mode == LoginMode.phone ? 'Phone' : 'Email',
selected: _mode == mode,
expand: true,
onTap: () => setState(() {
_mode = mode;
_identifier.clear();
}),
),
],
],
),
const SizedBox(height: 22),
DmTextField(
label: _isPhone ? 'Phone number' : 'Email address',
controller: _identifier,
hint: _isPhone ? '98765 43210' : 'you@example.com',
prefix: _isPhone ? '+91' : null,
keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress,
maxLength: _isPhone ? 10 : 60,
digitsOnly: _isPhone,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(),
),
// Sits under the field it explains rather than floating below the
// button, where it read as a fourth thing to deal with.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'Your number stays private',
message: "We'll send a 4-digit code to keep your parcels secure.",
),
],
);
}
}

View File

@@ -0,0 +1,369 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../shell_screen.dart';
import 'auth_scaffold.dart';
/// Verify the code. Shared by sign in and sign up.
///
/// How many digits and how long until a resend are the **server's** to say —
/// they arrive on [challenge] from the call that asked for the code. Nothing
/// here is a constant that a backend change could make wrong.
class OtpScreen extends StatefulWidget {
const OtpScreen({
super.key,
required this.identifier,
this.name,
this.challenge = OtpChallenge.unspecified,
});
/// The phone number or email the code was sent to.
final String identifier;
/// Set when arriving from sign up, so the new account keeps its name.
final String? name;
/// What the server said about this challenge.
final OtpChallenge challenge;
@override
State<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends State<OtpScreen> {
late OtpChallenge _challenge = widget.challenge;
late final _boxes = List.generate(
_challenge.codeLength,
(_) => TextEditingController(),
);
late final _focus = List.generate(
_challenge.codeLength,
(_) => FocusNode(),
);
Timer? _ticker;
late int _resendIn = _challenge.resendAfterSeconds;
bool _verifying = false;
int get _length => _boxes.length;
String get _code => _boxes.map((c) => c.text).join();
bool get _complete => _code.length == _length;
@override
void initState() {
super.initState();
_startResendTimer();
WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus());
}
void _startResendTimer() {
_ticker?.cancel();
setState(() => _resendIn = _challenge.resendAfterSeconds);
if (_resendIn <= 0) return;
_ticker = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) return;
setState(() => _resendIn--);
if (_resendIn <= 0) t.cancel();
});
}
@override
void dispose() {
_ticker?.cancel();
for (final c in _boxes) {
c.dispose();
}
for (final f in _focus) {
f.dispose();
}
super.dispose();
}
Future<void> _resend() async {
late final OtpChallenge challenge;
try {
challenge = await AppScope.read(context).sendOtp(widget.identifier);
} on ApiException catch (e) {
if (!mounted) return;
DmToast.show(context, e.message);
return;
}
if (!mounted) return;
// The box count is fixed for the life of this screen — the controllers are
// already built — but the resend window can move, so only that is adopted.
_challenge = OtpChallenge(
codeLength: _length,
resendAfterSeconds: challenge.resendAfterSeconds,
sent: challenge.sent,
);
_startResendTimer();
DmToast.show(
context,
challenge.sent ? 'Code sent again' : 'We could not send a code',
);
}
Future<void> _verify() async {
setState(() => _verifying = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.verifyOtp(widget.identifier, _code, name: widget.name);
if (!mounted) return;
app.detectPickupLocation();
unawaited(HapticFeedback.mediumImpact());
await navigator.pushAndRemoveUntil(
MaterialPageRoute<void>(builder: (_) => const ShellScreen()),
(route) => false,
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _verifying = false);
for (final c in _boxes) {
c.clear();
}
_focus.first.requestFocus();
DmToast.show(context, e.message);
}
}
/// Accepts a pasted code by spreading it across the boxes.
void _onChanged(int index, String value) {
if (value.length > 1) {
final digits = value.replaceAll(RegExp(r'\D'), '');
for (var i = 0; i < _boxes.length; i++) {
_boxes[i].text = i < digits.length ? digits[i] : '';
}
_focus[digits.length.clamp(0, _length - 1)].requestFocus();
} else if (value.isNotEmpty && index < 3) {
_focus[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focus[index - 1].requestFocus();
}
setState(() {});
if (_complete && !_verifying) _verify();
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
compactBanner: true,
// The mark alone, without the CUSTOMER label: by the third screen of the
// entrance nobody is still asking which app this is, and the question on
// this screen is a four-digit code, not a brand.
badge: false,
onBack: () => Navigator.of(context).maybePop(),
title: 'Verify your number',
subtitle: 'Enter the $_length-digit code we just sent.',
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Verify and continue',
busy: _verifying,
busyLabel: 'Verifying…',
onPressed: _complete ? _verify : null,
),
const SizedBox(height: 12),
const AuthLegal(),
],
),
children: [
// The number is restated in mono so the digits can be checked one by
// one — this is the screen where a transposed digit means a code that
// never arrives — with the way back one tap away.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
child: Row(
children: [
const DmTile(icon: LucideIcons.shieldCheck, tone: DmTileTone.wash),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.identifier,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.monoLg.copyWith(
fontSize: 15.5,
color: DmColors.brandPress,
),
),
),
DmTextAction(
label: 'Change',
onPressed: () => Navigator.of(context).maybePop(),
),
],
),
),
],
),
const SizedBox(height: 22),
// Four equal boxes across the column, which is how the code is drawn
// in the design and how every other code field a customer has used
// behaves. Capped, though: stretched across a tablet each digit would
// sit alone in a 150-wide slab and stop looking like a digit.
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 360),
child: Row(
children: [
for (var i = 0; i < _length; i++) ...[
if (i > 0) const SizedBox(width: 11),
Expanded(
child: _OtpBox(
controller: _boxes[i],
focusNode: _focus[i],
onChanged: (v) => _onChanged(i, v),
),
),
],
],
),
),
),
const SizedBox(height: 20),
// The countdown is mono so the seconds tick in place instead of the
// whole line reflowing every second as the glyph widths change.
Row(
children: [
Expanded(
child: _resendIn > 0
? Text.rich(
TextSpan(
style: DmText.tiny,
children: [
const TextSpan(text: 'Resend available in '),
TextSpan(
text: '0:${_resendIn.toString().padLeft(2, '0')}',
style: DmText.monoSm.copyWith(
fontSize: 13,
color: DmColors.brand,
),
),
],
),
)
: Text('Did not get it?', style: DmText.tiny),
),
DmTextAction(
label: 'Resend',
color: _resendIn > 0 ? DmColors.ink4 : DmColors.brand,
onPressed: _resendIn > 0 ? null : _resend,
),
],
),
],
);
}
}
/// One digit of the code.
///
/// Was a filled `TextField` with no border, which read as a disabled field:
/// four grey slabs with nothing to say which one you were typing into. It is
/// the only thing on the screen, so it carries the state instead — empty is
/// outlined, focused takes the brand edge and lifts, filled tints. The
/// `TextField` inside is bare; the container owns every visual.
class _OtpBox extends StatefulWidget {
const _OtpBox({
required this.controller,
required this.focusNode,
required this.onChanged,
});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
@override
State<_OtpBox> createState() => _OtpBoxState();
}
class _OtpBoxState extends State<_OtpBox> {
@override
void initState() {
super.initState();
widget.focusNode.addListener(_onFocus);
}
@override
void dispose() {
widget.focusNode.removeListener(_onFocus);
super.dispose();
}
void _onFocus() => setState(() {});
@override
Widget build(BuildContext context) {
final focused = widget.focusNode.hasFocus;
final filled = widget.controller.text.isNotEmpty;
return AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
height: 60,
decoration: BoxDecoration(
color: filled && !focused ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(
color: focused
? DmColors.brand
: filled
? DmColors.brandLine
: DmColors.border,
),
// The focus ring, exactly as the fields draw it — this is the same
// control, one digit wide.
boxShadow: focused
? const [
BoxShadow(
color: DmColors.brandSoft,
spreadRadius: 3,
blurRadius: 0,
),
]
: null,
),
child: Center(
child: TextField(
controller: widget.controller,
focusNode: widget.focusNode,
onChanged: widget.onChanged,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
cursorColor: DmColors.brand,
cursorWidth: 2,
showCursor: true,
style: DmText.otp,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
counterText: '',
isDense: true,
filled: false,
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../state/app_scope.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/pieces.dart';
import 'auth_scaffold.dart';
import 'otp_screen.dart';
/// Create an account — name and phone, with email optional.
///
/// The same OTP screen verifies both sign in and sign up; the name is carried
/// through so the new account is created with it.
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpScreenState extends State<SignUpScreen> {
final _name = TextEditingController();
final _phone = TextEditingController();
final _email = TextEditingController();
bool _sending = false;
@override
void initState() {
super.initState();
for (final c in [_name, _phone, _email]) {
c.addListener(() => setState(() {}));
}
}
@override
void dispose() {
for (final c in [_name, _phone, _email]) {
c.dispose();
}
super.dispose();
}
bool get _valid =>
_name.text.trim().length >= 2 && _phone.text.trim().length >= 10;
Future<void> _continue() async {
setState(() => _sending = true);
final phone = '+91 ${_phone.text.trim()}';
try {
final challenge = await AppScope.read(context).signUp(
name: _name.text.trim(),
phone: phone,
email: _email.text.trim().isEmpty ? null : _email.text.trim(),
);
if (!mounted) return;
setState(() => _sending = false);
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OtpScreen(
identifier: phone,
name: _name.text.trim(),
challenge: challenge,
),
),
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _sending = false);
DmToast.show(context, e.message);
}
}
@override
Widget build(BuildContext context) {
return AuthScaffold(
onBack: () => Navigator.of(context).maybePop(),
// Same entrance as sign in: the headline sits on the brand field at
// display size and the sheet starts straight into the form. Sign in and
// sign up are one doorway with two doors, so they cannot be laid out
// differently — a customer who bounces between them should see the
// screen change its words, not its shape.
heroTitle: 'Create your account.',
heroSub: 'A few details and you can book your first pickup.',
// Pinned for the same reason as sign in: a three-field form plus the
// keyboard leaves no room, and the CTA must not be the thing that loses.
footer: Column(
mainAxisSize: MainAxisSize.min,
children: [
DmButton(
label: 'Continue',
busy: _sending,
busyLabel: 'Sending code…',
onPressed: _valid ? _continue : null,
),
const SizedBox(height: 10),
AuthSwitchLink(
question: 'Already have an account?',
action: 'Sign in',
onTap: () => Navigator.of(context).maybePop(),
),
const SizedBox(height: 6),
const AuthLegal(),
],
),
children: [
DmTextField(
label: 'Full name',
controller: _name,
hint: 'Joe Oommen',
keyboardType: TextInputType.name,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Phone number',
controller: _phone,
hint: '98765 43210',
prefix: '+91',
keyboardType: TextInputType.phone,
digitsOnly: true,
maxLength: 10,
textInputAction: TextInputAction.next,
),
DmTextField(
label: 'Email',
optional: true,
controller: _email,
hint: 'you@example.com',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _valid ? _continue() : null,
),
// The same reassurance sign in carries, in the same wash card, under
// the field it is about.
const DmInfoBanner(
icon: LucideIcons.shieldCheck,
title: 'We verify every number',
message: "A 4-digit code confirms it's you and keeps your parcels "
'secure.',
),
],
);
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
/// Named steps in the booking flow.
///
/// Naming the routes lets Review's "Edit" actions pop straight back to the
/// step being corrected instead of stacking a second copy of it.
class BookingRoutes {
BookingRoutes._();
static const pickup = 'booking/pickup';
static const destination = 'booking/destination';
static const slot = 'booking/slot';
static const review = 'booking/review';
static const confirmed = 'booking/confirmed';
static const tracking = 'tracking';
}
/// A page route tagged with its step name.
Route<T> bookingRoute<T>(String name, Widget Function(BuildContext) builder) {
return MaterialPageRoute<T>(
settings: RouteSettings(name: name),
builder: builder,
);
}
/// Returns to an earlier step if it is still on the stack, otherwise pushes a
/// fresh copy of it. The navigator is captured up front because popping
/// unmounts the calling screen.
void editStep(
BuildContext context,
String name,
Widget Function(BuildContext) builder,
) {
final navigator = Navigator.of(context);
var found = false;
navigator.popUntil((route) {
if (route.settings.name == name) found = true;
return found || route.isFirst;
});
if (!found) {
navigator.push(bookingRoute<void>(name, builder));
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/app_config.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/misc.dart';
import '../../widgets/pieces.dart';
import '../../widgets/route_rail.dart';
import '../tracking_screen.dart';
import 'booking_routes.dart';
/// Booking confirmed.
///
/// A booking reference and a reserved window — not a shipment. The tracking
/// number appears later, once the Miler completes pickup.
///
/// ── What this screen stopped doing ──
///
/// It opened with a green tick in a circle, then a map, then a reference, then
/// a summary card, then two banners: five surfaces to say one thing. The tick
/// was also the only green in the product, which made the app's happiest moment
/// look like a system dialog rather than like Doormile.
///
/// It is one crimson card now — the payoff — followed by the reference, the
/// route as a thread, and the live search for a Miler as a row on that same
/// card. The map is gone: the customer chose this route two screens ago and a
/// second picture of it says nothing new while the parcel has not moved.
class ConfirmedScreen extends StatefulWidget {
const ConfirmedScreen({super.key});
@override
State<ConfirmedScreen> createState() => _ConfirmedScreenState();
}
class _ConfirmedScreenState extends State<ConfirmedScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _pop = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 520),
)..forward();
@override
void dispose() {
_pop.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final booking = app.trackedBooking;
final slot = booking == null ? null : app.slotById(booking.slotId);
final packages = booking?.totalPackages ?? 1;
return Scaffold(
backgroundColor: DmColors.canvas,
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 22, DmSpace.pad, 24),
children: [
DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 22, 18, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ScaleTransition(
scale: CurvedAnimation(
parent: _pop,
curve: Curves.easeOutBack,
),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.onBrandRaise,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
size: 24,
color: Colors.white,
),
),
),
const SizedBox(height: 16),
Text(
'Pickup booked',
style: DmText.display.copyWith(color: Colors.white),
),
const SizedBox(height: 8),
Text(
booking == null
? "We've reserved your slot."
: "We've reserved your slot. A nearby Miler is being "
'assigned to collect '
'${packages == 1 ? 'your package' : 'your packages'}.',
style: DmText.lede.copyWith(color: DmColors.onBrand2),
),
],
),
),
],
),
if (booking != null) ...[
const SizedBox(height: 10),
DmCard(
tone: DmCardTone.wash,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
child: DmCopyRow(
label: 'Booking reference',
value: booking.reference,
onWash: true,
),
),
],
),
const SizedBox(height: 10),
DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drops: [
for (final group in booking.destinations)
DmRailStop(
label: group.destination.label,
note: booking.isMultiDestination
? null
: group.details.exactAddress,
trailing:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],
),
),
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.calendarCheck,
size: 15,
color: DmColors.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// The day rides with the label so the window itself
// stays on one line. "Scheduled window" against
// "Today · 2:00 – 4:00 PM" broke across two, and
// wrapped a time range mid-range.
slot == null
? 'Scheduled window'
: 'Scheduled ${slot.day.toLowerCase()}',
style: DmText.small.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
slot?.window ?? '—',
textAlign: TextAlign.right,
style: DmText.cardTitle.copyWith(
color: DmColors.brandPress,
),
),
),
],
),
),
// The search is live from the moment this screen opens, so it
// belongs on the card rather than in a banner underneath it.
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const DmPill('Searching', tone: DmPillTone.live),
const SizedBox(width: 12),
Expanded(
child: Text(
'Finding the nearest Miler',
style: DmText.small.copyWith(
color: DmColors.brand,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation(DmColors.brand),
),
),
],
),
),
],
),
const SizedBox(height: 10),
// The one screen where a fake booking is most convincing, so it
// is the one screen that has to admit it.
if (AppConfig.useDevData)
const DmInfoBanner(
icon: LucideIcons.wifiOff,
tone: DmTagTone.warn,
title: 'Offline dev data — nothing was sent',
message: 'This build is not talking to a server, so this '
'booking exists only on this phone and will not appear '
'in the admin console.',
)
else
const DmInfoBanner(
icon: LucideIcons.package,
tone: DmTagTone.neutral,
title: 'Keep the parcel ready',
message: 'Sealed and labelled with the reference above.',
),
],
],
),
),
bottomNavigationBar: DmFooter(
children: [
DmButton(
label: 'Track pickup',
icon: LucideIcons.arrowRight,
onPressed: () => Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
),
),
DmButton(
label: 'Back to home',
kind: DmButtonKind.ghost,
onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst),
),
],
),
);
}
}

View File

@@ -0,0 +1,406 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import 'booking_routes.dart';
import 'slot_screen.dart';
/// Step 2 — where the packages are going.
///
/// Opens as a single destination, which is the common case: pick a state, pick
/// a district, done. Package count is never asked for — it keeps its model
/// default of 1 — and sending to a second place is a secondary action that only
/// appears once the first one is settled. A customer with one package to one
/// district never sees the multi-destination machinery.
class DestinationScreen extends StatefulWidget {
const DestinationScreen({super.key});
@override
State<DestinationScreen> createState() => _DestinationScreenState();
}
class _DestinationScreenState extends State<DestinationScreen> {
/// Which block is open for editing. Null when every block is settled, so
/// coming back from Review shows the chosen places rather than re-opening
/// the picker.
int? _editing;
@override
void initState() {
super.initState();
final groups = AppScope.read(context).draftDestinations;
final firstIncomplete = groups.indexWhere((g) => !g.isComplete);
_editing = firstIncomplete == -1 ? null : firstIncomplete;
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final groups = app.draftDestinations;
final limits = app.limits;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 2, total: 4, label: 'Destination'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
28,
),
children: [
// The question is the instruction. The line under it said
// "Choose from the areas we currently serve", which is what the
// list below already demonstrates by only containing those
// areas — a caption for a control that captions itself.
Text('Where are you sending it?', style: DmText.title),
const SizedBox(height: 20),
for (var i = 0; i < groups.length; i++) ...[
_DestinationBlock(
index: i,
group: groups[i],
editing: _editing == i || !groups[i].isComplete,
showRemove: groups.length > 1,
onEdit: () => setState(() => _editing = i),
onSettled: () => setState(() => _editing = null),
onRemove: () {
app.removeDestination(i);
setState(() => _editing = null);
},
),
const SizedBox(height: 12),
],
// Offered only where the backend allows more than one place.
// While `maxDestinations` is 1 there is nothing to explain and
// nothing to disable — a control that can never be used is
// better absent than permanently greyed.
if (app.allowsMultipleDestinations)
_AddDestination(
enabled: app.canAddDestination,
reason: _capReason(app, limits),
onTap: () {
if (app.addDestination()) {
setState(
() => _editing = app.draftDestinations.length - 1,
);
}
},
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Next',
icon: LucideIcons.arrowRight,
onPressed: app.draftReady
? () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.slot,
(_) => const SlotScreen(),
),
)
: null,
),
],
),
);
}
/// Friendly, non-alarming explanation when a cap blocks another place.
String? _capReason(dynamic app, BookingLimits limits) {
if (app.canAddDestination as bool) return null;
// Mid-selection is not a problem to explain — the action simply is not
// offered yet.
if (!(app.draftReady as bool)) return null;
if ((app.draftDestinations as List).length >= limits.maxDestinations) {
return 'Up to ${limits.maxDestinations} places in one pickup. '
'Book another pickup for more.';
}
return 'Up to ${limits.maxPackages} packages in one pickup.';
}
}
/// One place: state, district, how many packages, optional details.
class _DestinationBlock extends StatelessWidget {
const _DestinationBlock({
required this.index,
required this.group,
required this.editing,
required this.showRemove,
required this.onEdit,
required this.onSettled,
required this.onRemove,
});
final int index;
final DestinationGroup group;
final bool editing;
final bool showRemove;
final VoidCallback onEdit;
final VoidCallback onSettled;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return AnimatedSize(
duration: DmMotion.base,
curve: DmMotion.ease,
alignment: Alignment.topCenter,
child: editing
? _Picker(index: index, group: group, onSettled: onSettled)
: _Settled(
index: index,
group: group,
showRemove: showRemove,
onEdit: onEdit,
onRemove: onRemove,
),
);
}
}
/// Choose the state, then the districts open beneath it.
class _Picker extends StatelessWidget {
const _Picker({
required this.index,
required this.group,
required this.onSettled,
});
final int index;
final DestinationGroup group;
final VoidCallback onSettled;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final destination = group.destination;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DmMicroHead('Sending to', top: 0, bottom: 10),
DmAsyncList<ServiceArea>(
skeletonRows: 1,
load: ({bool refresh = false}) => app.loadStates(refresh: refresh),
emptyTitle: 'No service areas available',
emptyMessage: "We're not accepting new pickups right now. Please check back shortly.",
builder: (context, states) => SizedBox(
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: states.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, i) => DmChoiceChip(
label: states[i].name,
selected: destination.stateCode == states[i].code,
onTap: () => app.selectStateFor(index, states[i]),
),
),
),
),
if (destination.stateCode != null) ...[
const SizedBox(height: 18),
const DmMicroHead('District', top: 0, bottom: 10),
DmAsyncList<District>(
key: ValueKey('districts-${destination.stateCode}'),
skeletonRows: 3,
load: ({bool refresh = false}) =>
app.loadDistricts(destination.stateCode!, refresh: refresh),
emptyTitle: 'No districts open here yet',
emptyMessage:
"We're not delivering inside ${destination.stateName} right now. "
'Pick another state above.',
emptyActionLabel: 'Got it',
onEmptyAction: () {},
builder: (context, districts) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
itemCount: districts.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
// The cell grows with the reader's text size rather than
// clipping the district name.
// One per row as soon as the reader's text grows, so the
// place name always has room.
crossAxisCount:
MediaQuery.textScalerOf(context).scale(1) > 1.15
? 1
: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
mainAxisExtent: MediaQuery.textScalerOf(context).scale(70),
),
itemBuilder: (context, i) {
final d = districts[i];
return DmGridTile(
// Just the place. The delivery promise appears on the
// settled card, where it actually informs a decision.
label: d.name,
selected: destination.districtCode == d.code,
onTap: () {
app.selectDistrictFor(index, d);
onSettled();
},
);
},
),
// Places we are opening are named once, quietly — never as a
// grid of tiles the customer cannot use.
if (app.upcomingDistricts(destination.stateCode!).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12, left: 2, right: 2),
child: Text(
'Coming soon: '
'${app.upcomingDistricts(destination.stateCode!).join(', ')}',
style: DmText.small.copyWith(fontSize: 12.5),
),
),
],
),
),
],
],
);
}
}
/// A chosen place, collapsed to a card with its package count.
class _Settled extends StatelessWidget {
const _Settled({
required this.index,
required this.group,
required this.showRemove,
required this.onEdit,
required this.onRemove,
});
final int index;
final DestinationGroup group;
final bool showRemove;
final VoidCallback onEdit;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
onTap: onEdit,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
// Numbered only when there is more than one to tell apart.
eyebrow: showRemove ? 'Destination ${index + 1}' : 'Sending to',
title: group.destination.label,
subtitle: group.district?.promise,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
DmTextAction(label: 'Change', onPressed: onEdit),
if (showRemove)
IconButton(
onPressed: onRemove,
icon: const Icon(LucideIcons.x, size: 17),
color: DmColors.ink3,
tooltip: 'Remove this destination',
visualDensity: VisualDensity.compact,
),
],
),
),
// ── Two rows removed: package count, and exact delivery details ──
//
// A destination block was a card containing a divider, a stepper in
// its own row, another divider, and a row that opened a bottom sheet
// for optional detail — four decisions stacked under an address the
// customer had already given. Almost every booking is one package to
// one door, so the common path was paying for two controls it never
// touched.
//
// `packageCount` keeps its model default and the read-only screens
// still show it; what is gone is asking. The delivery-details sheet
// is deleted outright — see `sheets/`.
],
);
}
}
/// The quiet secondary action that unlocks multi-destination pickups.
class _AddDestination extends StatelessWidget {
const _AddDestination({
required this.enabled,
required this.reason,
required this.onTap,
});
final bool enabled;
final String? reason;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (!enabled) {
return reason == null
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Text(reason!, style: DmText.small),
);
}
return InkWell(
onTap: onTap,
borderRadius: DmRadius.all(DmRadius.xs),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
LucideIcons.circlePlus,
size: 19,
color: DmColors.brand,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'Send to another place',
style: DmText.label.copyWith(
fontSize: 15,
color: DmColors.brand,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/location_service.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/map_panel.dart';
import '../../widgets/pieces.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'destination_screen.dart';
/// Step 1 — where we collect from.
///
/// One card: a real map on top with the pin locked to its centre, and the
/// address it resolves to in the box directly underneath. Drag the map, the
/// address follows. Nothing is typed unless the customer wants to.
class PickupLocationScreen extends StatefulWidget {
const PickupLocationScreen({super.key});
@override
State<PickupLocationScreen> createState() => _PickupLocationScreenState();
}
class _PickupLocationScreenState extends State<PickupLocationScreen> {
/// True between the first touch on the map and the address coming back.
bool _pinMoving = false;
@override
void initState() {
super.initState();
// Arriving without a pin — ask the device where we are.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final app = AppScope.read(context);
if (app.pickup == null && !app.resolvingPickup) {
app.detectPickupLocation();
}
});
}
Future<void> _search(AppState app) async {
final picked = await showPlaceSearchSheet(context);
if (picked == null || !mounted) return;
app.setPickup(picked);
setState(() => _pinMoving = false);
DmToast.show(context, 'Pickup location updated');
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final place = app.pickup;
final resolving = app.resolvingPickup || _pinMoving;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 1, total: 4, label: 'Pickup point'),
const Padding(
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0),
child: _Intro(),
),
const SizedBox(height: 16),
// The map and the address it resolves to are one object, so they get
// one card. Not a ListView: the map must win vertical drags.
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
14,
),
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Column(
children: [
Expanded(
child: DmMapPanel(
lat: app.mapLat,
lng: app.mapLng,
deviceLat: app.deviceLat,
deviceLng: app.deviceLng,
deviceAccuracy: app.deviceAccuracy,
loading: resolving,
borderRadius: BorderRadius.zero,
onLocate: () {
setState(() => _pinMoving = false);
app.detectPickupLocation();
},
onPinMoveStart: () {
if (!_pinMoving) setState(() => _pinMoving = true);
},
onPinMoved: (lat, lng) {
setState(() => _pinMoving = false);
app.setPickupFromPin(lat, lng);
},
),
),
_AddressBox(
title: place?.title,
sub: place?.sub,
resolving: resolving,
onChange: () => _search(app),
),
],
),
),
),
),
if (app.locationDenial != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
12,
),
child: _LocationNotice(
denial: app.locationDenial!,
onSettings: app.openLocationSettings,
onSearch: () => _search(app),
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Next',
icon: LucideIcons.arrowRight,
// Gated on the pin, not just the address: a booking without
// coordinates is one the Miler cannot be routed to.
onPressed: !app.hasPickupPoint || resolving
? null
: () {
app.draftPickup = place;
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.destination,
(_) => const DestinationScreen(),
),
);
},
),
],
),
);
}
}
class _Intro extends StatelessWidget {
const _Intro();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Where should we pick up?', style: DmText.title),
const SizedBox(height: 8),
Text('Drag the map to move the pin.', style: DmText.lede),
],
);
}
}
/// The address under the map. It is the same card, so a hairline separates
/// them rather than a gap.
class _AddressBox extends StatelessWidget {
const _AddressBox({
required this.title,
required this.sub,
required this.resolving,
required this.onChange,
});
final String? title;
final String? sub;
final bool resolving;
final VoidCallback onChange;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(14, 13, 10, 14),
decoration: const BoxDecoration(
color: DmColors.brandSoft,
border: Border(top: BorderSide(color: DmColors.border)),
),
child: Row(
children: [
// The resolved address is the map's answer, so it takes the wash: it
// is brand-owned context, not a field waiting to be filled.
const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash),
const SizedBox(width: 12),
Expanded(
child: AnimatedSwitcher(
duration: DmMotion.base,
child: resolving
? const _ResolvingLines(key: ValueKey('resolving'))
: Column(
key: ValueKey('$title|$sub'),
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title ?? 'Locating you…',
key: const ValueKey('pickupAddressTitle'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle,
),
const SizedBox(height: 3),
Text(
sub ?? 'Please wait a moment',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.small.copyWith(color: DmColors.brandPress),
),
],
),
),
),
DmTextAction(label: 'Change', onPressed: onChange),
],
),
);
}
}
/// What the address box shows while the pin is moving: the shape of an
/// address, so the card never collapses or jumps.
class _ResolvingLines extends StatelessWidget {
const _ResolvingLines({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Getting address…',
style: DmText.cardTitle.copyWith(color: DmColors.ink3),
),
const SizedBox(height: 6),
Container(
width: 148,
height: 9,
decoration: BoxDecoration(
color: DmColors.brandLine,
borderRadius: BorderRadius.circular(4),
),
),
],
);
}
}
/// Shown only when the device refused us a position. Says what happened and
/// offers the way out, rather than leaving the pin sitting on a default.
class _LocationNotice extends StatelessWidget {
const _LocationNotice({
required this.denial,
required this.onSettings,
required this.onSearch,
});
final LocationDenial denial;
final VoidCallback onSettings;
final VoidCallback onSearch;
@override
Widget build(BuildContext context) {
final settings = denial == LocationDenial.deniedForever;
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 12, 12),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
const Icon(
LucideIcons.mapPinOff,
size: 18,
color: DmColors.ink2,
),
const SizedBox(width: 10),
Expanded(
child: Text(
LocationFix.denied(denial).message,
style: DmText.small.copyWith(color: DmColors.ink2),
),
),
const SizedBox(width: 8),
DmChipButton(
label: settings ? 'Settings' : 'Search',
onPressed: settings ? onSettings : onSearch,
),
],
),
);
}
}

View File

@@ -0,0 +1,377 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/pieces.dart';
import '../../widgets/route_map.dart';
import '../../widgets/route_rail.dart';
import '../../widgets/summary.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'confirmed_screen.dart';
import 'destination_screen.dart';
import 'slot_screen.dart';
/// Step 4 — review.
///
/// Reads the same whether the pickup carries one package to one district or
/// six across three. Nothing is charged here: the fare is indicative until the
/// Miler verifies the packages at the door.
class ReviewScreen extends StatefulWidget {
const ReviewScreen({super.key});
@override
State<ReviewScreen> createState() => _ReviewScreenState();
}
class _ReviewScreenState extends State<ReviewScreen> {
bool _busy = false;
Future<void> _confirm() async {
setState(() => _busy = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.confirmBooking();
unawaited(HapticFeedback.mediumImpact());
navigator.pushAndRemoveUntil(
bookingRoute<void>(BookingRoutes.confirmed, (_) => const ConfirmedScreen()),
(route) => route.isFirst,
);
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _busy = false);
DmToast.show(context, e.message);
// A slot that filled up, or whose window passed while this screen was
// open, is not something the customer can fix from Review — the only
// move is to pick again. `confirmBooking` has already dropped the stale
// list, so going back re-reads it.
if (e.needsFreshSlots) {
navigator.popUntil(
(route) => route.settings.name == BookingRoutes.slot || route.isFirst,
);
}
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final pickup = app.draftPickup ?? app.pickup;
final slot = app.slotById(app.draftSlotId);
final groups = app.draftDestinations;
final multi = groups.length > 1;
final packages = app.draftTotalPackages;
final fare = app.draftFare;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 4, total: 4, label: 'Review'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// "Pay nothing now" belongs beside the amount, where the
// summary already states it — not as a paragraph under the
// heading that has to be read before the thing it describes.
Text('Review pickup details', style: DmText.title),
const SizedBox(height: 18),
DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: pickup,
destination: groups.first.hub,
pickupLabel: pickup?.title,
destinationLabel: multi
? '${groups.length} destinations'
: groups.first.destination.districtName,
badge: _MapBadge(
icon: multi ? LucideIcons.route : LucideIcons.arrowRight,
label: multi
? '${groups.length} destinations'
: 'Door to door',
),
),
],
),
const SizedBox(height: 10),
DmSummaryCard(
children: [
// One journey, one rail: pickup connected to everywhere it
// is going, exactly as an order card reads.
_RouteSection(
pickup: pickup,
groups: groups,
onEditPickup: () async {
final picked = await showPlaceSearchSheet(context);
if (picked != null && context.mounted) {
app.setPickup(picked);
app.draftPickup = picked;
}
},
onChangeDestinations: () => editStep(
context,
BookingRoutes.destination,
(_) => const DestinationScreen(),
),
),
DmSummaryRow(
label: 'Pickup time',
value: slot?.label ?? '—',
actionLabel: 'Change',
onAction: () => editStep(
context,
BookingRoutes.slot,
(_) => const SlotScreen(),
),
),
],
),
if (multi) ...[
const SizedBox(height: 10),
Row(
children: [
const Icon(
LucideIcons.package,
size: 17,
color: DmColors.ink3,
),
const SizedBox(width: 8),
Flexible(
child: Text(
'$packages packages · ${groups.length} destinations',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 14),
),
),
const SizedBox(width: 10),
Flexible(
child: Text(
'One Miler visit',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small,
),
),
],
),
],
const SizedBox(height: 10),
DmNote(
child: Text.rich(
TextSpan(
style: DmText.small.copyWith(color: DmColors.ink2, height: 1.5),
children: [
const TextSpan(text: 'This books '),
const TextSpan(
text: 'one Miler visit',
style: TextStyle(
fontWeight: FontWeight.w700,
color: DmColors.ink,
),
),
TextSpan(
text: '. Tracking is created once your packages are '
'collected, and cancelling is free until then. '
'The amount below covers ',
),
TextSpan(
text: '$packages '
'${packages == 1 ? 'package' : 'packages'} and is '
'confirmed after the Miler verifies them at your '
'door.',
),
],
),
),
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
// The price sits directly above the button that agrees to it. It
// used to live in a card halfway up the page, which meant the last
// thing on screen before "Book Pickup" could easily be a note about
// cancellation rather than the amount.
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('Estimated total', style: DmText.small),
// The method is its own line: appended to the label it
// made one long string that truncated mid-word, and the
// customer reads the two facts separately anyway.
if (fare?.paymentMethod != null)
Text(
fare!.paymentMethod!,
style: DmText.tiny.copyWith(fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
Text(
fare?.range ?? 'Calculating…',
style: DmText.monoLg.copyWith(fontSize: 20, color: DmColors.brand),
),
],
),
),
DmButton(
label: 'Book pickup',
icon: LucideIcons.arrowRight,
busy: _busy,
busyLabel: 'Booking…',
onPressed: app.draftReady ? _confirm : null,
),
DmButton(
label: 'Edit details',
kind: DmButtonKind.ghost,
onPressed: _busy ? null : () => Navigator.of(context).maybePop(),
),
],
),
);
}
}
/// Pickup and every destination on one rail, with the two edit actions.
class _RouteSection extends StatelessWidget {
const _RouteSection({
required this.pickup,
required this.groups,
required this.onEditPickup,
required this.onChangeDestinations,
});
final Place? pickup;
final List<DestinationGroup> groups;
final VoidCallback onEditPickup;
final VoidCallback onChangeDestinations;
bool get _anyMissingDetails => groups.any(
(g) => g.details.exactAddress == null && g.details.recipient == null,
);
@override
Widget build(BuildContext context) {
final multi = groups.length > 1;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
pickupEyebrow: 'Pickup',
pickup: pickup?.title ?? '—',
pickupNote: pickup?.sub,
pickupTrailing: DmTextAction(
label: 'Edit',
onPressed: onEditPickup,
minHeight: 32,
),
dropsEyebrow: 'Sending to',
drops: [
for (final group in groups)
DmRailStop(
label: group.destination.label,
trailing:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
note: multi
? null
: (group.details.exactAddress ??
(group.details.recipient == null
? 'Not added — the Miler can confirm this at pickup.'
: 'Recipient · ${group.details.recipient}')),
),
],
),
// With several places, say it once rather than per line.
if (multi && _anyMissingDetails)
Padding(
padding: const EdgeInsets.only(top: 8, left: 30),
child: Text(
'Exact addresses not added — the Miler can confirm these at pickup.',
style: DmText.small.copyWith(fontSize: 12.5),
),
),
Align(
alignment: Alignment.centerRight,
child: DmTextAction(
label: multi ? 'Change destinations' : 'Change destination',
onPressed: onChangeDestinations,
minHeight: 36,
),
),
],
),
);
}
}
class _MapBadge extends StatelessWidget {
const _MapBadge({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
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: DmColors.brand),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
import '../../widgets/option_tile.dart';
import '../../widgets/pieces.dart';
import '../../widgets/states.dart';
import '../sheets/place_search_sheet.dart';
import 'booking_routes.dart';
import 'review_screen.dart';
/// Step 2 — Pickup timing.
///
/// Confirms where we are collecting from and which window the customer wants,
/// showing only what the backend still has capacity for.
class SlotScreen extends StatefulWidget {
const SlotScreen({super.key});
@override
State<SlotScreen> createState() => _SlotScreenState();
}
class _SlotScreenState extends State<SlotScreen> {
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final pickup = app.draftPickup ?? app.pickup;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Schedule pickup'),
body: Column(
children: [
const DmStepHeader(step: 3, total: 4, label: 'Pickup time'),
Expanded(
child: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// Reassurance, not instruction: "A verified Miler arrives at
// your door inside the window you pick" restated the heading,
// the product, and the list of windows underneath it. The
// customer is here to pick a time.
Text('When should we come?', style: DmText.title),
const SizedBox(height: 20),
// ── Where we are collecting from ──
//
// A wash card, because it is brand-owned context rather than a
// field: the customer already chose this address on the screen
// before, and it is here to be confirmed at a glance, not
// re-entered. Change is the only control on it.
DmCard(
tone: DmCardTone.wash,
children: [
DmCardRow(
tone: DmCardTone.wash,
leading: const DmTile(
icon: LucideIcons.mapPin,
tone: DmTileTone.wash,
),
eyebrow: 'Collecting from',
title: pickup?.title ?? 'Locating you…',
subtitle: pickup?.sub,
trailing: DmTextAction(
label: 'Change',
onPressed: () async {
final picked = await showPlaceSearchSheet(context);
if (picked != null && context.mounted) {
app.setPickup(picked);
app.draftPickup = picked;
DmToast.show(context, 'Pickup location updated');
}
},
),
),
],
),
const SizedBox(height: 22),
// ---- available windows --------------------------------------
// One eyebrow, not two. "Local time (IST)" sat opposite this
// one explaining a timezone the customer is standing in.
const DmMicroHead('Available windows', top: 0, bottom: 10),
DmAsyncList<PickupSlot>(
// Re-reads whenever the slot list is invalidated — starting a
// booking, or the server refusing a window that has passed.
reloadToken: app.slotsEpoch,
load: ({bool refresh = false}) => app.loadSlots(refresh: refresh),
emptyIcon: LucideIcons.clock,
emptyTitle: 'No pickup slots left today',
emptyMessage:
'All windows are full right now. Try again in a little while.',
builder: (context, slots) {
final days = <String>[];
for (final slot in slots) {
if (!days.contains(slot.day)) days.add(slot.day);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final day in days) ...[
Padding(
padding: const EdgeInsets.only(bottom: 10, top: 4),
child: Text(day, style: DmText.sectionHead),
),
for (final slot in slots.where((s) => s.day == day))
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SlotCard(
slot: slot,
selected: app.draftSlotId == slot.id,
onTap: () => app.selectSlot(slot),
),
),
const SizedBox(height: 10),
],
],
);
},
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Review booking',
icon: LucideIcons.arrowRight,
onPressed: app.draftSlotId == null
? null
: () {
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.review,
(_) => const ReviewScreen(),
),
);
},
),
],
),
);
}
}
/// One window.
///
/// Selection **fills** here (see [DmOptionTile]): on a list of five windows a
/// tinted card was too quiet to answer "which one did I pick?" at a glance,
/// and this is the last decision before the customer commits to a price.
class _SlotCard extends StatelessWidget {
const _SlotCard({required this.slot, required this.selected, required this.onTap});
final PickupSlot slot;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final enabled = slot.available;
return DmOptionTile(
title: slot.window,
// The time is the thing being chosen, so it is set in the display face
// rather than at row weight.
titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2),
subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'),
selected: selected,
enabled: enabled,
onTap: onTap,
tags: [
if (slot.tag != null)
DmTag(
slot.tag!,
tone: selected ? DmTagTone.onFill : DmTagTone.brand,
icon: LucideIcons.zap,
),
if (slot.milersNearby > 0)
DmTag(
'${slot.milersNearby} Milers nearby',
tone: selected ? DmTagTone.onFill : DmTagTone.neutral,
icon: LucideIcons.userRound,
),
],
// A full window offers the only thing left to do with it.
trailing: enabled
? null
: DmTextAction(
label: 'Notify me',
color: DmColors.ink3,
onPressed: () =>
DmToast.show(context, "We'll let you know if it frees up"),
),
);
}
}

View File

@@ -0,0 +1,345 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart';
import 'booking/pickup_location_screen.dart';
import 'shell_screen.dart';
import 'sheets/place_search_sheet.dart';
import 'tracking_screen.dart';
/// Home — one question, one dominant action, and whatever is live right now.
/// Nothing else competes; history belongs to the Orders tab.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
final firstRun = app.orders.isEmpty;
// The pickup point is already confirmed on this screen, so booking starts
// at the destination — two taps from here to a reserved slot.
void startBooking() {
AppScope.read(context).startBooking();
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmBrandHeader(
eyebrow: 'Welcome, ${_firstName(app.customer?.name)}',
title: firstRun ? 'Send your first parcel' : 'Send a parcel',
action: InkResponse(
onTap: () => ShellScreen.of(context)?.goToTab(2),
radius: 26,
child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true),
),
// The address is the one fact every booking starts from, so it sits
// in the header as a live value the customer can change in place —
// not as the first question of the flow.
child: DmHeaderTrack(
leading: const DmLiveDot(size: 6, color: Colors.white),
trailing: const Icon(
LucideIcons.chevronDown,
size: 16,
color: DmColors.onBrand3,
),
onTap: () async {
final place = await showPlaceSearchSheet(context);
if (place != null && context.mounted) app.setPickup(place);
},
child: Text(
app.pickup?.title ?? 'Set a pickup address',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.cardTitle.copyWith(color: Colors.white),
),
),
),
Expanded(
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: () async {
await Future.wait([app.detectPickupLocation(), app.refreshOrders()]);
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
children: [
_BookCta(onTap: startBooking),
if (active != null) ...[
const DmMicroHead('Happening now'),
_ActiveCard(booking: active),
],
if (firstRun) const _HowItWorks(),
],
),
),
),
],
);
}
static String _firstName(String? name) {
final first = (name ?? '').trim().split(RegExp(r'\s+')).first;
return first.isEmpty ? 'there' : first;
}
}
/// The screen's one action, as a filled crimson card.
///
/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object,
/// which on first run told a new customer nothing about what the app does. The
/// card says what will happen ("a Miler collects from your door in a window you
/// choose") and puts the action inside it, so the promise and the button are
/// one object rather than a shout followed by an explanation elsewhere.
class _BookCta extends StatelessWidget {
const _BookCta({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Book a pickup',
style: DmText.heading.copyWith(fontSize: 19, color: Colors.white),
),
const SizedBox(height: 4),
Text(
'A Miler collects from your door in a window you choose.',
style: DmText.small.copyWith(color: DmColors.onBrand2),
),
const SizedBox(height: 14),
// White on crimson: inside a filled card the primary action has
// to invert, or it disappears into its own background.
_WhiteButton(label: 'Start booking', onTap: onTap),
],
),
),
],
);
}
}
class _WhiteButton extends StatefulWidget {
const _WhiteButton({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
State<_WhiteButton> createState() => _WhiteButtonState();
}
class _WhiteButtonState extends State<_WhiteButton> {
bool _down = false;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapUp: (_) => setState(() => _down = false),
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
// A minimum, not a height: at large accessibility text the label
// needs the extra line, and a fixed 46 would clip it.
constraints: const BoxConstraints(minHeight: 46),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: _down ? DmColors.brandSoft : Colors.white,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
widget.label,
textAlign: TextAlign.center,
style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand),
),
),
const SizedBox(width: 9),
const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand),
],
),
),
),
);
}
}
/// Shown on first run only: three numbered rows that match the booking flow
/// exactly, so the first send has no surprises in it.
class _HowItWorks extends StatelessWidget {
const _HowItWorks();
static const _steps = [
('Tell us where it goes', 'City, address and recipient'),
('Pick a two-hour window', 'With live Miler availability'),
('Hand it over and track', 'Door to hub to doorstep, with proof'),
];
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmMicroHead('How it works'),
DmCard(
children: [
for (var i = 0; i < _steps.length; i++)
DmCardRow(
leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand),
title: _steps[i].$1,
subtitle: _steps[i].$2,
),
],
),
],
);
}
}
class _ActiveCard extends StatelessWidget {
const _ActiveCard({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(booking.slotId);
final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index;
final windowLabel = slot == null
? 'Pickup scheduled'
: '${slot.day} · ${slot.window}';
final courierLine = switch (booking.stage) {
JourneyStage.booked => 'Matching the nearest Miler',
JourneyStage.assigned when booking.miler != null =>
'${booking.miler!.name} will collect your parcel',
JourneyStage.onTheWay when booking.miler != null =>
'${booking.miler!.name} is on route'
'${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}',
JourneyStage.arrived when booking.miler != null =>
'${booking.miler!.name} is at your door',
_ =>
booking.isMultiDestination
? 'To ${booking.destinations.length} destinations'
: 'To ${booking.destination.label}',
};
return DmCard(
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(BookingRoutes.tracking, (_) => const TrackingScreen()),
);
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(booking.stage.milestoneLabel)),
const SizedBox(width: 10),
Expanded(
child: Text(
booking.displayId,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
),
),
],
),
),
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
),
const SizedBox(height: 12),
Text(courierLine, style: DmText.small),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
Icon(
beforeHandover ? LucideIcons.clock : LucideIcons.truck,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
beforeHandover ? windowLabel : 'On its way',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Track',
icon: LucideIcons.arrowRight,
onPressed: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.tracking,
(_) => const TrackingScreen(),
),
);
},
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,619 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/milestones.dart';
import '../widgets/misc.dart';
import '../widgets/pieces.dart';
import '../widgets/route_rail.dart';
import '../widgets/states.dart';
import '../widgets/summary.dart';
/// A finished order, in full: where it went, what happened when, and exactly
/// what it cost.
class OrderDetailsScreen extends StatelessWidget {
const OrderDetailsScreen({super.key, required this.reference});
final String reference;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final booking = app.byReference(reference);
if (booking == null) {
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Order details'),
body: const DmEmptyState(
icon: LucideIcons.package,
title: 'Order not found',
message: 'It may have been removed.',
),
);
}
final cancelled = booking.isCancelled;
final slot = app.slotById(booking.slotId);
final recipient = booking.details.recipient;
final fare = booking.fare;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Order details'),
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28),
children: [
// ── The outcome, as the screen's one filled surface ──
//
// Delivered used to be an eyebrow, a pill, a 32pt word and a date,
// all lying flat on the canvas above a stack of white cards. The
// journey is over; this is the moment the app should feel like
// something happened, so it gets the crimson card and everything
// underneath it stays a record.
if (cancelled)
DmCard(
children: [
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const DmPill('Cancelled', tone: DmPillTone.off),
const Spacer(),
Flexible(
child: Text(
booking.displayId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.mono,
),
),
],
),
const SizedBox(height: 12),
Text(
'Booking cancelled',
style: DmText.display.copyWith(fontSize: 25),
),
const SizedBox(height: 6),
Text(
'Cancelled on ${dateLabel(booking.createdAt)}. '
'You were not charged.',
style: DmText.small,
),
],
),
),
],
)
else
DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.onBrandRaise,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.check,
size: 22,
color: Colors.white,
),
),
const SizedBox(height: 14),
Text(
recipient == null
? 'Delivered'
: 'Delivered to $recipient',
style: DmText.display.copyWith(
fontSize: 26,
color: Colors.white,
),
),
const SizedBox(height: 6),
Text(
'${booking.destination.districtName} · '
'${dateLabel(booking.deliveredAt ?? booking.createdAt)}',
style: DmText.small.copyWith(color: DmColors.onBrand2),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
const Icon(
LucideIcons.package,
size: 15,
color: DmColors.onBrand3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
booking.packageSummary ??
'${booking.totalPackages} '
'${booking.totalPackages == 1 ? 'package' : 'packages'}',
style: DmText.small.copyWith(color: DmColors.onBrand2),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
booking.displayId,
style: DmText.monoSm.copyWith(color: Colors.white),
),
],
),
),
],
),
const SizedBox(height: 10),
// ---- where it went ------------------------------------------------
_Card(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote:
booking.details.exactAddress ??
(recipient == null ? null : 'Recipient · $recipient'),
),
),
const SizedBox(height: 10),
// ---- what the Miler actually collected ----------------------------
if (booking.destinations.any((d) => d.verification != null)) ...[
_VerificationCard(booking: booking),
const SizedBox(height: 10),
],
// ---- each place it went to, once there was more than one ----------
if (booking.isMultiDestination) ...[
_Card(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
child: Text(
'PACKAGES',
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
for (final group in booking.destinations)
_Line(
label: group.destination.label,
value:
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
const SizedBox(height: 6),
],
),
),
const SizedBox(height: 10),
],
// ---- what it cost -------------------------------------------------
if (!cancelled) ...[
_Card(
padding: EdgeInsets.zero,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
child: Row(
children: [
Expanded(
child: Text(
'PAYMENT',
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
Text(
'₹${booking.amountPaid ?? fare?.max ?? 0}',
style: DmText.monoLg.copyWith(fontSize: 22),
),
],
),
),
const Divider(
height: 1,
thickness: 1,
color: DmColors.hairline,
),
if (fare?.parcel != null)
_Line(label: 'Parcel', value: fare!.parcel!),
_Line(
label: 'Base fare',
value: fare == null ? '—' : '₹${fare.min}',
),
if (booking.routeKm != null)
_Line(label: 'Distance', value: '${booking.routeKm} km'),
_Line(
label: 'Weight adjustment',
value: fare == null || booking.amountPaid == null
? '—'
: '₹${booking.amountPaid! - fare.min}',
),
if (booking.totalWeightLabel != null)
_Line(
label: 'Verified weight',
value: booking.totalWeightLabel!,
),
const Divider(
height: 1,
thickness: 1,
color: DmColors.hairline,
),
_Line(
label: 'Total paid',
value: '₹${booking.amountPaid ?? fare?.max ?? 0}',
strong: true,
),
if (fare?.paymentMethod != null)
_Line(label: 'Method', value: fare!.paymentMethod!),
const SizedBox(height: 6),
],
),
),
const SizedBox(height: 10),
],
// ---- the facts ----------------------------------------------------
DmSummaryCard(
children: [
DmSummaryRow(
label: 'Booking reference',
value: booking.reference,
mono: true,
),
for (final group in booking.destinations)
if (group.trackingId != null)
DmSummaryRow(
label: booking.isMultiDestination
? 'Tracking · ${group.destination.districtName}'
: 'Tracking number',
value: group.trackingId!,
mono: true,
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
if (recipient != null)
DmSummaryRow(label: 'Recipient', value: recipient),
if (booking.miler != null)
DmSummaryRow(
label: 'Collected by',
value: booking.miler!.name,
secondary: booking.miler!.vehicle,
),
if (cancelled && booking.cancelReason != null)
DmSummaryRow(
label: 'Cancellation reason',
value: booking.cancelReason!,
),
],
),
// ---- what happened, when -----------------------------------------
if (!cancelled) ...[
const SizedBox(height: 10),
DmMilestones(booking: booking, title: 'Journey'),
],
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
DmButton(
label: 'Need help with this order?',
kind: DmButtonKind.outline,
onPressed: () => DmToast.show(
context,
'Support chat is not available yet',
),
),
],
),
);
}
}
/// Weight and photographs taken by the Miler at the door.
///
/// This is the customer's record of what was handed over, and the weight the
/// final price was calculated from.
class _VerificationCard extends StatelessWidget {
const _VerificationCard({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
final groups = booking.destinations
.where((g) => g.verification != null)
.toList();
final first = groups.first.verification!;
return _Card(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 12),
child: Row(
children: [
Expanded(
child: Text(
'COLLECTED AT YOUR DOOR',
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
const SizedBox(width: 10),
const Flexible(
child: DmTag(
'Miler verified',
tone: DmTagTone.ok,
icon: LucideIcons.badgeCheck,
),
),
],
),
),
for (var i = 0; i < groups.length; i++) ...[
if (i > 0)
const Padding(
padding: EdgeInsets.only(left: 18),
child: Divider(
height: 1,
thickness: 1,
color: DmColors.hairline,
),
),
_VerifiedGroup(
group: groups[i],
showDestination: booking.isMultiDestination,
),
],
Padding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 16),
child: Text(
'Weighed and photographed by '
'${first.capturedBy ?? 'your Miler'} on '
'${dateLabel(first.capturedAt)}.',
style: DmText.small.copyWith(fontSize: 12.5),
),
),
],
),
);
}
}
class _VerifiedGroup extends StatelessWidget {
const _VerifiedGroup({required this.group, required this.showDestination});
final DestinationGroup group;
final bool showDestination;
@override
Widget build(BuildContext context) {
final verification = group.verification!;
return Padding(
padding: const EdgeInsets.fromLTRB(18, 12, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
showDestination
? group.destination.label
: '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
style: DmText.bodyStrong.copyWith(fontSize: 15),
),
),
const SizedBox(width: 10),
Text(
verification.weightLabel,
style: DmText.monoLg.copyWith(fontSize: 17),
),
],
),
if (showDestination) ...[
const SizedBox(height: 2),
Text(
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
style: DmText.small,
),
],
const SizedBox(height: 12),
SizedBox(
height: 76,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: verification.photos.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) =>
_ParcelPhoto(
source: verification.photos[i],
index: i + 1,
total: verification.photos.length),
),
),
],
),
);
}
}
/// A photograph the Miler took of this parcel at pickup.
///
/// [source] is whatever `ParcelVerification.photos` carried. It is a fetchable
/// URL once the images are stored remotely, and until then it is an opaque
/// reference — so the frame is drawn either way and the image only when there
/// is genuinely one to load. A broken URL falls back to the same frame rather
/// than a broken-image glyph.
class _ParcelPhoto extends StatelessWidget {
const _ParcelPhoto({
required this.source,
required this.index,
required this.total,
});
final String source;
final int index;
final int total;
bool get _isFetchable =>
source.startsWith('http://') || source.startsWith('https://');
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Pickup photo $index of $total',
image: true,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.xs),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
const Center(
child: Icon(
LucideIcons.package,
size: 26,
color: DmColors.ink4,
),
),
if (_isFetchable)
Image.network(
source,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
Positioned(
left: 6,
bottom: 5,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$index of $total',
style: DmText.monoSm.copyWith(fontSize: 10.5, color: DmColors.ink),
),
),
),
],
),
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child, this.padding = const EdgeInsets.all(16)});
final Widget child;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: child,
);
}
}
class _Line extends StatelessWidget {
const _Line({required this.label, required this.value, this.strong = false});
final String label;
final String value;
final bool strong;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
label,
style: strong ? DmText.cardTitle : DmText.small,
),
),
const SizedBox(width: 12),
// Figures are mono so a column of them lines up and can be compared
// without being read.
//
// Sized to its own content rather than made a second flex child: two
// flex children split the row between them and left the figure
// floating in the middle instead of on the card's right edge. The
// cap is what keeps that honest at large text — a long value ("Cash
// at doorstep" at 1.8x) wraps inside it instead of pushing the row
// off the card.
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 170),
child: Text(
value,
textAlign: TextAlign.right,
style: DmText.mono.copyWith(
fontSize: strong ? 15 : 13.5,
color: strong ? DmColors.brand : DmColors.ink,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/cards.dart';
import '../widgets/misc.dart';
import '../widgets/route_rail.dart';
import 'booking/booking_routes.dart';
import 'order_details_screen.dart';
import 'tracking_screen.dart';
/// One row in Orders, as a pickup → drop card.
///
/// Before collection the card is the pickup. Afterwards each destination has
/// its own tracking number and journey, so it gets its own card with a quiet
/// line back to the visit they were collected in.
///
/// ── Status first, reference beside it ──
///
/// The state and the identifier sit together in a washed strip across the top,
/// and the route sits underneath. A customer scanning this list is asking one
/// question — what is happening to my parcel — and the answer is now the first
/// thing on every card, at the same height on each, instead of being a pill
/// tucked under a route of variable length.
class OrderCard extends StatelessWidget {
const OrderCard({super.key, required this.entry});
final OrderEntry entry;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final booking = entry.booking;
final group = entry.group;
final live = booking.status == BookingStatus.active;
final stage = group?.stage ?? booking.stage;
final (label, tone) = switch (booking.status) {
BookingStatus.cancelled => ('Cancelled', DmPillTone.off),
BookingStatus.completed => ('Delivered', DmPillTone.done),
BookingStatus.active => (
stage.milestoneLabel,
// Crimson-filled only while something is actually moving towards the
// customer. Everything else in progress holds the outline, so a list
// of six active orders has at most a couple of filled pills on it.
_isMoving(stage) ? DmPillTone.live : DmPillTone.neutral,
),
};
final drop = entry.isPickup
? (booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label)
: group!.destination.label;
final reference = entry.isPickup
? booking.reference
: (group!.trackingId ?? booking.reference);
final packages = entry.isPickup ? booking.totalPackages : group!.packageCount;
final meta = [
'$packages ${packages == 1 ? 'package' : 'packages'}',
if (entry.isPickup && booking.isMultiDestination)
'${booking.destinations.length} destinations',
if (booking.status == BookingStatus.completed && booking.amountPaid != null)
'₹${booking.amountPaid} paid'
else
dateLabel(booking.createdAt),
].join(' · ');
return DmCard(
// The spine marks the one card the customer has to do something about.
rail: live && stage == JourneyStage.arrived,
onTap: () {
app.openTracking(booking.reference);
Navigator.of(context).push(
live
? bookingRoute<void>(
BookingRoutes.tracking,
(_) => TrackingScreen(focusedDestination: entry.destinationIndex),
)
: MaterialPageRoute<void>(
builder: (_) => OrderDetailsScreen(reference: booking.reference),
),
);
},
children: [
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
child: Row(
children: [
Flexible(child: DmPill(label, tone: tone)),
const SizedBox(width: 10),
Expanded(
child: Text(
reference,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.mono,
),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DmRouteRail(
compact: true,
pickup: booking.pickup.title,
drop: drop,
),
const SizedBox(height: 11),
Text(meta, style: DmText.tiny),
if (entry.collectedTogether) ...[
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: DmText.tiny.copyWith(color: DmColors.ink4),
children: [
const TextSpan(text: 'Collected on '),
TextSpan(
text: booking.reference,
style: DmText.monoSm.copyWith(color: DmColors.ink4),
),
],
),
),
],
],
),
),
],
);
}
/// True while the parcel — or the Miler coming for it — is physically on the
/// move, which is the only time the loud pill is earned.
static bool _isMoving(JourneyStage stage) => switch (stage) {
JourneyStage.booked ||
JourneyStage.onTheWay ||
JourneyStage.arrived ||
JourneyStage.outForDelivery =>
true,
_ => false,
};
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../state/app_scope.dart';
import '../format.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/chrome.dart';
import '../widgets/states.dart';
import 'order_row.dart';
/// Orders — current bookings and past ones, as route cards.
///
/// The month, the title and the filter all live inside one crimson field at
/// the top. That is deliberate: the filter is this screen's only control, and
/// recessing it into the header means the list below can be pure content with
/// no chrome of its own — no title bar competing with the first card, no
/// separate count row, because each tab carries its own figure.
class OrdersScreen extends StatelessWidget {
const OrdersScreen({super.key});
static const _tabs = BookingStatus.values;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final rows = app.entriesOf(app.ordersTab);
final index = _tabs.indexOf(app.ordersTab);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmBrandHeader(
eyebrow: monthLabel(DateTime.now()),
title: 'Orders',
action: DmIconButton(
icon: LucideIcons.refreshCw,
tooltip: 'Refresh',
background: DmColors.onBrandRaise,
foreground: Colors.white,
border: Colors.transparent,
radius: DmRadius.lg,
size: 42,
onPressed: app.refreshOrders,
),
child: DmSegmented(
labels: const ['Active', 'Completed', 'Cancelled'],
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
index: index < 0 ? 0 : index,
onChanged: (i) => app.setOrdersTab(_tabs[i]),
),
),
Expanded(
child: RefreshIndicator(
color: DmColors.brand,
backgroundColor: DmColors.surface,
onRefresh: app.refreshOrders,
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
itemCount: rows.isEmpty ? 1 : rows.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, i) =>
rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]),
),
),
),
],
);
}
Widget _empty(BookingStatus tab) {
final (title, message) = switch (tab) {
BookingStatus.active => (
'No active pickups',
'Book a pickup and it will show up here.',
),
BookingStatus.completed => (
'Nothing completed yet',
'Delivered orders will appear here.',
),
BookingStatus.cancelled => (
'No cancelled bookings',
'Anything you cancel will appear here.',
),
};
return DmEmptyState(
icon: LucideIcons.package,
title: title,
message: message,
);
}
}

View File

@@ -0,0 +1,140 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/feedback.dart';
/// 17. Cancel booking confirmation.
///
/// Only reachable while the backend still allows cancellation — see
/// `DoormileApi.isCancellable`. Returns true when the booking was cancelled.
Future<bool?> showCancelSheet(BuildContext context, Booking booking) {
return showDmSheet<bool>(
context: context,
builder: (context) => _CancelBody(booking: booking),
);
}
class _CancelBody extends StatefulWidget {
const _CancelBody({required this.booking});
final Booking booking;
@override
State<_CancelBody> createState() => _CancelBodyState();
}
class _CancelBodyState extends State<_CancelBody> {
static const _reasons = [
'Booked by mistake',
'Package not ready',
'Sending it another day',
'Changed the destination',
'Other',
];
String? _reason;
bool _busy = false;
Future<void> _cancel() async {
setState(() => _busy = true);
final app = AppScope.read(context);
final navigator = Navigator.of(context);
try {
await app.cancelBooking(widget.booking, _reason);
unawaited(HapticFeedback.mediumImpact());
navigator.pop(true);
} catch (_) {
if (!mounted) return;
setState(() => _busy = false);
DmToast.show(context, "Couldn't cancel. Try again.");
}
}
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final slot = app.slotById(widget.booking.slotId);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
DmSheetHeader(
title: 'Cancel this pickup?',
subtitle:
'${widget.booking.reference} · ${slot?.label ?? 'Pickup scheduled'}. '
'You can cancel free of charge until the Miler collects your package.',
),
Text('REASON (OPTIONAL)', style: DmText.eyebrow.copyWith(color: DmColors.brand)),
const SizedBox(height: 10),
for (final reason in _reasons) ...[
_ReasonTile(
label: reason,
selected: _reason == reason,
onTap: () => setState(() => _reason = reason),
),
const SizedBox(height: 8),
],
const SizedBox(height: 12),
DmButton(
label: 'Keep my pickup',
onPressed: _busy ? null : () => Navigator.of(context).pop(false),
),
const SizedBox(height: 4),
DmButton(
label: 'Yes, cancel pickup',
kind: DmButtonKind.ghost,
danger: true,
busy: _busy,
busyLabel: 'Cancelling…',
onPressed: _cancel,
),
],
);
}
}
class _ReasonTile extends StatelessWidget {
const _ReasonTile({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
constraints: const BoxConstraints(minHeight: 46),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: selected ? DmColors.brand : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.sm),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
boxShadow: selected ? DmShadow.liftSm : null,
),
child: Text(
label,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? Colors.white : DmColors.ink2,
),
),
),
);
}
}

View File

@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/states.dart';
/// Alias used from Review, where the sheet edits the pickup point in place.
Future<Place?> showPlaceSearchSheetForReview(BuildContext context) =>
showPlaceSearchSheet(context);
/// Pickup-location picker. Returns the chosen [Place], or null if dismissed.
Future<Place?> showPlaceSearchSheet(BuildContext context) {
return showDmSheet<Place>(
context: context,
builder: (context) => const _PlaceSearchBody(),
);
}
class _PlaceSearchBody extends StatefulWidget {
const _PlaceSearchBody();
@override
State<_PlaceSearchBody> createState() => _PlaceSearchBodyState();
}
class _PlaceSearchBodyState extends State<_PlaceSearchBody> {
final _query = TextEditingController();
Timer? _debounce;
late final AppState _app = AppScope.read(context);
late Future<List<Place>> _results = _app.searchPlaces('');
@override
void dispose() {
_debounce?.cancel();
_query.dispose();
super.dispose();
}
void _onChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 220), () {
setState(() => _results = _app.searchPlaces(value));
});
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const DmSheetHeader(
title: 'Pickup location',
subtitle: 'Search or pick a saved place.',
),
TextField(
controller: _query,
onChanged: _onChanged,
autofocus: false,
cursorColor: DmColors.brand,
style: DmText.field,
decoration: InputDecoration(
hintText: 'Search street, area or landmark',
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.lineStrong, 1),
enabledBorder: _border(DmColors.lineStrong, 1),
focusedBorder: _border(DmColors.brand, 2),
),
),
const SizedBox(height: 8),
Flexible(
child: SingleChildScrollView(
child: FutureBuilder<List<Place>>(
future: _results,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.only(top: 12),
child: DmSkeleton(rows: 3),
);
}
final places = snap.data ?? const <Place>[];
if (places.isEmpty) {
return const DmEmptyState(
icon: LucideIcons.searchX,
title: 'No places found',
message: 'Try a different street or landmark.',
);
}
return Column(
children: [
for (final place in places)
_PlaceRow(
place: place,
onTap: () => Navigator.of(context).pop(place),
),
],
);
},
),
),
),
],
);
}
OutlineInputBorder _border(Color color, double width) => OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}
class _PlaceRow extends StatelessWidget {
const _PlaceRow({required this.place, required this.onTap});
final Place place;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 60),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.line)),
),
child: Row(
children: [
const DmTile(icon: LucideIcons.mapPin),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
place.title,
style: DmText.cardTitle,
),
Text(
place.sub,
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'account_screen.dart';
import 'home_screen.dart';
import 'orders_screen.dart';
/// Bottom-navigation shell: Home · Orders · Account.
///
/// Booking and tracking screens are pushed on top, so the tab bar only ever
/// shows on the three destinations that need it.
class ShellScreen extends StatefulWidget {
const ShellScreen({super.key, this.initialTab = 0});
final int initialTab;
static ShellScreenState? of(BuildContext context) =>
context.findAncestorStateOfType<ShellScreenState>();
@override
State<ShellScreen> createState() => ShellScreenState();
}
class ShellScreenState extends State<ShellScreen> {
late int _index = widget.initialTab;
void goToTab(int index) {
if (index == _index) return;
HapticFeedback.selectionClick();
setState(() => _index = index);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DmColors.canvas,
body: IndexedStack(
index: _index,
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
),
bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab),
);
}
}
/// The tab bar, fixed to the bottom edge.
///
/// ── Why it is no longer a floating pill-shaped bar ──
///
/// It used to hover 10pt above the bottom with a 28pt radius and a shadow
/// under it, which put a second rounded surface between the content and the
/// gesture bar and made every screen end in two stacked edges. It now sits on
/// the edge with a single hairline above it, like the footers in the rest of
/// the app.
///
/// The indicator does the work instead: a crimson pill that travels to the tab
/// you pick, with the glyph turning white as it arrives. Selection reads as one
/// object moving rather than three tabs changing colour in place — and the
/// label under it stays crimson, so the tab is still identifiable in the
/// half-second the pill is in flight.
class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged});
final int index;
final ValueChanged<int> onChanged;
/// Lucide, not Material. One 1.5px stroke family at one weight, so the three
/// glyphs read as a set instead of three different drawing styles — and the
/// same set the pushed screens use.
static const _items = [
(label: 'Home', icon: LucideIcons.house),
(label: 'Orders', icon: LucideIcons.package),
(label: 'Account', icon: LucideIcons.userRound),
];
/// The travelling pill's size. Narrower than its slot so it reads as an
/// object sitting in the row rather than as a selected cell.
static const double _pillWidth = 62;
static const double _pillHeight = 34;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: DmColors.surface,
border: Border(top: BorderSide(color: DmColors.border)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7),
child: SizedBox(
// The row is the pill plus the label, and the label is type: at
// 1.8x accessibility text a fixed 58 clipped it by two points. The
// bar gets taller instead, which is what a larger-text user asked
// for.
height: _pillHeight +
5 +
MediaQuery.textScalerOf(context).scale(11.5) * 1.3,
child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / _items.length;
return Stack(
children: [
AnimatedPositioned(
duration: DmMotion.slow,
curve: DmMotion.glide,
left: slot * index + (slot - _pillWidth) / 2,
width: _pillWidth,
top: 0,
height: _pillHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.liftSm,
),
),
),
Row(
children: [
for (var i = 0; i < _items.length; i++)
Expanded(
child: _TabButton(
label: _items[i].label,
icon: _items[i].icon,
selected: index == i,
onTap: () => onChanged(i),
),
),
],
),
],
);
},
),
),
),
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
required this.label,
required this.icon,
required this.selected,
required this.onTap,
});
final String label;
final IconData icon;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
icon,
key: ValueKey(selected),
size: 22,
color: selected ? Colors.white : DmColors.ink3,
),
),
),
),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: selected ? DmColors.brand : DmColors.ink3,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,878 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../../data/app_config.dart';
import '../../state/app_scope.dart';
import '../../state/app_state.dart';
import '../tokens.dart';
import '../widgets/buttons.dart';
import '../widgets/cards.dart';
import '../widgets/chrome.dart';
import '../widgets/feedback.dart';
import '../widgets/milestones.dart';
import '../widgets/misc.dart';
import '../widgets/pieces.dart';
import '../widgets/route_map.dart';
import '../widgets/route_rail.dart';
import '../widgets/states.dart';
import '../widgets/summary.dart';
import 'booking/booking_routes.dart';
import 'booking/pickup_location_screen.dart';
import 'sheets/cancel_sheet.dart';
/// Live tracking.
///
/// One screen for the whole journey. While a Miler is being matched it shows a
/// radar; once one is assigned it shows their live position, who they are, and
/// how to reach them; after pickup it follows the order to the door.
/// Cancellation is offered only while the backend still allows it.
class TrackingScreen extends StatelessWidget {
const TrackingScreen({super.key, this.focusedDestination});
/// When set, this screen follows one destination's own order rather than the
/// pickup as a whole. Only meaningful once the orders exist.
final int? focusedDestination;
@override
Widget build(BuildContext context) {
final app = AppScope.of(context);
final booking = app.trackedBooking;
if (booking == null) {
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: const DmTopBar(title: 'Live tracking'),
body: const DmEmptyState(
icon: LucideIcons.package,
title: 'Nothing to track',
message: 'Book a pickup to get started.',
),
);
}
final cancelled = booking.isCancelled;
final slot = app.slotById(booking.slotId);
// A focused destination follows its own journey; the pickup follows its own.
final focused =
(focusedDestination != null &&
focusedDestination! < booking.destinations.length &&
booking.destinations[focusedDestination!].trackingId != null)
? booking.destinations[focusedDestination!]
: null;
final stage = focused?.stage ?? booking.stage;
final milerEnRoute =
!cancelled &&
booking.miler != null &&
stage.index >= JourneyStage.assigned.index &&
stage.index <= JourneyStage.pickedUp.index;
final assigning = !cancelled && stage == JourneyStage.booked;
return Scaffold(
backgroundColor: DmColors.canvas,
appBar: DmTopBar(
title: cancelled ? 'Booking' : 'Live tracking',
// The reference belongs in the bar, not in a card three scrolls down:
// it is what a customer reads out when they call about this parcel.
trailing: Text(
focused?.trackingId ?? booking.displayId,
style: DmText.mono,
),
),
body: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: [
_StatusHeader(booking: booking, slot: slot, focused: focused),
if (assigning)
const _AssigningHero()
else if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
// The map is a cell of a card, not a floating rounded rectangle
// of its own: it gets the card's edge, and the line under it
// carries the fact the picture cannot state precisely.
child: DmCard(
children: [
DmRouteMap(
height: 178,
radius: 0,
pickup: booking.pickup,
destination: (focused ?? booking.destinations.first).hub,
showMiler: milerEnRoute,
progress: _routeProgress(stage),
pickupLabel: booking.pickup.title,
destinationLabel: booking.destination.districtName,
badge: milerEnRoute && booking.milerDistanceKm != null
? _Badge(
icon: LucideIcons.bike,
label: booking.milerDistanceKm == 0
? 'At your door'
: '${booking.miler?.name ?? 'Your Miler'}, '
'${booking.milerDistanceKm} km away',
)
: null,
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 12, 10, 12),
child: Row(
children: [
const Icon(
LucideIcons.clock,
size: 15,
color: DmColors.ink3,
),
const SizedBox(width: 10),
Expanded(
child: Text(
// Short enough to sit beside Refresh without
// ellipsizing, which is the whole job of this line.
milerEnRoute && booking.milerEtaMinutes != null
? 'Pickup in about ${booking.milerEtaMinutes} min'
: 'Tracking your parcel live',
style: DmText.small,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
DmTextAction(
label: 'Refresh',
onPressed: app.refreshOrders,
),
],
),
),
],
),
),
if (assigning && booking.milersInZone > 0)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: DmInfoBanner(
icon: LucideIcons.users,
title:
'${booking.milersInZone} verified Milers active in your zone',
message: 'Most pickups are accepted within a few minutes.',
),
),
if (milerEnRoute)
_MilerCard(person: booking.miler!, role: 'Your Miler'),
if (!cancelled &&
stage == JourneyStage.outForDelivery &&
booking.deliveryAgent != null)
_MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'),
if (!cancelled)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _Journey(booking: booking),
),
if (!cancelled && booking.hasOrders && booking.isMultiDestination)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
10,
),
child: _YourPackages(booking: booking, focused: focused),
),
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: _RouteOutline(
booking: booking,
slot: slot,
focused: focused,
),
),
// Stands in for backend push updates. Real stage changes arrive
// from `GET /customer/bookings/{reference}`, so this exists only
// where there is no server to send them.
if (AppConfig.showStageStepper) _StageStepper(booking: booking),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: _footerActions(context, app, booking),
),
);
}
/// How far along the route the Miler is drawn, by stage.
static double _routeProgress(JourneyStage stage) => switch (stage) {
JourneyStage.assigned => 0.08,
JourneyStage.onTheWay => 0.42,
JourneyStage.arrived || JourneyStage.pickedUp => 0.92,
_ => 0.5,
};
/// The footer is a hierarchy, not a single button: the helpful action is the
/// filled one and cancelling is a text button under it. Cancel used to be a
/// full-width outlined button in the danger colour — the most prominent
/// control on a screen whose job is to reassure someone that their parcel is
/// fine.
List<Widget> _footerActions(BuildContext context, AppState app, Booking booking) {
if (booking.isCancelled || booking.stage == JourneyStage.delivered) {
return [
DmButton(
label: 'Book another pickup',
icon: LucideIcons.arrowRight,
onPressed: () {
app.startBooking();
Navigator.of(context).pushAndRemoveUntil(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
(route) => route.isFirst,
);
},
),
];
}
return [
DmButton(
label: 'Share live tracking',
icon: LucideIcons.share2,
kind: DmButtonKind.outline,
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
),
// Backend policy decides whether cancelling is still possible.
if (app.isCancellable(booking.stage))
DmButton(
label: 'Cancel this pickup',
kind: DmButtonKind.ghost,
danger: true,
onPressed: () async {
final cancelled = await showCancelSheet(context, booking);
if (cancelled == true && context.mounted) {
DmToast.show(context, 'Pickup cancelled');
}
},
)
else
DmButton(
label: 'Need help with this order?',
kind: DmButtonKind.ghost,
onPressed: () =>
DmToast.show(context, 'Support chat is not available yet'),
),
];
}
}
/// The screen's one loud surface: what is happening, right now.
///
/// ── Why this is a filled card and not a heading ──
///
/// The status used to be an eyebrow, a 32pt headline and a row of chips laid
/// straight onto the canvas. It said the right words, but it looked like every
/// other heading in the app, so the screen opened with no focal point and the
/// map below it — a decoration — became the loudest thing on the page.
///
/// It is now the one crimson card the design allows per screen. It carries the
/// live pulse, the headline, the journey's own progress and the window, so the
/// whole answer is in one object above the fold. A cancelled booking drops to a
/// plain card: nothing is live, and a crimson field would keep insisting it is.
class _StatusHeader extends StatelessWidget {
const _StatusHeader({
required this.booking,
required this.slot,
this.focused,
});
final Booking booking;
final PickupSlot? slot;
/// Set when following one destination's own order.
final DestinationGroup? focused;
@override
Widget build(BuildContext context) {
final cancelled = booking.isCancelled;
final stage = focused?.stage ?? booking.stage;
final headline = switch (true) {
_ when cancelled => 'Booking cancelled',
_ when stage == JourneyStage.booked => 'Finding your Miler',
_ when stage == JourneyStage.assigned && booking.miler != null =>
'${booking.miler!.name} is assigned',
_ when stage == JourneyStage.onTheWay && booking.miler != null =>
booking.milerEtaMinutes == null
? '${booking.miler!.name} is on the way'
: '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away',
_ when stage == JourneyStage.arrived && booking.miler != null =>
'${booking.miler!.name} is at your door',
_ => stage.headline,
};
final sub = cancelled
? 'This pickup was cancelled. You were not charged.'
: stage.sub;
final total = CustomerMilestone.values.length;
final step = CustomerMilestone.of(stage).index + 1;
if (cancelled) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: DmCard(
children: [
DmCardCell(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const DmPill('Cancelled', tone: DmPillTone.off),
const SizedBox(height: 12),
Text(headline, style: DmText.display.copyWith(fontSize: 25)),
const SizedBox(height: 6),
Text(sub, style: DmText.small),
],
),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: DmCard(
tone: DmCardTone.fill,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const DmLiveDot(size: 9, color: Colors.white),
const SizedBox(width: 4),
Text(
'LIVE',
style: DmText.eyebrow.copyWith(
fontSize: 11,
color: Colors.white,
),
),
const Spacer(),
if (focused != null)
Flexible(
child: Text(
'To ${focused!.destination.label}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
style: DmText.small.copyWith(color: DmColors.onBrand3),
),
),
],
),
const SizedBox(height: 12),
Text(
headline,
style: DmText.display.copyWith(fontSize: 27, color: Colors.white),
),
const SizedBox(height: 6),
Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)),
const SizedBox(height: 16),
DmStepBar(
progress: step / total,
steps: total,
current: step,
onFill: true,
// The milestone, not the window: the window is already the
// line underneath, and at this width it clipped to
// "Window 2:00 – 4:…".
label: CustomerMilestone.of(stage).label,
),
const SizedBox(height: 10),
Text(
_footnote(stage),
style: DmText.tiny.copyWith(color: DmColors.onBrand3),
),
],
),
),
],
),
);
}
/// The one fact under the rail: whatever is most useful at this stage.
String _footnote(JourneyStage stage) {
if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) {
return 'Arriving in about ${booking.milerEtaMinutes} min';
}
if (stage == JourneyStage.arrived) return 'Waiting at your door';
if (stage.index <= JourneyStage.assigned.index && slot != null) {
return '${slot!.day}, ${slot!.window}';
}
if (booking.expectedDelivery != null) {
return 'Expected ${booking.expectedDelivery}';
}
return CustomerMilestone.of(stage).label;
}
}
/// The journey, compressed to what just happened.
class _Journey extends StatefulWidget {
const _Journey({required this.booking});
final Booking booking;
@override
State<_Journey> createState() => _JourneyState();
}
class _JourneyState extends State<_Journey> {
bool _all = false;
@override
Widget build(BuildContext context) {
return DmMilestones(
booking: widget.booking,
title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey',
maxRows: _all ? null : 3,
onExpand: () => setState(() => _all = true),
);
}
}
/// A chip floating on the map — "En route, 1.4 km away".
class _Badge extends StatelessWidget {
const _Badge({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(10, 7, 12, 7),
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: DmColors.brand),
const SizedBox(width: 6),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink),
),
),
],
),
);
}
}
/// Radar shown while the backend matches a Miler.
class _AssigningHero extends StatelessWidget {
const _AssigningHero();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20),
child: Container(
height: 200,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: DmRadarPulse(
size: 170,
child: Container(
width: 62,
height: 62,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
child: const Icon(
LucideIcons.bike,
size: 28,
color: Colors.white,
),
),
),
),
);
}
}
/// Who is collecting, how good they are, and how to reach them.
///
/// A wash rather than a plain card: this is the one row on the screen about a
/// person rather than a parcel, and the tint is what separates "who" from the
/// facts stacked above and below it. The plate is mono — it is read off a
/// number plate at the door.
class _MilerCard extends StatelessWidget {
const _MilerCard({required this.person, required this.role});
final Person person;
final String role;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
child: DmCard(
tone: DmCardTone.wash,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
child: Row(
children: [
DmAvatar(initials: person.initials, size: 44),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(person.name, style: DmText.cardTitle),
const SizedBox(height: 2),
// Absent unless the backend sent a rating or a pickup
// count. A default would put an invented reputation on a
// real person's card.
if (person.credentials != null)
Row(
children: [
const Icon(
LucideIcons.star,
size: 12,
color: DmColors.brand,
),
const SizedBox(width: 5),
Flexible(
child: Text(
person.credentials!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny,
),
),
],
)
else
Text(role, style: DmText.tiny),
],
),
),
const SizedBox(width: 10),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 122),
child: DmTag(person.vehicle, mono: true),
),
],
),
),
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 13),
child: Row(
children: [
DmMiniButton(
label: 'Call',
icon: LucideIcons.phone,
filled: true,
onPressed: () => DmToast.show(context, 'Calling ${person.name}…'),
),
const SizedBox(width: 8),
DmMiniButton(
label: 'Message',
icon: LucideIcons.messageSquare,
onPressed: () =>
DmToast.show(context, 'Chat is not available yet'),
),
],
),
),
],
),
);
}
}
/// Where it is going, and the few facts worth keeping on screen.
class _RouteOutline extends StatelessWidget {
const _RouteOutline({
required this.booking,
required this.slot,
this.focused,
});
final Booking booking;
final PickupSlot? slot;
final DestinationGroup? focused;
@override
Widget build(BuildContext context) {
final group = focused;
final recipient = (group ?? booking.destinations.first).details.recipient;
return DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
pickupNote: booking.pickup.sub,
drop: group != null
? group.destination.label
: booking.isMultiDestination
? '${booking.destinations.length} destinations'
: booking.destination.label,
dropNote: group != null
? '${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}'
: booking.isMultiDestination
? booking.packageSummary
: booking.details.exactAddress ??
(recipient == null
? 'Exact address collected at pickup'
: 'Recipient · $recipient'),
),
),
DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'),
DmSummaryRow(
label: (group?.trackingId ?? booking.trackingId) == null
? 'Booking reference'
: 'Tracking number',
value: group?.trackingId ?? booking.displayId,
mono: true,
secondary: group == null
? null
: 'Collected together · Pickup ${booking.reference}',
),
],
);
}
}
/// The individual orders a completed pickup produced.
///
/// Deliberately absent until the backend has actually created them: before
/// collection the customer has one scheduled visit, not several shipments.
class _YourPackages extends StatelessWidget {
const _YourPackages({required this.booking, this.focused});
final Booking booking;
/// The one being followed, so the list can mark it.
final DestinationGroup? focused;
@override
Widget build(BuildContext context) {
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
child: Row(
children: [
Expanded(
child: Text(
'YOUR PACKAGES',
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
Flexible(
child: Text(
focused == null
? 'Collected in one visit'
: 'Pickup ${booking.reference}',
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.tiny,
),
),
],
),
),
for (final group in booking.destinations)
_PackageRow(
group: group,
isFocused: identical(group, focused),
),
],
);
}
}
class _PackageRow extends StatelessWidget {
const _PackageRow({required this.group, this.isFocused = false});
final DestinationGroup group;
final bool isFocused;
@override
Widget build(BuildContext context) {
final stage = group.stage;
return DmCardCell(
background: isFocused ? DmColors.brandSoft : null,
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(group.destination.label, style: DmText.cardTitle),
const SizedBox(height: 3),
Text.rich(
TextSpan(
style: DmText.tiny,
children: [
TextSpan(
text: group.trackingId ?? '—',
style: DmText.monoSm,
),
TextSpan(
text: ' · ${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 10),
if (stage != null)
Flexible(
child: DmPill(
stage.milestoneLabel,
tone: stage == JourneyStage.delivered
? DmPillTone.done
: DmPillTone.neutral,
),
),
],
),
);
}
}
/// The backend's QA stage helper, as a control.
///
/// It is not a simulator: each tap posts to
/// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads
/// the booking, so what it shows is what the **server** recorded. Off entirely
/// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on
/// production or in a release.
class _StageStepper extends StatelessWidget {
const _StageStepper({required this.booking});
final Booking booking;
@override
Widget build(BuildContext context) {
final app = AppScope.read(context);
final index = booking.stage.index;
final locked = booking.isCancelled;
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20),
child: Container(
padding: const EdgeInsets.fromLTRB(16, 10, 10, 10),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
border: Border.all(color: DmColors.border),
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
Expanded(
child: Text(
'Move stage (QA)',
style: DmText.label.copyWith(color: DmColors.ink3),
),
),
_StepButton(
icon: LucideIcons.minus,
onTap: locked || index == 0
? null
: () => app.advanceStage(booking, JourneyStage.at(index - 1)),
),
const SizedBox(width: 8),
_StepButton(
icon: LucideIcons.plus,
onTap: locked || index == JourneyStage.values.length - 1
? null
: () => app.advanceStage(booking, JourneyStage.at(index + 1)),
),
],
),
),
);
}
}
class _StepButton extends StatelessWidget {
const _StepButton({required this.icon, this.onTap});
final IconData icon;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final enabled = onTap != null;
return Opacity(
opacity: enabled ? 1 : 0.35,
child: Material(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.xs),
child: InkWell(
borderRadius: DmRadius.all(DmRadius.xs),
onTap: onTap,
child: Ink(
width: 38,
height: 38,
decoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.xs),
border: Border.all(color: DmColors.border),
),
child: Icon(icon, size: 18, color: DmColors.ink2),
),
),
),
);
}
}

395
lib/ui/tokens.dart Normal file
View File

@@ -0,0 +1,395 @@
import 'package:flutter/material.dart';
/// Design tokens. Every colour, radius, shadow and text style in the app comes
/// from here — screens never hardcode a value.
///
/// Direction (2026-09-15 rebuild, from the 14-screen design):
///
/// **Edges, not elevation.** Surfaces are separated by a 1px warm hairline and
/// a 10pt radius, not by a shadow. The only shadows left in the system are the
/// crimson glow under the one committing action on a screen, the float under a
/// control that genuinely hovers over a map, and the sheet lift. A screen that
/// drops shadows everywhere has no way left to say "this one matters".
///
/// **One accent, three intensities.** Crimson fills the header and the primary
/// action, washes (`brandSoft` over `brandLine`) carry supporting information,
/// and everything else is warm neutral. There is no second hue competing for
/// meaning — `ok` survives only for the delivered state, `danger` only for
/// destructive text.
///
/// **Manrope for voice, Geist Mono for data.** Headings run at w800 with tight
/// negative tracking; anything a customer might read back aloud or check
/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a
/// countdown — is set in mono so the glyphs line up and a transposed character
/// is visible.
class DmColors {
DmColors._();
// ── Brand ────────────────────────────────────────────────────────────────
/// The mark's single colour.
static const brand = Color(0xFF8F0F06);
/// Pressed state, and crimson text sitting on a [brandSoft] wash where the
/// full brand would vibrate against the tint.
static const brandPress = Color(0xFF730C05);
static const brandDeep = brandPress;
/// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2);
/// The wash's own edge, and the outline on quiet brand buttons.
static const brandLine = Color(0xFFF0DEDC);
// ── Ink — warm neutrals, never pure black ────────────────────────────────
static const ink = Color(0xFF191716);
static const ink2 = Color(0xFF5C5654);
static const ink3 = Color(0xFF6E6864);
static const ink4 = Color(0xFFB4AEAA);
// ── Surfaces ─────────────────────────────────────────────────────────────
/// The page canvas.
static const canvas = Color(0xFFF7F6F4);
static const surface = Color(0xFFFFFFFF);
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
/// treatment for a disabled primary action.
static const surfaceAlt = Color(0xFFF1EFEC);
/// Barely-there divider between rows inside one card.
static const hairline = Color(0xFFF1EFEC);
/// A card's own edge — the workhorse of this design.
static const border = Color(0xFFE8E5E2);
// ── On-brand text ────────────────────────────────────────────────────────
/// Secondary text on a crimson field. Not white-at-opacity: a tinted rose
/// keeps its warmth where translucent white goes grey and dirty.
static const onBrand2 = Color(0xFFFFD9D4);
/// Tertiary text on a crimson field.
static const onBrand3 = Color(0xFFFFD1CB);
/// Recessed track inside the header (the segmented control's bed).
static const onBrandSunken = Color(0x24000000);
/// A control resting on the crimson field.
static const onBrandRaise = Color(0x2EFFFFFF);
// ── Semantic ─────────────────────────────────────────────────────────────
static const ok = Color(0xFF0A6B56);
static const okSoft = Color(0xFFE2F4EF);
static const danger = Color(0xFFB3231F);
/// "You are here" on a map. Deliberately not the brand crimson — on a map
/// crimson means the pickup pin, and blue is the convention every customer
/// already reads as their own position.
static const locationDot = Color(0xFF1B6EF3);
static const locationHalo = Color(0x241B6EF3);
static const scrim = Color(0x80191716);
// Backwards-compatible aliases used across the widget layer.
static const bg = canvas;
static const surface2 = surfaceAlt;
static const line = hairline;
static const lineStrong = border;
}
class DmRadius {
DmRadius._();
/// Tiles, chips, the small square that holds a glyph.
static const xs = 8.0;
/// Buttons, fields, icon buttons. The design's most-used corner.
static const sm = 9.0;
/// Cards and every panel that holds rows.
static const md = 10.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 12.0;
/// Bottom sheets and the auth sheet that overlaps the hero.
static const xl = 16.0;
/// The brand header's bottom corners.
static const header = 18.0;
static const pill = 999.0;
/// Kept for call sites that mean "a primary bar's corner".
static const book = sm;
static BorderRadius all(double r) => BorderRadius.circular(r);
}
class DmSpace {
DmSpace._();
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas
/// content — the header's crimson field reads as tighter than it measures,
/// so it takes the smaller inset.
static const pad = 24.0;
static const headerPad = 22.0;
/// Minimum touch target.
static const tap = 48.0;
/// Primary button height.
static const button = 54.0;
/// Field height — matched to [button] so a form and its action share a rhythm.
static const field = 54.0;
}
class DmShadow {
DmShadow._();
/// Under the one committing action on a screen, and under a selected slot.
static const lift = [
BoxShadow(
color: Color(0x808F0F06),
blurRadius: 24,
spreadRadius: -12,
offset: Offset(0, 10),
),
];
/// A smaller version of [lift] for chips and half-width controls.
static const liftSm = [
BoxShadow(
color: Color(0xA68F0F06),
blurRadius: 16,
spreadRadius: -8,
offset: Offset(0, 7),
),
];
/// Controls that genuinely float over content — map buttons, map chips.
static const float = [
BoxShadow(
color: Color(0x0D191716),
blurRadius: 2,
offset: Offset(0, 1),
),
];
static const sheet = [
BoxShadow(
color: Color(0x2E191716),
blurRadius: 40,
spreadRadius: -20,
offset: Offset(0, -8),
),
];
// Aliases kept for existing call sites.
static const two = float;
static const three = sheet;
}
class DmMotion {
DmMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 250);
static const slow = Duration(milliseconds: 460);
static const ease = Curves.easeOutCubic;
/// The bottom nav's travelling pill — a touch of overshoot, which reads as
/// the indicator catching up rather than teleporting.
static const glide = Cubic(0.34, 1.06, 0.36, 1);
}
/// Manrope for voice, Geist Mono for data.
///
/// Both ship as single variable-font files, which is why every style below sets
/// `fontVariations` as well as `fontWeight` — see the note inside the class.
class DmText {
DmText._();
static const _f = 'Manrope';
static const _mono = 'GeistMono';
// The weight axis. Manrope and Geist Mono both ship as variable fonts, so a
// style has to move `wght` to change weight — `fontWeight` alone picks the
// nearest named instance and, on a single-file VF, that is always the
// default. Both are set on every style: the axis does the work, the enum
// keeps the semantics (and the system-font fallback) correct.
static const _w500 = [FontVariation('wght', 500)];
static const _w600 = [FontVariation('wght', 600)];
static const _w700 = [FontVariation('wght', 700)];
static const _w800 = [FontVariation('wght', 800)];
// Letter spacing is quoted in em by the design and converted here at each
// size, because -0.035em is a different number of pixels on a 31pt display
// head than on a 15pt row title, and splitting the difference would soften
// exactly the heads that are supposed to feel tight.
// ── Display ──────────────────────────────────────────────────────────────
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.12,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.09, color: DmColors.ink,
);
/// The brand header's own title, sitting on crimson.
static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.36, color: Colors.white,
);
/// Screen headings on a canvas.
static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.18,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.75, color: DmColors.ink,
);
/// Tab-root page titles where no brand header is used.
static const pageTitle = title;
/// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.45, color: DmColors.ink,
);
static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 19, height: 1.2,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.57, color: DmColors.ink,
);
// ── Rows ─────────────────────────────────────────────────────────────────
/// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.18, color: DmColors.ink,
);
/// Alias: the same weight of statement, used mid-form.
static const bodyStrong = cardTitle;
static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.12, color: DmColors.ink,
);
/// Supporting line under a row title — `t2`.
static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink3,
);
/// Paragraph under a screen heading.
static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The right-hand value in a settings row.
static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Field labels, tags, chips, inline actions.
static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
color: DmColors.ink2,
);
static const button = TextStyle(
fontFamily: _f, fontSize: 15.5, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.23,
);
/// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
color: DmColors.ink2,
);
/// Uppercase micro-label above a group — the design's `micro`. Crimson when
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: 1.16, color: DmColors.ink3,
);
/// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: 1.84, color: DmColors.onBrand3,
);
/// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.3, color: DmColors.ink,
);
/// Alias kept for existing call sites.
static const groupHead = sectionHead;
/// Field input text.
static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
// ── Mono: anything checkable digit by digit ──────────────────────────────
/// References, plates and phone numbers sitting inline in a row.
static const mono = TextStyle(
fontFamily: _mono, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.26, color: DmColors.ink3,
);
/// Step counters, timers, the smallest data.
static const monoSm = TextStyle(
fontFamily: _mono, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.23, color: DmColors.ink3,
);
/// A booking reference given its own line, or a fare.
static const monoLg = TextStyle(
fontFamily: _mono, fontSize: 19, height: 1.2,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.38, color: DmColors.ink,
);
/// The OTP digits.
static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w700, fontVariations: _w700,
color: DmColors.ink,
);
}

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

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

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

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

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

@@ -0,0 +1,655 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/app_config.dart';
import '../tokens.dart';
import 'buttons.dart';
/// Top bar for pushed screens: a square back affordance, a quiet title, an
/// optional trailing slot (usually the booking reference in mono), and the
/// booking step indicator.
class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
const DmTopBar({
super.key,
this.title,
this.trailing,
this.progress,
this.steps,
this.stepLabel,
this.onBack,
this.background = DmColors.canvas,
});
final String? title;
final Widget? trailing;
/// 0..1 — omit to hide the step indicator.
final double? progress;
/// When given, the indicator is drawn as [steps] separate segments rather
/// than one continuous rail. Discrete steps are countable; a filled bar is
/// only a proportion, and "how many screens until I am done" is the question
/// a customer is actually asking.
final int? steps;
/// The line above the segments — "Step 2 of 4 · Destination".
final String? stepLabel;
final VoidCallback? onBack;
final Color background;
@override
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
@override
Widget build(BuildContext context) {
final current = progress == null || steps == null
? 0
: (progress! * steps!).round().clamp(0, steps!);
return Material(
color: background,
child: SafeArea(
bottom: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 12),
DmIconButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 6),
Expanded(
child: title == null
? const SizedBox.shrink()
: Text(
title!,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
letterSpacing: -0.31,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
child: DmStepBar(
progress: progress!,
steps: steps,
current: current,
label: stepLabel,
),
),
],
),
),
);
}
}
/// Booking progress.
///
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
/// without, it falls back to a single filled rail for callers that only know a
/// fraction.
class DmStepBar extends StatelessWidget {
const DmStepBar({
super.key,
required this.progress,
this.steps,
this.current = 0,
this.label,
this.onFill = false,
});
final double progress;
final int? steps;
final int current;
final String? label;
/// Drawn on a crimson field (the live-tracking card), where the track is a
/// lightened white and the fill is solid white.
final bool onFill;
@override
Widget build(BuildContext context) {
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
final fill = onFill ? Colors.white : DmColors.brand;
final bar = steps == null
? ClipRRect(
borderRadius: DmRadius.all(DmRadius.pill),
child: SizedBox(
height: 3,
child: Stack(
children: [
Positioned.fill(child: ColoredBox(color: track)),
FractionallySizedBox(
widthFactor: progress.clamp(0, 1),
child: AnimatedContainer(
duration: DmMotion.slow,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: fill,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
),
),
)
: Row(
children: [
for (var i = 0; i < steps!; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
height: 3,
decoration: BoxDecoration(
color: i < current ? fill : track,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
],
);
if (label == null && steps == null) return bar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null || steps != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (steps != null)
Text.rich(
TextSpan(
style: DmText.monoSm.copyWith(
color: onFill ? DmColors.onBrand3 : DmColors.ink3,
),
children: [
const TextSpan(text: 'Step '),
TextSpan(
text: '$current',
style: TextStyle(color: onFill ? Colors.white : DmColors.brand),
),
TextSpan(text: ' of ${steps!}'),
],
),
),
const Spacer(),
if (label != null)
Flexible(
child: Text(
label!,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 12.5,
color: onFill ? DmColors.onBrand3 : DmColors.ink,
),
),
),
],
),
const SizedBox(height: 9),
],
bar,
],
);
}
}
/// Sticky bottom action area.
///
/// `edge: true` lifts it onto white with a hairline above — used when the
/// content behind it scrolls, so the action never looks like the end of the
/// list. On a short screen the canvas variant keeps the page quiet.
class DmFooter extends StatelessWidget {
const DmFooter({
super.key,
required this.children,
this.edge = false,
this.background,
this.divider = false,
});
final List<Widget> children;
/// White bed, hairline above.
final bool edge;
final Color? background;
/// Retained for call-site compatibility; superseded by [edge].
final bool divider;
@override
Widget build(BuildContext context) {
final lifted = edge || divider;
return DecoratedBox(
decoration: BoxDecoration(
color: background ?? (lifted ? DmColors.surface : DmColors.canvas),
border: lifted
? const Border(top: BorderSide(color: DmColors.border))
: null,
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(height: 4),
children[i],
],
],
),
),
),
);
}
}
/// The crimson field at the top of a tab root.
///
/// Home, Orders and Account all open with one. It does three jobs at once —
/// it states where you are (eyebrow + title), it holds that destination's one
/// control (a segmented filter, an address switcher, a phone number), and it
/// gives the app a recognisable brand surface that the white canvas below can
/// then stay completely quiet.
///
/// The arcs are drawn, not an asset: they are struck from the same geometry as
/// the mark, they scale to any header height, and they cost no bytes.
class DmBrandHeader extends StatelessWidget {
const DmBrandHeader({
super.key,
required this.title,
this.eyebrow,
this.leading,
this.action,
this.child,
});
final String title;
final String? eyebrow;
/// Sits at the top-left of the header — the avatar on Account.
final Widget? leading;
/// Top-right — refresh, help.
final Widget? action;
/// The control that belongs to this destination, under the title.
final Widget? child;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
child: ColoredBox(
color: DmColors.brand,
child: CustomPaint(
painter: const DmArcField(),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.headerPad,
2,
DmSpace.headerPad,
18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(
children: [
?leading,
const Spacer(),
// ── A build on dev data says so, on every screen ──
//
// Twice now a booking made offline has been hunted for
// in the admin console. The Account screen named the
// mode, but nobody goes to Account before booking. It
// belongs where the booking is made.
if (AppConfig.useDevData) ...[
Container(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
decoration: BoxDecoration(
border: Border.all(color: const Color(0x80FFFFFF)),
borderRadius: DmRadius.all(5),
),
child: Text(
'OFFLINE DATA',
style: DmText.eyebrow.copyWith(
fontSize: 10,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(width: 10),
],
?action,
],
),
),
const SizedBox(height: 10),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
const SizedBox(height: 8),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 18), child!],
],
),
),
),
),
),
);
}
}
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
/// uses. Decorative only — it never carries meaning, so it is not announced.
class DmArcField extends CustomPainter {
const DmArcField({this.opacity = 0.15});
final double opacity;
@override
void paint(Canvas canvas, Size size) {
// The arcs are authored on a 340-square field anchored to the top-right,
// then scaled — so the same geometry reads identically on a 210pt Orders
// header and a 260pt Account one.
const field = 340.0;
final scale = size.width / 390 * 1.0;
canvas.save();
canvas.translate(size.width - field * scale + 62 * scale, -72 * scale);
canvas.scale(scale);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale
..color = Colors.white.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint);
for (final (y1, y2, cx, cy) in const [
(300.0, 40.0, 60.0, 300.0),
(330.0, 60.0, 70.0, 330.0),
]) {
final path = Path()
..moveTo(-20, y1)
..cubicTo(cx, cy, field - 40, y2 + 140, field, y2);
canvas.drawPath(path, paint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity;
}
/// The segmented filter recessed into the brand header — Orders' Active /
/// Completed / Cancelled, with each tab's count.
///
/// The selected tab lifts out as a white pill and keeps its figure; the others
/// stay in the recess. One control, three states, no separate count row.
class DmSegmented extends StatelessWidget {
const DmSegmented({
super.key,
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
/// Null where a tab has no meaningful figure yet (still loading).
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
for (var i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: _Segment(
label: labels[i],
count: i < counts.length ? counts[i] : null,
selected: i == index,
onTap: () => onChanged(i),
),
),
],
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.brand : DmColors.onBrand3,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? DmColors.brand : Colors.white,
),
),
),
],
],
),
),
),
);
}
}
/// The recessed strip under a header title that carries one live fact — the
/// address a pickup is coming from, or the customer's phone number.
class DmHeaderTrack extends StatelessWidget {
const DmHeaderTrack({
super.key,
required this.child,
this.leading,
this.trailing,
this.onTap,
});
final Widget child;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 10)],
Expanded(child: child),
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
],
),
),
),
);
}
}
/// Large title for a screen that has no brand header.
class DmPageTitle extends StatelessWidget {
const DmPageTitle(this.text, {super.key, this.subtitle});
final String text;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(text, style: DmText.pageTitle),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: DmText.lede),
],
],
),
);
}
}
/// Sentence-case heading above a group of fields or rows.
class DmGroupHead extends StatelessWidget {
const DmGroupHead(this.text, {super.key, this.top = 24});
final String text;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: 10),
child: Text(text, style: DmText.sectionHead),
);
}
}
/// Screen heading and its supporting line — shared by every booking step.
class DmScreenHeader extends StatelessWidget {
const DmScreenHeader({super.key, required this.title, this.lede, this.leding});
final String title;
final String? lede;
/// Rich alternative to [lede].
final Widget? leding;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(title, style: DmText.title),
if (lede != null) ...[
const SizedBox(height: 8),
Text(lede!, style: DmText.lede),
],
if (leding != null) ...[const SizedBox(height: 10), leding!],
const SizedBox(height: 22),
],
);
}
}

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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