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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user