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