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