import 'package:flutter/material.dart'; import 'package:flutter/services.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/states.dart'; import 'booking/booking_routes.dart'; import 'booking/send_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: '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; return Scaffold( backgroundColor: DmColors.canvas, appBar: DmTopBar( 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, backgroundColor: DmColors.surface, onRefresh: () => app.refreshBooking(booking.reference), child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: 24), children: [ _TrackCard(booking: booking, slot: slot, focused: focused), // ── No map, and no radar ── // // 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( 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), ), // `_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 // where there is no server to send them. if (AppConfig.showStageStepper) _StageStepper(booking: booking), ], ), ), // 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( children: _footerActions(context, app, booking), ), ); } /// How far along the route the Miler is drawn, by stage. /// 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.send, (_) => const SendScreen()), (route) => route.isFirst, ); }, ), ]; } 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, iconLeading: true, 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 state, the route, and the shipment's own figures. /// /// ── Card → bare type → this ── /// /// 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. /// /// 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; /// 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 group = focused ?? booking.destinations.first; // What the customer can act on: when, before the handover; where, after. // ── One line only, and only when it is not a time ── // // This carried "Today · 2:00 – 4:00 PM" under the headline while the strip // below it carried "WINDOW · 2:00 – 4:00 PM" — the same promise twice, // eighty points apart. Every timing this used to show has a home of its // own now: the ETA is the pill beside the headline, the window and the // expected date are cells in the strip. // // What is left is the one thing that is not a time and has nowhere else // to go. final when = cancelled ? 'You were not charged' : null; final eta = booking.milerEtaMinutes; final reference = focused?.trackingId ?? booking.displayId; // 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. // The parcel is collected once it is past the handover, and after that // the window it was collected in is history — what the customer wants is // when it lands. final collected = stage.index >= JourneyStage.pickedUp.index; final figures = [ _Figure( label: booking.totalPackages == 1 ? 'Package' : 'Packages', value: '${booking.totalPackages}', ), // The timing, in the one place it appears. if (collected && booking.expectedDelivery != null) _Figure(label: 'Expected', value: booking.expectedDelivery!) else if (!collected && slot != null) _Figure(label: 'Window', value: slot!.window), if (booking.totalWeightLabel != null) _Figure(label: 'Weight', value: booking.totalWeightLabel!) else if (booking.fare?.routeKm != null) _Figure( label: 'Distance', value: '${booking.fare!.routeKm!.toStringAsFixed(1)} km', ) else if (booking.miler?.vehicleType != null) _Figure(label: 'Vehicle', value: booking.miler!.vehicleType!), ]; return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 2, DmSpace.pad, 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // The reference used to float here as a small crimson chip above // the headline — an identifier introducing a state, which is the // wrong way round and left it as the smallest thing on the screen // in the loudest position. It is the last row of the shipment card // now, where it identifies the record it belongs to and can be // copied. 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, ), ), ), ], ], ), 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), // ── Pickup → drop ── // // "ORIGIN" and "DESTINATION" are the words a logistics system // uses about a consignment. The customer's parcel is collected // from somewhere and dropped somewhere, and those are the two // words they already use for it. // // The arrow between them is the point: two labelled columns // are a table, and a table does not say which way the parcel // is going. It sits in a well of its own so it reads as a // joint rather than as a stray glyph. child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _Place( label: 'PICKUP', value: booking.pickup.title, note: booking.pickup.sub, ), ), const SizedBox(width: 12), Padding( padding: const EdgeInsets.only(top: 14), child: Container( width: 28, height: 28, alignment: Alignment.center, decoration: const BoxDecoration( color: DmColors.surfaceAlt, shape: BoxShape.circle, ), child: const Icon( LucideIcons.arrowRight, size: 14, color: DmColors.ink2, ), ), ), const SizedBox(width: 12), Expanded( child: _Place( label: 'DROP', value: group.destination.shortLabel, note: group.destination.stateName, end: true, ), ), ], ), ), DmCardCell( padding: const EdgeInsets.fromLTRB(18, 13, 12, 13), child: _Reference( reference: reference, cancelled: cancelled, ), ), if (figures.length > 1) DmCardCell( padding: const EdgeInsets.fromLTRB(4, 13, 4, 14), // ── Red glass, not grey ── // // It was `groove`, a warm grey chosen so the band read as // *inside* the card rather than spilling out of it. That // worked and said nothing: a neutral strip under a white // card is a strip. Eight percent of the brand is a tint the // white above it can still be read against, and it ties the // figures to the shipment rather than to the furniture. // // The type stays ink. Crimson on a crimson wash is the one // combination this design has never allowed, and the figures // are facts — they are not the live thing on the screen. background: DmColors.brand.withValues(alpha: 0.08), child: Row(children: figures), ), ], ), ], ), ); } } /// 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(), // A step darker than the eyebrow's usual grey. On the tint the // default `ink3` went soft, and a label nobody can read is a row // of numbers with no names. style: DmText.eyebrow.copyWith( fontSize: 9.5, color: DmColors.ink2, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 3), Text( value, style: DmText.cardTitle.copyWith( fontSize: 14.5, color: DmColors.ink, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ); } } /// The journey. All of it, always. /// /// It used to show the three most recent milestones behind a "Full timeline" /// action, which was the right compression for seven and is the wrong one for /// five: the whole shipment now fits in the space the summary occupied, and a /// customer who opens tracking to ask "where is my parcel" should not have to /// tap to see the answer's context. class _Journey extends StatelessWidget { const _Journey({required this.booking}); final Booking booking; @override Widget build(BuildContext context) { return DmMilestones( booking: booking, title: 'Trip milestones', showStep: true, ); } } /// Who is collecting, and how to reach them. /// /// ── 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}); final Person person; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad, 10), child: DmCard( raised: true, children: [ DmCardCell( padding: const EdgeInsets.fromLTRB(16, 15, 16, 15), child: Row( children: [ 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.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( person.vehicle, style: DmText.monoSm.copyWith(color: DmColors.ink3), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), const SizedBox(width: 10), _Reach( icon: LucideIcons.phone, tooltip: 'Call ${person.name}', filled: true, onPressed: () => DmToast.show(context, 'Calling ${person.name}…'), ), const SizedBox(width: 9), _Reach( icon: LucideIcons.messageSquare, tooltip: 'Message ${person.name}', onPressed: () => DmToast.show(context, 'Chat is not available yet'), ), ], ), ), ], ), ); } } /// 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 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) { 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, // ── Call is green ── // // It was the brand, which made a phone call the same colour as // BOOK, the primary button and every live mark in the app. Green // is what a call button is in every phone anybody owns, and it // buys back a crimson on a screen that already spends one on the // current milestone and one on Cancel. decoration: BoxDecoration( color: widget.filled ? DmColors.ok : DmColors.surfaceAlt, shape: BoxShape.circle, ), child: Icon( widget.icon, size: 19, color: widget.filled ? Colors.white : DmColors.ink, ), ), ), ), ), ); } } /// 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), ), ), ), ); } } /// "★ 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, ), ), ], ); } } /// The booking's own number, at the foot of the shipment card. /// /// ── Why it copies, and why it is here ── /// /// A reference exists to be quoted — into a chat with support, into a message /// to whoever is receiving the parcel. Every one of those ends with somebody /// reading eight characters off a screen and typing them somewhere else, which /// is the one interaction a phone is worst at. Orders learned this first; this /// is the same chip in the same colours. /// /// It sits under the route and the figures because that is what it identifies. /// Above the headline it was an identifier introducing a state, which reads /// backwards: you learn *what is happening* first and reach for the number /// only when you need to tell somebody about it. class _Reference extends StatelessWidget { const _Reference({required this.reference, required this.cancelled}); final String reference; final bool cancelled; @override Widget build(BuildContext context) { final shown = reference.startsWith('#') ? reference : '#$reference'; return Semantics( button: true, label: 'Copy order ID $reference', child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () async { await Clipboard.setData(ClipboardData(text: reference)); await HapticFeedback.selectionClick(); if (context.mounted) DmToast.show(context, 'Order ID copied'); }, child: Row( children: [ Text('ORDER ID', style: DmText.eyebrow), const Spacer(), Flexible( child: Text( shown, style: DmText.monoSm.copyWith( fontSize: 13, color: cancelled ? DmColors.ink3 : DmColors.ink, ), textAlign: TextAlign.right, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 9), const Icon(LucideIcons.copy, size: 14, color: DmColors.ink3), ], ), ), ); } }