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

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