Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
@@ -3,14 +3,16 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../data/app_config.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../../data/models.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import '../format.dart';
|
||||
import 'shell_screen.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';
|
||||
|
||||
@@ -35,49 +37,133 @@ class AccountScreen extends StatelessWidget {
|
||||
final name = customer?.name ?? '';
|
||||
final phone = customer?.phone ?? '';
|
||||
|
||||
// Counted off what is already loaded, so the figures move with the list
|
||||
// rather than claiming a lifetime total the client cannot know.
|
||||
final completed =
|
||||
app.orders.where((o) => o.status == BookingStatus.completed);
|
||||
final delivered = completed.fold<int>(0, (n, o) => n + o.totalPackages);
|
||||
final cities = {
|
||||
for (final o in completed)
|
||||
for (final g in o.destinations)
|
||||
?g.destination.districtCode,
|
||||
}.length;
|
||||
|
||||
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'),
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => ShellScreen.of(context)?.goToTab(1),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
// ── The profile, centred ──
|
||||
//
|
||||
// It was a 34pt name left-aligned with the number under it, which is
|
||||
// the shape a *list* screen takes. The reference puts the person at
|
||||
// the top of their own account: a large avatar with an edit badge, the
|
||||
// name, the number, and the account's standing — and that is what
|
||||
// makes Account read as somewhere you are rather than as a menu.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 18),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
DmAvatar(initials: initialsOf(name), size: 86),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.pencil,
|
||||
size: 14,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
phone,
|
||||
style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
name.isEmpty ? 'Your account' : name,
|
||||
style: DmText.headerTitle.copyWith(fontSize: 24),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (phone.isNotEmpty) ...[
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
phone,
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 13,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── Two figures, both counted here ──
|
||||
//
|
||||
// The reference puts "24 all-time parcels" and "100% carbon
|
||||
// offset" side by side under the name. The first is real — it
|
||||
// is the orders already in memory — so it is shown. The second
|
||||
// is not: nothing in this backend measures an offset, and a
|
||||
// sustainability claim is the last thing an app should invent.
|
||||
// Delivered parcels and cities reached are what the data
|
||||
// actually supports.
|
||||
if (delivered > 0) ...[
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Stat(
|
||||
icon: LucideIcons.package,
|
||||
label: 'DELIVERED',
|
||||
value: '$delivered',
|
||||
note: delivered == 1 ? 'parcel' : 'parcels',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Stat(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'CITIES',
|
||||
value: '$cities',
|
||||
note: cities == 1 ? 'destination' : 'destinations',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28),
|
||||
// Clears the floating tab bar, which the shell stacks over the
|
||||
// body rather than laying out beneath it.
|
||||
padding: EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
|
||||
children: [
|
||||
const DmMicroHead('Preferences', top: 16, brand: false),
|
||||
const DmMicroHead('Preferences', top: 4, brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
label: 'Saved addresses',
|
||||
value: app.pickup?.title ?? 'None',
|
||||
note: 'Pickup and drop locations',
|
||||
// No value. It was the pickup address, which is a street
|
||||
// name and rendered as "38 Mettupal…" — a truncation
|
||||
// nobody can read anything out of, in the slot meant for a
|
||||
// word.
|
||||
showChevron: true,
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
@@ -90,19 +176,49 @@ class AccountScreen extends StatelessWidget {
|
||||
const DmRow(
|
||||
icon: LucideIcons.bell,
|
||||
label: 'Notifications',
|
||||
value: 'SMS and push',
|
||||
note: 'Real-time SMS and push',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
icon: LucideIcons.wallet,
|
||||
label: 'Payment methods',
|
||||
value: 'UPI',
|
||||
note: 'UPI, on delivery',
|
||||
showChevron: true,
|
||||
),
|
||||
const DmRow(
|
||||
],
|
||||
),
|
||||
// ── A second group, because the first was two things ──
|
||||
//
|
||||
// "Preferences" held three settings and one destination, and
|
||||
// the destination — help — was the row a customer in trouble
|
||||
// scans for. The two policy rows every app is expected to carry
|
||||
// had nowhere to go at all, so they were simply missing.
|
||||
//
|
||||
// Split, each head says what its rows are: things you change,
|
||||
// and things you read.
|
||||
const DmMicroHead('About', brand: false),
|
||||
DmRowGroup(
|
||||
children: [
|
||||
DmRow(
|
||||
icon: LucideIcons.circleHelp,
|
||||
label: 'Help and support',
|
||||
note: 'Talk to the courier desk',
|
||||
showChevron: true,
|
||||
onTap: () => DmToast.show(context, 'Support is on the way'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.fileText,
|
||||
label: 'Terms and policies',
|
||||
note: 'What Doormile covers',
|
||||
showChevron: true,
|
||||
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
DmRow(
|
||||
icon: LucideIcons.info,
|
||||
label: 'About Doormile',
|
||||
note: 'Version and licences',
|
||||
showChevron: true,
|
||||
onTap: () => DmToast.show(context, 'Opening doormile.com…'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -111,36 +227,29 @@ class AccountScreen extends StatelessWidget {
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 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.
|
||||
//
|
||||
// Not a card either. It is a line of mono on the page, like
|
||||
// every other thing on this screen.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2, bottom: 4),
|
||||
child: Text(
|
||||
AppConfig.describe,
|
||||
style: DmText.mono.copyWith(
|
||||
height: 1.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
DmButton(
|
||||
label: 'Sign out',
|
||||
label: 'Log Out',
|
||||
icon: LucideIcons.logOut,
|
||||
iconLeading: true,
|
||||
kind: DmButtonKind.ghost,
|
||||
danger: true,
|
||||
onPressed: () {
|
||||
@@ -156,9 +265,9 @@ class AccountScreen extends StatelessWidget {
|
||||
TextSpan(
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink4),
|
||||
children: [
|
||||
const TextSpan(text: 'Doormile Customer '),
|
||||
const TextSpan(text: 'DOORMILE CUSTOMER '),
|
||||
TextSpan(
|
||||
text: 'v1.0.0',
|
||||
text: 'v${AppConfig.appVersion}',
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
),
|
||||
],
|
||||
@@ -172,3 +281,69 @@ class AccountScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One counted figure under the profile.
|
||||
///
|
||||
/// A glyph in the corner, a micro-label, the number at display size and the
|
||||
/// unit under it — the shape a dashboard tile takes when it is one number
|
||||
/// rather than a chart.
|
||||
class _Stat extends StatelessWidget {
|
||||
const _Stat({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.note,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 14, 15),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.eyebrow,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 15, color: DmColors.ink4),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.headerTitle.copyWith(fontSize: 28),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
note,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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';
|
||||
@@ -145,22 +144,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
),
|
||||
if (booking != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
// ── One card, not two ──
|
||||
//
|
||||
// The reference sat in a washed crimson card of its own, ten
|
||||
// points above a white card holding the route — two containers
|
||||
// and a pink field for a screen whose whole message is that
|
||||
// there is nothing left to do. The reference is the first cell
|
||||
// of the same card now, where it reads as the heading of the
|
||||
// record underneath it rather than as a second announcement.
|
||||
DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 8, 14),
|
||||
child: DmCopyRow(
|
||||
label: 'Booking reference',
|
||||
value: booking.reference,
|
||||
onWash: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
DmCard(
|
||||
children: [
|
||||
DmCardCell(
|
||||
child: DmRouteRail(
|
||||
pickup: booking.pickup.title,
|
||||
@@ -184,21 +185,22 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
|
||||
DmCardCell(
|
||||
background: DmColors.brandSoft,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
// Was a filled crimson pill reading "Searching" beside a
|
||||
// crimson sentence saying the same thing at greater length.
|
||||
// The spinner is the pill's whole job — it is the only
|
||||
// element here that is actually live — so the words go back
|
||||
// to ink and the crimson is left in the wash and the arc.
|
||||
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,
|
||||
),
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(
|
||||
width: 15,
|
||||
height: 15,
|
||||
|
||||
@@ -5,9 +5,11 @@ 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 '../../../state/app_state.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/chrome.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
@@ -108,9 +110,8 @@ class _SendScreenState extends State<SendScreen> {
|
||||
final slot = app.slotById(app.draftSlotId);
|
||||
final fare = app.draftFare;
|
||||
|
||||
final ready = city.districtCode != null &&
|
||||
app.draftSlotId != null &&
|
||||
app.draftReady;
|
||||
final ready =
|
||||
city.districtCode != null && app.draftSlotId != null && app.draftReady;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
@@ -118,79 +119,152 @@ class _SendScreenState extends State<SendScreen> {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
|
||||
children: [
|
||||
// Where it comes from and where it goes, as one object.
|
||||
// ── The head ──
|
||||
//
|
||||
// These were two separate blocks — an eyebrow, a 25pt crimson city
|
||||
// and a Change action, then a bordered line with a label, an
|
||||
// address and a second Change. Seven strings and two identical
|
||||
// words to say what a thread between two points says without any.
|
||||
_RouteHead(
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
to: [for (final g in app.draftDestinations) g.destination.label],
|
||||
onTapFrom: () => Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
),
|
||||
onTapTo: () async {
|
||||
final picked = await showDestinationSheet(context);
|
||||
if (picked != null && picked.isNotEmpty && context.mounted) {
|
||||
app.setDestinations(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
// A numbered chip and the step in words, because a customer who
|
||||
// arrived here from Home's sphere has answered two questions and
|
||||
// wants to know this is the last one. Three dots said the same thing
|
||||
// and made you count them.
|
||||
const _StepHead(step: 3, of: 3),
|
||||
const SizedBox(height: 14),
|
||||
// One row, no card around it: a card holding a single control is a
|
||||
// border drawn for its own sake.
|
||||
Row(
|
||||
// The route, as one card with the two ends and a thread between
|
||||
// them. It was four ruled rows of equal weight, so where it comes
|
||||
// from, where it goes, when and how many all read as the same kind
|
||||
// of fact — and the two that are the *journey* were indistinguishable
|
||||
// from the two that are its particulars.
|
||||
DmCard(
|
||||
raised: true,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 17,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.cardTitle,
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: _RouteHead(
|
||||
from: pickup?.title ?? 'Set a pickup address',
|
||||
fromNote: pickup?.sub,
|
||||
to: [for (final g in app.draftDestinations) g.destination],
|
||||
onTapFrom: () => Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.pickup,
|
||||
(_) => const PickupLocationScreen(),
|
||||
),
|
||||
),
|
||||
onTapTo: () async {
|
||||
final picked = await showDestinationSheet(context);
|
||||
if (picked != null &&
|
||||
picked.isNotEmpty &&
|
||||
context.mounted) {
|
||||
app.setDestinations(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
DmStepper(
|
||||
value: group.packageCount,
|
||||
min: 1,
|
||||
canIncrease: app.canAddPackageTo(0),
|
||||
onChanged: (v) => app.setPackageCount(0, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
// Was three sentences about weighing, charging and cancelling. The
|
||||
// customer is about to commit, so the only part that changes the
|
||||
// decision is that committing costs nothing yet.
|
||||
Text(
|
||||
'Nothing is charged now · Free cancellation',
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.tiny.copyWith(color: DmColors.ink3),
|
||||
const SizedBox(height: 10),
|
||||
_DetailCard(
|
||||
icon: LucideIcons.calendar,
|
||||
label: 'PICKUP WINDOW',
|
||||
value: slot == null
|
||||
? 'Choose a window'
|
||||
: '${slot.day}, ${slot.window}',
|
||||
onTap: () => showWindowSheet(context),
|
||||
trailing: slot == null
|
||||
? null
|
||||
: const Icon(
|
||||
LucideIcons.circleCheck,
|
||||
size: 20,
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_DetailCard(
|
||||
icon: LucideIcons.package,
|
||||
label: 'PARCELS',
|
||||
value:
|
||||
'${group.packageCount} '
|
||||
'${group.packageCount == 1 ? 'parcel' : 'parcels'}',
|
||||
note: 'Up to 15 kg each',
|
||||
trailing: DmStepper(
|
||||
value: group.packageCount,
|
||||
min: 1,
|
||||
canIncrease: app.canAddPackageTo(0),
|
||||
onChanged: (v) => app.setPackageCount(0, v),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// The one promise this screen makes that the customer cannot see for
|
||||
// themselves: nobody is guessing the weight.
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 13, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(LucideIcons.scale, size: 17, color: DmColors.ink3),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Your Miler weighs every parcel at your door. '
|
||||
'Nothing is estimated.',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 13,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: DmFooter(
|
||||
edge: true,
|
||||
children: [
|
||||
_Commitments(
|
||||
window: slot == null ? null : '${slot.day} · ${slot.window}',
|
||||
price: fare?.range,
|
||||
onChangeWindow: () => showWindowSheet(context),
|
||||
// ── Stacked, and left ──
|
||||
//
|
||||
// Label opposite figure meant the two were competing for one line,
|
||||
// and at 30pt the band lost: "₹49 – …" is the half of a price that
|
||||
// tells you nothing. Above and below, neither can crowd the other
|
||||
// and the price starts at the same margin as everything else on the
|
||||
// screen.
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text('ESTIMATED', style: DmText.eyebrow),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
fare?.range ?? '—',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 30),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.receipt,
|
||||
size: 13,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Pay after doorstep weigh · Free cancellation',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
DmButton(
|
||||
label: 'Book pickup',
|
||||
label: 'Confirm Booking',
|
||||
icon: ready ? LucideIcons.arrowRight : null,
|
||||
busy: _busy,
|
||||
busyLabel: 'Booking…',
|
||||
@@ -200,133 +274,192 @@ class _SendScreenState extends State<SendScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Where it comes from, where it goes, and both of them tappable.
|
||||
/// Which step this is.
|
||||
///
|
||||
/// Deliberately not [DmRouteRail]: that one is a read-only thread used on
|
||||
/// receipts, orders and tracking, and giving it tap targets would make every
|
||||
/// other use of it look pressable. This is two controls that happen to be
|
||||
/// drawn as a thread.
|
||||
/// ── Dots → a numbered chip ──
|
||||
///
|
||||
/// There are no "Change" labels. A chevron at the end of a row is the whole
|
||||
/// sentence, and this screen had two of the word stacked one above the other.
|
||||
class _RouteHead extends StatelessWidget {
|
||||
const _RouteHead({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onTapFrom,
|
||||
required this.onTapTo,
|
||||
});
|
||||
/// Three dots with the current one elongated is a shape you have to count.
|
||||
/// A numeral in a disc and "Step 3 of 3" beside it is the same information
|
||||
/// read rather than parsed, and it leaves the screen's own title to the thing
|
||||
/// the customer is about to press.
|
||||
class _StepHead extends StatelessWidget {
|
||||
const _StepHead({required this.step, required this.of});
|
||||
|
||||
final String from;
|
||||
|
||||
/// One row per destination. A pickup that fans out to three places is three
|
||||
/// rows on one thread, not "3 destinations" standing in for them — the whole
|
||||
/// point of the screen is that the customer can see what they arranged.
|
||||
final List<String> to;
|
||||
|
||||
final VoidCallback onTapFrom;
|
||||
final VoidCallback onTapTo;
|
||||
final int step;
|
||||
final int of;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.border)),
|
||||
),
|
||||
child: Column(
|
||||
return Semantics(
|
||||
header: true,
|
||||
label: 'Step $step of $of',
|
||||
excludeSemantics: true,
|
||||
child: Row(
|
||||
children: [
|
||||
_RouteRow(
|
||||
icon: LucideIcons.locateFixed,
|
||||
eyebrow: 'FROM',
|
||||
value: from,
|
||||
onTap: onTapFrom,
|
||||
),
|
||||
for (var i = 0; i < to.length; i++)
|
||||
_RouteRow(
|
||||
icon: LucideIcons.mapPin,
|
||||
// Numbered only when there is more than one, because "TO 1" on a
|
||||
// single-destination booking is a count of one.
|
||||
eyebrow: to.length == 1 ? 'TO' : 'TO · ${i + 1}',
|
||||
value: to[i],
|
||||
onTap: onTapTo,
|
||||
strong: true,
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.ink,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$step',
|
||||
style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Step $step of $of',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteRow extends StatelessWidget {
|
||||
const _RouteRow({
|
||||
required this.icon,
|
||||
required this.eyebrow,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
this.strong = false,
|
||||
/// Where it comes from, where it goes, on one thread.
|
||||
///
|
||||
/// The two ends of a journey are not two rows of a list. A black dot for the
|
||||
/// door it leaves and a brand pin for the door it arrives at, with a rule
|
||||
/// running between them, says *route* in a way four equal rows cannot.
|
||||
class _RouteHead extends StatelessWidget {
|
||||
const _RouteHead({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.onTapFrom,
|
||||
required this.onTapTo,
|
||||
this.fromNote,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String eyebrow;
|
||||
final String value;
|
||||
final VoidCallback onTap;
|
||||
final String from;
|
||||
final String? fromNote;
|
||||
|
||||
/// The destination is set in the brand colour: it is the only choice on this
|
||||
/// screen that moves the price.
|
||||
final bool strong;
|
||||
/// One stop per destination. A pickup that fans out to three places is three
|
||||
/// stops on one thread, not "3 destinations" standing in for them.
|
||||
final List<Destination> to;
|
||||
|
||||
final VoidCallback onTapFrom;
|
||||
final VoidCallback onTapTo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Stop(
|
||||
label: 'ORIGIN',
|
||||
value: from,
|
||||
note: fromNote,
|
||||
onTap: onTapFrom,
|
||||
origin: true,
|
||||
last: false,
|
||||
),
|
||||
for (var i = 0; i < to.length; i++)
|
||||
_Stop(
|
||||
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
|
||||
value: to[i].shortLabel,
|
||||
note: to[i].stateName,
|
||||
onTap: onTapTo,
|
||||
origin: false,
|
||||
last: i == to.length - 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Stop extends StatelessWidget {
|
||||
const _Stop({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.onTap,
|
||||
required this.origin,
|
||||
required this.last,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? note;
|
||||
final VoidCallback onTap;
|
||||
final bool origin;
|
||||
final bool last;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: '$eyebrow $value',
|
||||
label: '$label $value',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: DmSpace.tap),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 18,
|
||||
color: strong ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
// The node and the thread it hangs from. The thread is drawn by
|
||||
// the stop *above* the join, so the last one ends clean.
|
||||
SizedBox(
|
||||
width: 22,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(eyebrow, style: DmText.eyebrow),
|
||||
Text(
|
||||
value,
|
||||
// One weight down from where this started. At w700
|
||||
// crimson, five destinations stacked read as five
|
||||
// headings — the page shouted its own contents back. A
|
||||
// list of places the customer chose is a list, not a
|
||||
// series of titles.
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 15.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: strong ? DmColors.brand : DmColors.ink,
|
||||
const SizedBox(height: 15),
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: origin ? DmColors.ink : DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!last)
|
||||
const Expanded(
|
||||
child: VerticalDivider(
|
||||
width: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.border,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 17,
|
||||
color: DmColors.ink4,
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 16.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (note != null && note!.isNotEmpty)
|
||||
Text(
|
||||
note!,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -336,82 +469,87 @@ class _RouteRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The two things the button is about to agree to, side by side above it.
|
||||
///
|
||||
/// The window used to be a row in a card in the middle of the form and the
|
||||
/// price used to appear in the footer only once everything was valid, which
|
||||
/// meant the customer had to scroll back up to check what time they had been
|
||||
/// given. Both live here now, and the left half is tappable.
|
||||
class _Commitments extends StatelessWidget {
|
||||
const _Commitments({
|
||||
required this.window,
|
||||
required this.price,
|
||||
required this.onChangeWindow,
|
||||
/// One particular of the booking, on its own card: a glyph well, a micro-label
|
||||
/// over a value, and whatever acts on it.
|
||||
class _DetailCard extends StatelessWidget {
|
||||
const _DetailCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.note,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String? window;
|
||||
final String? price;
|
||||
final VoidCallback onChangeWindow;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String? note;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
return DmCard(
|
||||
onTap: onTap,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onChangeWindow,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('PICKUP', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
window ?? 'Choose a window',
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: window == null ? DmColors.brand : null,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(icon, size: 18, color: DmColors.ink2),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (note != null)
|
||||
Text(
|
||||
note!,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
trailing!,
|
||||
] else if (onTap != null) ...[
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('ESTIMATED', style: DmText.eyebrow),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
price ?? '—',
|
||||
style: DmText.monoLg.copyWith(
|
||||
fontSize: 18,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
|
||||
import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import 'shell_screen.dart';
|
||||
import '../format.dart';
|
||||
import '../widgets/cards.dart';
|
||||
import '../widgets/book_orb.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import '../widgets/misc.dart';
|
||||
import 'booking/booking_routes.dart';
|
||||
import 'booking/send_screen.dart';
|
||||
import 'shell_screen.dart';
|
||||
import 'sheets/destination_sheet.dart';
|
||||
import 'sheets/place_search_sheet.dart';
|
||||
import 'sheets/window_sheet.dart';
|
||||
@@ -89,105 +91,179 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final active = app.activeBookings.isEmpty ? null : app.activeBookings.first;
|
||||
final firstRun = app.orders.isEmpty;
|
||||
|
||||
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 sphere's own field, floored so a very short window leaves it a
|
||||
// sphere rather than a slot. It is a *minimum*: the scroll view hands it
|
||||
// whatever is left once the card below has taken what it needs.
|
||||
final orbHeight = math.max(300.0, MediaQuery.sizeOf(context).height * 0.38);
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// No wordmark bar here.
|
||||
//
|
||||
// Orders and Account open on a title and want a bar over it. Home
|
||||
// opens on the customer's own name with one object under it, and a
|
||||
// second header above that made the screen start twice.
|
||||
// ── Greeting, then where from ──
|
||||
//
|
||||
// "Hello, Joe ●" with the brand dot, the avatar opposite it, and the
|
||||
// address as a bordered pill under both. With the wordmark bar gone
|
||||
// this is the top of the screen, so it takes the inset the bar used
|
||||
// to hold.
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'Hello, ${_firstName(app.customer?.name)}',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
InkResponse(
|
||||
onTap: () => ShellScreen.of(context)?.goToTab(2),
|
||||
radius: 26,
|
||||
child: DmAvatar(
|
||||
initials: initialsOf(app.customer?.name),
|
||||
size: 40,
|
||||
badge: active != null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_PickupLine(
|
||||
title: app.pickup?.title ?? 'Set a pickup address',
|
||||
onTap: () async {
|
||||
final place = await showPlaceSearchSheet(context);
|
||||
if (place != null && context.mounted) app.setPickup(place);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 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: AnimatedScale(
|
||||
scale: _handingOver ? 0.96 : 1,
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _handingOver ? 0.45 : 1,
|
||||
Expanded(
|
||||
child: AnimatedScale(
|
||||
scale: _handingOver ? 0.96 : 1,
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
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: [
|
||||
const SizedBox(height: 6),
|
||||
DmBookOrb(
|
||||
onTap: _book,
|
||||
held: _handingOver,
|
||||
// A returning customer knows what BOOK does. The line is
|
||||
// for the first run only, and one line is enough to say
|
||||
// what kind of app this is.
|
||||
caption: firstRun
|
||||
? 'A Miler collects from your door.'
|
||||
: null,
|
||||
).animate().fadeIn(duration: 420.ms).scale(
|
||||
begin: const Offset(0.82, 0.82),
|
||||
end: const Offset(1, 1),
|
||||
duration: 620.ms,
|
||||
// A shade of overshoot, so it arrives rather than
|
||||
// appears.
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
if (active != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 22),
|
||||
child: _LiveRow(booking: active),
|
||||
).animate().fadeIn(duration: 380.ms, delay: 160.ms).slideY(
|
||||
begin: 0.12,
|
||||
curve: DmMotion.ease,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _handingOver ? 0.45 : 1,
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
child: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
backgroundColor: DmColors.surface,
|
||||
onRefresh: () async {
|
||||
await Future.wait([
|
||||
app.detectPickupLocation(),
|
||||
app.refreshOrders(),
|
||||
]);
|
||||
},
|
||||
child: LayoutBuilder(
|
||||
builder: (context, box) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
0,
|
||||
DmSpace.pad,
|
||||
0,
|
||||
),
|
||||
if (firstRun) const _HowItWorks(),
|
||||
],
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: math.max(orbHeight, box.maxHeight),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
DmBookOrb(onTap: _book, held: _handingOver)
|
||||
.animate()
|
||||
.fadeIn(duration: 420.ms)
|
||||
.scale(
|
||||
begin: const Offset(0.82, 0.82),
|
||||
end: const Offset(1, 1),
|
||||
duration: 620.ms,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// The promise, as a chip. It was a sentence under
|
||||
// the sphere on first run and nothing afterwards;
|
||||
// a returning customer is the one who most wants
|
||||
// to know a Miler is three minutes away.
|
||||
const _PromiseChip(
|
||||
icon: LucideIcons.zap,
|
||||
label: '3 min pickup',
|
||||
).animate().fadeIn(
|
||||
duration: 380.ms,
|
||||
delay: 220.ms,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// ── The foot: what is running, and nothing else ──
|
||||
//
|
||||
// A "quick dispatch" row of shortcut cities lived here and was
|
||||
// removed. It was a second way to start the one thing this screen
|
||||
// starts, and it cost the sphere the space that makes it read as an
|
||||
// object floating in a page rather than as a button in a stack.
|
||||
//
|
||||
// The card sits outside the scroll view, so the sphere is centred in
|
||||
// what is actually left rather than in a viewport pretending the
|
||||
// card is not there.
|
||||
if (active != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
DmSpace.pad,
|
||||
14,
|
||||
DmSpace.pad,
|
||||
6,
|
||||
),
|
||||
child: _LiveCard(booking: active),
|
||||
)
|
||||
.animate()
|
||||
.fadeIn(duration: 380.ms, delay: 160.ms)
|
||||
.slideY(begin: 0.12, curve: DmMotion.ease),
|
||||
// Clears the floating tab bar, which the shell stacks over the body.
|
||||
//
|
||||
// Measured, not guessed. A fixed 74 was right on the 844pt golden
|
||||
// and eight points short on a device with a home indicator, where
|
||||
// the bar is its own 58 plus 10 of padding plus a 34pt inset — so
|
||||
// the pill sat on top of the live card.
|
||||
SizedBox(height: MediaQuery.paddingOf(context).bottom + 74),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,14 +273,24 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The live booking, as one row.
|
||||
/// The live booking, at the foot of Home.
|
||||
///
|
||||
/// This was a card with four cells. A customer glancing at Home wants two
|
||||
/// things from it — *what is happening* and *how long* — and everything else it
|
||||
/// carried was available one tap away on the screen this row opens. The route,
|
||||
/// the reference and the package count all went back there.
|
||||
class _LiveRow extends StatelessWidget {
|
||||
const _LiveRow({required this.booking});
|
||||
/// ── The loader went, and the card grew ──
|
||||
///
|
||||
/// It carried a thin filled line under the destination — a progress bar across
|
||||
/// the five customer milestones. It was the one fact that cannot be written as
|
||||
/// a word, and it still read as a *loader*: a bar that fills is the shape an
|
||||
/// app uses to say "wait", and a parcel two days into a journey is not
|
||||
/// something the customer is waiting through. The rail on tracking says the
|
||||
/// same thing with five labelled nodes and no ambiguity.
|
||||
///
|
||||
/// What the space bought is size. This is the only thing on Home besides the
|
||||
/// sphere and it was a 90pt strip — a chip, a place and a courier compressed
|
||||
/// into three tight lines. The destination is set at display weight with its
|
||||
/// promise under it now, a rule separates the journey from the person carrying
|
||||
/// it, and the arrow is a target rather than a glyph.
|
||||
class _LiveCard extends StatelessWidget {
|
||||
const _LiveCard({required this.booking});
|
||||
|
||||
final Booking booking;
|
||||
|
||||
@@ -212,162 +298,259 @@ class _LiveRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.read(context);
|
||||
final slot = app.slotById(booking.slotId);
|
||||
final miler = booking.miler ?? booking.deliveryAgent;
|
||||
|
||||
// One supporting line, chosen by what the customer can act on right now:
|
||||
// before the handover that is when the Miler is coming, after it that is
|
||||
// where the parcel is going.
|
||||
final support = switch (booking.stage) {
|
||||
// The district alone. "Chennai, Tamil Nadu" shares its row with the state
|
||||
// and the reference, and on a 402pt device it rendered "Chenna…" — the
|
||||
// half of the name that identifies nothing.
|
||||
final place = booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.shortLabel;
|
||||
|
||||
// The promise, in the order a customer can act on it: how long, then
|
||||
// where they are, then what was agreed.
|
||||
final promise = switch (booking.stage) {
|
||||
JourneyStage.arrived => 'At your door now',
|
||||
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
|
||||
'Arriving in about ${booking.milerEtaMinutes} min',
|
||||
JourneyStage.booked || JourneyStage.assigned || JourneyStage.onTheWay
|
||||
when slot != null =>
|
||||
'${slot.day} · ${slot.window}',
|
||||
JourneyStage.arrived => 'At your door now',
|
||||
_ =>
|
||||
booking.isMultiDestination
|
||||
? 'To ${booking.destinations.length} destinations'
|
||||
: 'To ${booking.destination.label}',
|
||||
_ when booking.expectedDelivery != null =>
|
||||
'Expected ${booking.expectedDelivery}',
|
||||
_ => booking.stage.milestoneLabel,
|
||||
};
|
||||
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => const TrackingScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 64),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 12, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: DmColors.brandLine),
|
||||
return DmCard(
|
||||
raised: true,
|
||||
onTap: () {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => const TrackingScreen(),
|
||||
),
|
||||
child: Row(
|
||||
);
|
||||
},
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 17, 18, 18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const DmLiveDot(size: 7),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
booking.stage.milestoneLabel,
|
||||
style: DmText.cardTitle.copyWith(
|
||||
color: DmColors.brandPress,
|
||||
Row(
|
||||
children: [
|
||||
const DmLiveDot(size: 7),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.stage.chipLabel.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.9,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
support,
|
||||
style: DmText.small,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
booking.displayId,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
place,
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
promise,
|
||||
style: DmText.body.copyWith(
|
||||
fontSize: 14.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// A disc, not a bare chevron. It is the only affordance on
|
||||
// the card and it sits against 26pt of type, where a 17pt
|
||||
// glyph on its own disappears.
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.brandSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.arrowRight,
|
||||
size: 19,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (miler != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: DmColors.hairline,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
DmAvatar(initials: miler.initials, size: 38),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
miler.name,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
miler.vehicle,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 18,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the Miler is coming to, under the greeting.
|
||||
///
|
||||
/// ── Box → line → pill → line → row → pill ──
|
||||
///
|
||||
/// It has been most shapes. The one it has landed on is the reference's: a
|
||||
/// small bordered pill holding a brand pin, the address, and a chevron-down.
|
||||
///
|
||||
/// The pill is right *here* and was wrong before because the screen changed
|
||||
/// around it. On a page whose only other objects were a name and a sphere it
|
||||
/// was a container built to hold one line of text. On a page that now opens
|
||||
/// with a wordmark bar and ends with a floating one, it is the middle term —
|
||||
/// and the chevron-down is the honest glyph, because what it opens is a
|
||||
/// chooser, not another screen.
|
||||
class _PickupLine extends StatelessWidget {
|
||||
const _PickupLine({required this.title, required this.onTap});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: 'Pickup from $title. Change',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 40, maxWidth: 300),
|
||||
padding: const EdgeInsets.fromLTRB(11, 8, 11, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
border: Border.all(color: DmColors.border),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(LucideIcons.mapPin, size: 15, color: DmColors.brand),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 15,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// How it works, on first run only.
|
||||
/// The promise under the sphere: a glyph, a figure, a tint.
|
||||
///
|
||||
/// This was a bordered card holding three numbered rows, each with a subtitle.
|
||||
/// It is a timeline now — glyph, hairline, title — and the subtitles are gone:
|
||||
/// they described the step the title had already named, on the one screen where
|
||||
/// a new customer is least willing to read.
|
||||
class _HowItWorks extends StatelessWidget {
|
||||
const _HowItWorks();
|
||||
|
||||
static const _steps = [
|
||||
(LucideIcons.mapPin, 'Tell us where it goes'),
|
||||
(LucideIcons.clock3, 'Pick a two-hour window'),
|
||||
(LucideIcons.packageCheck, 'Hand it over and track'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 30),
|
||||
for (var i = 0; i < _steps.length; i++)
|
||||
_Step(
|
||||
icon: _steps[i].$1,
|
||||
title: _steps[i].$2,
|
||||
last: i == _steps.length - 1,
|
||||
),
|
||||
],
|
||||
).animate(delay: 320.ms).fadeIn(duration: 520.ms).slideY(
|
||||
begin: 0.06,
|
||||
curve: DmMotion.ease,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Step extends StatelessWidget {
|
||||
const _Step({required this.icon, required this.title, required this.last});
|
||||
/// It was a full sentence — "A Miler collects from your door." — shown on the
|
||||
/// first run only. That is the app explaining itself to somebody who has
|
||||
/// already pressed nothing; what a customer actually wants before they commit
|
||||
/// is how long they will wait, and that is worth saying every time.
|
||||
class _PromiseChip extends StatelessWidget {
|
||||
const _PromiseChip({required this.icon, required this.label});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final bool last;
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IntrinsicHeight(
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 7, 13, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// The rail: a washed disc per step, joined by a hairline that runs
|
||||
// the height of that step's text. Sized by [IntrinsicHeight] so the
|
||||
// connector always meets the next disc, whatever the text wraps to.
|
||||
SizedBox(
|
||||
width: 34,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: DmColors.brandSoft,
|
||||
),
|
||||
child: Icon(icon, size: 15, color: DmColors.brand),
|
||||
),
|
||||
if (!last)
|
||||
const Expanded(
|
||||
child: SizedBox(
|
||||
width: 1,
|
||||
child: ColoredBox(color: DmColors.border),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 8, bottom: last ? 0 : 20),
|
||||
child: Text(title, style: DmText.cardTitle),
|
||||
),
|
||||
Icon(icon, size: 13, color: DmColors.brand),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
label,
|
||||
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -97,8 +97,19 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
else
|
||||
// ── The hero stopped being a crimson slab ──
|
||||
//
|
||||
// A filled brand card was the first and biggest thing on a screen
|
||||
// about an order that is *finished*. Crimson is this app's action
|
||||
// colour — the sphere, the button, the live dot — so the loudest
|
||||
// possible use of it was sitting on the one screen with nothing
|
||||
// left to do.
|
||||
//
|
||||
// Green for the tick, ink for the type, white for the card. The
|
||||
// design already reserves green for exactly one meaning, and this
|
||||
// is it; the confirmation screen says the same word the same way.
|
||||
DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 18),
|
||||
@@ -110,13 +121,13 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
color: DmColors.onBrandRaise,
|
||||
color: DmColors.okSoft,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.check,
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
color: DmColors.ok,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
@@ -124,16 +135,13 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
recipient == null
|
||||
? 'Delivered'
|
||||
: 'Delivered to $recipient',
|
||||
style: DmText.display.copyWith(
|
||||
fontSize: 26,
|
||||
color: Colors.white,
|
||||
),
|
||||
style: DmText.display.copyWith(fontSize: 26),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${booking.destination.districtName} · '
|
||||
'${dateLabel(booking.deliveredAt ?? booking.createdAt)}',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
style: DmText.small.copyWith(color: DmColors.ink2),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -145,7 +153,7 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
const Icon(
|
||||
LucideIcons.package,
|
||||
size: 15,
|
||||
color: DmColors.onBrand3,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
@@ -153,14 +161,14 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
booking.packageSummary ??
|
||||
'${booking.totalPackages} '
|
||||
'${booking.totalPackages == 1 ? 'package' : 'packages'}',
|
||||
style: DmText.small.copyWith(color: DmColors.onBrand2),
|
||||
style: DmText.small.copyWith(color: DmColors.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
booking.displayId,
|
||||
style: DmText.monoSm.copyWith(color: Colors.white),
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -201,7 +209,7 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
|
||||
child: Text(
|
||||
'PACKAGES',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
for (final group in booking.destinations)
|
||||
@@ -231,7 +239,7 @@ class OrderDetailsScreen extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'PAYMENT',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
@@ -370,7 +378,7 @@ class _VerificationCard extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
'COLLECTED AT YOUR DOOR',
|
||||
style: DmText.eyebrow.copyWith(color: DmColors.brand),
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
@@ -558,12 +566,15 @@ class _Card extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// The same lift [DmCard] carries. This screen's own wrapper predates it
|
||||
// and was still drawing a border, so four outlined panels sat under a card
|
||||
// that had stopped having one.
|
||||
return Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.border),
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: DmRadius.all(DmRadius.card),
|
||||
boxShadow: DmShadow.card,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
@@ -4,32 +4,35 @@ import '../../data/models.dart';
|
||||
import '../../state/app_scope.dart';
|
||||
import '../format.dart';
|
||||
import '../tokens.dart';
|
||||
import '../widgets/chrome.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.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.
|
||||
/// One order in the list.
|
||||
///
|
||||
/// 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.
|
||||
/// ── The shape it landed on ──
|
||||
///
|
||||
/// ── Status first, reference quiet ──
|
||||
/// Bordered card → bare type on the canvas → lifted card → this. The
|
||||
/// reference this is drawn against gives the card a *structure* rather than a
|
||||
/// template, and the structure is four bands:
|
||||
///
|
||||
/// A customer scanning this list is asking one question — what is happening to
|
||||
/// my parcel — so the state is the first thing on every card, at the same
|
||||
/// height on each.
|
||||
/// #DM-6912 ● OUT FOR DELIVERY ⏱ Arriving in 30m
|
||||
/// Bengaluru 1 parcel · 2.4 kg
|
||||
/// 38 Mettupalayam Road → Indiranagar
|
||||
/// ─────────────────────────────────────────────────────
|
||||
/// ⊕ Rajesh K. · On the way [ Track → ]
|
||||
///
|
||||
/// The reference used to share a crimson-washed strip with it, which gave a
|
||||
/// string nobody reads while scanning the same weight as the only word they do.
|
||||
/// It is a quiet identifier on the same line now, and the strip is gone: one
|
||||
/// cell, not two.
|
||||
/// identity, destination, route, and whatever is happening right now. The
|
||||
/// fourth band is what stops four orders reading as four instances of one
|
||||
/// widget: it carries the Miler when there is one, the window when there is
|
||||
/// not, and a filled Track button only while the order is live.
|
||||
///
|
||||
/// "Collected on DM-471200" went with it. It explained why two orders share a
|
||||
/// pickup, which is a question asked on the order, not in a list of them.
|
||||
/// Hub names — "Whitefield Hub → Indiranagar 100ft Rd" in the reference — are
|
||||
/// not on a booking. `hub` belongs to the service-area catalogue, so the route
|
||||
/// band shows the two ends the booking actually has.
|
||||
class OrderCard extends StatelessWidget {
|
||||
const OrderCard({super.key, required this.entry});
|
||||
|
||||
@@ -43,104 +46,190 @@ class OrderCard extends StatelessWidget {
|
||||
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 (label, Color tone) = switch (booking.status) {
|
||||
BookingStatus.cancelled => ('Cancelled', DmColors.ink4),
|
||||
BookingStatus.completed => ('Delivered', DmColors.ok),
|
||||
BookingStatus.active => (stage.chipLabel, DmColors.brand),
|
||||
};
|
||||
|
||||
final drop = entry.isPickup
|
||||
final destination = entry.isPickup
|
||||
? (booking.isMultiDestination
|
||||
? '${booking.destinations.length} destinations'
|
||||
: booking.destination.label)
|
||||
: group!.destination.label;
|
||||
: booking.destination.shortLabel)
|
||||
: group!.destination.shortLabel;
|
||||
|
||||
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 (booking.status == BookingStatus.completed && booking.amountPaid != null)
|
||||
'₹${booking.amountPaid} paid',
|
||||
final slot = app.slotById(booking.slotId);
|
||||
|
||||
// Top right: when. An ETA while a Miler is moving, otherwise the window
|
||||
// that was agreed, otherwise the day it was placed.
|
||||
final eta = booking.milerEtaMinutes;
|
||||
final collected = stage.index >= JourneyStage.pickedUp.index;
|
||||
final (timing, urgent) = switch (true) {
|
||||
_ when live && eta != null && eta > 0 => ('Arriving in ${eta}m', true),
|
||||
_ when live && stage == JourneyStage.arrived => ('At your door', true),
|
||||
// The pickup window is a promise about the *collection*. Once the parcel
|
||||
// is on the network it says nothing, and printing it next to "In
|
||||
// transit" reads as a delivery time that nobody promised.
|
||||
_ when live && !collected && slot != null => (slot.window, false),
|
||||
_ when live && booking.expectedDelivery != null =>
|
||||
('Exp. ${booking.expectedDelivery}', false),
|
||||
_ => (dateLabel(booking.createdAt), false),
|
||||
};
|
||||
|
||||
// Second line, right: what is in it. The weight appears only once the
|
||||
// Miler has actually weighed it at the door.
|
||||
// The weight of *these* parcels, and only once a Miler has actually
|
||||
// weighed them. `totalWeightLabel` is the whole visit's, which on one
|
||||
// destination's row is somebody else's parcel.
|
||||
final weight = entry.isPickup
|
||||
? booking.totalWeightLabel
|
||||
: group!.verification?.weightLabel;
|
||||
final contents = [
|
||||
'$packages ${packages == 1 ? 'parcel' : 'parcels'}',
|
||||
?weight,
|
||||
].join(' · ');
|
||||
|
||||
// The foot: who has it, or when it was promised.
|
||||
final miler = booking.miler ?? booking.deliveryAgent;
|
||||
final footNote = miler != null
|
||||
? '${miler.name} · ${stage.chipLabel}'
|
||||
: (booking.status == BookingStatus.completed
|
||||
? 'Delivered${booking.amountPaid == null ? '' : ' · ₹${booking.amountPaid}'}'
|
||||
: stage.milestoneLabel);
|
||||
|
||||
void open() {
|
||||
app.openTracking(booking.reference);
|
||||
Navigator.of(context).push(
|
||||
live
|
||||
? bookingRoute<void>(
|
||||
BookingRoutes.tracking,
|
||||
(_) => TrackingScreen(focusedDestination: entry.destinationIndex),
|
||||
)
|
||||
: MaterialPageRoute<void>(
|
||||
builder: (_) => OrderDetailsScreen(reference: booking.reference),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
raised: live,
|
||||
onTap: open,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 15),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
// The date belongs at the right edge, on the same line on
|
||||
// every card, so the eye can run down the column. Left to the
|
||||
// default packing it sits wherever the status text happens to
|
||||
// end, which is a different place on all four.
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Flexible, not Expanded: the pill is sized by its own text
|
||||
// and only gives way if the row runs out of room. Expanded
|
||||
// made it fill every spare pixel, which drew "In transit"
|
||||
// as a 600pt bar with one word in it. Expanding the *date*
|
||||
// instead — the version before that — clipped the only word
|
||||
// on the row that matters, to make space for "Yesterday".
|
||||
// Neither of them is flexible; the pill is.
|
||||
Flexible(child: DmPill(label, tone: tone)),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 10),
|
||||
child: Text(
|
||||
dateLabel(booking.createdAt),
|
||||
maxLines: 1,
|
||||
style: DmText.tiny,
|
||||
Flexible(child: _RefChip(reference: reference)),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: DmStatusChip(
|
||||
label: label,
|
||||
tone: tone,
|
||||
quiet: !live,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (urgent) ...[
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 12,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
timing,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: urgent ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 13),
|
||||
DmRouteRail(
|
||||
compact: true,
|
||||
pickup: booking.pickup.title,
|
||||
drop: drop,
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
// The date belongs at the right edge, on the same line on
|
||||
// every card, so the eye can run down the column. Left to the
|
||||
// default packing it sits wherever the status text happens to
|
||||
// end, which is a different place on all four.
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Expanded(child: Text(meta, style: DmText.tiny)),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
reference,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
destination,
|
||||
style: DmText.headerTitle.copyWith(
|
||||
fontSize: 26,
|
||||
color: live ? DmColors.ink : DmColors.ink2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
contents,
|
||||
textAlign: TextAlign.right,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_Route(
|
||||
from: booking.pickup.title,
|
||||
to: entry.isPickup
|
||||
? (booking.isMultiDestination
|
||||
? '${booking.destinations.length} stops'
|
||||
: booking.destination.label)
|
||||
: group!.destination.label,
|
||||
),
|
||||
const SizedBox(height: 13),
|
||||
const Divider(height: 1, thickness: 1, color: DmColors.hairline),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
miler != null ? LucideIcons.userRound : LucideIcons.clock,
|
||||
size: 15,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(
|
||||
footNote,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 13,
|
||||
color: DmColors.ink2,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_EndButton(live: live, onPressed: open),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -149,15 +238,114 @@ class OrderCard extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// The reference, in a quiet well.
|
||||
///
|
||||
/// It reads as an identifier rather than as a sentence — which is what it is,
|
||||
/// and the reason it can sit at 11.5pt beside a 26pt place name without
|
||||
/// disappearing.
|
||||
class _RefChip extends StatelessWidget {
|
||||
const _RefChip({required this.reference});
|
||||
|
||||
final String reference;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 4, 8, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.chip),
|
||||
),
|
||||
child: Text(
|
||||
reference.startsWith('#') ? reference : '#$reference',
|
||||
style: DmText.monoSm.copyWith(fontSize: 11, color: DmColors.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where it leaves from and where it is going, as one line with an arrow.
|
||||
class _Route extends StatelessWidget {
|
||||
const _Route({required this.from, required this.to});
|
||||
|
||||
final String from;
|
||||
final String to;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3);
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(from, style: style, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
const Icon(LucideIcons.arrowRight, size: 13, color: DmColors.ink4),
|
||||
const SizedBox(width: 7),
|
||||
Flexible(
|
||||
child: Text(
|
||||
to,
|
||||
style: style.copyWith(color: DmColors.ink2),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Track while it is running, Details once it is not.
|
||||
///
|
||||
/// Filled crimson for the live one and a quiet text action for the rest, so a
|
||||
/// screen of eleven delivered parcels has no red on it at all.
|
||||
class _EndButton extends StatelessWidget {
|
||||
const _EndButton({required this.live, required this.onPressed});
|
||||
|
||||
final bool live;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
padding: live
|
||||
? const EdgeInsets.fromLTRB(15, 9, 12, 10)
|
||||
: const EdgeInsets.fromLTRB(10, 9, 6, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: live ? DmColors.brand : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
live ? 'Track' : 'Details',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: live ? Colors.white : DmColors.ink2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Icon(
|
||||
live ? LucideIcons.arrowRight : LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: live ? Colors.white : DmColors.ink4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,54 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/inputs.dart';
|
||||
import '../widgets/feedback.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 {
|
||||
/// ── The filter came out of the header ──
|
||||
///
|
||||
/// It was a segmented control recessed into the crimson field, which made it
|
||||
/// look like part of the title bar rather than like something to press. A
|
||||
/// filter is a control the customer uses on every visit; it belongs on the
|
||||
/// canvas with the list it filters, where it can scroll into view as a row of
|
||||
/// chips rather than sit locked into a band of chrome.
|
||||
///
|
||||
/// Each chip carries its own count, so there is still no separate figure row.
|
||||
class OrdersScreen extends StatefulWidget {
|
||||
const OrdersScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OrdersScreen> createState() => _OrdersScreenState();
|
||||
}
|
||||
|
||||
class _OrdersScreenState extends State<OrdersScreen> {
|
||||
static const _tabs = BookingStatus.values;
|
||||
|
||||
final _query = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Reference or destination, case-insensitively. Two fields, because those
|
||||
/// are the two strings a customer has: the number they were given, and the
|
||||
/// place they sent it to.
|
||||
bool _matches(OrderEntry e, String q) {
|
||||
if (q.isEmpty) return true;
|
||||
final needle = q.toLowerCase();
|
||||
final reference = e.isPickup
|
||||
? e.booking.reference
|
||||
: (e.group?.trackingId ?? e.booking.reference);
|
||||
final place = e.isPickup
|
||||
? e.booking.destination.label
|
||||
: (e.group?.destination.label ?? '');
|
||||
return reference.toLowerCase().contains(needle) ||
|
||||
place.toLowerCase().contains(needle);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final app = AppScope.of(context);
|
||||
final rows = app.entriesOf(app.ordersTab);
|
||||
final q = _query.text.trim();
|
||||
final all = app.entriesOf(app.ordersTab);
|
||||
final rows = [for (final e in all) if (_matches(e, q)) e];
|
||||
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]),
|
||||
DmAppBar(
|
||||
unread: app.activeBookings.isNotEmpty,
|
||||
onSearch: () => DmToast.show(context, 'Search is coming soon'),
|
||||
onNotifications: () =>
|
||||
DmToast.show(context, 'Nothing new right now'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 14),
|
||||
// The filter gets its own row.
|
||||
//
|
||||
// Beside the title it had a third of the width for three segments,
|
||||
// and rendered "Act… Pa… Ca…" — the reference fits its control on
|
||||
// the title line because it carries two segments, not three. A row
|
||||
// is cheaper than an unreadable control.
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Orders',
|
||||
style: DmText.headerTitle.copyWith(fontSize: 26),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Filter(
|
||||
labels: const ['Active', 'Past', 'Cancelled'],
|
||||
counts: [for (final tab in _tabs) app.entriesOf(tab).length],
|
||||
index: index < 0 ? 0 : index,
|
||||
onChanged: (i) => app.setOrdersTab(_tabs[i]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// ── A search box, because the list is the archive ──
|
||||
//
|
||||
// Orders is the only screen that grows without bound: every
|
||||
// parcel a customer has ever sent ends up here. Scrolling is
|
||||
// fine at four and useless at forty, and the two things they
|
||||
// have to go on are the reference somebody quoted at them and
|
||||
// the city they sent it to. Both are matched.
|
||||
DmSearchField(
|
||||
controller: _query,
|
||||
hint: 'Search by order ID or city',
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@@ -58,11 +118,14 @@ class OrdersScreen extends StatelessWidget {
|
||||
onRefresh: app.refreshOrders,
|
||||
child: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28),
|
||||
// The last card clears the floating tab bar, which the shell
|
||||
// stacks over the body rather than laying out beneath it.
|
||||
padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 74 + MediaQuery.paddingOf(context).bottom),
|
||||
itemCount: rows.isEmpty ? 1 : rows.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, i) =>
|
||||
rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, i) => rows.isEmpty
|
||||
? (q.isEmpty ? _empty(app.ordersTab) : _noMatch(q))
|
||||
: OrderCard(entry: rows[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -70,6 +133,14 @@ class OrdersScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Nothing matched what they typed — which is a different problem from
|
||||
/// having no orders, and says so.
|
||||
Widget _noMatch(String query) => DmEmptyState(
|
||||
icon: LucideIcons.search,
|
||||
title: 'No orders match "$query"',
|
||||
message: 'Try an order ID, or the city it was going to.',
|
||||
);
|
||||
|
||||
Widget _empty(BookingStatus tab) {
|
||||
final (title, message) = switch (tab) {
|
||||
BookingStatus.active => (
|
||||
@@ -92,3 +163,123 @@ class OrdersScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three filters, as chips on the canvas.
|
||||
///
|
||||
/// Scrollable because a fourth state is one backend release away and a row of
|
||||
/// four fixed chips at 1.8x text has nowhere to go. Each carries its count in
|
||||
/// the same pill, because "Active" and "4" are one fact.
|
||||
/// Active / Past / Cancelled, as a segmented control.
|
||||
///
|
||||
/// ── Chips → text tabs → a segment ──
|
||||
///
|
||||
/// The chips were three containers, one of them filled solid black, sitting
|
||||
/// above a list of cards — the loudest object on the screen was the filter.
|
||||
/// Plain text tabs with a rule fixed that and left the row looking like a
|
||||
/// heading rather than a control.
|
||||
///
|
||||
/// A groove with a raised chip in it is the shape that is unambiguously a
|
||||
/// control and still quiet: white lifted out of a warm bed, ink type, and the
|
||||
/// count beside the label because that is the reason anyone looks at the two
|
||||
/// tabs they are not on.
|
||||
class _Filter extends StatelessWidget {
|
||||
const _Filter({
|
||||
required this.labels,
|
||||
required this.counts,
|
||||
required this.index,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final List<String> labels;
|
||||
final List<int?> counts;
|
||||
final int index;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < labels.length; i++)
|
||||
Flexible(
|
||||
child: _Tab(
|
||||
label: labels[i],
|
||||
count: counts[i],
|
||||
selected: i == index,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Tab extends StatelessWidget {
|
||||
const _Tab({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int? count;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
if (count != null && count! > 0)
|
||||
TextSpan(
|
||||
text: ' ($count)',
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12,
|
||||
color: selected ? DmColors.ink3 : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13,
|
||||
color: selected ? DmColors.ink : DmColors.ink3,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../../../state/app_scope.dart';
|
||||
import '../../state_marks.dart';
|
||||
import '../../tokens.dart';
|
||||
import '../../widgets/buttons.dart';
|
||||
import '../../widgets/cards.dart';
|
||||
import '../../widgets/feedback.dart';
|
||||
import '../../widgets/inputs.dart';
|
||||
import '../../widgets/states.dart';
|
||||
@@ -165,8 +164,29 @@ class _DestinationSheetState extends State<_DestinationSheet> {
|
||||
Expanded(
|
||||
child: DmSheetHeader(
|
||||
title: browsing?.name ?? 'Where is it going?',
|
||||
// ── A line of orientation ──
|
||||
//
|
||||
// The sheet asked a question and then showed a list, and a
|
||||
// customer who has never used it cannot tell whether tapping
|
||||
// a state chooses it or opens it. One sentence says which,
|
||||
// and at the district step says how many they may pick.
|
||||
subtitle: browsing == null
|
||||
? 'Pick a state to see the cities it serves'
|
||||
: (app.allowsMultipleDestinations
|
||||
? 'Choose one or more districts'
|
||||
: 'Choose a district'),
|
||||
),
|
||||
),
|
||||
// A close, because a sheet that can only be dismissed by dragging
|
||||
// is a sheet somebody will get stuck in.
|
||||
DmIconButton(
|
||||
icon: LucideIcons.x,
|
||||
tooltip: 'Close',
|
||||
background: DmColors.surface,
|
||||
border: Colors.transparent,
|
||||
size: 38,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
DmSearchField(
|
||||
@@ -295,23 +315,22 @@ class _StateRow extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return _Row(
|
||||
onTap: onTap,
|
||||
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash),
|
||||
// The mark, with nothing behind it. It sat in a 38pt washed-crimson
|
||||
// tile, so a list of six states was six pink squares in a column — six
|
||||
// containers and six spots of brand to hold six glyphs that read
|
||||
// perfectly well on the sheet's own surface.
|
||||
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
|
||||
title: area.name,
|
||||
subtitle:
|
||||
'$count ${count == 1 ? 'city' : 'cities'}'
|
||||
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
|
||||
// The number of districts already chosen in this state. It was a filled
|
||||
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
|
||||
trailing: chosen == 0
|
||||
? null
|
||||
: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$chosen',
|
||||
style: DmText.label.copyWith(color: Colors.white),
|
||||
),
|
||||
: Text(
|
||||
'$chosen',
|
||||
style: DmText.cardTitle.copyWith(color: DmColors.brand),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -400,11 +419,27 @@ class _Row extends StatelessWidget {
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
// ── Cards, not ruled rows ──
|
||||
//
|
||||
// The rules were the right correction to the washed-crimson tiles that
|
||||
// came before them, and they left the sheet as the one screen in the
|
||||
// app still made of open rows while everything behind it had moved to
|
||||
// lifted white. A chooser is a set of objects you pick between, which
|
||||
// is exactly what a card is for; the selected one takes the brand
|
||||
// edge, so *chosen* reads at a glance rather than from a tick.
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: DmSpace.tap),
|
||||
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: DmColors.hairline)),
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: selected
|
||||
? Border.all(color: DmColors.brand, width: 1.5)
|
||||
: null,
|
||||
boxShadow: selected ? null : DmShadow.card,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -88,18 +88,27 @@ class _WindowSheetState extends State<_WindowSheet> {
|
||||
// One row of days rather than a heading per day: the list
|
||||
// under it stays short enough to take in at a glance, which
|
||||
// is the whole reason this is a sheet.
|
||||
Row(
|
||||
children: [
|
||||
for (final d in days) ...[
|
||||
if (d != days.first) const SizedBox(width: 8),
|
||||
DmChoiceChip(
|
||||
label: d,
|
||||
selected: d == day,
|
||||
expand: true,
|
||||
onTap: () => setState(() => _day = d),
|
||||
),
|
||||
// The segments sit in a groove, which is what makes the
|
||||
// chosen one read as raised rather than as merely white.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.groove,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final d in days) ...[
|
||||
if (d != days.first) const SizedBox(width: 4),
|
||||
DmChoiceChip(
|
||||
label: d,
|
||||
selected: d == day,
|
||||
expand: true,
|
||||
onTap: () => setState(() => _day = d),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
for (final slot in shown) ...[
|
||||
|
||||
@@ -36,6 +36,9 @@ class ShellScreenState extends State<ShellScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
// The bar floats, so it is stacked over the content rather than laid
|
||||
// out beneath it — and each root pads its own scroll view past it.
|
||||
extendBody: true,
|
||||
body: IndexedStack(
|
||||
index: _index,
|
||||
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
|
||||
@@ -47,35 +50,22 @@ class ShellScreenState extends State<ShellScreen> {
|
||||
|
||||
/// The tab bar, fixed to the bottom edge.
|
||||
///
|
||||
/// ── Why it is no longer a floating pill-shaped bar ──
|
||||
/// ── A floating pill, after all ──
|
||||
///
|
||||
/// 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.
|
||||
/// This bar has been three things. It hovered as a pill, then sat on the edge
|
||||
/// with a hairline over it and a crimson indicator travelling along it, then
|
||||
/// lost the indicator's edge. The reference the app is now drawn against puts
|
||||
/// it back in the air — and the reason is better than the first time round.
|
||||
///
|
||||
/// 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.
|
||||
/// The pill works *because* the screens above it stopped having edges. When
|
||||
/// every card was outlined, a floating bar was a fourth rounded surface in a
|
||||
/// stack of them. Now the content is white shapes lifted off a near-white
|
||||
/// page, and the bar is one more of those: same radius language, same shadow,
|
||||
/// read as the same kind of object.
|
||||
///
|
||||
/// ── What the 2026-09-22 pass changed ──
|
||||
///
|
||||
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson
|
||||
/// shadow built for the committing button on a screen — which on a bar resting
|
||||
/// against the bottom edge read as a smear under the tab rather than as lift.
|
||||
/// It is a quarter of that strength now and sits closer to the shape.
|
||||
///
|
||||
/// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands
|
||||
/// under it, on the same overshoot curve the pill travels on, so the two read
|
||||
/// as one gesture instead of a moving rectangle and an icon that recolours when
|
||||
/// it happens to be covered.
|
||||
///
|
||||
/// **A tab gives way when pressed.** The whole column scales to 0.9 under the
|
||||
/// finger. Without it the only feedback for a tap was the pill starting to
|
||||
/// move, which on the tab you are already on is no feedback at all.
|
||||
/// The labels went with the edge. Three glyphs a customer has seen on every
|
||||
/// app they own do not need naming, and without them the bar is 62pt instead
|
||||
/// of 86 — which is the space the pill is floating in.
|
||||
class _TabBar extends StatelessWidget {
|
||||
const _TabBar({required this.index, required this.onChanged});
|
||||
|
||||
@@ -91,80 +81,46 @@ class _TabBar extends StatelessWidget {
|
||||
(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;
|
||||
|
||||
/// The pill's own lift. Not [DmShadow.liftSm]: that is the glow under a
|
||||
/// committing action, and at the bottom edge of the screen it read as dirt
|
||||
/// under the tab.
|
||||
static const _pillShadow = [
|
||||
BoxShadow(
|
||||
color: Color(0x2E8F0F06),
|
||||
blurRadius: 10,
|
||||
spreadRadius: -2,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
];
|
||||
static const double _slot = 62;
|
||||
static const double _height = 58;
|
||||
|
||||
@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: _pillShadow,
|
||||
),
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
// A Row, not a Center: `bottomNavigationBar` hands its child an
|
||||
// unbounded height, and a Center in that slot expands to fill the
|
||||
// whole screen with the pill floating in the middle of it.
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
height: _height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
boxShadow: DmShadow.cardRaised,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < _items.length; i++)
|
||||
SizedBox(
|
||||
width: _slot,
|
||||
child: _TabButton(
|
||||
label: _items[i].label,
|
||||
icon: _items[i].icon,
|
||||
selected: index == i,
|
||||
onTap: () => onChanged(i),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -193,9 +149,11 @@ class _TabButtonState extends State<_TabButton> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = widget.selected;
|
||||
return Semantics(
|
||||
button: true,
|
||||
selected: widget.selected,
|
||||
selected: selected,
|
||||
label: widget.label,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
@@ -203,54 +161,34 @@ class _TabButtonState extends State<_TabButton> {
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedScale(
|
||||
// The whole column, including the label: a press that only moved the
|
||||
// glyph would read as the icon coming loose from its own tab.
|
||||
scale: _down ? 0.9 : 1,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: _TabBar._pillHeight,
|
||||
child: Center(
|
||||
child: AnimatedScale(
|
||||
// Lands with the pill, on the pill's own curve. The
|
||||
// overshoot is what ties the two together — the glyph is
|
||||
// reacting to the thing arriving under it.
|
||||
scale: widget.selected ? 1.14 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DmMotion.base,
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
key: ValueKey(widget.selected),
|
||||
size: 21,
|
||||
color: widget.selected ? Colors.white : DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
width: 42,
|
||||
height: 42,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? DmColors.brandSoft : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
),
|
||||
child: AnimatedScale(
|
||||
// A shade of overshoot as the tint arrives under it, so the
|
||||
// two read as one gesture rather than as a glyph that
|
||||
// recolours when a rectangle happens to appear behind it.
|
||||
scale: selected ? 1.06 : 1,
|
||||
duration: DmMotion.slow,
|
||||
curve: DmMotion.glide,
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 21,
|
||||
color: selected ? DmColors.brand : DmColors.ink4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 11.5,
|
||||
height: 1.0,
|
||||
color: widget.selected ? DmColors.brand : DmColors.ink3,
|
||||
fontWeight:
|
||||
widget.selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
child: Text(
|
||||
widget.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,11 +12,7 @@ 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/send_screen.dart';
|
||||
import 'sheets/cancel_sheet.dart';
|
||||
@@ -42,7 +38,7 @@ class TrackingScreen extends StatelessWidget {
|
||||
if (booking == null) {
|
||||
return Scaffold(
|
||||
backgroundColor: DmColors.canvas,
|
||||
appBar: const DmTopBar(title: 'Live tracking'),
|
||||
appBar: const DmTopBar(title: 'Tracking'),
|
||||
body: const DmEmptyState(
|
||||
icon: LucideIcons.package,
|
||||
title: 'Nothing to track',
|
||||
@@ -68,18 +64,14 @@ class TrackingScreen extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
title: cancelled ? 'Booking' : 'Live Order Tracking',
|
||||
// No reference here. The head card carries it at 26pt, which is what
|
||||
// a customer reads out over a phone, and the same string twice on one
|
||||
// screen is one of them doing nothing.
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
color: DmColors.brand,
|
||||
@@ -89,92 +81,25 @@ class TrackingScreen extends StatelessWidget {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_StatusHeader(booking: booking, slot: slot, focused: focused),
|
||||
_TrackCard(booking: booking, slot: slot, focused: focused),
|
||||
|
||||
// ── No radar while a Miler is being found ──
|
||||
// ── No map, and no radar ──
|
||||
//
|
||||
// This showed a 200pt card holding a pulsing radar with a bike
|
||||
// glyph at its centre, then a banner counting "verified Milers
|
||||
// active in your zone". Both were animation standing in for an
|
||||
// answer: the radar is not looking at anything, and the count is a
|
||||
// number the customer cannot use. The rail below says *Booking
|
||||
// created* with the line stopped there, and the crimson card above
|
||||
// says what is happening in words. That is the answer, and it is
|
||||
// the same rail whether a Miler has been found or not.
|
||||
if (!assigning && !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,
|
||||
),
|
||||
// Only when there is something to say. This cell used to
|
||||
// read "Tracking your parcel live" whenever no ETA existed,
|
||||
// beside a Refresh action — a sentence that described the
|
||||
// screen the customer was already looking at, and a button
|
||||
// for a gesture the list now handles by being pulled down.
|
||||
if (milerEnRoute && booking.milerEtaMinutes != null)
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.clock,
|
||||
size: 15,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pickup in about '
|
||||
'${booking.milerEtaMinutes} min',
|
||||
style: DmText.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (milerEnRoute)
|
||||
_MilerCard(person: booking.miler!, role: 'Your Miler'),
|
||||
if (!cancelled &&
|
||||
stage == JourneyStage.outForDelivery &&
|
||||
booking.deliveryAgent != null)
|
||||
_MilerCard(
|
||||
person: booking.deliveryAgent!,
|
||||
role: 'Delivery Miler',
|
||||
),
|
||||
|
||||
// The radar was a 200pt card with a pulsing sweep and a bike glyph
|
||||
// at its centre, beside a banner counting "verified Milers active
|
||||
// in your zone" — animation standing in for an answer.
|
||||
//
|
||||
// The map went for a harder reason. It was 178pt of basemap, two
|
||||
// labels and a pin, on a screen whose question is "where is my
|
||||
// parcel" and whose honest answer is a milestone. The pin was the
|
||||
// *pickup address* — a place the customer chose and already knows
|
||||
// — not the Miler, who only has a position for the twenty minutes
|
||||
// between assignment and the door. A picture that cannot show the
|
||||
// thing it implies invites somebody to stare at a stationary dot
|
||||
// and conclude nothing is happening.
|
||||
//
|
||||
// The live position, when there is one, is a sentence on the
|
||||
// Miler's own card.
|
||||
if (!cancelled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
@@ -197,19 +122,26 @@ class TrackingScreen extends StatelessWidget {
|
||||
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,
|
||||
),
|
||||
),
|
||||
// `_RouteOutline` was here: the same pickup, destination and
|
||||
// window the head card now draws as a rail, repeated at the foot
|
||||
// of the screen. It existed when the head was a crimson headline
|
||||
// that carried no route at all.
|
||||
|
||||
// ── Who is carrying it comes last ──
|
||||
//
|
||||
// The Miler's card sat directly under the head, between the
|
||||
// reference and the journey, which put a person between the
|
||||
// customer and the answer they opened the screen for. It is real
|
||||
// and it is reachable — a name, a plate, Call and Message — but it
|
||||
// is *who*, and the screen's question is *where*. The journey has
|
||||
// the middle of the page now and the person is at the foot of it,
|
||||
// one thumb away.
|
||||
if (milerEnRoute)
|
||||
_MilerCard(person: booking.miler!),
|
||||
if (!cancelled &&
|
||||
stage == JourneyStage.outForDelivery &&
|
||||
booking.deliveryAgent != null)
|
||||
_MilerCard(person: booking.deliveryAgent!),
|
||||
|
||||
// Stands in for backend push updates. Real stage changes arrive
|
||||
// from `GET /customer/bookings/{reference}`, so this exists only
|
||||
@@ -218,20 +150,16 @@ class TrackingScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
// No bed under the actions. `edge: true` painted a white bar with a
|
||||
// hairline across the bottom, which on this canvas reads as a second
|
||||
// surface holding two buttons — and the buttons already have edges.
|
||||
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
|
||||
@@ -260,10 +188,15 @@ class TrackingScreen extends StatelessWidget {
|
||||
}
|
||||
|
||||
return [
|
||||
// Filled, not outlined. Sharing a live link is the only thing a
|
||||
// customer can *do* from this screen while the parcel is moving, and an
|
||||
// outlined button on a page of white cards was the quietest object on
|
||||
// it. Cancelling stays a text action, which is the right weight for
|
||||
// something you should not reach for by accident.
|
||||
DmButton(
|
||||
label: 'Share live tracking',
|
||||
icon: LucideIcons.share2,
|
||||
kind: DmButtonKind.outline,
|
||||
iconLeading: true,
|
||||
onPressed: () => DmToast.show(context, 'Sharing is coming soon'),
|
||||
),
|
||||
// Backend policy decides whether cancelling is still possible.
|
||||
@@ -290,25 +223,21 @@ class TrackingScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen's one loud surface: what is happening, right now.
|
||||
/// The state, the route, and the shipment's own figures.
|
||||
///
|
||||
/// ── Why this is a filled card and not a heading ──
|
||||
/// ── Card → bare type → this ──
|
||||
///
|
||||
/// 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.
|
||||
/// The bordered card repeated the milestone the journey below it already
|
||||
/// listed. Stripping it to type fixed that and left the top of the screen with
|
||||
/// nothing on it but two words and two places.
|
||||
///
|
||||
/// 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,
|
||||
});
|
||||
/// The reference's answer is better than both: the state stays the hero, but
|
||||
/// it earns a row of its own with the reference and a priority mark beside it,
|
||||
/// the promise sits under it, and the route becomes a card carrying the two
|
||||
/// ends *and* the three figures a customer checks while waiting — how far,
|
||||
/// what is carrying it, how heavy.
|
||||
class _TrackCard extends StatelessWidget {
|
||||
const _TrackCard({required this.booking, required this.slot, this.focused});
|
||||
|
||||
final Booking booking;
|
||||
final PickupSlot? slot;
|
||||
@@ -320,140 +249,225 @@ class _StatusHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final cancelled = booking.isCancelled;
|
||||
final stage = focused?.stage ?? booking.stage;
|
||||
final group = focused ?? booking.destinations.first;
|
||||
|
||||
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,
|
||||
// What the customer can act on: when, before the handover; where, after.
|
||||
final when = switch (true) {
|
||||
_ when cancelled => 'You were not charged',
|
||||
_ when booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0 =>
|
||||
'Arriving in about ${booking.milerEtaMinutes} min',
|
||||
_ when stage == JourneyStage.arrived => 'At your door now',
|
||||
_ when stage.index <= JourneyStage.assigned.index && slot != null =>
|
||||
'Today · ${slot!.window}',
|
||||
_ when booking.expectedDelivery != null =>
|
||||
'Expected ${booking.expectedDelivery}',
|
||||
_ => null,
|
||||
};
|
||||
|
||||
final sub = cancelled
|
||||
? 'This pickup was cancelled. You were not charged.'
|
||||
: stage.sub;
|
||||
final eta = booking.milerEtaMinutes;
|
||||
final reference = focused?.trackingId ?? booking.displayId;
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Only figures the backend actually sent. A strip with one cell in it is
|
||||
// a fact centred in a grey band, which is worse than the fact on its own
|
||||
// — so it is drawn at two or more.
|
||||
final figures = <Widget>[
|
||||
if (booking.fare?.routeKm != null)
|
||||
_Figure(
|
||||
label: 'Distance',
|
||||
value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km',
|
||||
),
|
||||
);
|
||||
}
|
||||
_Figure(
|
||||
label: booking.totalPackages == 1 ? 'Package' : 'Packages',
|
||||
value: '${booking.totalPackages}',
|
||||
),
|
||||
if (booking.totalWeightLabel != null)
|
||||
_Figure(label: 'Weight', value: booking.totalWeightLabel!)
|
||||
else if (booking.miler?.vehicleType != null)
|
||||
_Figure(label: 'Vehicle', value: booking.miler!.vehicleType!)
|
||||
else if (slot != null)
|
||||
_Figure(label: 'Window', value: slot!.window),
|
||||
];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.fill,
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
DmStatusChip(
|
||||
label: reference.startsWith('#') ? reference : '#$reference',
|
||||
tone: cancelled ? DmColors.ink4 : DmColors.brand,
|
||||
quiet: cancelled,
|
||||
),
|
||||
// No priority chip. The reference carries "EXPRESS PRIORITY"
|
||||
// beside the reference; this backend has no express tier, and a
|
||||
// badge that every booking wears is decoration.
|
||||
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
cancelled ? 'Cancelled' : stage.milestoneLabel,
|
||||
style: DmText.headerTitle.copyWith(
|
||||
fontSize: 29,
|
||||
color: cancelled ? DmColors.ink3 : DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (eta != null && eta > 0 && !cancelled) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 7, 11, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.chip),
|
||||
),
|
||||
child: Text(
|
||||
'ETA $eta mins',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
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,
|
||||
),
|
||||
),
|
||||
// The supporting sentence is gone. It restated the headline
|
||||
// — "Arun Kumar is assigned" over "Your Miler will arrive in
|
||||
// your pickup window" — and sat above a milestone label
|
||||
// saying "Miler assigned" and a counter saying "Step 2 of 7".
|
||||
// Four lines to answer a question the first one had answered.
|
||||
//
|
||||
// What is left is the one fact the headline does not carry:
|
||||
// when. It is set at heading size because after "what is
|
||||
// happening" it is the only thing the customer came for.
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_footnote(stage),
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 17,
|
||||
color: DmColors.onBrand2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DmStepBar(
|
||||
progress: step / total,
|
||||
steps: total,
|
||||
current: step,
|
||||
onFill: true,
|
||||
caption: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (when != null) ...[
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
when,
|
||||
style: DmText.body.copyWith(fontSize: 14.5, color: DmColors.ink3),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
DmCard(
|
||||
raised: !cancelled,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Place(
|
||||
label: 'ORIGIN',
|
||||
value: booking.pickup.title,
|
||||
note: booking.pickup.sub,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _Place(
|
||||
label: 'DESTINATION',
|
||||
value: group.destination.shortLabel,
|
||||
note: group.destination.stateName,
|
||||
end: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (figures.length > 1)
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(4, 12, 4, 13),
|
||||
// `groove`, not `surfaceAlt`: the latter is within a point
|
||||
// of the canvas, so the strip read as spilling out of the
|
||||
// bottom of the card rather than as a band inside it.
|
||||
background: DmColors.groove,
|
||||
child: Row(children: figures),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
/// A labelled place. Two of these are the route.
|
||||
class _Place extends StatelessWidget {
|
||||
const _Place({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.note,
|
||||
this.end = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
final String? note;
|
||||
|
||||
/// The destination, which aligns to the card's right edge so the two ends
|
||||
/// read as two ends rather than as two columns of a table.
|
||||
final bool end;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
end ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: DmText.eyebrow),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
value,
|
||||
textAlign: end ? TextAlign.right : TextAlign.left,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 17),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (note != null && note!.isNotEmpty)
|
||||
Text(
|
||||
note!,
|
||||
textAlign: end ? TextAlign.right : TextAlign.left,
|
||||
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
|
||||
// Two lines. A pickup note is a locality and a pincode, and at one
|
||||
// line it rendered "Gandhipuram, Coimbat…" — which is the half of
|
||||
// it a customer already knows.
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One of the three figures in the strip under the route.
|
||||
class _Figure extends StatelessWidget {
|
||||
const _Figure({required this.label, required this.value});
|
||||
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label.toUpperCase(),
|
||||
style: DmText.eyebrow.copyWith(fontSize: 9.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 14.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,130 +487,94 @@ class _Journey extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return DmMilestones(
|
||||
booking: booking,
|
||||
title: booking.trackingId == null ? 'Journey' : 'Live journey',
|
||||
title: 'Trip milestones',
|
||||
showStep: 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.
|
||||
/// Who is collecting, how good they are, and how to reach them.
|
||||
/// Who is collecting, 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.
|
||||
/// ── Card → bare row → card ──
|
||||
///
|
||||
/// The first card was a bordered box with an avatar, a name, a plate tag and
|
||||
/// a two-cell footer holding a filled crimson Call and an outlined Message —
|
||||
/// six objects to say "Arun is coming, here is his number". Stripping it to a
|
||||
/// row under a hairline fixed the clutter and lost the person: the one human
|
||||
/// being in the whole flow became the quietest thing on the screen.
|
||||
///
|
||||
/// It is a lifted card again, with the contents the flat pass arrived at. The
|
||||
/// avatar keeps its crimson disc — this is the one place in the app standing
|
||||
/// in for a person, which is the job the solid tone was written for — and the
|
||||
/// two actions are a filled Call beside an outlined Message, because a phone
|
||||
/// number is what this card is *for*.
|
||||
///
|
||||
/// "Your Miler" / "Delivery Miler" stayed gone. The two are shown at mutually
|
||||
/// exclusive stages, so the label distinguished between things that are never
|
||||
/// on the screen together.
|
||||
class _MilerCard extends StatelessWidget {
|
||||
const _MilerCard({required this.person, required this.role});
|
||||
const _MilerCard({required this.person});
|
||||
|
||||
final Person person;
|
||||
final String role;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
|
||||
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10),
|
||||
child: DmCard(
|
||||
tone: DmCardTone.wash,
|
||||
raised: true,
|
||||
children: [
|
||||
DmCardCell(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
||||
child: Row(
|
||||
children: [
|
||||
DmAvatar(initials: person.initials, size: 44),
|
||||
const SizedBox(width: 13),
|
||||
DmAvatar(initials: person.initials, size: 46),
|
||||
const SizedBox(width: 14),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
Text(
|
||||
person.name,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 16.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
// ── Reputation, then the plate ──
|
||||
//
|
||||
// The reference puts "★ 4.9 · 1,240 deliveries" under
|
||||
// the name, which is the line that makes a stranger at
|
||||
// your door a person somebody has vouched for. It is
|
||||
// shown only when the backend actually sent a rating or
|
||||
// a count — a default here would be an invented
|
||||
// reputation on a real person — and the plate takes the
|
||||
// line whenever it did not.
|
||||
if (person.rating != null || person.trips != null)
|
||||
_Credentials(person: person)
|
||||
else
|
||||
Text(role, style: DmText.tiny),
|
||||
Text(
|
||||
person.vehicle,
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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',
|
||||
_Reach(
|
||||
icon: LucideIcons.phone,
|
||||
tooltip: 'Call ${person.name}',
|
||||
filled: true,
|
||||
onPressed: () =>
|
||||
DmToast.show(context, 'Calling ${person.name}…'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
DmMiniButton(
|
||||
label: 'Message',
|
||||
const SizedBox(width: 9),
|
||||
_Reach(
|
||||
icon: LucideIcons.messageSquare,
|
||||
tooltip: 'Message ${person.name}',
|
||||
onPressed: () =>
|
||||
DmToast.show(context, 'Chat is not available yet'),
|
||||
),
|
||||
@@ -609,57 +587,66 @@ class _MilerCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Call and Message, as two discs.
|
||||
///
|
||||
/// They were full-width buttons in a second cell, which gave a phone call the
|
||||
/// same footprint as the screen's primary action. A round target beside the
|
||||
/// name is the shape every app that has ever put a courier on a screen
|
||||
/// arrived at, and it costs a third of the height.
|
||||
class _Reach extends StatefulWidget {
|
||||
const _Reach({
|
||||
required this.icon,
|
||||
required this.tooltip,
|
||||
required this.onPressed,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
final PickupSlot? slot;
|
||||
final DestinationGroup? focused;
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
State<_Reach> createState() => _ReachState();
|
||||
}
|
||||
|
||||
class _ReachState extends State<_Reach> {
|
||||
bool _down = false;
|
||||
|
||||
@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'),
|
||||
return Tooltip(
|
||||
message: widget.tooltip,
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: widget.tooltip,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => setState(() => _down = true),
|
||||
onTapUp: (_) => setState(() => _down = false),
|
||||
onTapCancel: () => setState(() => _down = false),
|
||||
onTap: widget.onPressed,
|
||||
child: AnimatedScale(
|
||||
scale: _down ? 0.92 : 1,
|
||||
duration: DmMotion.fast,
|
||||
curve: DmMotion.ease,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.filled ? DmColors.brand : DmColors.surfaceAlt,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
size: 19,
|
||||
color: widget.filled ? Colors.white : DmColors.ink,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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}',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -854,3 +841,42 @@ class _StepButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// "★ 4.9 · 1,240 deliveries · TN 37 BX 4412", minus whatever is missing.
|
||||
class _Credentials extends StatelessWidget {
|
||||
const _Credentials({required this.person});
|
||||
|
||||
final Person person;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rest = [
|
||||
if (person.trips != null)
|
||||
'${person.trips} ${person.trips == 1 ? 'delivery' : 'deliveries'}',
|
||||
if (person.vehicle.isNotEmpty) person.vehicle,
|
||||
].join(' · ');
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (person.rating != null) ...[
|
||||
const Icon(LucideIcons.star, size: 12, color: DmColors.brand),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
person.rating!.toStringAsFixed(1),
|
||||
style: DmText.label.copyWith(fontSize: 12.5, color: DmColors.ink),
|
||||
),
|
||||
if (rest.isNotEmpty) const SizedBox(width: 7),
|
||||
],
|
||||
if (rest.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(
|
||||
rest,
|
||||
style: DmText.small.copyWith(fontSize: 12.5, color: DmColors.ink3),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user