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:
2026-09-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -345,7 +345,25 @@ class LiveDoormileApi extends DoormileApi {
// This app does not have them: the pickup is a map pin, and the
// reverse geocoder returns an address, not a serviceable-area code. The
// coordinates say the same thing and the server already resolves them.
'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng},
//
// ── `lat`/`lng`, and this one was quoting the wrong price ──
//
// Same field-name mismatch as `Place.toJson`, and on this endpoint it
// fails silently instead of erroring. With `latitude`/`longitude` the
// server cannot read the pin, measures no route, and answers a flat
// fallback band for every journey. Measured against production on
// 2026-09-23, same pickup, one request apart:
//
// latitude/longitude local ₹280–520 routeKm 0
// 427 km ₹280–520 routeKm 0
// lat/lng local ₹45–90 routeKm 0.2
// 427 km ₹150–280 routeKm 427.3
//
// So every fare this app has ever shown was the fallback: roughly six
// times the real price on a local run, and the same number quoted for
// a parcel crossing the state. A 422 tells you it is broken; this just
// lies.
'pickup': {'lat': ?pickup.lat, 'lng': ?pickup.lng},
'destinations': [
for (final d in destinations)
{

View File

@@ -114,6 +114,23 @@ enum JourneyStage {
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
/// The same state in one or two words, for a status chip.
///
/// [milestoneLabel] is a sentence fragment written to sit under a heading —
/// "Miler assigned", "Miler at your door". Set in a 9.5pt chip beside a
/// reference and a time it renders as "MILER ASSIG…", which is not a state.
String get chipLabel => switch (this) {
JourneyStage.booked => 'Booked',
JourneyStage.assigned => 'Assigned',
JourneyStage.onTheWay => 'On the way',
JourneyStage.arrived => 'At your door',
JourneyStage.pickedUp => 'Collected',
JourneyStage.orderCreated => 'Created',
JourneyStage.inTransit => 'In transit',
JourneyStage.outForDelivery => 'Out for delivery',
JourneyStage.delivered => 'Delivered',
};
}
/// What the customer sees on the timeline.
@@ -418,11 +435,31 @@ class Place {
lng: coordinate(json, 'lng'),
);
/// ── The coordinates are `lat`/`lng`, NOT `latitude`/`longitude` ──
///
/// The written contract says `latitude`/`longitude` and this sent them for
/// months. The server reads `lat`/`lng`, so it saw a pickup with no
/// coordinates at all, could not place it in a serviceable area, and
/// answered every single booking with **422 `unserviceable` — "We are not
/// collecting from that area yet"**. The app could not create a booking
/// against production; the error blamed the customer's address for a field
/// name.
///
/// Verified against production on 2026-09-23, one request apart: the same
/// pickup, the same destination, the same slot. `latitude`/`longitude` →
/// 422 unserviceable. `lat`/`lng` → 201 and booking DM-252803. It is also
/// what `GET /places/reverse-geocode` returns, which is where this Place
/// came from in the first place — the app was renaming the server's own
/// keys on the way back out.
///
/// Second one of these on this surface: `POST /auth/otp/verify` takes
/// `code` where the document says `otp`. Follow the server, not the
/// document.
Map<String, dynamic> toJson() => {
'title': title,
'sub': sub,
'latitude': ?lat,
'longitude': ?lng,
'lat': ?lat,
'lng': ?lng,
};
/// `POST /customer/bookings` ▸ `pickup`.
@@ -471,6 +508,11 @@ class Destination {
return coded.isEmpty ? '—' : coded.join(', ');
}
/// The district alone, for a headline. Falls back to the full [label] when
/// the backend sent only a state or only codes — a card headed "—" is worse
/// than a card headed with two words.
String get shortLabel => _clean(districtName) ?? _clean(districtCode) ?? label;
static String? _clean(String? v) {
final t = v?.trim();
return (t == null || t.isEmpty) ? null : t;

View File

@@ -111,7 +111,7 @@ ThemeData buildDoormileTheme() {
return ThemeData(
useMaterial3: true,
fontFamily: 'Poppins',
fontFamily: 'Switzer',
colorScheme: scheme,
scaffoldBackgroundColor: DmColors.bg,
splashFactory: InkSparkle.splashFactory,

View File

@@ -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,
),
],
),
),
],
);
}
}

View File

@@ -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,

View File

@@ -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,
),
),
],
),
],
);
}
}

View File

@@ -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),
),
],
),

View File

@@ -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,
);

View File

@@ -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,
),
],
),
),
),
);
}
}

View File

@@ -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,
),
),
),
),
);
}
}

View File

@@ -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: [

View File

@@ -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) ...[

View File

@@ -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,
),
),
],
),
),
),
),

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -27,13 +27,22 @@ class DmColors {
// ── Brand ────────────────────────────────────────────────────────────────
/// The mark's single colour.
///
/// Briefly brightened to a vermilion on 2026-09-23 to match a reference
/// design and put straight back. The palette is not up for redesign — the
/// reference's *layout* is what the app is being drawn against, and the
/// oxblood carries it perfectly well.
static const brand = Color(0xFF8F0F06);
/// Pressed state, and crimson text sitting on a [brandSoft] wash where the
/// Pressed state, and brand text sitting on a [brandSoft] wash where the
/// full brand would vibrate against the tint.
static const brandPress = Color(0xFF730C05);
static const brandDeep = brandPress;
/// The lit edge of the sphere and of the primary button's gradient. Named
/// rather than repeated as a literal in three files.
static const brandLit = Color(0xFFA6180B);
/// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2);
@@ -48,13 +57,25 @@ class DmColors {
// ── Surfaces ─────────────────────────────────────────────────────────────
/// The page canvas.
static const canvas = Color(0xFFF7F6F4);
///
/// Deepened on 2026-09-23. At `#F7F6F4` a white card on it was a 3% step,
/// so the 1px border was doing all the separating and every screen read as
/// a list of outlined boxes. A canvas the cards can sit *on* is what lets
/// the edge go quiet.
static const canvas = Color(0xFFF1F0EC);
static const surface = Color(0xFFFFFFFF);
/// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken"
/// treatment for a disabled primary action.
static const surfaceAlt = Color(0xFFF1EFEC);
/// The bed a segmented control's chosen segment is raised out of.
///
/// A step below [surfaceAlt], which is within a point of the canvas and so
/// drew no groove at all on a sheet. A segment only reads as *raised* if
/// there is something visible for it to be raised out of.
static const groove = Color(0xFFE7E4E0);
/// Barely-there divider between rows inside one card.
static const hairline = Color(0xFFF1EFEC);
@@ -111,7 +132,15 @@ class DmRadius {
static const sm = 12.0;
/// Cards and every panel that holds rows.
static const md = 14.0;
static const md = 18.0;
/// A card that stands alone on the canvas rather than in a stack of rows.
/// Wider than [md] because a shadowed card with no edge needs the corner to
/// carry the shape the border used to.
static const card = 20.0;
/// A chip: a status pill, a tier badge, a stat tile's glyph well.
static const chip = 8.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 16.0;
@@ -137,11 +166,14 @@ class DmRadius {
class DmSpace {
DmSpace._();
/// Horizontal page padding. 22 on the brand header's body, 24 on canvas
/// content — the header's crimson field reads as tighter than it measures,
/// so it takes the smaller inset.
static const pad = 24.0;
static const headerPad = 22.0;
/// Horizontal page padding.
///
/// 24 once, when the header was a crimson field and the canvas held loose
/// rows. On a page of white cards it left a broad margin down both sides and
/// the cards read as narrower than the screen — the reference designs this
/// was drawn against run their cards to within about a finger of the edge.
static const pad = 16.0;
static const headerPad = 16.0;
/// Minimum touch target.
static const tap = 48.0;
@@ -176,6 +208,53 @@ class DmShadow {
),
];
/// A card resting on the canvas.
///
/// ── Why the cards stopped having edges ──
///
/// Every surface in this app was separated by a 1px hairline, which is
/// honest and cheap and, repeated eight times down a screen, reads as a
/// wireframe: the outline is the loudest thing about an object whose whole
/// job is to be quiet and hold something else.
///
/// Two very soft layers do it instead — a wide, faint ambient wash and a
/// short contact shadow just under the shape. Neither is visible as a
/// shadow; together they lift the white off the warm canvas so the card is
/// read as *nearer* rather than as *outlined*. That is the difference
/// between a premium consumer surface and a bordered panel, and it costs
/// nothing but two `BoxShadow`s.
static const card = [
BoxShadow(
color: Color(0x0D1F1A17),
blurRadius: 22,
spreadRadius: -10,
offset: Offset(0, 8),
),
BoxShadow(
color: Color(0x081F1A17),
blurRadius: 3,
spreadRadius: -1,
offset: Offset(0, 1),
),
];
/// A card the customer has to act on, or the one live thing in a list.
/// The same two layers, a third again as deep.
static const cardRaised = [
BoxShadow(
color: Color(0x141F1A17),
blurRadius: 30,
spreadRadius: -12,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x0B1F1A17),
blurRadius: 4,
spreadRadius: -1,
offset: Offset(0, 2),
),
];
/// Controls that genuinely float over content — map buttons, map chips.
static const float = [
BoxShadow(
@@ -250,18 +329,33 @@ class DmMotion {
class DmText {
DmText._();
static const _f = 'Poppins';
/// ── Switzer → Plus Jakarta Sans (2026-09-24) ──
///
/// Both are variable neo-grotesques and the swap is a one-line change, but
/// two things come with it. Plus Jakarta's humanist curves and taller
/// x-height give the place names the editorial weight the reference design
/// has, and — the practical part — **it carries the rupee glyph**, which
/// Switzer does not. Every amount in this app was set in Geist Mono for that
/// reason alone; the ones that are prices rather than data can now be type.
///
/// Its axis is wght 200–800, so every weight the design uses is real rather
/// than synthesised.
static const _f = 'PlusJakartaSans';
static const _mono = 'GeistMono';
// The weight axis, for Geist Mono only. It ships as one variable file, so a
// style has to move `wght` to change weight — `fontWeight` alone picks the
// The weight axis. Switzer and Geist Mono both ship as one variable file, so
// a style has to move `wght` to change weight — `fontWeight` alone picks the
// nearest named instance and, on a single-file VF, that is always the
// default. Both are set on its styles: the axis does the work, the enum keeps
// the semantics (and the system-font fallback) correct.
// default. Both are set on every style: the axis does the work, the enum
// keeps the semantics (and the system-font fallback) correct.
//
// Poppins needs none of this. It is four static files and Flutter matches
// them on `fontWeight`.
// This came back when Poppins left. Poppins has no variable release, so for
// one release the sans styles carried `fontWeight` alone and this block was
// the mono's private business.
static const _w500 = [FontVariation('wght', 500)];
static const _w600 = [FontVariation('wght', 600)];
static const _w700 = [FontVariation('wght', 700)];
static const _w800 = [FontVariation('wght', 800)];
// Letter spacing is quoted in em by the design and converted here at each
// size, because -0.035em is a different number of pixels on a 31pt display
@@ -273,22 +367,26 @@ class DmText {
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.1,
fontWeight: FontWeight.w800,
letterSpacing: -0.95, color: DmColors.ink,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.1, color: DmColors.ink,
);
/// The brand header's own title, sitting on crimson.
/// The largest line on a screen: the name on Home, the title of a tab root.
///
/// It was white, because it was written for a crimson header. That header is
/// a canvas now and every caller was overriding the colour back to ink, so
/// the white was a trap waiting for the first caller who did not.
static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800,
letterSpacing: -1.2, color: Colors.white,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.4, color: DmColors.ink,
);
/// Screen headings on a canvas.
static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.16,
fontWeight: FontWeight.w800,
letterSpacing: -0.65, color: DmColors.ink,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.8, color: DmColors.ink,
);
/// Tab-root page titles where no brand header is used.
@@ -297,14 +395,14 @@ class DmText {
/// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700,
letterSpacing: -0.4, color: DmColors.ink,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.45, color: DmColors.ink,
);
static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 21, height: 1.2,
fontWeight: FontWeight.w800,
letterSpacing: -0.55, color: DmColors.ink,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.65, color: DmColors.ink,
);
// ── Rows ─────────────────────────────────────────────────────────────────
@@ -312,8 +410,8 @@ class DmText {
/// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w600,
letterSpacing: -0.15, color: DmColors.ink,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: -0.2, color: DmColors.ink,
);
/// Alias: the same weight of statement, used mid-form.
@@ -321,55 +419,55 @@ class DmText {
static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500,
letterSpacing: -0.1, color: DmColors.ink,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.12, color: DmColors.ink,
);
/// Supporting line under a row title — `t2`.
static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink3,
);
/// Paragraph under a screen heading.
static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The right-hand value in a settings row.
static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Field labels, tags, chips, inline actions.
static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
static const button = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.2,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.25,
);
/// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
@@ -377,22 +475,22 @@ class DmText {
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: 1.16, color: DmColors.ink3,
);
/// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: 1.84, color: DmColors.onBrand3,
);
/// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700,
letterSpacing: -0.25, color: DmColors.ink,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.3, color: DmColors.ink,
);
/// Alias kept for existing call sites.
@@ -401,7 +499,7 @@ class DmText {
/// Field input text.
static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
@@ -431,7 +529,7 @@ class DmText {
/// The OTP digits.
static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
}

View File

@@ -60,7 +60,8 @@ class DmBookOrb extends StatefulWidget {
const DmBookOrb({
super.key,
required this.onTap,
this.label = 'BOOK',
this.label = 'BOOK NOW',
this.strapline = 'INSTANT PARCEL',
this.caption,
this.held = false,
});
@@ -80,6 +81,9 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
/// The small line inside the sphere, under the label.
final String strapline;
@override
State<DmBookOrb> createState() => _DmBookOrbState();
}
@@ -117,8 +121,8 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
return Semantics(
button: true,
label: widget.caption == null
? widget.label
: '${widget.label}. ${widget.caption}',
? '${widget.label}. ${widget.strapline}'
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
child: Column(
children: [
GestureDetector(
@@ -160,7 +164,11 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
),
child: _Sphere(size: disc, label: widget.label),
child: _Sphere(
size: disc,
label: widget.label,
strapline: widget.strapline,
),
),
),
],
@@ -184,10 +192,15 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
/// The lit body. Four layers, all obeying one light from the upper left:
/// gradient fill, specular bloom, rim, then the word.
class _Sphere extends StatelessWidget {
const _Sphere({required this.size, required this.label});
const _Sphere({
required this.size,
required this.label,
required this.strapline,
});
final double size;
final String label;
final String strapline;
@override
Widget build(BuildContext context) {
@@ -201,7 +214,7 @@ class _Sphere extends StatelessWidget {
// takes its light from.
center: Alignment(-0.38, -0.52),
radius: 1.08,
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
colors: [DmColors.brandLit, DmColors.brand, Color(0xFF5C0802)],
stops: [0.0, 0.5, 1.0],
),
boxShadow: [
@@ -256,28 +269,52 @@ class _Sphere extends StatelessWidget {
),
),
),
// ── The face ──
//
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
// which filled the sphere edge to edge and read as a warning label.
// The reference sets a glyph, a sentence-case line and a small
// strapline — the same three parts every physical button that has
// ever been designed well carries, and the air round them is what
// makes the circle read as an object rather than as a word in a
// red disc.
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.navigation,
size: 21,
color: Colors.white,
),
const SizedBox(height: 11),
Text(
label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith(
fontSize: 32,
height: 1,
letterSpacing: 5,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
fontSize: 23,
height: 1.05,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
color: Colors.white,
shadows: const [
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)),
Shadow(
color: Color(0x4D5C0802),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
),
const SizedBox(height: 6),
const Icon(
LucideIcons.arrowUpRight,
size: 18,
color: Color(0xB3FFFFFF),
const SizedBox(height: 5),
Text(
strapline,
textAlign: TextAlign.center,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 1.7,
color: const Color(0xB3FFFFFF),
),
),
],
),

View File

@@ -102,6 +102,11 @@ class _DmButtonState extends State<DmButton> {
? DmColors.surfaceAlt
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
// A disabled primary on the deepened canvas is a 1% step from it, so
// the screen's one action read as a grey smear rather than as a
// button waiting for the form to be filled. The edge is what keeps it
// a button.
if (!_enabled) border = Border.all(color: DmColors.border);
shadow = _enabled && !_down ? DmShadow.lift : null;
// Lit like the sphere, and only while it can be pressed: a disabled
// control that still catches the light is a control that is lying.
@@ -109,7 +114,7 @@ class _DmButtonState extends State<DmButton> {
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
stops: [0.0, 0.55, 1.0],
)
: null;

View File

@@ -21,24 +21,40 @@ enum DmCardTone {
sunken,
}
/// A bordered panel that stacks rows and rules them apart.
/// A panel that stacks rows and rules them apart.
///
/// This replaces the shadowed card the app used to draw everywhere. The rule
/// between rows belongs to the card, not to the rows: a row does not know
/// whether it is first, and a card that is handed one child draws no rule at
/// all, so the same [DmCardCell] works alone or in a stack of five.
/// The rule between rows belongs to the card, not to the rows: a row does not
/// know whether it is first, and a card that is handed one child draws no rule
/// at all, so the same [DmCardCell] works alone or in a stack of five.
///
/// ── Edge → shadow (2026-09-23) ──
///
/// This was a bordered panel, which was the right correction to the shadowed
/// card it replaced — that one dropped a shadow under everything, so nothing
/// on a screen could be said to matter more than anything else. Eight hairline
/// boxes down a screen turned out to have the same failure in reverse: the
/// outline became the loudest thing about an object whose job is to be quiet.
///
/// A card is lifted now, not outlined. [DmShadow.card] is two very soft layers
/// — an ambient wash and a short contact shadow — neither of which is legible
/// *as* a shadow; together they read as the white being nearer than the canvas.
/// [raised] doubles them for the one card on a screen that needs acting on.
class DmCard extends StatelessWidget {
const DmCard({
super.key,
required this.children,
this.tone = DmCardTone.plain,
this.rail = false,
this.raised = false,
this.onTap,
});
final List<Widget> children;
final DmCardTone tone;
/// Deeper lift, for the one card in a list that is live or needs a decision.
final bool raised;
/// A 3pt crimson spine down the leading edge — marks the one row in a list
/// that needs the customer's attention now.
final bool rail;
@@ -62,6 +78,9 @@ class DmCard extends StatelessWidget {
/// The rule between two rows of the same card. On crimson it is a lightened
/// white rather than a darker line: a dark rule on a saturated field reads as
/// a crack, a light one as a seam.
/// Only the white card lifts. See the note in [build].
bool get _lifts => tone == DmCardTone.plain;
Color get _rule => switch (tone) {
DmCardTone.plain => DmColors.hairline,
DmCardTone.fill => const Color(0x2EFFFFFF),
@@ -79,18 +98,25 @@ class DmCard extends StatelessWidget {
// and the card lost its outline exactly where it needed one most. A
// foreground decoration paints last, so every cell is clipped inside an
// edge that is always visible.
final radius = DmRadius.all(DmRadius.card);
final card = DecoratedBox(
decoration: BoxDecoration(
color: _background,
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: _edge),
),
// A tone that is not white keeps its edge: a crimson or washed card is
// already separated from the canvas by its own colour, and a shadow
// under a saturated fill reads as grime rather than as lift.
foregroundDecoration: _lifts
? null
: BoxDecoration(
borderRadius: radius,
border: Border.all(color: _edge),
),
child: ClipRRect(
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -109,7 +135,8 @@ class DmCard extends StatelessWidget {
? Container(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
boxShadow: _lifts ? DmShadow.cardRaised : null,
),
padding: const EdgeInsets.only(left: 3),
child: card,

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
@@ -387,16 +386,25 @@ class DmFooter extends StatelessWidget {
}
}
/// The crimson field at the top of a tab root.
/// The head of a tab root: an eyebrow, a title, an optional action, and the
/// control that belongs to this destination.
///
/// Home, Orders and Account all open with one. It does three jobs at once —
/// it states where you are (eyebrow + title), it holds that destination's one
/// control (a segmented filter, an address switcher, a phone number), and it
/// gives the app a recognisable brand surface that the white canvas below can
/// then stay completely quiet.
/// ── It stopped being a crimson field ──
///
/// The arcs are drawn, not an asset: they are struck from the same geometry as
/// the mark, they scale to any header height, and they cost no bytes.
/// This was 260pt of solid brand on Home, Orders and Account — three of the
/// app's five screens opening on a wall of it, with white type, a tinted rose
/// for the secondary line, a recessed track for the address and a lightened
/// control on top. Every one of those tokens existed only to survive the
/// crimson, and the accent that is supposed to mean *do this* was the
/// background of half the product.
///
/// It is the canvas now, with ink type. What is left of the brand on these
/// screens is the sphere, the primary button, and a live dot — which is what
/// an accent is for.
///
/// The arcs survive at a fraction of their old opacity: they were struck from
/// the mark's geometry and they are the one thing here that was never the
/// colour doing the work.
class DmBrandHeader extends StatelessWidget {
const DmBrandHeader({
super.key,
@@ -421,57 +429,35 @@ class DmBrandHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
child: DecoratedBox(
// Lit from the top-left like every other brand object in the app, so
// the crimson field has a direction instead of sitting flat. The spread
// is deliberately narrow — a header that reads as a gradient rather
// than as a colour has stopped being the brand.
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.52, 1.0],
),
),
child: CustomPaint(
painter: const DmArcField(),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.headerPad,
2,
DmSpace.headerPad,
18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(
children: [
?leading,
const Spacer(),
?action,
],
),
),
const SizedBox(height: 10),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
const SizedBox(height: 8),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 18), child!],
return DecoratedBox(
decoration: const BoxDecoration(color: DmColors.canvas),
child: CustomPaint(
painter: const DmArcField(opacity: 0.035, colour: DmColors.brand),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
2,
DmSpace.pad,
14,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(children: [?leading, const Spacer(), ?action]),
),
const SizedBox(height: 6),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
const SizedBox(height: 6),
],
),
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 16), child!],
],
),
),
),
@@ -483,10 +469,13 @@ class DmBrandHeader extends StatelessWidget {
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
/// uses. Decorative only — it never carries meaning, so it is not announced.
class DmArcField extends CustomPainter {
const DmArcField({this.opacity = 0.15});
const DmArcField({this.opacity = 0.15, this.colour = Colors.white});
final double opacity;
/// White on a crimson field; crimson, very faint, on the canvas.
final Color colour;
@override
void paint(Canvas canvas, Size size) {
// The arcs are authored on a 340-square field anchored to the top-right,
@@ -501,7 +490,7 @@ class DmArcField extends CustomPainter {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale
..color = Colors.white.withValues(alpha: opacity);
..color = colour.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint);
@@ -521,126 +510,7 @@ class DmArcField extends CustomPainter {
@override
bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity;
}
/// The segmented filter recessed into the brand header — Orders' Active /
/// Completed / Cancelled, with each tab's count.
///
/// The selected tab lifts out as a white pill and keeps its figure; the others
/// stay in the recess. One control, three states, no separate count row.
class DmSegmented extends StatelessWidget {
const DmSegmented({
super.key,
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
/// Null where a tab has no meaningful figure yet (still loading).
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
for (var i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: _Segment(
label: labels[i],
count: i < counts.length ? counts[i] : null,
selected: i == index,
onTap: () => onChanged(i),
),
),
],
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.brand : DmColors.onBrand3,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? DmColors.brand : Colors.white,
),
),
),
],
],
),
),
),
);
}
oldDelegate.opacity != opacity || oldDelegate.colour != colour;
}
/// The recessed strip under a header title that carries one live fact — the
@@ -668,9 +538,14 @@ class DmHeaderTrack extends StatelessWidget {
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
// A bordered white field, not a recess. `onBrandSunken` is 14%
// black, which on the canvas reads as a grey smudge rather than as
// something cut into the surface — a recess only works against a
// colour dark enough to be cut.
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.lg),
border: Border.all(color: DmColors.border),
),
child: Row(
children: [
@@ -753,3 +628,211 @@ class DmScreenHeader extends StatelessWidget {
);
}
}
/// The wordmark bar that sits at the top of every tab root.
///
/// ── Why the app grew a permanent header ──
///
/// The three roots each began with their own arrangement: Home with a name,
/// Orders with a title, Account with another name. Nothing on any of them
/// said which app this was, and the two things a customer reaches for from
/// anywhere — find an order, see what changed — had no home at all.
///
/// One bar, on all three. The mark on the left with its dot, a search and a
/// bell on the right. It is 52pt of chrome that makes the three roots read as
/// one product rather than as three screens that happen to share a tab bar.
class DmAppBar extends StatelessWidget {
const DmAppBar({super.key, this.onSearch, this.onNotifications, this.unread = false});
final VoidCallback? onSearch;
final VoidCallback? onNotifications;
/// Puts the brand dot on the bell.
final bool unread;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad - 4, 2),
child: SizedBox(
height: 46,
child: Row(
children: [
const DmWordmark(),
const Spacer(),
_BarIcon(
icon: LucideIcons.search,
tooltip: 'Search orders',
onPressed: onSearch,
),
_BarIcon(
icon: LucideIcons.bell,
tooltip: 'Notifications',
dot: unread,
onPressed: onNotifications,
),
],
),
),
);
}
}
/// "Doormile ·" — the name, then the mark's dot.
///
/// The dot is the whole logo at this size. A 100pt lockup in a 46pt bar would
/// be a picture of a logo; a word and a full stop in the brand colour is a
/// wordmark, which is what a bar wants.
class DmWordmark extends StatelessWidget {
const DmWordmark({super.key, this.size = 17});
final double size;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Doormile',
header: true,
excludeSemantics: true,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Doormile',
style: DmText.cardTitle.copyWith(
fontSize: size,
letterSpacing: -0.45,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
),
),
SizedBox(width: size * 0.22),
Container(
width: size * 0.26,
height: size * 0.26,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
],
),
);
}
}
class _BarIcon extends StatelessWidget {
const _BarIcon({
required this.icon,
required this.tooltip,
this.onPressed,
this.dot = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool dot;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkResponse(
onTap: onPressed,
radius: 22,
child: SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
Icon(icon, size: 20, color: DmColors.ink),
if (dot)
Positioned(
top: 11,
right: 11,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 1.5),
),
),
),
],
),
),
),
);
}
}
/// A status mark: a dot, a word, and a tint the colour of the state.
///
/// ── The shape the whole app now says "what is happening" in ──
///
/// It replaces three different objects: the bordered `DmPill`, the bare dot
/// and word on the flat order rows, and the hand-built chip on the live card.
/// One shape, three intensities of the same idea — a coloured dot, uppercase
/// micro-type, and six percent of the dot's colour behind them.
class DmStatusChip extends StatelessWidget {
const DmStatusChip({
super.key,
required this.label,
this.tone,
this.icon,
this.quiet = false,
});
final String label;
/// Defaults to the brand. `ok` for delivered, `ink4` for cancelled.
final Color? tone;
/// Replaces the dot — the lightning bolt on "EXPRESS PRIORITY".
final IconData? icon;
/// A finished state. Same shape, less of it.
final bool quiet;
@override
Widget build(BuildContext context) {
final colour = tone ?? DmColors.brand;
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 8 : 7, 4.5, 9, 5),
decoration: BoxDecoration(
color: colour.withValues(alpha: quiet ? 0.055 : 0.085),
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 11, color: colour)
else
Container(
width: 5.5,
height: 5.5,
decoration: BoxDecoration(color: colour, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 0.85,
color: quiet ? DmColors.ink3 : DmColors.ink,
),
),
),
],
),
);
}
}

View File

@@ -259,6 +259,7 @@ class DmRow extends StatelessWidget {
this.onTap,
this.showChevron = false,
this.icon,
this.note,
});
final String label;
@@ -277,6 +278,12 @@ class DmRow extends StatelessWidget {
/// just as well.
final IconData? icon;
/// The line under the label — "3 locations configured", "Real-time SMS &
/// Push". It is what turns a menu into a list of states the customer can
/// check without opening anything, which is most of what a settings screen
/// is for.
final String? note;
@override
Widget build(BuildContext context) {
return Material(
@@ -286,38 +293,64 @@ class DmRow extends StatelessWidget {
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
child: Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 19, color: DmColors.ink3),
const SizedBox(width: 14),
// A glyph in a well, not a glyph. Six bare strokes down a
// settings list read as a column of marks; six wells read as
// six rows, and the well is what gives the label a left edge
// to sit against.
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(icon, size: 17, color: DmColors.ink2),
),
const SizedBox(width: 13),
],
// The label takes the width it needs and the value takes what
// is left. A fixed 3:2 split gave the label room it was not
// using — "Saved addresses" is short — and rendered the value
// beside it as "38 Mettupal…", which is a truncation nobody can
// read anything out of. The label stays Flexible so a long one
// at large text sizes can still give way.
Flexible(
child: Text(
label,
style: DmText.cardTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// The label is what the row IS and it is never the thing that
// truncates. Two rounds of flex arithmetic here produced
// "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
// third ratio, it was to stop passing values that cannot fit.
// A [value] is a word — "UPI", "SMS and push" — and anything
// longer belongs on the screen the row opens.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
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,
),
],
),
),
if (value != null) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
),
],
?trailing,
@@ -337,7 +370,18 @@ class DmRow extends StatelessWidget {
}
}
/// Card that rules a group of [DmRow]s together.
/// A run of [DmRow]s on one surface.
///
/// ── Card → rules → card ──
///
/// It was a bordered card, then bare ruled rows on the canvas. The bare rows
/// were right about the border — three decorations were doing one job — and
/// wrong about the surface: with nothing under them Account read as a text
/// file, while every other list in the app had come back onto a card.
///
/// It is a [DmCard] again, which now separates by lift rather than by edge.
/// The rules stay, because they belong to the card and are what let one
/// [DmRow] work alone or in a stack of five.
class DmRowGroup extends StatelessWidget {
const DmRowGroup({super.key, required this.children});

View File

@@ -28,6 +28,7 @@ class DmMilestones extends StatelessWidget {
this.title = 'Recent updates',
this.maxRows,
this.onExpand,
this.showStep = false,
});
final Booking booking;
@@ -44,6 +45,10 @@ class DmMilestones extends StatelessWidget {
/// Tapped from the "Full timeline" action. Null hides the action.
final VoidCallback? onExpand;
/// Puts "Step N of 5" opposite the title. Only where the whole rail is on
/// screen — a counter over a compressed list counts rows nobody can see.
final bool showStep;
@override
Widget build(BuildContext context) {
final current = stage ?? booking.stage;
@@ -89,6 +94,20 @@ class DmMilestones extends StatelessWidget {
style: DmText.eyebrow,
),
),
// ── "Step 2 of 5" ──
//
// The rail shows where the parcel is; the counter says how much
// of the journey that is. A customer looking at five rows with
// one of them lit has to count to know, and counting is the one
// thing a progress indicator exists to save them.
if (showStep)
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.label.copyWith(
fontSize: 12.5,
color: DmColors.brand,
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
// Was "All 7 stages". The number counts something the
@@ -258,9 +277,11 @@ class _MilestoneRow extends StatelessWidget {
const SizedBox(width: 10),
Text(
time,
style: DmText.monoSm.copyWith(
color: isCurrent ? DmColors.brand : DmColors.ink3,
),
// Always quiet. The crimson on the current row is
// the "you are here" marker and it belongs to the
// milestone; a timestamp set in it made the accent
// mean "recent" on one row and "now" on the next.
style: DmText.monoSm.copyWith(color: DmColors.ink3),
),
],
],

View File

@@ -8,8 +8,12 @@ enum DmPillTone {
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
live,
/// Moving, but between beats — assigned, in transit. The crimson is held in
/// an outline so a list of six orders is not six red blocks.
/// Moving, but between beats — assigned, in transit.
///
/// The crimson used to be held in an outline *and* in the label, so a list
/// of six orders was six red words. It is held in the dot now: the colour
/// still says "this one is running", and the word is read at ink weight
/// like every other word on the card.
neutral,
/// Finished. Grey, with a filled dot: the journey happened.
@@ -32,8 +36,8 @@ class DmPill extends StatelessWidget {
DmPillTone.live => (DmColors.brand, Colors.white, null),
DmPillTone.neutral => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
DmColors.ink,
Border.all(color: DmColors.border),
),
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
@@ -43,6 +47,10 @@ class DmPill extends StatelessWidget {
// for anything that happened or is happening, hollow for what never did.
final hollow = tone == DmPillTone.off;
// On the neutral pill the dot is the only crimson left, so it does not
// take its colour from the label the way the other tones do.
final dot = tone == DmPillTone.neutral ? DmColors.brand : fg;
return Container(
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
decoration: BoxDecoration(
@@ -58,9 +66,9 @@ class DmPill extends StatelessWidget {
height: 6,
margin: const EdgeInsets.only(right: 7),
decoration: BoxDecoration(
color: hollow ? Colors.transparent : fg,
color: hollow ? Colors.transparent : dot,
shape: BoxShape.circle,
border: hollow ? Border.all(color: fg, width: 1.5) : null,
border: hollow ? Border.all(color: dot, width: 1.5) : null,
),
),
Flexible(
@@ -163,34 +171,73 @@ class DmAvatar extends StatelessWidget {
super.key,
required this.initials,
this.size = 42,
this.onBrand = false,
this.quiet = false,
this.badge = false,
});
final String initials;
final double size;
final bool onBrand;
/// A wash with the initials in crimson, instead of a crimson disc.
///
/// The solid disc is right where the avatar stands in for a person the
/// customer is looking at — the Miler on tracking. It is wrong where the
/// avatar is chrome: on Home it put a second saturated circle on a screen
/// whose whole design rests on there being exactly one, and the one that
/// matters is the sphere you press.
final bool quiet;
/// A brand dot on the lower-right, cut out of the surface behind it. Home
/// wears it when a booking is live, which is the one thing the avatar's
/// destination — Account — would not tell you.
final bool badge;
@override
Widget build(BuildContext context) {
return Container(
final disc = Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
color: quiet ? DmColors.brandSoft : DmColors.brand,
shape: BoxShape.circle,
border: quiet ? Border.all(color: DmColors.brandLine) : null,
),
child: Text(
initials,
style: DmText.label.copyWith(
fontSize: size < 40 ? 12 : 13,
// Scales with the disc. It was capped at 13pt, which is right on a
// 42pt avatar in a row and looks like a typo on the 86pt one at the
// top of Account.
fontSize: size < 40 ? 12 : (size <= 48 ? 13 : size * 0.30),
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
letterSpacing: 0.13,
color: Colors.white,
color: quiet ? DmColors.brand : Colors.white,
),
),
);
if (!badge) return disc;
return Stack(
clipBehavior: Clip.none,
children: [
disc,
Positioned(
right: 0,
bottom: 1,
child: Container(
width: size * 0.28,
height: size * 0.28,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 2),
),
),
),
],
);
}
}

View File

@@ -59,20 +59,27 @@ class _DmOptionTileState extends State<DmOptionTile> {
final selected = widget.selected;
final enabled = widget.enabled;
// ── Selection is a tint and an edge, not a crimson slab ──
//
// A chosen slot used to fill solid brand and carry [DmShadow.lift] — the
// 80%-crimson glow written for the one committing button on a screen. In
// a list of six windows that made the choice the loudest object in the
// sheet, louder than the button that confirms it, and the white text
// inside it broke the type colour every other row was set in.
//
// A wash with a crimson edge says *chosen* just as unambiguously, keeps
// the row's type where it was, and leaves the crimson fill to the footer
// button — which is the thing the customer presses next.
final Color background = selected
? DmColors.brand
? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
final Color edge = selected
? DmColors.brand
: (enabled ? DmColors.border : DmColors.border);
final titleColor = selected
? Colors.white
? DmColors.brandPress
: (enabled ? DmColors.ink : DmColors.ink3);
final subColor = selected
? DmColors.onBrand2
? DmColors.brandPress
: (enabled ? DmColors.ink2 : DmColors.ink3);
return Semantics(
@@ -107,8 +114,13 @@ class _DmOptionTileState extends State<DmOptionTile> {
decoration: BoxDecoration(
color: background,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: edge),
boxShadow: selected ? DmShadow.lift : null,
border: selected
? Border.all(color: DmColors.brand, width: 1.5)
: null,
// An unchosen, available option is a card like any other: lifted
// off the sheet rather than outlined on it. A disabled one is
// flat, because it is not an object you can pick up.
boxShadow: enabled && !selected ? DmShadow.card : null,
),
child: Row(
children: [
@@ -116,7 +128,7 @@ class _DmOptionTileState extends State<DmOptionTile> {
Icon(
widget.leading,
size: 20,
color: selected ? Colors.white : DmColors.ink3,
color: selected ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 13),
],
@@ -178,9 +190,11 @@ class _Radio extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
// The mark takes the brand now that the tile behind it is a wash
// rather than a crimson field — white on `brandSoft` is invisible.
border: Border.all(
color: selected
? Colors.white
? DmColors.brand
: (enabled ? DmColors.ink4 : DmColors.border),
width: selected ? 2 : 1.5,
),
@@ -190,7 +204,7 @@ class _Radio extends StatelessWidget {
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white,
color: DmColors.brand,
shape: BoxShape.circle,
),
)

View File

@@ -114,25 +114,29 @@ class DmChoiceChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fg = selected
? Colors.white
: (enabled ? DmColors.ink2 : DmColors.ink4);
// ── A segmented control, not a crimson block ──
//
// "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting
// eight points above a list of windows and two hundred above the crimson
// button that confirms one. Three crimson objects on a sheet that asks a
// single question, and the loudest of them was the day — which the
// customer changes once, if ever.
//
// It is a lifted white chip on a sunken track now: the same convention
// iOS and every banking app use for a segment, where *selected* means
// raised rather than coloured.
final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4);
final chip = AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 38),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: const BoxConstraints(minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.xs),
boxShadow: selected ? DmShadow.liftSm : null,
color: selected ? DmColors.surface : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: selected ? DmShadow.card : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -143,7 +147,11 @@ class DmChoiceChip extends StatelessWidget {
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: fg),
style: DmText.label.copyWith(
fontSize: 13.5,
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (count != null) ...[
@@ -151,14 +159,14 @@ class DmChoiceChip extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
color: selected ? DmColors.surfaceAlt : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? Colors.white : DmColors.ink3,
color: DmColors.ink3,
),
),
),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
@@ -20,15 +21,15 @@ enum DmNodeKind {
/// Where the parcel starts. A hollow circle: a door, not a decision.
origin,
/// A sorting hub in between. An outlined diamond — the parcel passes
/// A sorting hub in between. A hollow crimson ring — the parcel passes
/// through, it does not stop here.
hub,
/// The destination. A filled crimson diamond, the only solid node on the
/// thread, so the eye lands on where the parcel is going.
/// The destination. A filled map pin, the only solid node on the thread, so
/// the eye lands on where the parcel is going.
drop,
/// A leg that has not been arranged yet — dashed outline.
/// A leg that has not been arranged yet — an outlined pin.
pending,
}
@@ -243,44 +244,31 @@ class _Node extends StatelessWidget {
border: Border.all(color: DmColors.ink4, width: 2),
),
),
// Rotated squares rather than a second circle: on a thread of three
// identical dots nothing tells you which end matters, and at 11pt a
// diamond is distinguishable from a circle at a glance where two
// shades of the same dot are not.
DmNodeKind.hub => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.brand, width: 2),
),
// ── The destination is a pin, not a diamond ──
//
// A rotated square distinguishes the ends of the thread, which was
// the whole argument for it, and it does that by being a shape with
// no meaning attached. A map pin is the mark every customer already
// reads as *a place*, so the thread stops needing a legend: hollow
// circle where you are, pin where it is going.
DmNodeKind.hub => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: DmColors.surface,
border: Border.all(color: DmColors.brand, width: 2),
),
),
DmNodeKind.drop => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: BorderRadius.circular(2),
),
),
DmNodeKind.drop => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.brand,
),
DmNodeKind.pending => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.ink4, width: 1.5),
),
),
DmNodeKind.pending => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.ink4,
),
},
),