Redesign on the Stitch reference, in Plus Jakarta Sans

Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
2026-09-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -6,7 +6,6 @@ import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/chrome.dart';
import '../../widgets/misc.dart';
import '../../widgets/pieces.dart';
import '../../widgets/route_rail.dart';
import '../tracking_screen.dart';
@@ -145,22 +144,24 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
),
if (booking != null) ...[
const SizedBox(height: 10),
// ── One card, not two ──
//
// The reference sat in a washed crimson card of its own, ten
// points above a white card holding the route — two containers
// and a pink field for a screen whose whole message is that
// there is nothing left to do. The reference is the first cell
// of the same card now, where it reads as the heading of the
// record underneath it rather than as a second announcement.
DmCard(
tone: DmCardTone.wash,
raised: true,
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 13, 8, 13),
padding: const EdgeInsets.fromLTRB(16, 14, 8, 14),
child: DmCopyRow(
label: 'Booking reference',
value: booking.reference,
onWash: true,
),
),
],
),
const SizedBox(height: 10),
DmCard(
children: [
DmCardCell(
child: DmRouteRail(
pickup: booking.pickup.title,
@@ -184,21 +185,22 @@ class _ConfirmedScreenState extends State<ConfirmedScreen>
DmCardCell(
background: DmColors.brandSoft,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
// Was a filled crimson pill reading "Searching" beside a
// crimson sentence saying the same thing at greater length.
// The spinner is the pill's whole job — it is the only
// element here that is actually live — so the words go back
// to ink and the crimson is left in the wash and the arc.
child: Row(
children: [
const DmPill('Searching', tone: DmPillTone.live),
const SizedBox(width: 12),
Expanded(
child: Text(
'Finding the nearest Miler',
style: DmText.small.copyWith(
color: DmColors.brand,
fontWeight: FontWeight.w600,
),
style: DmText.cardTitle.copyWith(fontSize: 14.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 12),
const SizedBox(
width: 15,
height: 15,

View File

@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../../data/doormile_api.dart';
import '../../../data/models.dart';
import '../../../state/app_scope.dart';
import '../../../state/app_state.dart';
import '../../tokens.dart';
import '../../widgets/cards.dart';
import '../../widgets/buttons.dart';
import '../../widgets/chrome.dart';
import '../../widgets/feedback.dart';
@@ -108,9 +110,8 @@ class _SendScreenState extends State<SendScreen> {
final slot = app.slotById(app.draftSlotId);
final fare = app.draftFare;
final ready = city.districtCode != null &&
app.draftSlotId != null &&
app.draftReady;
final ready =
city.districtCode != null && app.draftSlotId != null && app.draftReady;
return Scaffold(
backgroundColor: DmColors.canvas,
@@ -118,79 +119,152 @@ class _SendScreenState extends State<SendScreen> {
body: ListView(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 24),
children: [
// Where it comes from and where it goes, as one object.
// ── The head ──
//
// These were two separate blocks — an eyebrow, a 25pt crimson city
// and a Change action, then a bordered line with a label, an
// address and a second Change. Seven strings and two identical
// words to say what a thread between two points says without any.
_RouteHead(
from: pickup?.title ?? 'Set a pickup address',
to: [for (final g in app.draftDestinations) g.destination.label],
onTapFrom: () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
),
onTapTo: () async {
final picked = await showDestinationSheet(context);
if (picked != null && picked.isNotEmpty && context.mounted) {
app.setDestinations(picked);
}
},
),
const SizedBox(height: 4),
// A numbered chip and the step in words, because a customer who
// arrived here from Home's sphere has answered two questions and
// wants to know this is the last one. Three dots said the same thing
// and made you count them.
const _StepHead(step: 3, of: 3),
const SizedBox(height: 14),
// One row, no card around it: a card holding a single control is a
// border drawn for its own sake.
Row(
// The route, as one card with the two ends and a thread between
// them. It was four ruled rows of equal weight, so where it comes
// from, where it goes, when and how many all read as the same kind
// of fact — and the two that are the *journey* were indistinguishable
// from the two that are its particulars.
DmCard(
raised: true,
children: [
const Icon(
LucideIcons.package,
size: 17,
color: DmColors.ink3,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${group.packageCount} '
'${group.packageCount == 1 ? 'package' : 'packages'}',
style: DmText.cardTitle,
DmCardCell(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 16),
child: _RouteHead(
from: pickup?.title ?? 'Set a pickup address',
fromNote: pickup?.sub,
to: [for (final g in app.draftDestinations) g.destination],
onTapFrom: () => Navigator.of(context).push(
bookingRoute<void>(
BookingRoutes.pickup,
(_) => const PickupLocationScreen(),
),
),
onTapTo: () async {
final picked = await showDestinationSheet(context);
if (picked != null &&
picked.isNotEmpty &&
context.mounted) {
app.setDestinations(picked);
}
},
),
),
DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
],
),
const SizedBox(height: 20),
// Was three sentences about weighing, charging and cancelling. The
// customer is about to commit, so the only part that changes the
// decision is that committing costs nothing yet.
Text(
'Nothing is charged now · Free cancellation',
textAlign: TextAlign.center,
style: DmText.tiny.copyWith(color: DmColors.ink3),
const SizedBox(height: 10),
_DetailCard(
icon: LucideIcons.calendar,
label: 'PICKUP WINDOW',
value: slot == null
? 'Choose a window'
: '${slot.day}, ${slot.window}',
onTap: () => showWindowSheet(context),
trailing: slot == null
? null
: const Icon(
LucideIcons.circleCheck,
size: 20,
color: DmColors.ok,
),
),
const SizedBox(height: 10),
_DetailCard(
icon: LucideIcons.package,
label: 'PARCELS',
value:
'${group.packageCount} '
'${group.packageCount == 1 ? 'parcel' : 'parcels'}',
note: 'Up to 15 kg each',
trailing: DmStepper(
value: group.packageCount,
min: 1,
canIncrease: app.canAddPackageTo(0),
onChanged: (v) => app.setPackageCount(0, v),
),
),
const SizedBox(height: 10),
// The one promise this screen makes that the customer cannot see for
// themselves: nobody is guessing the weight.
Container(
padding: const EdgeInsets.fromLTRB(16, 13, 16, 14),
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(LucideIcons.scale, size: 17, color: DmColors.ink3),
const SizedBox(width: 12),
Expanded(
child: Text(
'Your Miler weighs every parcel at your door. '
'Nothing is estimated.',
style: DmText.small.copyWith(
fontSize: 13,
color: DmColors.ink2,
),
),
),
],
),
),
],
),
bottomNavigationBar: DmFooter(
edge: true,
children: [
_Commitments(
window: slot == null ? null : '${slot.day} · ${slot.window}',
price: fare?.range,
onChangeWindow: () => showWindowSheet(context),
// ── Stacked, and left ──
//
// Label opposite figure meant the two were competing for one line,
// and at 30pt the band lost: "₹49 – …" is the half of a price that
// tells you nothing. Above and below, neither can crowd the other
// and the price starts at the same margin as everything else on the
// screen.
SizedBox(
width: double.infinity,
child: Text('ESTIMATED', style: DmText.eyebrow),
),
const SizedBox(height: 12),
const SizedBox(height: 2),
Text(
fare?.range ?? '—',
style: DmText.headerTitle.copyWith(fontSize: 30),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
const Icon(
LucideIcons.receipt,
size: 13,
color: DmColors.ink3,
),
const SizedBox(width: 7),
Flexible(
child: Text(
'Pay after doorstep weigh · Free cancellation',
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 14),
DmButton(
label: 'Book pickup',
label: 'Confirm Booking',
icon: ready ? LucideIcons.arrowRight : null,
busy: _busy,
busyLabel: 'Booking…',
@@ -200,133 +274,192 @@ class _SendScreenState extends State<SendScreen> {
),
);
}
}
/// Where it comes from, where it goes, and both of them tappable.
/// Which step this is.
///
/// Deliberately not [DmRouteRail]: that one is a read-only thread used on
/// receipts, orders and tracking, and giving it tap targets would make every
/// other use of it look pressable. This is two controls that happen to be
/// drawn as a thread.
/// ── Dots → a numbered chip ──
///
/// There are no "Change" labels. A chevron at the end of a row is the whole
/// sentence, and this screen had two of the word stacked one above the other.
class _RouteHead extends StatelessWidget {
const _RouteHead({
required this.from,
required this.to,
required this.onTapFrom,
required this.onTapTo,
});
/// Three dots with the current one elongated is a shape you have to count.
/// A numeral in a disc and "Step 3 of 3" beside it is the same information
/// read rather than parsed, and it leaves the screen's own title to the thing
/// the customer is about to press.
class _StepHead extends StatelessWidget {
const _StepHead({required this.step, required this.of});
final String from;
/// One row per destination. A pickup that fans out to three places is three
/// rows on one thread, not "3 destinations" standing in for them — the whole
/// point of the screen is that the customer can see what they arranged.
final List<String> to;
final VoidCallback onTapFrom;
final VoidCallback onTapTo;
final int step;
final int of;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.border)),
),
child: Column(
return Semantics(
header: true,
label: 'Step $step of $of',
excludeSemantics: true,
child: Row(
children: [
_RouteRow(
icon: LucideIcons.locateFixed,
eyebrow: 'FROM',
value: from,
onTap: onTapFrom,
),
for (var i = 0; i < to.length; i++)
_RouteRow(
icon: LucideIcons.mapPin,
// Numbered only when there is more than one, because "TO 1" on a
// single-destination booking is a count of one.
eyebrow: to.length == 1 ? 'TO' : 'TO · ${i + 1}',
value: to[i],
onTap: onTapTo,
strong: true,
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.ink,
shape: BoxShape.circle,
),
child: Text(
'$step',
style: DmText.label.copyWith(fontSize: 11.5, color: Colors.white),
),
),
const SizedBox(width: 10),
Text(
'Step $step of $of',
style: DmText.cardTitle.copyWith(
fontSize: 14.5,
color: DmColors.ink2,
),
),
],
),
);
}
}
class _RouteRow extends StatelessWidget {
const _RouteRow({
required this.icon,
required this.eyebrow,
required this.value,
required this.onTap,
this.strong = false,
/// Where it comes from, where it goes, on one thread.
///
/// The two ends of a journey are not two rows of a list. A black dot for the
/// door it leaves and a brand pin for the door it arrives at, with a rule
/// running between them, says *route* in a way four equal rows cannot.
class _RouteHead extends StatelessWidget {
const _RouteHead({
required this.from,
required this.to,
required this.onTapFrom,
required this.onTapTo,
this.fromNote,
});
final IconData icon;
final String eyebrow;
final String value;
final VoidCallback onTap;
final String from;
final String? fromNote;
/// The destination is set in the brand colour: it is the only choice on this
/// screen that moves the price.
final bool strong;
/// One stop per destination. A pickup that fans out to three places is three
/// stops on one thread, not "3 destinations" standing in for them.
final List<Destination> to;
final VoidCallback onTapFrom;
final VoidCallback onTapTo;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_Stop(
label: 'ORIGIN',
value: from,
note: fromNote,
onTap: onTapFrom,
origin: true,
last: false,
),
for (var i = 0; i < to.length; i++)
_Stop(
label: to.length == 1 ? 'DESTINATION' : 'DESTINATION ${i + 1}',
value: to[i].shortLabel,
note: to[i].stateName,
onTap: onTapTo,
origin: false,
last: i == to.length - 1,
),
],
);
}
}
class _Stop extends StatelessWidget {
const _Stop({
required this.label,
required this.value,
required this.onTap,
required this.origin,
required this.last,
this.note,
});
final String label;
final String value;
final String? note;
final VoidCallback onTap;
final bool origin;
final bool last;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: '$eyebrow $value',
label: '$label $value',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: const EdgeInsets.symmetric(vertical: 8),
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
icon,
size: 18,
color: strong ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 14),
Expanded(
// The node and the thread it hangs from. The thread is drawn by
// the stop *above* the join, so the last one ends clean.
SizedBox(
width: 22,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(eyebrow, style: DmText.eyebrow),
Text(
value,
// One weight down from where this started. At w700
// crimson, five destinations stacked read as five
// headings — the page shouted its own contents back. A
// list of places the customer chose is a list, not a
// series of titles.
style: DmText.body.copyWith(
fontSize: 15.5,
fontWeight: FontWeight.w600,
color: strong ? DmColors.brand : DmColors.ink,
const SizedBox(height: 15),
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: origin ? DmColors.ink : DmColors.brand,
shape: BoxShape.circle,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (!last)
const Expanded(
child: VerticalDivider(
width: 1,
thickness: 1,
color: DmColors.border,
),
),
],
),
),
const SizedBox(width: 10),
const Icon(
LucideIcons.chevronRight,
size: 17,
color: DmColors.ink4,
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: EdgeInsets.fromLTRB(0, 8, 0, last ? 4 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 3),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 16.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null && note!.isNotEmpty)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
],
),
@@ -336,82 +469,87 @@ class _RouteRow extends StatelessWidget {
}
}
/// The two things the button is about to agree to, side by side above it.
///
/// The window used to be a row in a card in the middle of the form and the
/// price used to appear in the footer only once everything was valid, which
/// meant the customer had to scroll back up to check what time they had been
/// given. Both live here now, and the left half is tappable.
class _Commitments extends StatelessWidget {
const _Commitments({
required this.window,
required this.price,
required this.onChangeWindow,
/// One particular of the booking, on its own card: a glyph well, a micro-label
/// over a value, and whatever acts on it.
class _DetailCard extends StatelessWidget {
const _DetailCard({
required this.icon,
required this.label,
required this.value,
this.note,
this.onTap,
this.trailing,
});
final String? window;
final String? price;
final VoidCallback onChangeWindow;
final IconData icon;
final String label;
final String value;
final String? note;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
return DmCard(
onTap: onTap,
children: [
Expanded(
child: Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChangeWindow,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('PICKUP', style: DmText.eyebrow),
const SizedBox(height: 3),
Row(
children: [
Flexible(
child: Text(
window ?? 'Choose a window',
style: DmText.cardTitle.copyWith(
color: window == null ? DmColors.brand : null,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
DmCardCell(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
child: Row(
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(icon, size: 18, color: DmColors.ink2),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: DmText.eyebrow),
const SizedBox(height: 2),
Text(
value,
style: DmText.cardTitle.copyWith(fontSize: 15.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(width: 4),
const Icon(
LucideIcons.chevronDown,
size: 15,
color: DmColors.ink3,
),
],
),
],
],
),
),
),
if (trailing != null) ...[
const SizedBox(width: 10),
trailing!,
] else if (onTap != null) ...[
const SizedBox(width: 10),
const Icon(
LucideIcons.chevronRight,
size: 18,
color: DmColors.ink4,
),
],
],
),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text('ESTIMATED', style: DmText.eyebrow),
const SizedBox(height: 3),
Text(
price ?? '—',
style: DmText.monoLg.copyWith(
fontSize: 18,
color: DmColors.brand,
),
),
],
),
],
);
}
}