import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../data/models.dart'; import '../../data/app_config.dart'; import '../../state/app_scope.dart'; import '../../state/app_state.dart'; import '../tokens.dart'; import '../widgets/buttons.dart'; import '../widgets/cards.dart'; 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/pickup_location_screen.dart'; import 'sheets/cancel_sheet.dart'; /// Live tracking. /// /// One screen for the whole journey. While a Miler is being matched it shows a /// radar; once one is assigned it shows their live position, who they are, and /// how to reach them; after pickup it follows the order to the door. /// Cancellation is offered only while the backend still allows it. class TrackingScreen extends StatelessWidget { const TrackingScreen({super.key, this.focusedDestination}); /// When set, this screen follows one destination's own order rather than the /// pickup as a whole. Only meaningful once the orders exist. final int? focusedDestination; @override Widget build(BuildContext context) { final app = AppScope.of(context); final booking = app.trackedBooking; if (booking == null) { return Scaffold( backgroundColor: DmColors.canvas, appBar: const DmTopBar(title: 'Live tracking'), body: const DmEmptyState( icon: LucideIcons.package, title: 'Nothing to track', message: 'Book a pickup to get started.', ), ); } final cancelled = booking.isCancelled; final slot = app.slotById(booking.slotId); // A focused destination follows its own journey; the pickup follows its own. final focused = (focusedDestination != null && focusedDestination! < booking.destinations.length && booking.destinations[focusedDestination!].trackingId != null) ? booking.destinations[focusedDestination!] : null; final stage = focused?.stage ?? booking.stage; final milerEnRoute = !cancelled && 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, ), ), body: ListView( padding: const EdgeInsets.only(bottom: 24), children: [ _StatusHeader(booking: booking, slot: slot, focused: focused), if (assigning) const _AssigningHero() else if (!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, ), DmCardCell( padding: const EdgeInsets.fromLTRB(16, 12, 10, 12), child: Row( children: [ const Icon( LucideIcons.clock, size: 15, color: DmColors.ink3, ), const SizedBox(width: 10), Expanded( child: Text( // Short enough to sit beside Refresh without // ellipsizing, which is the whole job of this line. milerEnRoute && booking.milerEtaMinutes != null ? 'Pickup in about ${booking.milerEtaMinutes} min' : 'Tracking your parcel live', style: DmText.small, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), DmTextAction( label: 'Refresh', onPressed: app.refreshOrders, ), ], ), ), ], ), ), if (assigning && booking.milersInZone > 0) Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 0, DmSpace.pad, 10, ), child: DmInfoBanner( icon: LucideIcons.users, title: '${booking.milersInZone} verified Milers active in your zone', message: 'Most pickups are accepted within a few minutes.', ), ), if (milerEnRoute) _MilerCard(person: booking.miler!, role: 'Your Miler'), if (!cancelled && stage == JourneyStage.outForDelivery && booking.deliveryAgent != null) _MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'), if (!cancelled) Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 0, DmSpace.pad, 10, ), child: _Journey(booking: booking), ), if (!cancelled && booking.hasOrders && booking.isMultiDestination) Padding( padding: const EdgeInsets.fromLTRB( DmSpace.pad, 0, DmSpace.pad, 10, ), 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, ), ), // Stands in for backend push updates. Real stage changes arrive // from `GET /customer/bookings/{reference}`, so this exists only // where there is no server to send them. if (AppConfig.showStageStepper) _StageStepper(booking: booking), ], ), 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 /// full-width outlined button in the danger colour — the most prominent /// control on a screen whose job is to reassure someone that their parcel is /// fine. List _footerActions(BuildContext context, AppState app, Booking booking) { if (booking.isCancelled || booking.stage == JourneyStage.delivered) { return [ DmButton( label: 'Book another pickup', icon: LucideIcons.arrowRight, onPressed: () { app.startBooking(); Navigator.of(context).pushAndRemoveUntil( bookingRoute( BookingRoutes.pickup, (_) => const PickupLocationScreen(), ), (route) => route.isFirst, ); }, ), ]; } return [ DmButton( label: 'Share live tracking', icon: LucideIcons.share2, kind: DmButtonKind.outline, onPressed: () => DmToast.show(context, 'Sharing is coming soon'), ), // Backend policy decides whether cancelling is still possible. if (app.isCancellable(booking.stage)) DmButton( label: 'Cancel this pickup', kind: DmButtonKind.ghost, danger: true, onPressed: () async { final cancelled = await showCancelSheet(context, booking); if (cancelled == true && context.mounted) { DmToast.show(context, 'Pickup cancelled'); } }, ) else DmButton( label: 'Need help with this order?', kind: DmButtonKind.ghost, onPressed: () => DmToast.show(context, 'Support chat is not available yet'), ), ]; } } /// The screen's one loud surface: what is happening, right now. /// /// ── Why this is a filled card and not a heading ── /// /// 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. /// /// 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, }); final Booking booking; final PickupSlot? slot; /// Set when following one destination's own order. final DestinationGroup? focused; @override Widget build(BuildContext context) { final cancelled = booking.isCancelled; final stage = focused?.stage ?? booking.stage; 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, }; final sub = cancelled ? 'This pickup was cancelled. You were not charged.' : stage.sub; 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), ], ), ), ], ), ); } return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), child: DmCard( tone: DmCardTone.fill, 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, ), ), 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), ), const SizedBox(height: 6), Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)), const SizedBox(height: 16), DmStepBar( progress: step / total, steps: total, current: step, onFill: true, // The milestone, not the window: the window is already the // line underneath, and at this width it clipped to // "Window 2:00 – 4:…". label: CustomerMilestone.of(stage).label, ), const SizedBox(height: 10), Text( _footnote(stage), style: DmText.tiny.copyWith(color: DmColors.onBrand3), ), ], ), ), ], ), ); } /// 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; } } /// The journey, compressed to what just happened. class _Journey extends StatefulWidget { const _Journey({required this.booking}); final Booking booking; @override State<_Journey> createState() => _JourneyState(); } class _JourneyState extends State<_Journey> { bool _all = false; @override Widget build(BuildContext context) { return DmMilestones( booking: widget.booking, title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey', maxRows: _all ? null : 3, onExpand: () => setState(() => _all = 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. class _AssigningHero extends StatelessWidget { const _AssigningHero(); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20), child: Container( height: 200, alignment: Alignment.center, decoration: BoxDecoration( color: DmColors.surface, border: Border.all(color: DmColors.border), borderRadius: DmRadius.all(DmRadius.md), ), child: DmRadarPulse( size: 170, child: Container( width: 62, height: 62, decoration: const BoxDecoration( color: DmColors.brand, shape: BoxShape.circle, ), child: const Icon( LucideIcons.bike, size: 28, color: Colors.white, ), ), ), ), ); } } /// Who is collecting, how good they are, 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. class _MilerCard extends StatelessWidget { const _MilerCard({required this.person, required this.role}); final Person person; final String role; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), child: DmCard( tone: DmCardTone.wash, children: [ DmCardCell( padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), child: Row( children: [ DmAvatar(initials: person.initials, size: 44), const SizedBox(width: 13), 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, ), ), ], ) else Text(role, style: DmText.tiny), ], ), ), 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', icon: LucideIcons.phone, filled: true, onPressed: () => DmToast.show(context, 'Calling ${person.name}…'), ), const SizedBox(width: 8), DmMiniButton( label: 'Message', icon: LucideIcons.messageSquare, onPressed: () => DmToast.show(context, 'Chat is not available yet'), ), ], ), ), ], ), ); } } /// 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, }); final Booking booking; final PickupSlot? slot; final DestinationGroup? focused; @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'), ), ), 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}', ), ], ); } } /// The individual orders a completed pickup produced. /// /// Deliberately absent until the backend has actually created them: before /// collection the customer has one scheduled visit, not several shipments. class _YourPackages extends StatelessWidget { const _YourPackages({required this.booking, this.focused}); final Booking booking; /// The one being followed, so the list can mark it. final DestinationGroup? focused; @override Widget build(BuildContext context) { return DmCard( children: [ DmCardCell( padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), child: Row( children: [ Expanded( child: Text( 'YOUR PACKAGES', style: DmText.eyebrow.copyWith(color: DmColors.brand), ), ), Flexible( child: Text( focused == null ? 'Collected in one visit' : 'Pickup ${booking.reference}', textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.tiny, ), ), ], ), ), for (final group in booking.destinations) _PackageRow( group: group, isFocused: identical(group, focused), ), ], ); } } class _PackageRow extends StatelessWidget { const _PackageRow({required this.group, this.isFocused = false}); final DestinationGroup group; final bool isFocused; @override Widget build(BuildContext context) { final stage = group.stage; return DmCardCell( background: isFocused ? DmColors.brandSoft : null, child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(group.destination.label, style: DmText.cardTitle), const SizedBox(height: 3), Text.rich( TextSpan( style: DmText.tiny, children: [ TextSpan( text: group.trackingId ?? '—', style: DmText.monoSm, ), TextSpan( text: ' · ${group.packageCount} ' '${group.packageCount == 1 ? 'package' : 'packages'}', ), ], ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), if (stage != null) Flexible( child: DmPill( stage.milestoneLabel, tone: stage == JourneyStage.delivered ? DmPillTone.done : DmPillTone.neutral, ), ), ], ), ); } } /// The backend's QA stage helper, as a control. /// /// It is not a simulator: each tap posts to /// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads /// the booking, so what it shows is what the **server** recorded. Off entirely /// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on /// production or in a release. class _StageStepper extends StatelessWidget { const _StageStepper({required this.booking}); final Booking booking; @override Widget build(BuildContext context) { final app = AppScope.read(context); final index = booking.stage.index; final locked = booking.isCancelled; return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20), child: Container( padding: const EdgeInsets.fromLTRB(16, 10, 10, 10), decoration: BoxDecoration( color: DmColors.surfaceAlt, border: Border.all(color: DmColors.border), borderRadius: DmRadius.all(DmRadius.md), ), child: Row( children: [ Expanded( child: Text( 'Move stage (QA)', style: DmText.label.copyWith(color: DmColors.ink3), ), ), _StepButton( icon: LucideIcons.minus, onTap: locked || index == 0 ? null : () => app.advanceStage(booking, JourneyStage.at(index - 1)), ), const SizedBox(width: 8), _StepButton( icon: LucideIcons.plus, onTap: locked || index == JourneyStage.values.length - 1 ? null : () => app.advanceStage(booking, JourneyStage.at(index + 1)), ), ], ), ), ); } } class _StepButton extends StatelessWidget { const _StepButton({required this.icon, this.onTap}); final IconData icon; final VoidCallback? onTap; @override Widget build(BuildContext context) { final enabled = onTap != null; return Opacity( opacity: enabled ? 1 : 0.35, child: Material( color: DmColors.surface, borderRadius: DmRadius.all(DmRadius.xs), child: InkWell( borderRadius: DmRadius.all(DmRadius.xs), onTap: onTap, child: Ink( width: 38, height: 38, decoration: BoxDecoration( borderRadius: DmRadius.all(DmRadius.xs), border: Border.all(color: DmColors.border), ), child: Icon(icon, size: 18, color: DmColors.ink2), ), ), ), ); } }