import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE TRIP SELECTOR — 1 · 2 · 3, under the header on white /// /// A rider is assigned at most three trips a day, so three targets fit across a /// phone and every trip is one tap away. /// /// ── It has no container ── /// /// It has had three, and each was a box drawn around a control that already /// had a boundary of its own. First a pinned grey strip below the header, with /// its own hairline. Then chips floating on the brand. Then a translucent /// rounded track holding three rounded segments — a rounded rectangle inside a /// rounded rectangle inside the header's own rounded rectangle, three nested /// shapes to say which of three words is selected. /// /// It is a row of pills now, on the white page under the header: /// /// ``` /// ╭─────────╮ ╭─────────╮ ╭─────────╮ /// │ Trip 1 │ │ Trip 2 │ │ Trip 3 │ /// ╰─────────╯ ╰─────────╯ ╰─────────╯ /// ━━━━━ /// ``` /// /// The selected trip is a white pill with a brand-red edge and brand-red type, /// under a short red rule that travels; the others are a quiet grey fill with /// grey type. Nothing here is a card, nothing is nested, and the row sizes /// itself to its labels rather than dividing the width into three — which is /// what makes it read as a filter rather than as a segmented control. /// /// ── Why it came off the brand ground ── /// /// It sat inside the red header for a while, which put the one control on the /// screen that changes what the *page below* shows inside the one surface that /// never changes. On white it belongs to the content it governs, the header /// goes back to being identity and duty, and the red is an accent on a page /// rather than a lighter shade of the thing it sits on. /// /// ── And it says only which trip it is ── /// /// The tabs carried `33 / 50` under each label for a while. That figure is the /// run's, not the tab's: it is the headline of the card immediately below, in /// type four times the size, and it was the third place on one screen the same /// ratio appeared. A filter says what it filters to. /// /// ───────────────────────────────────────────────────────────────────────── class TripSelector extends StatefulWidget { final List trips; final int selectedIndex; final ValueChanged onSelect; /// Optimistic accept/reject sets, for the per-trip count. final Set acceptedIds; final Set rejectedIds; /// Trips shown inline before the rest collapse into the overflow menu. /// /// Also the number of segments ALWAYS rendered: a rider's day is three trips, /// so 2 and 3 are shown before the hub has assigned them. A segment that /// appears and disappears through the morning makes the screen feel unstable; /// an empty third slot answers the question he actually has. static const int maxInlineTabs = 3; /// The travelling selection indicator. static const Key markerKey = ValueKey('trip_tab_marker'); /// The control's height — the pill, the air under it, and the indicator. /// /// Fixed, because it sits in a header whose extent must not move as trips /// arrive through the morning, and tight because every point of it is a point /// of the rider's first stop. /// /// ── Why there is no `.h` in this sum ── /// /// The parts used to be a mix: type in `.sp`, which ScreenUtil scales by /// screen **width**, and gaps in `.h`, which it scales by **height**. On a /// phone shaped like the design size those agree and nothing shows. On /// anything shorter and wider — a foldable open, a tablet in landscape, a /// test surface — the box shrinks on one axis while the type inside it does /// not, and the label gets squeezed while still technically fitting. /// /// So the control is measured on one axis only: [pillHeight] · 7 · /// [indicator]. static double get height => pillHeight + 7 + indicator; /// The pill itself. Comfortably over the 44pt tap floor without being a /// button — this is a filter, and a filter that looks like a primary action /// competes with the one at the foot of the card below it. static double get pillHeight => 40.sp; /// The travelling rule's thickness — the spec's 2–3dp. static const double indicator = 3; const TripSelector({ super.key, required this.trips, required this.selectedIndex, required this.onSelect, this.acceptedIds = const {}, this.rejectedIds = const {}, }); @override State createState() => _TripSelectorState(); } class _TripSelectorState extends State // Two tickers: the marker's journey and the running trip's pulse. They are // independent — the pulse repeats forever, the marker fires on a tap — so // they cannot share one controller. with TickerProviderStateMixin { /// Carries the marker from the segment it was on to the one just chosen. /// /// Same mechanism as the bottom nav bar, and for the same reason. The marker /// used to belong to each segment — an `AnimatedContainer` per tab whose fill /// went from transparent to white — so moving from 3 to 1 faded one out on /// the right and a *different* one in on the left. Nothing ever occupied the /// space between, so there was no object to travel across 2 with, and the /// marker appeared to teleport. /// /// There is one marker now, owned by the control, and this is its position in /// *slot* units — 1.5 is genuinely halfway between the second and third /// segment. It also drives the ink on every segment, so one warms up as the /// marker passes over it instead of switching the instant it is tapped while /// the marker is still two segments away. /// /// Initialised on the declaration rather than in `initState`: a `late final` /// assigned in `initState` is never filled in on a `State` that already /// exists, and hot reload reuses the existing `State`. See the identical note /// in `Bottom_page.dart`. late final AnimationController _markerAnim; late final Animation _markerCurve; late Tween _markerTween; @override void initState() { super.initState(); _markerAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), value: 1.0, ); _markerCurve = CurvedAnimation( parent: _markerAnim, curve: Curves.easeInOutCubic, ); _markerTween = Tween( begin: _slotOf(_inlineTabs()), end: _slotOf(_inlineTabs()), ); } double get _markerPos => _markerTween.evaluate(_markerCurve); /// Trip indices rendered as inline segments, left to right. /// /// Three is the norm, not a guarantee — a hub can assign more. Past three the /// control keeps the first two segments and folds the rest into a menu rather /// than shrinking every target until nothing can be hit. When the selection /// lives in the overflow it is pulled into the last inline slot, so the rider /// can always see which trip he is looking at. List _inlineTabs() { final tabCount = widget.trips.length > TripSelector.maxInlineTabs ? widget.trips.length : TripSelector.maxInlineTabs; final overflows = tabCount > TripSelector.maxInlineTabs; final inlineCount = overflows ? TripSelector.maxInlineTabs - 1 : tabCount; return [ for (var i = 0; i < inlineCount; i++) i, if (overflows && widget.selectedIndex >= inlineCount) widget.selectedIndex, ]; } List _hiddenTabs(List inline) { final tabCount = widget.trips.length > TripSelector.maxInlineTabs ? widget.trips.length : TripSelector.maxInlineTabs; final overflows = tabCount > TripSelector.maxInlineTabs; final inlineCount = overflows ? TripSelector.maxInlineTabs - 1 : tabCount; return [ for (var i = inlineCount; i < tabCount; i++) if (!inline.contains(i)) i, ]; } /// Which *slot* the selection occupies. Not the same as the trip index once /// an overflow menu is in play. double _slotOf(List inline) { final i = inline.indexOf(widget.selectedIndex); return (i < 0 ? 0 : i).toDouble(); } @override void didUpdateWidget(TripSelector old) { super.didUpdateWidget(old); // Selection is owned by the page, not by this control, so the slide starts // from here rather than from a tap handler. final target = _slotOf(_inlineTabs()); if ((target - _markerTween.end!).abs() < 0.001) return; // Begin from where the marker actually *is*. Starting from the previous // selection instead would snap it back and re-run the whole journey when // the rider changes his mind mid-slide. _markerTween = Tween(begin: _markerPos, end: target); _markerAnim.forward(from: 0); } @override void dispose() { _markerAnim.dispose(); super.dispose(); } /// ── Slot, not position ── /// /// Every lookup goes through [TripSlots.tripAt]. Slot 1 is the morning /// whether or not the rider has morning work, so `trips[i]` — which means /// "the i-th trip he happens to have" — would put an afternoon run under the /// first segment on a day with no morning stops, and the rider's trip 2 would /// not be the hub's. @override Widget build(BuildContext context) { final inline = _inlineTabs(); final hidden = _hiddenTabs(inline); return SizedBox( height: TripSelector.height, child: AnimatedBuilder( animation: _markerAnim, builder: (context, _) { final p = _markerPos; return Stack( children: [ Row( children: [ for (var i = 0; i < inline.length; i++) ...[ if (i > 0) SizedBox(width: 10.w), // ── Flexible, because the labels grow ── // // The pills size themselves to their text and the text // scales with the system font. Three of them at 2.0× are // wider than a 320pt phone, and a `Row` of fixed-width // children overflows rather than compressing. Loose flex // lets each take its natural width until there is no room, // and the label scales down inside it after that. Flexible( child: _TripTab( index: inline[i], // How lit this pill is: 1 with the rule under it, 0 // once the rule is a whole slot away. Tied to the // rule's position rather than to the selection, so a // pill warms as the rule crosses instead of flipping // the instant it is tapped while the rule is two slots // away. lit: (1.0 - (p - i).abs()).clamp(0.0, 1.0), onTap: () => widget.onSelect(inline[i]), onMeasured: (w) => _measure(i, w), ), ), ], if (hidden.isNotEmpty) ...[ SizedBox(width: 10.w), _OverflowChip( width: 44.w, hidden: hidden, trips: widget.trips, onSelect: widget.onSelect, ), ], ], ), // ── The rule, travelling ── // // One object that moves, rather than a mark each pill draws for // itself: moving from 3 to 1 used to fade one out on the right // and a different one in on the left, with nothing ever occupying // the space between, so it appeared to teleport across tab 2. // // The pills size themselves to their labels, so where the rule // has to be is not arithmetic — it is measured, from the pills // themselves. See [_measure]. if (_widths.length >= inline.length) Positioned( left: _leftOf(p), bottom: 0, width: _widthAt(p), child: Center( child: FractionallySizedBox( widthFactor: 0.52, child: Container( key: TripSelector.markerKey, height: TripSelector.indicator, decoration: BoxDecoration( color: ColorConstants.primary, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), // The one piece of decoration in this control, spent // on the mark that says where you are: its own colour // at a third, blurred wide, so the rule reads as lit // rather than as a bar with a shadow. boxShadow: [ BoxShadow( color: ColorConstants.primary.withValues( alpha: 0.34, ), blurRadius: 9, offset: const Offset(0, 3), ), ], ), ), ), ), ), ], ); }, ), ); } /// Every pill's measured width, by slot. /// /// A pill is as wide as its label, so the travelling rule cannot be placed by /// dividing the row into equal parts. Each pill reports its width on layout /// and the rule is positioned from the running total — which is also what /// lets it slide smoothly between two pills of different widths. final Map _widths = {}; void _measure(int slot, double width) { if (_widths[slot] == width) return; _widths[slot] = width; // After layout, so a rebuild here would be a build-during-build. WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) setState(() {}); }); } /// The gap between pills, matching the `SizedBox` in the row. double get _gap => 10.w; double _leftOf(double p) { final i = p.floor(); final f = p - i; double x = 0; for (var k = 0; k < i; k++) { x += (_widths[k] ?? 0) + _gap; } // Between two pills, walk the gap in proportion. return x + f * ((_widths[i] ?? 0) + _gap); } double _widthAt(double p) { final i = p.floor(); final f = p - i; final a = _widths[i] ?? 0; final b = _widths[i + 1] ?? a; return a + (b - a) * f; } } /// One pill: the trip's name, and whether it is the one being worked. /// /// See [TripSelector] for why there is no count on it and no card around it. class _TripTab extends StatelessWidget { final int index; /// 0 → 1, how far the travelling rule is over this pill. final double lit; final VoidCallback onTap; /// Reports this pill's laid-out width, so the rule can be placed against /// pills that are as wide as their labels rather than evenly divided. final ValueChanged onMeasured; const _TripTab({ required this.index, required this.lit, required this.onTap, required this.onMeasured, }); @override Widget build(BuildContext context) { // ── Selection is colour and edge, not fill ── // // The selected pill goes *lighter* — white, with a brand edge and brand // type — and the unselected ones carry the grey. That is the inverse of the // usual segmented control, and it is right here because the page under it // is white: a filled red pill would be the heaviest object on the screen, // competing with the action at the foot of the card below. final Color ink = Color.lerp( ColorConstants.secondaryText, ColorConstants.primary, lit, )!; final Color fill = Color.lerp( ColorConstants.tint(ColorConstants.slateText, 0.06), ColorConstants.pureSurface, lit, )!; final Color edge = Color.lerp( Colors.transparent, ColorConstants.primary.withValues(alpha: 0.55), lit, )!; return Semantics( button: true, selected: lit >= 0.999, label: 'Trip ${index + 1}', excludeSemantics: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: _Measured( onMeasured: onMeasured, child: AnimatedContainer( duration: DesignConstants.motionState, curve: Curves.easeOut, height: TripSelector.pillHeight, alignment: Alignment.center, padding: EdgeInsets.symmetric(horizontal: 16.w), decoration: BoxDecoration( color: fill, borderRadius: BorderRadius.circular(DesignConstants.radiusFull), border: Border.all(color: edge, width: 1.4), ), child: FittedBox( fit: BoxFit.scaleDown, child: Text( // Sentence case. `TRIP 1` set in caps is a label shouting a // proper noun; the tab is the trip's name, and a name is written // the way it is said. 'Trip ${index + 1}', maxLines: 1, style: TextStyle( fontSize: 14.5.sp, height: 1.0, // Weight carries the selection alongside the colour, so the // state survives a greyscale screenshot and a rider with a // colour-shifted screen. fontWeight: lit > 0.5 ? FontWeight.w700 : FontWeight.w600, letterSpacing: -0.1, color: ink, fontFamily: FontConstants.fontFamily, ), ), ), ), ), ), ); } } /// Reports its child's laid-out width once, after layout. /// /// A `LayoutBuilder` cannot do this: it hands over the *incoming constraints*, /// and these pills are unconstrained on the main axis — they are as wide as /// their own text. So the width is read off the render object instead, in the /// frame after it exists. class _Measured extends SingleChildRenderObjectWidget { final ValueChanged onMeasured; const _Measured({required this.onMeasured, required super.child}); @override _MeasuredBox createRenderObject(BuildContext context) => _MeasuredBox(onMeasured); @override void updateRenderObject(BuildContext context, _MeasuredBox box) { box.onMeasured = onMeasured; } } class _MeasuredBox extends RenderProxyBox { ValueChanged onMeasured; _MeasuredBox(this.onMeasured); @override void performLayout() { super.performLayout(); onMeasured(size.width); } } /// The way into trips four and beyond, on the rare day a hub assigns them. class _OverflowChip extends StatelessWidget { final double width; final List hidden; final List trips; final ValueChanged onSelect; const _OverflowChip({ required this.width, required this.hidden, required this.trips, required this.onSelect, }); @override Widget build(BuildContext context) { return SizedBox( width: width, child: Semantics( button: true, label: '${hidden.length} more trips', excludeSemantics: true, child: PopupMenuButton( tooltip: '', padding: EdgeInsets.zero, color: ColorConstants.pureSurface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusLg), ), onSelected: onSelect, itemBuilder: (context) => [ for (final i in hidden) PopupMenuItem( value: i, child: Text( // The one place a word is worth spending: a menu row has no // shape of its own to carry the meaning. 'Trip ${i + 1}', style: TextStyle( fontSize: 15.sp, fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), ), ], child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(20.r), ), child: Center( child: Text( '+${hidden.length}', style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w800, letterSpacing: -0.5, color: Colors.white, fontFamily: FontConstants.fontFamily, ), ), ), ), ), ), ); } }