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
366 lines
13 KiB
Dart
366 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../data/models.dart';
|
|
import '../format.dart';
|
|
import '../tokens.dart';
|
|
import 'buttons.dart';
|
|
|
|
/// The customer's journey: seven milestones, not nine backend states.
|
|
///
|
|
/// Operational detail — "Miler on the way", "Order created" — is shown as
|
|
/// context beneath the milestone that is currently happening, so a transition
|
|
/// informs without becoming another permanent row.
|
|
///
|
|
/// ── Newest first, and only as many as matter ──
|
|
///
|
|
/// The list used to run oldest-to-newest, full height, every time: seven rows
|
|
/// of which four were grey placeholders for things that have not happened. On
|
|
/// a screen whose job is "where is my parcel right now", the answer was at the
|
|
/// bottom, below the scroll. It now leads with what just happened and, when
|
|
/// [maxRows] is set, keeps the rest behind one line — the same compression the
|
|
/// design applies to the live tracking card.
|
|
class DmMilestones extends StatelessWidget {
|
|
const DmMilestones({
|
|
super.key,
|
|
required this.booking,
|
|
this.stage,
|
|
this.title = 'Recent updates',
|
|
this.maxRows,
|
|
this.onExpand,
|
|
this.showStep = false,
|
|
});
|
|
|
|
final Booking booking;
|
|
|
|
/// Journey to show. Defaults to the pickup's own stage; a destination order
|
|
/// passes its own.
|
|
final JourneyStage? stage;
|
|
|
|
final String title;
|
|
|
|
/// Show at most this many rows, newest first. Null shows the whole journey.
|
|
final int? maxRows;
|
|
|
|
/// Tapped from the "Full timeline" action. Null hides the action.
|
|
final VoidCallback? onExpand;
|
|
|
|
/// Puts "Step N of 5" opposite the title. Only where the whole rail is on
|
|
/// screen — a counter over a compressed list counts rows nobody can see.
|
|
final bool showStep;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final current = stage ?? booking.stage;
|
|
final currentMilestone = CustomerMilestone.of(current);
|
|
final milestones = CustomerMilestone.values;
|
|
final currentIndex = currentMilestone.index;
|
|
|
|
// ── Chronological, because the line is the progress ──
|
|
//
|
|
// This ran newest-first, on the argument that "where is my parcel right
|
|
// now" should not be at the bottom of the list. That was right when the
|
|
// rail was a list of rows and wrong now that it is a rail: the travelled
|
|
// part of the spine is drawn crimson and the rest grey, and a reversed
|
|
// order makes a filling line read backwards. The answer is also no longer
|
|
// down here — the crimson card at the top of tracking says it outright,
|
|
// so this is free to answer the other question, which is *how far along*.
|
|
//
|
|
// Compressed, it shows the current milestone and the two before it: recent
|
|
// activity stays on screen without the rail claiming the whole page.
|
|
final ordered = [for (var i = 0; i < milestones.length; i++) i];
|
|
final shown = maxRows == null
|
|
? ordered
|
|
: ordered
|
|
.sublist(
|
|
(currentIndex - maxRows! + 1).clamp(0, milestones.length),
|
|
(currentIndex + 1).clamp(1, milestones.length),
|
|
)
|
|
.toList();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
title.toUpperCase(),
|
|
// Neutral. It heads a rail that is deliberately receding,
|
|
// beside a crimson action — two accents on a group nobody
|
|
// is meant to study.
|
|
style: DmText.eyebrow,
|
|
),
|
|
),
|
|
// ── "Step 2 of 5" ──
|
|
//
|
|
// The rail shows where the parcel is; the counter says how much
|
|
// of the journey that is. A customer looking at five rows with
|
|
// one of them lit has to count to know, and counting is the one
|
|
// thing a progress indicator exists to save them.
|
|
if (showStep)
|
|
Text(
|
|
'Step ${currentIndex + 1} of ${milestones.length}',
|
|
style: DmText.label.copyWith(
|
|
fontSize: 12.5,
|
|
color: DmColors.brand,
|
|
),
|
|
),
|
|
if (onExpand != null && shown.length < ordered.length)
|
|
DmTextAction(
|
|
// Was "All 7 stages". The number counts something the
|
|
// customer has no model for — they do not know a parcel has
|
|
// seven of anything — and it changes with the backend's own
|
|
// vocabulary.
|
|
label: 'Full timeline',
|
|
onPressed: onExpand,
|
|
minHeight: 24,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
for (var n = 0; n < shown.length; n++)
|
|
_MilestoneRow(
|
|
milestone: milestones[shown[n]],
|
|
done: shown[n] < currentIndex,
|
|
isCurrent: shown[n] == currentIndex,
|
|
first: n == 0,
|
|
last: n == shown.length - 1,
|
|
time: shown[n] <= currentIndex
|
|
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
|
|
: '',
|
|
detail: shown[n] == currentIndex ? _detailFor(current) : null,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// One line of operational context for the stage actually in progress.
|
|
String? _detailFor(JourneyStage current) => switch (current) {
|
|
JourneyStage.booked => 'Matching the nearest available Miler',
|
|
JourneyStage.assigned =>
|
|
booking.miler == null
|
|
? null
|
|
: '${booking.miler!.name} accepted this pickup',
|
|
JourneyStage.onTheWay =>
|
|
booking.milerDistanceKm == null
|
|
? 'Miler on the way'
|
|
: 'Miler on the way · ${booking.milerDistanceKm} km away, '
|
|
'about ${booking.milerEtaMinutes} min',
|
|
JourneyStage.arrived => 'Miler arrived at your door · verification OTP ready',
|
|
JourneyStage.pickedUp => 'Weighing and sealing your packages',
|
|
JourneyStage.orderCreated =>
|
|
booking.isMultiDestination
|
|
? 'Order created for each destination · tracking issued'
|
|
: 'Order created · tracking number issued',
|
|
JourneyStage.inTransit => 'Moving through our network',
|
|
JourneyStage.outForDelivery => 'Out with the delivery Miler',
|
|
JourneyStage.delivered => null,
|
|
};
|
|
}
|
|
|
|
/// One milestone on the spine.
|
|
///
|
|
/// ── The line is the progress bar ──
|
|
///
|
|
/// Every connector used to be the same grey hairline, which made the rail a
|
|
/// list of dots that happened to be joined up: to work out how far along a
|
|
/// parcel was you had to find the filled dots and count them. The segment
|
|
/// *above* a node is now crimson when that node has been reached and grey when
|
|
/// it has not, so the line fills from the top as the parcel moves and "how far
|
|
/// along" is answered before a single word is read.
|
|
///
|
|
/// ── Identity left, time right ──
|
|
///
|
|
/// The same split the Miler app's route rail uses: what happened on the left,
|
|
/// when it happened on the right, on one baseline. Four stacked left-aligned
|
|
/// lines make the eye walk down the block to find the time; two columns answer
|
|
/// both in one fixation, and down a rail the times stack into a scan column.
|
|
class _MilestoneRow extends StatelessWidget {
|
|
const _MilestoneRow({
|
|
required this.milestone,
|
|
required this.done,
|
|
required this.isCurrent,
|
|
required this.first,
|
|
required this.last,
|
|
required this.time,
|
|
this.detail,
|
|
});
|
|
|
|
final CustomerMilestone milestone;
|
|
final bool done;
|
|
final bool isCurrent;
|
|
final bool first;
|
|
final bool last;
|
|
final String time;
|
|
final String? detail;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final upcoming = !done && !isCurrent;
|
|
final reached = done || isCurrent;
|
|
|
|
return IntrinsicHeight(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 14,
|
|
child: Column(
|
|
children: [
|
|
// The segment above this node, which is what says whether the
|
|
// parcel has got here. Absent on the first row: a line running
|
|
// up out of the top of the rail has nowhere to come from.
|
|
SizedBox(
|
|
height: 6,
|
|
child: first
|
|
? null
|
|
: Center(
|
|
child: Container(
|
|
width: 2,
|
|
color: reached
|
|
? DmColors.brand
|
|
: DmColors.border,
|
|
),
|
|
),
|
|
),
|
|
_Marker(done: done, isCurrent: isCurrent),
|
|
if (!last)
|
|
Expanded(
|
|
child: Center(
|
|
child: Container(
|
|
width: 2,
|
|
// Crimson only as far as the parcel has come. The
|
|
// segment below the current node is the road ahead.
|
|
color: done ? DmColors.brand : DmColors.border,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 14),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: EdgeInsets.only(top: 2, bottom: last ? 0 : 18),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
milestone.label,
|
|
// The one happening now is the only thing on the
|
|
// rail set in the brand colour, and the only one at
|
|
// full weight. Everything behind it is history and
|
|
// everything ahead of it has not happened.
|
|
style: switch ((isCurrent, upcoming)) {
|
|
(true, _) => DmText.cardTitle.copyWith(
|
|
fontSize: 15.5,
|
|
color: DmColors.brand,
|
|
),
|
|
(_, true) => DmText.small.copyWith(
|
|
color: DmColors.ink4,
|
|
),
|
|
_ => DmText.cardTitle.copyWith(fontSize: 14.5),
|
|
},
|
|
),
|
|
),
|
|
if (time.isNotEmpty) ...[
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
time,
|
|
// Always quiet. The crimson on the current row is
|
|
// the "you are here" marker and it belongs to the
|
|
// milestone; a timestamp set in it made the accent
|
|
// mean "recent" on one row and "now" on the next.
|
|
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (detail != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(detail!, style: DmText.small),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The node itself.
|
|
///
|
|
/// Three states and three shapes, so the rail is readable without colour: a
|
|
/// filled disc behind you, a ring with a lit centre where you are, a hollow
|
|
/// outline ahead.
|
|
class _Marker extends StatelessWidget {
|
|
const _Marker({required this.done, required this.isCurrent});
|
|
|
|
final bool done;
|
|
final bool isCurrent;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isCurrent) {
|
|
return Container(
|
|
width: 14,
|
|
height: 14,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.surface,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: DmColors.brand, width: 2),
|
|
boxShadow: const [
|
|
BoxShadow(color: DmColors.brandSoft, spreadRadius: 3.5),
|
|
],
|
|
),
|
|
child: Container(
|
|
width: 5,
|
|
height: 5,
|
|
decoration: const BoxDecoration(
|
|
color: DmColors.brand,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
if (done) {
|
|
return Container(
|
|
width: 14,
|
|
height: 14,
|
|
alignment: Alignment.center,
|
|
decoration: const BoxDecoration(
|
|
color: DmColors.brand,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: const Icon(
|
|
LucideIcons.check,
|
|
size: 9,
|
|
color: Colors.white,
|
|
),
|
|
);
|
|
}
|
|
return Container(
|
|
width: 14,
|
|
height: 14,
|
|
decoration: BoxDecoration(
|
|
color: DmColors.canvas,
|
|
shape: BoxShape.circle,
|
|
border: Border.all(color: DmColors.border, width: 2),
|
|
),
|
|
);
|
|
}
|
|
}
|