import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/widgets/app_widgets.dart'; /// ───────────────────────────────────────────────────────────────────────── /// STOP CARD — one order, drawn as the journey it is. /// /// ``` /// ✓ ACCEPTED ~5 min · by 12:30 PM ( ) /// /// ● 🍴 PICKUP /// │ Vidhya Kitchen /// │ 📍 Ritham Tours & Travels 20, Gandhi nagar, Peelamedu… /// │ Quantity 1 /// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ /// ● 📦 DROP /// Joe /// 📍 12, SNS Colony, Peelamedu, Coimbatore /// 🏢 Daily Grubs /// ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ /// 🧾 Stop 1 · #916-2024116374 [📞] [ⓘ] /// /// ┌───────────────────────────────────────────────────────┐ /// │ ➤ Navigate to pickup │ /// └───────────────────────────────────────────────────────┘ /// ``` /// /// ── The three rules this card is built on ── /// /// 1. **No boxes.** One pane of glass ([GlassCard]) with no border, and /// nothing bordered inside it. Everything that used to be a box — the type /// chip, the state panel, the reserved control slots, the tinted meta /// tags — is type and colour on plain rows now. /// /// 2. **The rider reads this at arm's length, in sun, on a bike.** So the /// things he actually reads are large: the place name at 19sp, the address /// at 14.5, the time he is working to at 13.5. Nothing on this card is /// under 11.5. /// /// 3. **One state, one next action.** The card always says where the order is /// (the chip, top left) and offers exactly one thing to press for it /// (bottom). An accepted order's one action is *get me there* — see /// [_primaryAction]. /// ───────────────────────────────────────────────────────────────────────── class StopCard extends StatelessWidget { final Map stop; /// 1-based position in the route, printed in the footer beside the order id. final int index; final StopState state; /// The pickup-side address, already shortened by the trip if the design's /// tail-trimming is on. final String address; /// Time at the door, shown in the card's header. final Duration service; /// Which bag this order travels in — `Bag 3`, or the label the kitchen /// printed. One order, one bag; the count is never a separate figure. See /// [BagManifest]. final String bag; /// True when this card sits under a heading that already names its pickup /// location. /// /// It changes what the card leads with. Under a kitchen heading, five cards /// each headlined "Vidhya Kitchen" is the same name six times on one screen — /// the heading, then once per card — and the one thing that actually tells /// the five apart, the customer, was demoted to a line underneath. So a /// grouped card leads with **who it is for** and shows the address it is /// going to, which is also how the kitchen's own manifest reads. final bool groupedUnderSource; final bool selected; /// True while this stop's acceptance is on the wire — the tick goes dead so /// it cannot disagree with the request in flight. final bool busy; /// Opens the detail sheet. The whole card carries it, and so does the ⓘ tile. final VoidCallback? onTap; /// Null hides the tick entirely — see the note in [TripCard] on which states /// are still choosable. final VoidCallback? onToggleSelect; final VoidCallback? onCall; /// Opens turn-by-turn navigation to whichever leg this stop is on. This is /// the accepted card's primary action, not a tile in a corner: an accepted /// order's whole content is "go to this kitchen". final VoidCallback? onNavigate; /// Returns a rejected stop to undecided. final VoidCallback? onUnreject; /// Takes the rider back into the stop he is already working. final VoidCallback? onContinue; /// The next rung for a stop already under way, behind the same slide sheet /// the selection bar uses. Only ever offered on an *arrived* stop — the rung /// before that is reached by navigating there and ticking the counter's /// orders. See [_primaryAction]. final VoidCallback? onAdvance; final String? advanceLabel; const StopCard({ super.key, required this.stop, required this.index, required this.state, required this.address, required this.service, this.bag = '', this.groupedUnderSource = false, this.selected = false, this.busy = false, this.onTap, this.onToggleSelect, this.onCall, this.onNavigate, this.onUnreject, this.onContinue, this.onAdvance, this.advanceLabel, }); bool get _live => state == StopState.active; bool get _rejected => state == StopState.rejected; @override Widget build(BuildContext context) { final kind = stopKindOf(stop); final source = stopSourceName(stop); final customer = (stop['pickupcustomer'] ?? stop['tenantname'] ?? 'Stop') .toString(); final drop = (stop['dropaddress'] ?? stop['DropAddress'] ?? '') .toString() .trim(); // ── The card leads with where he is going ── // // Not with both ends of the journey. A meal order has two, and drawing both // at equal weight — two avatars, two labels, two addresses joined by a // thread — made every card a diagram of a journey rather than an answer to // "where am I going and what do I do there". The destination takes the // headline; the other end drops to one supporting line underneath. // // Before pickup the destination is the counter, because that is the address // he is riding to. After it, the drop leads — and that card lives on the // work tab, where [PickupCard] does exactly the same thing in the other // direction. final twoLeg = ServiceProfile.active.deliversToCustomer && drop.isNotEmpty; final grouped = groupedUnderSource && source.isNotEmpty; final headline = (twoLeg && !grouped && source.isNotEmpty) ? source : customer; final headlineAddress = grouped && drop.isNotEmpty ? drop : address; final action = _primaryAction(); return GlassCard( margin: EdgeInsets.only(bottom: 12.h), onTap: onTap, opacity: _rejected ? 0.6 : 1, tint: _live ? ColorConstants.glassCardLive : null, child: Padding( // One inset for the whole card, and the button spans it. The card used // to run four different paddings — a header inset, a leg inset, a // footer inset and a CTA inset — which is what made the left edge of // the content wander down the card. padding: EdgeInsets.fromLTRB(18.w, 16.h, 18.w, 16.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _statusRow(kind), SizedBox(height: 10.h), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── The headline ── // // The loudest thing on the card, by a clear step. It is // what the rider shouts at a gate and what he checks a // shop sign against, and at 19sp beside a 16sp address it // was not winning by enough to be found without reading. Text( headline, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 21.sp, fontWeight: FontWeight.w800, height: 1.12, letterSpacing: -0.6, color: ColorConstants.slateText, decoration: _rejected ? TextDecoration.lineThrough : null, fontFamily: FontConstants.fontFamily, ), ), if (headlineAddress.isNotEmpty) ...[ SizedBox(height: 5.h), Text( headlineAddress, maxLines: kFullAddressTrial ? 3 : 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.sp, height: 1.4, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ], ), ), // Ringing ahead is the one thing a rider does from a list that // is not "go there", so it keeps a place on the card — a single // quiet glyph, not one of a row of tiles. if (onCall != null) ...[ SizedBox(width: 10.w), _IconAction( icon: LucideIcons.phone, semanticLabel: 'Call this stop', onTap: onCall!, ), ], ], ), if (twoLeg && !grouped) ...[ SizedBox(height: 12.h), _dropLine(customer), ], SizedBox(height: 12.h), _factsRow( kind, bagShownAbove: twoLeg && !grouped && bag.isNotEmpty, ), if (action != null) ...[SizedBox(height: 16.h), action], ], ), ), ); } /// A quantity of at least one: a stop that carries nothing is not a stop, and /// a payload that forgot to say so should not print "0". int _qty(int v) => v > 0 ? v : 1; /// The type, the state and the tick, on one line above the name. /// /// All three are *marks*: small, quiet, and never competing with the address /// underneath. The state chip is the only one that carries colour, because it /// is the only one that changes. Widget _statusRow(StopKind kind) { final chip = _live ? const LiveMark() : (state == StopState.pending && !ServiceProfile.active.handsOffAtCollection) ? null : StopStateChip(state: state, filled: true); return Row( children: [ Text( kind.chipLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w800, letterSpacing: 1.0, // A mark, in the type's own colour — never a fill and never a // border. See `stop_card_neutral_test`, which pins exactly that. color: kind.chipColor, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 10.w), if (chip != null) Flexible(child: chip), const Spacer(), if (onToggleSelect != null) SelectBox( selected: selected, accent: ColorConstants.primary, label: 'Select stop $index', circular: true, // Dead while this stop's acceptance is on the wire: un-ticking it // there would leave the tick and the request disagreeing. onTap: busy ? null : onToggleSelect, ), ], ); } /// `→ Joe · Bag 1` — the far end, in one line. /// /// Everything the second leg used to spend a whole block on: who it is for, /// and which bag it is. The address is one tap away in the detail sheet, /// where a rider going to a *kitchen* has no use for it yet. Widget _dropLine(String customer) { return Row( children: [ Icon( LucideIcons.arrowRight, size: 16.sp, color: ColorConstants.deliveryChip, ), SizedBox(width: 7.w), Flexible( child: Text( customer, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15.5.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), if (bag.isNotEmpty) ...[SizedBox(width: 10.w), _BagChip(label: bag)], ], ); } /// The figures, on one line: time at the door, when it is due, what is in it. /// /// This is the line a rider re-reads at a red light, so nothing on it is /// under 14sp and none of it is in a box. Widget _factsRow(StopKind kind, {required bool bagShownAbove}) { final due = DateTime.tryParse( (stop['expected_pickup_time'] ?? stop['eta'] ?? '').toString().trim(), ); final cash = stopCollectionAmount(stop); final parts = [ if (service > Duration.zero) '~${formatTripDuration(service)}', if (due != null) 'by ${RouteMetricsHelper.formatClock(due)}', // Position in the route. The trip's own index, which does not renumber // itself as stops leave the screen. 'Stop $index', ]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( LucideIcons.clock, size: 16.sp, color: ColorConstants.secondaryText, ), SizedBox(width: 7.w), Expanded( child: Text( parts.join(' · '), maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, letterSpacing: -0.2, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ), ], ), // What is in it, and which bag it is. The bag appears here whenever the // drop line above is not already carrying it — a card must never be // silent about which box this order is, and it must never say it twice. Builder( builder: (_) { final label = bag.isNotEmpty ? bag : stopBagLabel(stop); final meta = _MetaLine( deliver: kind.hasDelivery ? _qty(deliveryParcelCount(stop)) : 0, collect: kind.hasPickup ? _qty(pickupParcelCount(stop)) : 0, cash: cash, muted: _rejected || state == StopState.done, // On a meal run the parcel counts are the one-bag rule restated // as an item count, which is exactly the second number this app // does not allow. Only the money survives. countsHidden: ServiceProfile.active.deliversToCustomer, ); final showBag = !bagShownAbove && label.isNotEmpty; if (!showBag && meta.isEmpty) return const SizedBox.shrink(); return Padding( padding: EdgeInsets.only(top: 8.h), child: Row( children: [ if (!meta.isEmpty) Flexible(child: meta), if (showBag) ...[ if (!meta.isEmpty) SizedBox(width: 10.w), _BagChip(label: label), ], ], ), ); }, ), ], ); } /// The one thing to press on this card, sized like it matters. /// /// ── Why an accepted order's action is "navigate" ── /// /// Accepting is a promise; the next physical act is riding to the counter. /// The rung after that — *I have arrived* — is a claim about a place, and it /// is made from the selection bar with every order from that counter ticked, /// because he arrives at a kitchen, not at an order. So the card offers the /// journey and the bar offers the claim, and neither duplicates the other. /// /// A stop that is already *arrived* is the one exception: the single-order /// case ("one bag, one counter") would otherwise make him tick a card he is /// looking at to reach a control that talks about "the selection". That /// button opens the same slide sheet the bar does, over the same batch. Widget? _primaryAction() { Widget wrap(Widget child) => Padding( padding: EdgeInsets.fromLTRB(16.w, 6.h, 16.w, 16.h), child: SizedBox(width: double.infinity, child: child), ); if (_rejected) { return onUnreject == null ? null : wrap( MilerButton( label: 'Undo reject', icon: LucideIcons.undo2, variant: MilerButtonVariant.outlined, color: ColorConstants.slateText, height: ButtonSizes.secondary, loading: busy, onPressed: busy ? null : onUnreject, ), ); } // The stop under way, on a parcel route: back into the verification flow. if (_live && onContinue != null) { return wrap( MilerButton( label: stopKindOf(stop).isDelivery ? 'Continue delivery' : 'Continue pickup', icon: LucideIcons.arrowRight, color: ColorConstants.primary, height: ButtonSizes.primary, onPressed: onContinue, ), ); } // The stop under way, on a service route: the next rung, over the batch at // this counter. if (_live && onAdvance != null && advanceLabel != null) { return wrap( MilerButton( label: advanceLabel!, icon: LucideIcons.shoppingBag, color: ColorConstants.acceptGreen, height: ButtonSizes.primary, loading: busy, onPressed: busy ? null : onAdvance, ), ); } // Under way, with nothing on this screen able to move it on: say where it // *is* worked. A sentence, not a button — a control that cannot be pressed // is not a status, and this is the one case where the card has nothing to // offer but wayfinding. if (_live) { return Padding( padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 16.h), child: Row( children: [ Icon(LucideIcons.bike, size: 17.sp, color: ColorConstants.primary), SizedBox(width: 8.w), Flexible( child: Text( 'In progress — finish it in ' '${ServiceProfile.active.workTabLabel}', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, color: ColorConstants.primary, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } // Taken on, and not yet there. if (state == StopState.accepted && onNavigate != null) { return wrap( MilerButton( label: 'Navigate to pickup', icon: LucideIcons.navigation, // Maroon, not green: across this app green *advances the job* and // brand red is navigation and identity. Setting off changes nothing // at the hub, so it must not wear the colour that does. color: ColorConstants.primary, height: ButtonSizes.primary, onPressed: onNavigate, ), ); } return null; } } /// A quiet round glyph button — the card's only icon control. /// /// One, not a row of three. The footer used to carry call, navigate and /// details as equal tiles; navigating is the card's primary button now and the /// details are the card's own tap, which leaves exactly one thing worth a /// glyph. class _IconAction extends StatelessWidget { final IconData icon; final String semanticLabel; final VoidCallback onTap; const _IconAction({ required this.icon, required this.semanticLabel, required this.onTap, }); @override Widget build(BuildContext context) { return Semantics( button: true, label: semanticLabel, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: SizedBox( width: 48.w, height: 48.w, child: Center( child: Container( width: 42.w, height: 42.w, alignment: Alignment.center, decoration: BoxDecoration( color: ColorConstants.primary.withValues(alpha: 0.08), shape: BoxShape.circle, ), child: Icon(icon, size: 20.sp, color: ColorConstants.primary), ), ), ), ), ); } } /// `Bag 3` — the one code the rider matches against a box in his hand. /// /// A neutral chip, not a coloured one: it is an identifier, not a state, and /// the card has exactly one colour to spend. It is a chip rather than plain /// text because it is read *against an object* — a bordered word is findable on /// a card at arm's length in a way a run of grey text is not. class _BagChip extends StatelessWidget { final String label; const _BagChip({required this.label}); /// Backend labels are codes — `DG-1042`, `BAG-1` — and derived ones already /// read `Bag 3`. The word belongs on both, and the test for "already has it" /// is the *word*, not the prefix: `BAG-1` starts with those three letters and /// is still a code, so it becomes `Bag BAG-1` exactly as it always read. String get _text => label.toLowerCase().startsWith('bag ') ? label : 'Bag $label'; @override Widget build(BuildContext context) { return Container( padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h), decoration: BoxDecoration( color: ColorConstants.neutralLight, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), child: Text( _text, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13.5.sp, fontWeight: FontWeight.w700, letterSpacing: -0.1, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ); } } /// Tick box for selecting a stop. /// /// Square on the select-all strip, circular on a card — a card's tick sits /// beside a round avatar and a round dot, and a square there was the only /// corner in that column. class SelectBox extends StatelessWidget { final bool selected; final bool partial; final Color accent; final String label; final bool circular; /// Null while the stop's acceptance is in flight. final VoidCallback? onTap; const SelectBox({ super.key, required this.selected, required this.accent, required this.label, required this.onTap, this.partial = false, this.circular = false, }); @override Widget build(BuildContext context) { final on = selected || partial; return Semantics( checked: selected, label: label, child: GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, // 48dp hit area around a 28px box: the visible control stays compact // but the target clears the accessibility floor comfortably. This is // the control the rider hits most on this screen, one-handed, in // motion — it is worth the four extra points over the 44 floor. child: Container( width: 48.w, height: 48.w, alignment: Alignment.center, child: AnimatedContainer( duration: const Duration(milliseconds: 160), width: 28.w, height: 28.w, alignment: Alignment.center, decoration: BoxDecoration( color: on ? accent : ColorConstants.pureSurface, shape: circular ? BoxShape.circle : BoxShape.rectangle, borderRadius: circular ? null : BorderRadius.circular(DesignConstants.radiusLg), border: Border.all( color: on ? accent : ColorConstants.borderStrong, width: 2, ), ), child: on ? Icon( partial ? LucideIcons.minus : LucideIcons.check, size: 18.sp, color: Colors.white, ) : null, ), ), ), ); } } /// `● Active` — the stop the rider is on, with a dot that breathes. /// /// The word was `LIVE`, which is broadcast vocabulary: it says a stream is /// on air, not that a rider is standing at a counter. **Active** is the word /// the rest of the app already uses for this rung — the trip tab's status /// line, the queue's own heading, `StopStatus.active` itself — so the one /// state the rider must not miss now has one name everywhere he meets it. /// /// Movement is what makes "this one is running right now" read without being /// read; a static dot beside the word is a claim the interface does not back /// up. /// /// The controller is created on first *use* rather than on the declaration, so /// a `dispose()` on a card that never painted cannot construct a ticker during /// teardown — the same trap the banner hit, documented in `homepage_banner.dart`. class LiveMark extends StatefulWidget { const LiveMark({super.key}); @override State createState() => _LiveMarkState(); } class _LiveMarkState extends State with SingleTickerProviderStateMixin { AnimationController? _controller; AnimationController get _pulse => _controller ??= AnimationController( vsync: this, duration: const Duration(milliseconds: 1100), )..repeat(reverse: true); @override void dispose() { _controller?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final accent = ColorConstants.primary; return Container( padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h), decoration: BoxDecoration( color: accent.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _pulse, builder: (context, _) { // A halo that grows and fades around a dot that stays put: // scaling the dot itself would shift the word beside it every // frame. return SizedBox( width: 8.w, height: 8.w, child: Stack( alignment: Alignment.center, children: [ Transform.scale( scale: 1.0 + 1.1 * _pulse.value, child: DecoratedBox( decoration: BoxDecoration( color: accent.withValues( alpha: 0.55 * (1 - _pulse.value), ), shape: BoxShape.circle, ), child: SizedBox(width: 8.w, height: 8.w), ), ), Container( width: 7.w, height: 7.w, decoration: BoxDecoration( color: accent, shape: BoxShape.circle, ), ), ], ), ); }, ), SizedBox(width: 6.w), Flexible( child: Text( 'Active', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w800, letterSpacing: 0.2, color: accent, fontFamily: FontConstants.fontFamily, ), ), ), ], ), ); } } /// Where this order stands, named in one word. /// /// [filled] gives it the tinted pill it wears at the top of a card, where it is /// the first thing read; inline uses — anywhere it is one fact among others — /// keep the bare icon-and-word, because a pill in a paragraph is a box. class StopStateChip extends StatelessWidget { final StopState state; final bool filled; const StopStateChip({super.key, required this.state, this.filled = false}); ({Color color, IconData icon}) get _look => switch (state) { StopState.accepted => ( color: ColorConstants.acceptGreen, icon: LucideIcons.circleCheck, ), // In his hands. The service accent rather than green: green is the app's // "done" colour everywhere else, and a collected order is the opposite of // done — it is the point at which he owes somebody a delivery. StopState.collected => ( color: ColorConstants.serviceAccent, icon: LucideIcons.shoppingBag, ), StopState.active => (color: ColorConstants.primary, icon: LucideIcons.bike), StopState.done => ( color: ColorConstants.acceptGreen, icon: LucideIcons.badgeCheck, ), StopState.skipped => ( color: ColorConstants.warning, icon: LucideIcons.rotateCcw, ), StopState.rejected => ( color: ColorConstants.secondaryText, icon: LucideIcons.ban, ), StopState.pending => ( color: ColorConstants.secondaryText, icon: LucideIcons.clock, ), }; @override Widget build(BuildContext context) { final look = _look; final row = Row( mainAxisSize: MainAxisSize.min, children: [ Icon(look.icon, size: filled ? 15.sp : 13.sp, color: look.color), SizedBox(width: 6.w), Flexible( child: Text( state.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: filled ? 12.5.sp : 11.sp, fontWeight: FontWeight.w800, letterSpacing: 0.3, color: look.color, fontFamily: FontConstants.fontFamily, ), ), ), ], ); if (!filled) return row; return Container( padding: EdgeInsets.fromLTRB(10.w, 5.h, 12.w, 5.h), decoration: BoxDecoration( color: look.color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(DesignConstants.radiusFull), ), child: row, ); } } /// ── The work at this stop, on one line ── /// /// Plain text rather than tinted chips. The rule for a figure that has to /// survive a glance is weight and contrast, not a container; three containers /// on one row was the opposite of that, and on a combined stop they wrapped /// onto a second line and made the card taller than its neighbours. /// /// Only the cash is coloured. Parcel counts are labels; the money is a state /// the rider is accountable for, and green means money throughout the app. class _MetaLine extends StatelessWidget { final int deliver; final int collect; final double cash; final bool muted; /// Suppresses the parcel counts, leaving only money. See the call site. final bool countsHidden; const _MetaLine({ required this.deliver, required this.collect, required this.cash, this.muted = false, this.countsHidden = false, }); /// True when there is nothing to say, so the caller can skip its own spacing /// rather than laying out a gap around a zero-height widget. bool get isEmpty => cash <= 0 && (countsHidden || (deliver <= 0 && collect <= 0)); @override Widget build(BuildContext context) { TextStyle style(Color c) => TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, letterSpacing: -0.2, color: c, fontFamily: FontConstants.fontFamily, ); final dot = TextSpan( text: ' · ', style: style(ColorConstants.borderStrong), ); final body = muted ? ColorConstants.secondaryText : ColorConstants.slateText; final spans = []; if (deliver > 0 && !countsHidden) { spans.add(TextSpan(text: 'Deliver $deliver', style: style(body))); } if (collect > 0 && !countsHidden) { if (spans.isNotEmpty) spans.add(dot); spans.add(TextSpan(text: 'Collect $collect', style: style(body))); } if (cash > 0) { if (spans.isNotEmpty) spans.add(dot); spans.add( TextSpan( text: '₹${_trim(cash)} cash', style: style(ColorConstants.moneyGreen), ), ); } if (spans.isEmpty) return const SizedBox.shrink(); return Text.rich( TextSpan(children: spans), maxLines: 2, overflow: TextOverflow.ellipsis, ); } } /// Rupee amounts without trailing noise: `1030`, `40`, `12.5`. String _trim(double v) { if (v >= 100) return v.round().toString(); final rounded = (v * 10).round() / 10; return rounded == rounded.roundToDouble() ? rounded.round().toString() : rounded.toStringAsFixed(1); }