import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:tab_container/tab_container.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'; import 'package:miler/views/helpers/constants/miler_surface.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE TRIP SELECTOR — 1 · 2 · 3, and what the chosen slot is /// /// 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 is a [TabContainer] now ── /// /// The control used to be a bare row of pills with a rule travelling under /// them, drawn by hand: a marker animation, a per-pill width measurement pass, /// and an overflow menu once a hub assigned more than three trips. All of that /// was mechanism — tab geometry, tab transitions, tab overflow — re-implemented /// for one screen. /// /// `tab_container` owns that mechanism now. It draws the tab strip as part of /// the panel's own shape, so the selected tab *is* the top edge of the body /// below it, and it carries the selection between tabs itself. Past three /// trips its tab strip scrolls, which is what the overflow menu was for. /// /// ``` /// ╭────────╮╭────────╮ ╭────────╮ /// │ Trip 1 ││ Trip 2 │ │ Trip 3 │ /// │ ━━━ ││ │ │ │ /// ├────────╯╰────────┴─┴────────┴──────────╮ /// │ 10:00 AM – 1:00 PM · 12 to collect │ /// ╰────────────────────────────────────────╯ /// ``` /// /// ── The rule stayed ── /// /// The package's own idiom is that the selected tab reads as a shaped /// extension of the panel. On its own that is a *filled* tab, and a filled tab /// is the thing this control has twice been redesigned away from — it makes a /// filter look like a primary action and competes with the real button at the /// foot of the card below. /// /// So selection is still a short brand rule under the chosen numeral, drawn /// inside the tab itself. The panel's shape says which tab owns the body; the /// rule says which trip the rider is on. `test/card_density_test.dart` holds /// both — one mark at a time, and a rule rather than a fill. /// /// ── The body is the trip's own brief, and nothing else ── /// /// A [TabContainer] has a body, and an empty one would be a panel drawn around /// nothing. What goes in it is the brief the tabs choose between — the figures /// for the selected trip — handed in bare, because this panel is already the /// surface they stand on. /// /// It carried a line of its own for a version: `1 delivery on this round`, /// under a heading that already read `1 stop left`. One panel, one count, /// stated twice — and on the device the two sat eight points apart. A panel /// under a set of tabs does not also need to narrate what the tabs selected. /// /// The panel deliberately does **not** repeat `33 / 50` either. That ratio is /// the headline of the card below in type four times this size; it was on these /// tabs once and was taken off for exactly that reason. 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-slot count in the brief. final Set acceptedIds; final Set rejectedIds; /// The selected trip's whole page — brief, route and action bar. /// /// ── The panel encloses the work, it does not sit above it ── /// /// The selector used to be a control in a sliver of its own, with the trip's /// content in the next sliver down: two unrelated boxes, one of which /// happened to govern the other. A [TabContainer] is a *container*, and the /// thing it is built to contain is the content its tabs choose — so the /// trip's page is handed in here and the selected tab is literally the top /// edge of it. /// /// Null leaves the control standing on its own at [height], which is what the /// widget tests measure and what any caller that only wants the tabs gets. final Widget? body; /// The number of tab slots ALWAYS rendered. /// /// A rider's day is three trips, so 2 and 3 are shown before the hub has /// assigned them. A tab that appears and disappears through the morning makes /// the screen feel unstable; an empty third slot answers the question he /// actually has. Past three, the hub's own count wins and the strip scrolls. static const int maxInlineTabs = 3; /// The selection rule. static const Key markerKey = ValueKey('trip_tab_marker'); /// The tab strip — [TabContainer.tabExtent]. /// /// 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 => 42.sp; /// The rule's thickness — the spec's 2–3dp. static const double indicator = 3; /// The panel's own inner padding, measured to land its copy on the same /// vertical as the cards below it. /// /// A [MilerPanel] insets its content by its gutter, its padding and the one /// point of border it keeps — which is why `TODAY'S RUN` begins at 29 and why /// `home_gutter_test` measures that number. This panel is a [TabContainer] /// rather than a [MilerPanel], and its caller supplies the gutter, so what is /// left for it to add is the other two parts. Built from the same tokens, not /// from a number that happens to match today. static double get contentInset => MilerSurface.panelPad + 1; /// The control's own height: strip plus brief, with no [body] in it. /// /// Fixed, because the tabs and the line under them must not move under the /// rider's thumb as trips arrive through the morning. With a [body] the panel /// is as tall as the page it encloses and this is just its head. /// /// ── And capped, because it is charged continuously ── /// /// This is header furniture: the rider sees it at every scroll position, so /// every point it takes is a point the route below never gets. The budget is /// 80 and `card_density_test.dart` holds it. Adding the body cost the strip /// two points rather than the budget four — the panel earns its height by /// saying what the slot is, not by being taller than the row it replaced. /// /// ── 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. static double get height => pillHeight; const TripSelector({ super.key, required this.trips, required this.selectedIndex, required this.onSelect, this.acceptedIds = const {}, this.rejectedIds = const {}, this.body, }); @override State createState() => _TripSelectorState(); } class _TripSelectorState extends State with TickerProviderStateMixin { TabController? _tabs; /// How many tab slots the strip draws. /// /// Three is the norm, not a guarantee — a hub can assign more, and when it /// does every one of them gets a tab rather than being folded away. int get _slotCount => widget.trips.length > TripSelector.maxInlineTabs ? widget.trips.length : TripSelector.maxInlineTabs; /// The selection, clamped into the strip. /// /// The page owns the selection and is allowed to hold an index this control /// cannot draw — a slot that existed before a refresh shortened the day. A /// [TabController] cannot, so it is clamped here rather than asserted. int get _safeIndex => widget.selectedIndex.clamp(0, _slotCount - 1); @override void initState() { super.initState(); _buildController(); } void _buildController() { _tabs?.removeListener(_onTabChanged); _tabs?.dispose(); _tabs = TabController( length: _slotCount, initialIndex: _safeIndex, vsync: this, )..addListener(_onTabChanged); } /// The controller moved — either the rider tapped, or [didUpdateWidget] /// drove it to follow the page. /// /// Reported on `indexIsChanging == false` as well as on settle, so a tap is /// answered as the animation starts rather than a frame after it lands: the /// page swaps the route below, and waiting for the tab to arrive first shows /// the outgoing trip sliding under the incoming label. void _onTabChanged() { final i = _tabs?.index; if (i == null || i == widget.selectedIndex) return; widget.onSelect(i); } @override void didUpdateWidget(TripSelector old) { super.didUpdateWidget(old); // A day that grew or shrank needs a controller of the new length; there is // no way to re-length one in place. if (_tabs == null || _tabs!.length != _slotCount) { setState(_buildController); return; } // Selection is owned by the page, not by this control, so the travel // starts from here rather than from a tap handler. Guarded, or the // listener above and this line drive each other in a loop. if (_tabs!.index != _safeIndex) _tabs!.animateTo(_safeIndex); } @override void dispose() { _tabs?.removeListener(_onTabChanged); _tabs?.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 tab 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 controller = _tabs!; final selected = _safeIndex; // With nothing to enclose, the control is the tab strip and nothing else — // a fixed head, which is what `trip_tabs_pinned_test` measures. final body = widget.body; return SizedBox( height: body == null ? TripSelector.height : null, child: TabContainer( controller: controller, tabEdge: TabEdge.top, tabExtent: TripSelector.pillHeight, // Wide enough to stay a target at any text scale. Past three trips the // strip scrolls at this width rather than shrinking every tab until // none of them can be hit. tabMinLength: 88.w, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), tabBorderRadius: BorderRadius.circular(DesignConstants.radiusLg), // ── The app bar's own red ── // // The body and the selected tab are one shape in one fill, which is // what says which tab owns what is under it. That fill is the brand // bar's red — the same [ColorConstants.brandBarTop] the header above is // painted in, from one definition so the two cannot drift into being // *nearly* the same colour, which reads as a mistake rather than as a // family. // // A gradient is not available here: [TabContainer] paints the tab notch // and the body as one path in one colour. The bar's top stop is the // more saturated of its two and the one the eye reads as "the app's // red", so that is the one a solid takes. // // Everything inside the panel inverts to white with it — the brief // included, which is why it is handed over with // `buildSummary(onBrand: true)` rather than merely bare. // // The *unselected* tabs are not painted by this at all; they stand on // the page canvas beside the panel. That is what makes the control read // as tabs rather than as three words on a red band. color: ColorConstants.brandBarTop, // ── `childPadding` is not applied to a single `child` ── // // The package pads only the `children` branch: `_buildChild` reads // `widget.child ?? Padding(padding: childPadding, …)`, so handing it one // child skips the padding entirely and the brief sat hard against the // screen edge. It is passed anyway for the day this takes `children`, // and the inset that actually runs is [briefInset] below. childPadding: EdgeInsets.symmetric(horizontal: MilerSurface.panelPad), // The tabs are built here, fully styled, so the package must not // re-style them: its own selected/unselected interpolation would scale // the numeral and fight the rule underneath it. overrideTextProperties: true, duration: const Duration(milliseconds: 220), curve: Curves.easeInOutCubic, tabs: [ for (var i = 0; i < _slotCount; i++) _TripTab(index: i, selected: i == selected), ], // ── The dropdown is what the tabs are attached to ── // // The brief comes first and touches the tab strip, because the tabs // choose the trip and the brief is that trip's figures: the selected // tab is literally the top edge of the panel holding them. The slot // line closes the panel underneath, where it reads as a caption on the // figures rather than as something between the tabs and their content. child: body == null // Full width, zero height. `SizedBox.shrink()` is 0x0, and the // panel takes its width from its child — so an empty one collapsed // the whole control to nothing and the tabs stopped being tappable // while still being painted. ? const SizedBox(width: double.infinity, height: 0) : Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Padding( // Air under the tab strip. Without it the brief's heading // sits on the seam it shares with the selected tab, and the // two read as one squeezed block rather than as a tab and // the panel it opens. padding: EdgeInsets.fromLTRB( TripSelector.contentInset, 12, TripSelector.contentInset, 0, ), child: body, ), SizedBox(height: TripSelector.contentInset), ], ), ), ); } } /// One tab: the trip's name, and the rule when it is the one being worked. /// /// See [TripSelector] for why selection is a rule rather than the package's /// own filled tab. class _TripTab extends StatelessWidget { final int index; final bool selected; const _TripTab({required this.index, required this.selected}); @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ // Scale-down rather than ellipsis: the numeral is the whole label and // `Trip 3` truncated to `Tri…` names nothing. This is also what keeps // the strip one height at 2.0× text — see [TripSelector.height]. Flexible( child: FittedBox( fit: BoxFit.scaleDown, child: Text( Trip.tabLabel(index), maxLines: 1, style: TextStyle( fontFamily: FontConstants.fontFamily, fontSize: 14.sp, // ── Two grounds, so two inks ── // // The selected tab is part of the red panel; the others stand // on the page canvas beside it. There is no one colour for // both — brand red on the brand bar is invisible, slate on it // fails contrast — so the chosen numeral goes white and the // rest stay slate, quiet enough that the row reads as one // control with one choice in it rather than as three buttons. fontWeight: selected ? FontWeight.w800 : FontWeight.w600, color: selected ? Colors.white : ColorConstants.onSurfaceVariant, ), ), ), ), SizedBox(height: 4.sp), // The rule. Only the selected tab draws it, so there is exactly one in // the tree at a time — which is what `card_density_test` asserts. SizedBox( height: TripSelector.indicator, width: 26.w, child: selected ? Container( key: TripSelector.markerKey, decoration: BoxDecoration( // White, because the rule sits on the brand. color: Colors.white, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), ), ) : null, ), ], ); } }