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