import 'dart:ui' show ImageFilter, lerpDouble; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:miler/views/Dashboard/pickups/pickups.dart'; import 'package:miler/views/Dashboard/home/homepage.dart'; import 'package:miler/views/Dashboard/activity/activity_page.dart'; import 'package:miler/views/Dashboard/profile/Profilepage.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/design_constants.dart'; class BottomPage extends StatefulWidget { final int initialIndex; final Widget? overridePage; const BottomPage({super.key, this.initialIndex = 0, this.overridePage}); /// Cross-tab navigation request. static final ValueNotifier tabRequest = ValueNotifier(null); /// Switch the shell to [index]. 0 Home · 1 Bookings · 2 Activity · 3 Account. static void goToTab(int index) => tabRequest.value = index; /// The tab on screen right now. /// /// Published because the shell keeps every tab **alive**: the pages live in /// an `IndexedStack`, which builds all four at launch and only changes which /// one is painted. A page's `initState` therefore runs once, before the rider /// has done anything — so a tab that loads its data there shows whatever was /// true at app start for the rest of the session. Activity hit exactly that: /// the rider finished a stop, opened the tab, and read a result fetched /// before he had finished anything. /// /// A page that needs to be current listens to this and re-reads when its own /// index arrives. Cheaper than rebuilding the tab from scratch, and it keeps /// scroll position and state, which is why the IndexedStack is there. static final ValueNotifier currentTab = ValueNotifier(0); /// Height of the floating bar at rest, and the gap to the screen edge. static const double navBarHeight = 66; static const double navBarGap = 10; /// Height of the bar once the rider has scrolled into a list. /// /// ── Why it shrinks instead of leaving ── /// /// It used to slide off the bottom of the screen entirely and come back when /// he reversed. That buys 66pt of list, and costs him the ability to change /// tab at all until he scrolls the other way — on a long Activity list, the /// only route out is a gesture he has to know about. A control that vanishes /// is not a smaller control; it is a missing one. /// /// Compacting keeps every target on screen and still hands back most of the /// room: labels go, the icons draw in, and the pill narrows. This is what /// Instagram's iOS tab bar does, and it is why that bar never feels in the /// way despite never going away. static const double navBarHeightCompact = 46; /// Room the bar claims at the foot of a tab page, system inset included. /// /// Always the *resting* height, even while the bar is compact. The bar is a /// `Positioned` pill inside the shell's `Stack`, not a `bottomNavigationBar`, /// so the Scaffold reserves no room for it and `SafeArea` knows nothing about /// it — anything a page pins to the bottom ends up underneath it otherwise. /// /// Deliberately not the live height. Pages used to follow the bar up and down /// through a `navVisible` notifier, which meant a pinned banner re-laid itself /// out every time the rider changed scroll direction. Reserving the larger of /// the two costs 20pt of gap while compact and buys a layout that never moves. static double bottomInset(BuildContext context) => MediaQuery.of(context).padding.bottom + navBarGap.h + navBarHeight.h; @override State createState() => _BottomPageState(); } class _BottomPageState extends State with TickerProviderStateMixin { late int selected; late final List _pages; late final AnimationController _tabAnim; late final Animation _tabCurve; /// Drives the selection pill from the tab it was on to the tab just tapped. /// /// The pill used to be part of each tab — an `AnimatedContainer` whose fill /// went from transparent to red — so going Account → Home faded one pill out /// on the right while a second faded in on the left. Nothing moved, which is /// why it appeared "suddenly": there was never anything to cross Bookings and /// Activity with. /// /// There is now exactly one pill, owned by the bar, and this is its position /// in tab units (2.0 is dead centre of Activity, 2.5 is between Activity and /// Account). It is the single source of truth for the tint on every icon and /// label too, so a tab lights up as the pill passes over it rather than /// switching colour the instant it is tapped and waiting for the pill to /// catch up. /// Initialised *here* rather than in `initState`, deliberately. /// /// A `late final` assigned in `initState` is never filled in on a `State` that /// already exists, and hot reload reuses the existing `State` — it does not /// re-run `initState`. So the reloaded `build` read a controller this instance /// had never been given and threw `LateInitializationError: Field '_pillAnim' /// has not been initialized`, taking the whole app down to a red screen until /// a full restart. /// /// A `late` field initialiser runs on first *access* instead, which happens /// during that first post-reload build, so the field is simply there. `this` /// is available because the initialiser is lazy — a plain (non-late) field /// initialiser could not reach `vsync: this`. late final AnimationController _pillAnim = AnimationController( vsync: this, // Slower than the 280ms the fading pills used. That duration was chosen for // a cross-fade in place; a pill that has to physically cross two tabs needs // long enough for the eye to follow it, or the travel is technically there // and reads as a jump anyway. duration: const Duration(milliseconds: 380), value: 1.0, ); /// Symmetric easing: it accelerates away from the old tab and settles into the /// new one. `easeOutCubic` — right for something appearing — starts at full /// speed, which on a sliding object looks like it was already moving before /// the tap. late final Animation _pillCurve = CurvedAnimation( parent: _pillAnim, curve: Curves.easeInOutCubic, ); late Tween _pillTween = Tween( begin: selected.toDouble(), end: selected.toDouble(), ); double get _pillPos => _pillTween.evaluate(_pillCurve); /// 0 at rest, 1 fully compact. Everything the bar changes as it shrinks — /// height, corner radius, side inset, icon size, label opacity — is read off /// this one value, so the parts cannot drift out of step with each other. late final AnimationController _compactAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 260), ); late final Animation _compact = CurvedAnimation( parent: _compactAnim, curve: Curves.easeOutCubic, reverseCurve: Curves.easeOutCubic, ); bool _isCompact = false; void _setCompact(bool compact) { if (_isCompact == compact) return; _isCompact = compact; compact ? _compactAnim.forward() : _compactAnim.reverse(); } double _scrollAccum = 0; static const double _compactAfter = 24; static const double _expandAfter = 6; static const _navItems = [ _NavItem(Icons.home_rounded, Icons.home_outlined, 'Home'), _NavItem( Icons.local_shipping_rounded, Icons.local_shipping_outlined, 'Bookings', ), // Was Earnings. A pay figure is a once-or-twice-a-day read and it did not // need a permanent quarter of the bar — it opens from Account now. What the // rider glances at between stops is what he has already finished, which // used to sit on Home clogging the list he decides from. _NavItem(Icons.history_rounded, Icons.history_outlined, 'Activity'), _NavItem(Icons.person_rounded, Icons.person_outline, 'Account'), ]; @override void initState() { super.initState(); selected = widget.initialIndex; BottomPage.currentTab.value = widget.initialIndex; _pages = [ widget.overridePage ?? const Homepage(), const MyPickups(), const ActivityPage(), const ProfilePage(), ]; _tabAnim = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), value: 1.0, ); _tabCurve = CurvedAnimation(parent: _tabAnim, curve: Curves.easeOutCubic); BottomPage.tabRequest.addListener(_onTabRequested); } void _onTabRequested() { final requested = BottomPage.tabRequest.value; if (requested == null || !mounted) return; BottomPage.tabRequest.value = null; if (requested >= 0 && requested < _pages.length) _selectTab(requested); } void _selectTab(int i) { _scrollAccum = 0; _setCompact(false); if (i == selected) return; HapticFeedback.lightImpact(); // Start from where the pill actually *is*, not from the tab it was last // headed to. Tapping again mid-slide otherwise teleports it back to the // previous tab and re-runs the whole journey. _pillTween = Tween(begin: _pillPos, end: i.toDouble()); _pillAnim.forward(from: 0.0); setState(() => selected = i); BottomPage.currentTab.value = i; _tabAnim.forward(from: 0.0); } @override void dispose() { BottomPage.tabRequest.removeListener(_onTabRequested); _tabAnim.dispose(); _pillAnim.dispose(); _compactAnim.dispose(); super.dispose(); } bool _onScroll(ScrollNotification n) { if (n.metrics.axis != Axis.vertical) return false; // A page too short to scroll can never compact the bar — otherwise a rider // on a short screen ends up with the small version for no reason. if (n.metrics.maxScrollExtent <= 0) { _scrollAccum = 0; _setCompact(false); return false; } if (n is! ScrollUpdateNotification) return false; final delta = n.scrollDelta ?? 0; if (delta == 0) return false; _scrollAccum = (_scrollAccum * delta > 0) ? _scrollAccum + delta : delta; if (delta > 0 && !_isCompact && _scrollAccum > _compactAfter) { _scrollAccum = 0; _setCompact(true); } else if (delta < 0 && _isCompact && _scrollAccum < -_expandAfter) { _scrollAccum = 0; _setCompact(false); } return false; } @override Widget build(BuildContext context) { return Theme( data: Theme.of(context).copyWith( textTheme: Theme.of( context, ).textTheme.apply(fontFamily: FontConstants.fontFamily), splashColor: Colors.transparent, highlightColor: Colors.transparent, ), child: Scaffold( backgroundColor: ColorConstants.surface, extendBody: true, body: NotificationListener( onNotification: _onScroll, child: Stack( children: [ // Page content Positioned.fill( child: FadeTransition( opacity: _tabCurve, child: AnimatedBuilder( animation: _tabCurve, builder: (_, child) => Transform.translate( offset: Offset(0, 8 * (1 - _tabCurve.value)), child: child, ), child: IndexedStack(index: selected, children: _pages), ), ), ), // ── Floating frosted bar, compacting rather than leaving ── // // Every dimension is lerped off one animation value, so the // height, the radius, the inset and the contents shrink together // instead of each running its own clock. AnimatedBuilder( animation: Listenable.merge([_compact, _pillAnim]), builder: (context, _) { final k = _compact.value; final side = lerpDouble(16.w, 30.w, k)!; final height = lerpDouble( BottomPage.navBarHeight.h, BottomPage.navBarHeightCompact.h, k, )!; final radius = lerpDouble(32.r, 23.r, k)!; final pos = _pillPos; return Positioned( left: side, right: side, bottom: MediaQuery.of(context).padding.bottom + BottomPage.navBarGap.h, child: ClipRRect( borderRadius: BorderRadius.circular(radius), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), child: DecoratedBox( decoration: BoxDecoration( color: ColorConstants.pureSurface.withValues( alpha: 0.82, ), borderRadius: BorderRadius.circular(radius), border: Border.all( color: Colors.black.withValues(alpha: 0.06), width: 1.0, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.07), blurRadius: 28, offset: const Offset(0, 8), ), ], ), child: SizedBox( height: height, child: Padding( padding: EdgeInsets.symmetric( horizontal: 6.w, vertical: lerpDouble(6.h, 4.h, k)!, ), child: LayoutBuilder( builder: (context, box) { // Every tab gets an equal slot, matching the // `Expanded` row below it, so the pill lands // exactly under an icon at whole values of // `pos`. final slot = box.maxWidth / _navItems.length; return Stack( children: [ PositionedDirectional( start: slot * pos, top: 0, bottom: 0, width: slot, child: Container( margin: EdgeInsets.symmetric( horizontal: 3.w, ), decoration: BoxDecoration( // Brand, to agree with the glyph // it sits behind — see the tint in // [_NavBarItem]. color: ColorConstants.primaryWash, borderRadius: BorderRadius.circular( lerpDouble(34.r, 20.r, k)!, ), ), ), ), Row( children: List.generate( _navItems.length, (i) { // How lit this tab is: 1 with the // pill on it, 0 once the pill is a // whole tab away. The tabs being // crossed therefore warm up and cool // down as it passes, which is what // sells the movement. final t = (1.0 - (pos - i).abs()) .clamp(0.0, 1.0); return Expanded( child: _NavBarItem( item: _navItems[i], t: t, compact: k, onTap: () => _selectTab(i), ), ); }, ), ), ], ); }, ), ), ), ), ), ), ); }, ), ], ), ), ), ); } } class _NavItem { final IconData activeIcon; final IconData inactiveIcon; final String label; const _NavItem(this.activeIcon, this.inactiveIcon, this.label); } class _NavBarItem extends StatelessWidget { final _NavItem item; /// How lit this tab is, 0 → 1, driven by where the selection pill currently /// is rather than by which tab is selected. /// /// This is the whole point of the redesign. With a boolean the icon flipped /// red the instant it was tapped while the pill was still two tabs away — /// destination and vehicle arriving at different times. Tying the tint to the /// pill's distance instead means Bookings and Activity glow briefly as it /// crosses them and fade again behind it. final double t; /// 0 at rest, 1 fully compact. The label collapses and the icon draws in as /// this rises — see [BottomPage.navBarHeightCompact]. final double compact; final VoidCallback onTap; const _NavBarItem({ required this.item, required this.t, required this.compact, required this.onTap, }); @override Widget build(BuildContext context) { // ── The selected tab wears the brand ── // // This was [ColorConstants.emphasis], the neutral slate, on the rule that // colour marks meaning and neutral marks state. The brand mark is not on // any tab now that Home carries the rider's own avatar, so the nav bar is // where Doormile's red belongs: it is the one piece of chrome on screen at // all times, and the selected tab is the one thing on it worth colouring. // // #960019 reads at 9.4:1 on the bar's near-white frost, so it is legible // outdoors — which is the only reason the neutral was ever safer. final Color activeColor = ColorConstants.primary; final Color inactiveColor = ColorConstants.onSurfaceVariant; // Eased rather than linear: a straight ramp makes every tab the pill passes // over look half-selected, so the bar reads as having no clear selection // mid-slide. Cubed keeps the glow tight around the pill. final lit = t * t * t; final tint = Color.lerp(inactiveColor, activeColor, lit)!; // The app's primary navigation was four unlabelled `InkWell`s: a screen // reader announced "Home", "Bookings", "Activity", "Account" as loose text // with no role and no indication of which one the rider was on. `selected` // is the part that matters — without it he cannot tell where he is. return Semantics( button: true, selected: lit > 0.5, label: item.label, excludeSemantics: true, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(DesignConstants.radiusXl), splashColor: Colors.transparent, highlightColor: Colors.transparent, // Transparent, not tinted: the pill behind the row is the fill now. child: Padding( padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 4.w), child: Center( child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Transform.scale( scale: (1.0 + 0.08 * lit) * (1 - 0.06 * compact), // Filled and outline glyph stacked and cross-faded on the same // value, so the icon fills in as the pill arrives. An // `AnimatedSwitcher` keyed off a bool ran on its own 220ms clock // and finished well before the pill did. child: Stack( alignment: Alignment.center, children: [ Opacity( opacity: 1 - lit, child: Icon( item.inactiveIcon, color: tint, size: 21.sp, ), ), Opacity( opacity: lit, child: Icon(item.activeIcon, color: tint, size: 21.sp), ), ], ), ), // ── The label collapses, it does not just fade ── // // Fading alone would leave its height behind, so the bar could // not actually get shorter — it would shrink around a blank // line. `Align(heightFactor:)` inside a `ClipRect` hands the // room back as the text goes. // // The opacity runs ahead of the height (×1.8) so the label is // gone before the line has finished closing, rather than being // squashed on the way out. ClipRect( child: Align( alignment: Alignment.topCenter, heightFactor: (1 - compact).clamp(0.0, 1.0), child: Opacity( opacity: (1 - compact * 1.8).clamp(0.0, 1.0), child: Padding( padding: EdgeInsets.only(top: 2.h), child: Text( item.label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( color: tint, fontSize: 10.5.sp, fontWeight: FontWeight.lerp( FontWeight.w500, FontWeight.w700, lit, ), fontFamily: FontConstants.fontFamily, letterSpacing: -0.1, ), ), ), ), ), ), ], ), ), ), ), ); } }