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:
174
lib/ui/screens/account_screen.dart
Normal file
174
lib/ui/screens/account_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
440
lib/ui/screens/auth/auth_scaffold.dart
Normal file
440
lib/ui/screens/auth/auth_scaffold.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
170
lib/ui/screens/auth/login_screen.dart
Normal file
170
lib/ui/screens/auth/login_screen.dart
Normal 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.",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
369
lib/ui/screens/auth/otp_screen.dart
Normal file
369
lib/ui/screens/auth/otp_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
146
lib/ui/screens/auth/signup_screen.dart
Normal file
146
lib/ui/screens/auth/signup_screen.dart
Normal 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.',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
43
lib/ui/screens/booking/booking_routes.dart
Normal file
43
lib/ui/screens/booking/booking_routes.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
263
lib/ui/screens/booking/confirmed_screen.dart
Normal file
263
lib/ui/screens/booking/confirmed_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
406
lib/ui/screens/booking/destination_screen.dart
Normal file
406
lib/ui/screens/booking/destination_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal file
324
lib/ui/screens/booking/pickup_location_screen.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
377
lib/ui/screens/booking/review_screen.dart
Normal file
377
lib/ui/screens/booking/review_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
lib/ui/screens/booking/slot_screen.dart
Normal file
206
lib/ui/screens/booking/slot_screen.dart
Normal 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"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
345
lib/ui/screens/home_screen.dart
Normal file
345
lib/ui/screens/home_screen.dart
Normal 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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
619
lib/ui/screens/order_details_screen.dart
Normal file
619
lib/ui/screens/order_details_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/ui/screens/order_row.dart
Normal file
153
lib/ui/screens/order_row.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
94
lib/ui/screens/orders_screen.dart
Normal file
94
lib/ui/screens/orders_screen.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal file
140
lib/ui/screens/sheets/cancel_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal file
177
lib/ui/screens/sheets/place_search_sheet.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
201
lib/ui/screens/shell_screen.dart
Normal file
201
lib/ui/screens/shell_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
878
lib/ui/screens/tracking_screen.dart
Normal file
878
lib/ui/screens/tracking_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user