import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/helpers/constants/miler_surface.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'; /// ───────────────────────────────────────────────────────────────────────── /// TRIP TABS — Trip 1 · Trip 2 · Trip 3 /// /// A rider is assigned at most three trips a day, so three tabs fit across a /// phone and every trip is one tap away. Tabs beat a dropdown here for a /// reason: with a dropdown the rider cannot see how many trips he has or how /// far along each one is without opening it, and "how much is left today" is /// the question he asks most. /// /// Each tab carries its own completion percentage, so the whole day reads at a /// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%` /// /// **Overflow.** Three is the norm, not a guarantee — a hub can assign more. /// Past three the bar keeps the first two tabs and folds the rest into a /// "More" menu, rather than shrinking every tab until the labels are /// unreadable. The dropdown is the exception path, exactly as asked. /// ───────────────────────────────────────────────────────────────────────── class TripTabs extends StatefulWidget { final List trips; final int selectedIndex; final ValueChanged onSelect; /// Optimistic accept/reject sets, for the per-tab completion figure. final Set acceptedIds; final Set rejectedIds; /// Tabs shown inline before the rest collapse into the overflow menu. /// /// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so /// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab /// that appears and disappears through the morning makes the screen feel /// unstable; an empty Trip 3 that says "nothing assigned yet" answers the /// question he actually has. static const int maxInlineTabs = 3; /// The travelling selection marker at the foot of the pill. static const Key markerKey = ValueKey('trip_tab_marker'); const TripTabs({ super.key, required this.trips, required this.selectedIndex, required this.onSelect, this.acceptedIds = const {}, this.rejectedIds = const {}, }); @override State createState() => _TripTabsState(); } class _TripTabsState extends State with SingleTickerProviderStateMixin { /// Carries the pill from the tab it was on to the tab just chosen. /// /// Same mechanism as the bottom nav bar, and for the same reason. The pill /// used to belong to each tab — an `AnimatedContainer` per segment whose fill /// went from transparent to white — so moving from Trip 3 to Trip 1 faded one /// pill 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 Trip 2 /// with, and the marker appeared to teleport. /// /// There is one pill 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 tint on every label, so a tab warms up as the /// pill passes over it instead of switching the instant it is tapped while the /// pill 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 _pillAnim = AnimationController( vsync: this, // Matches the nav bar. A pill crossing two segments needs long enough for // the eye to follow it; the 200ms the cross-fade used reads as a jump. duration: const Duration(milliseconds: 380), value: 1.0, ); late final Animation _pillCurve = CurvedAnimation( parent: _pillAnim, curve: Curves.easeInOutCubic, ); late Tween _pillTween = Tween( begin: _slotOf(_inlineTabs()), end: _slotOf(_inlineTabs()), ); double get _pillPos => _pillTween.evaluate(_pillCurve); /// Trip indices rendered as inline segments, left to right. /// /// Three is the norm, not a guarantee — a hub can assign more. Past three the /// bar keeps the first two segments and folds the rest into a "More" menu /// rather than shrinking every tab until the labels are unreadable. 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 > TripTabs.maxInlineTabs ? widget.trips.length : TripTabs.maxInlineTabs; final overflows = tabCount > TripTabs.maxInlineTabs; final inlineCount = overflows ? TripTabs.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 > TripTabs.maxInlineTabs ? widget.trips.length : TripTabs.maxInlineTabs; final overflows = tabCount > TripTabs.maxInlineTabs; final inlineCount = overflows ? TripTabs.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(TripTabs 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 - _pillTween.end!).abs() < 0.001) return; // Begin from where the pill 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. _pillTween = Tween(begin: _pillPos, end: target); _pillAnim.forward(from: 0); } @override void dispose() { _pillAnim.dispose(); super.dispose(); } /// The colour a segment settles on once the pill is over it. Color _litColourOf(int tripIndex) { if (widget.trips.tripAt(tripIndex) == null) { // Selected-but-empty keeps a readable voice — the rider deliberately // looked at this slot, so it answers — but never the brand: there is // nothing here to be mission about. return ColorConstants.secondaryText; } return _percentOf(tripIndex) >= 100 ? ColorConstants.acceptGreen : ColorConstants.primary; } /// The colour a segment sits at with the pill elsewhere. /// /// A finished trip stays green whether or not you are looking at it — "Trip 1, /// Done" is worth knowing from Trip 2 — so for those the two colours are the /// same and nothing changes as the pill goes by. Color _restColourOf(int tripIndex) { if (widget.trips.tripAt(tripIndex) == null) { // ── An empty slot is furniture, not a peer ── // // At `secondaryText` an unassigned tab was indistinguishable from a // real unselected trip, so a morning screen read as three equal trips // when two of them were placeholders. The disabled token is what the // app already means by "nothing here to act on", and it is exactly one // voice quieter — the slot is still legible, it has simply stopped // competing. return ColorConstants.disabledFill; } return _percentOf(tripIndex) >= 100 ? ColorConstants.acceptGreen : ColorConstants.secondaryText; } /// ── Slot, not position ── /// /// Every lookup here goes through [TripSlots.tripAt]. Tab 1 is the morning /// whether or not the rider has morning work, so `trips[i]` — which meant /// "the i-th trip he happens to have" — would put an afternoon run under the /// Trip 1 tab on a day with no morning stops, and the rider's Trip 2 would /// not be the hub's. int _percentOf(int tripIndex) => widget.trips .tripAt(tripIndex) ?.completionPercent( acceptedIds: widget.acceptedIds, rejectedIds: widget.rejectedIds, ) ?? 0; /// True when this trip has a stop in progress — the rider is on it now. /// /// Derived rather than stored: a trip is running when any of its stops is, and /// [stopStateOf] already resolves that from the server status plus the local /// accept/reject sets. bool _isRunning(Trip trip) => trip.stops.any( (s) => stopStateOf( s, acceptedIds: widget.acceptedIds, rejectedIds: widget.rejectedIds, ) == StopState.active, ); @override Widget build(BuildContext context) { final inline = _inlineTabs(); final hidden = _hiddenTabs(inline); // ── A segmented control on a track ── // // Three bordered chips became a tab bar became this: one light track holding // three segments, with the tab you are looking at marked by a pill that // slides between them. It is the iOS/Material segmented pattern, and it is // the right one here because the choice is *exclusive and complete* — a // rider is always looking at exactly one of his three trips, and a track // makes that set visible as a set. return Container( // 16 — the app's base gutter, and the narrowest inset on this screen. // // The cards above sit on 28 and the route below on 32, so the track is // deliberately the widest element in the column. Two reasons it reads // right rather than misaligned: // // • Every card here carries a drop shadow with an 18–20pt blur, which // bleeds past the card's box on each side. A card's *visible* edge is // softly wider than its layout edge, while the track is a flat fill // with a hard edge and no bleed — so matching the numbers exactly made // the track look inset further than everything around it. It was // measured at an identical 28 and still read as narrower. // // • A segmented control is a *container* for the three trips, not another // card in the stack. Running it wider than the content it governs is // the usual way that reads, and it gives each of the three segments // more room for its label and status line. margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h), padding: EdgeInsets.all(3.w), decoration: BoxDecoration( // Layer 1 on the canvas, same as every working surface on Home. // `neutralLight` separated from the old cream backing by 1.02:1 — // a track you had to be told about. color: MilerSurface.working, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), // LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder // refuses to answer intrinsic-dimension queries, so nesting it under // IntrinsicHeight throws rather than laying out. child: LayoutBuilder( builder: (context, box) { // The overflow button is a fixed 48 plus a 6 gap, so the segments // divide what is left. Without this the pill would sit off its tab // whenever a hub assigned an unusual day. final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w; final slot = (box.maxWidth - reserved) / inline.length; return AnimatedBuilder( animation: _pillAnim, builder: (context, _) { final pos = _pillPos; // The trailing underline travels *with* the pill as one object. // Left in the segments it would have had to blink out under the // old tab and in under the new one, which is the behaviour being // removed. Its colour lerps between the two segments it is // between, so crossing a finished trip shades red → green → red // rather than snapping at the midpoint. final lo = pos.floor().clamp(0, inline.length - 1); final hi = pos.ceil().clamp(0, inline.length - 1); final markColour = Color.lerp( _litColourOf(inline[lo]), _litColourOf(inline[hi]), pos - pos.floor(), )!; // No fixed height: the tab content is text, and a user with large // font settings overflowed a hard 54.h by a few pixels. // IntrinsicHeight sizes the row to its tallest tab instead, so // every tab matches and nothing can overflow. return IntrinsicHeight( child: Stack( children: [ PositionedDirectional( start: slot * pos, top: 0, bottom: 0, width: slot, child: Container( margin: EdgeInsets.symmetric(horizontal: 2.w), decoration: BoxDecoration( // The same light glass red the nav bar uses for its // selection. A white pill was doing the job with a // drop shadow — fine on its own, but it made this the // one selected-state in the app that was not the brand // tint, so the two controls a rider uses most looked // like they came from different apps. color: ColorConstants.primary.withValues(alpha: 0.12), borderRadius: BorderRadius.circular( DesignConstants.radiusLg, ), ), child: Align( alignment: Alignment.bottomCenter, child: Container( // Keyed so tests can follow it across the strip. // Identifying it by colour instead breaks the moment // it travels over an unassigned trip, where it is // deliberately grey rather than the brand red. key: TripTabs.markerKey, height: 3.h, margin: EdgeInsets.only( left: 12.w, right: 12.w, bottom: 4.h, ), decoration: BoxDecoration( color: markColour, borderRadius: BorderRadius.circular( DesignConstants.radiusFull, ), ), ), ), ), ), Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for ( var slotIndex = 0; slotIndex < inline.length; slotIndex++ ) ...[ Builder( builder: (context) { final i = inline[slotIndex]; // How lit this segment is: 1 with the pill on it, // 0 once the pill is a whole slot away. Cubed, so // the glow stays tight around the pill — a linear // ramp leaves every segment it crosses looking // half-selected, and the control then reads as // having no clear selection mid-slide. final d = (pos - slotIndex).abs(); final t = (1.0 - d).clamp(0.0, 1.0); final lit = t * t * t; return Expanded( child: _Tab( label: Trip.tabLabel(i), percent: _percentOf(i), assigned: widget.trips.tripAt(i) != null, selected: i == widget.selectedIndex, active: switch (widget.trips.tripAt(i)) { final t? => _isRunning(t), _ => false, }, lit: lit, restColour: _restColourOf(i), litColour: _litColourOf(i), onTap: () => widget.onSelect(i), ), ); }, ), if (hidden.isNotEmpty && slotIndex == inline.length - 1) SizedBox(width: 6.w), ], if (hidden.isNotEmpty) _OverflowMenu( trips: widget.trips, hidden: hidden, onSelect: widget.onSelect, acceptedIds: widget.acceptedIds, rejectedIds: widget.rejectedIds, ), ], ), ], ), ); }, ); }, ), ); } } class TripTabsHeader extends SliverPersistentHeaderDelegate { final List trips; final int selectedIndex; final ValueChanged onSelect; final Set acceptedIds; final Set rejectedIds; /// Reserved height. Pass [extentFor] unless you have measured otherwise. final double extent; const TripTabsHeader({ required this.trips, required this.selectedIndex, required this.onSelect, required this.extent, this.acceptedIds = const {}, this.rejectedIds = const {}, }); /// Height to reserve for the bar at this context's text scale. /// /// Sums what a tab is actually made of — outer margin, border, padding, one /// line of label, the gap, the progress underline — then adds a deliberate /// safety margin. Over-reserving is the safe direction; see the class note. static double extentFor(BuildContext context) { final scaler = MediaQuery.textScalerOf(context); final labelLine = scaler.scale(12.5.sp) * 1.4; final statusLine = scaler.scale(10.5.sp) * 1.4; return 6 .h // Container margin, 2 top + 4 bottom + 6 .w // track padding, 3 top + 3 bottom + 5 .h // lead-in above the label + labelLine + 1 .h // gap between label and status + statusLine + 4 .h // gap above the running underline + 3 .h // the underline + 4 .h // tail below it + 6; // safety, so a rounding error adds air rather than a clip } @override double get minExtent => extent; @override double get maxExtent => extent; @override Widget build( BuildContext context, double shrinkOffset, bool overlapsContent, ) { return _PinnedSurface( // True exactly when stops have scrolled underneath the bar. That is the // moment it stops being part of the page and starts floating over it, so // it is the moment it needs an edge — without one the tabs and the top of // a stop card blur into each other. floating: overlapsContent || shrinkOffset > 0, child: TripTabs( trips: trips, selectedIndex: selectedIndex, onSelect: onSelect, acceptedIds: acceptedIds, rejectedIds: rejectedIds, ), ); } @override bool shouldRebuild(TripTabsHeader old) => old.extent != extent || old.selectedIndex != selectedIndex || old.trips != trips || old.acceptedIds != acceptedIds || old.rejectedIds != rejectedIds; } /// Opaque backing for the pinned bar, with an edge that appears only once /// content is passing underneath. class _PinnedSurface extends StatelessWidget { final bool floating; final Widget child; const _PinnedSurface({required this.floating, required this.child}); @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 160), decoration: BoxDecoration( // Opaque, or the stop cards show through the bar as they pass under // it. And it is the CANVAS, not `ColorConstants.surface` — that token // is the warm near-white the surface audit flagged as "warm, unlike // everything else", and here it painted a cream band across the top // of a cool grey page. The bar is the page holding its position, so // it wears the page's own ground; the white segmented track stands on // it at the full canvas step. color: MilerSurface.canvas, border: Border( bottom: BorderSide( color: floating ? ColorConstants.borderStrong : Colors.transparent, width: 1, ), ), ), // Centred rather than stretched: the reserved extent over-estimates by // design, so the spare pixels become air above and below instead of // stretching the tabs into a shape they were not designed for. child: Center(child: child), ); } } class _Tab extends StatelessWidget { final String label; final int percent; /// False when the hub has not assigned this trip yet. final bool assigned; /// The tab the rider is looking at. Used for semantics only — everything /// painted follows [lit] instead, so the label tracks the pill rather than /// flipping the instant it is tapped. final bool selected; /// The trip actually under way. final bool active; /// How close the travelling pill is, 0 → 1. final double lit; /// Colour with the pill elsewhere, and colour with the pill on this tab. final Color restColour; final Color litColour; final VoidCallback onTap; const _Tab({ required this.label, required this.percent, required this.selected, required this.lit, required this.restColour, required this.litColour, required this.onTap, this.assigned = true, this.active = false, }); @override Widget build(BuildContext context) { final complete = assigned && percent >= 100; // ── One thing is marked, and it is the tab you are on ── // // Colour follows the pill. It briefly followed a separate "this trip is // running" signal, and the result on a real screen was that Trip 1 sat // permanently red and underlined while the rider stood on Trip 2 — two tabs // claiming to be the important one, and the tap he had just made appearing // to do nothing. A navigation control has to answer "where am I" before it // answers anything else. // // Running is not lost, it is demoted: the tab still says "Active" in its // status line, in whatever colour that tab's state calls for. That is the // right weight for it — useful to know, not something to fight the selection // for the eye. final Color accent = Color.lerp(restColour, litColour, lit)!; final String status = !assigned // "Not set" read as a chore waiting for the rider. The hub fills // these slots; all this tab can honestly say is that nothing has // arrived for it yet. ? 'None yet' : complete ? 'Done' : active ? 'Active' : percent > 0 ? '$percent% done' : '$percent%'; return Semantics( button: true, selected: selected, label: assigned ? '$label, $status${active ? ', in progress' : ''}' : '$label, not assigned yet', child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ SizedBox(height: 5.h), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5.sp, // A placeholder slot also gives up the headline weight — // colour alone was not enough separation on a sunlit LCD. fontWeight: assigned ? FontWeight.w700 : FontWeight.w600, letterSpacing: -0.3, color: accent, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 1.h), // Progress as words, not a bar. The underline is reserved for "this // is the one you are on", and one mark cannot mean two things — but // "how far through is each trip" is why tabs beat a dropdown here, // so the figure has to survive somewhere legible. Text( status, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10.5.sp, fontWeight: FontWeight.w600, color: accent, fontFamily: FontConstants.fontFamily, ), ), SizedBox(height: 4.h), // Where the underline used to be drawn. It now travels with the // pill, but the space it occupied is still reserved here so every // segment keeps the height `TripTabsHeader.extentFor` reserves for // it and nothing shifts. SizedBox(height: 3.h), SizedBox(height: 4.h), ], ), ), ); } } /// "More" button holding trips 4+ when a hub assigns an unusual day. class _OverflowMenu extends StatelessWidget { final List trips; final List hidden; final ValueChanged onSelect; final Set acceptedIds; final Set rejectedIds; const _OverflowMenu({ required this.trips, required this.hidden, required this.onSelect, required this.acceptedIds, required this.rejectedIds, }); @override Widget build(BuildContext context) { return Semantics( button: true, label: '${hidden.length} more trips', child: Material( color: Colors.white, borderRadius: BorderRadius.circular(DesignConstants.radiusLg), child: PopupMenuButton( onSelected: onSelect, tooltip: 'More trips', position: PopupMenuPosition.under, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(DesignConstants.radiusXl), ), itemBuilder: (_) => [ for (final i in hidden) PopupMenuItem( value: i, child: Row( children: [ Text( Trip.tabLabel(i), style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w700, color: ColorConstants.slateText, fontFamily: FontConstants.fontFamily, ), ), SizedBox(width: 8.w), Text( trips.tripAt(i)?.slotLabel ?? 'Not assigned', style: TextStyle( fontSize: 11.5.sp, fontWeight: FontWeight.w500, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ], child: Container( width: 48.w, alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.chevronDown, size: 22.sp, color: ColorConstants.slateText, ), Text( '+${hidden.length}', style: TextStyle( fontSize: 11.sp, fontWeight: FontWeight.w700, color: ColorConstants.secondaryText, fontFamily: FontConstants.fontFamily, ), ), ], ), ), ), ), ); } }