Files
doormile_customer_app/lib/ui/widgets/milestones.dart
Thiru-tenext 86b6af48c2 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
2026-09-24 11:08:25 +05:30

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