import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../core/theme.dart'; class KrowNavItem { const KrowNavItem({ required this.icon, required this.label, this.showDot = false, }); final IconData icon; final String label; /// Marks the tab as needing attention, without saying how much. final bool showDot; } /// Bottom navigation whose indicator slides between tabs. /// /// Material's own [NavigationBar] fades its indicator in at the destination /// and out at the origin, so the selection appears to teleport. Here the pill /// travels, and the icons hand it over as it passes. class KrowNavBar extends StatelessWidget { const KrowNavBar({ super.key, required this.items, required this.currentIndex, required this.onSelect, }); final List items; final int currentIndex; final ValueChanged onSelect; static const _pillWidth = 58.0; static const _pillHeight = 32.0; /// Breathing room between the bar's top edge and the icons. The pill and the /// icons both start here, which is also what keeps them aligned — centring /// the icons while pinning the pill to a fixed offset left the two a couple /// of pixels apart. static const _topPadding = 12.0; static const _labelSize = 11.0; static const _dotSlot = 6.0; /// Lets tests find the travelling indicator unambiguously. static const indicatorKey = ValueKey('krow-nav-indicator'); @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: KrowPalette.line)), ), child: SafeArea( top: false, // No fixed height: the Stack below takes the height of the item row, // and the pill is positioned within it. A hard number here has to be // retuned every time the label grows — a bigger text scale, a longer // word, a different font — and overflows the moment it is not. child: LayoutBuilder( builder: (context, constraints) { final slot = constraints.maxWidth / items.length; // The selected index animated as a double, so the pill's // position and every icon's colour come from one value. return TweenAnimationBuilder( tween: Tween(end: currentIndex.toDouble()), duration: const Duration(milliseconds: 360), curve: Curves.easeOutQuint, builder: (context, position, _) { final travel = (position - position.roundToDouble()).abs(); final stretch = 20 * (travel * 2); return Stack( children: [ Positioned( left: position * slot + (slot - _pillWidth) / 2 - stretch / 2, top: _topPadding, width: _pillWidth + stretch, height: _pillHeight, child: DecoratedBox( key: indicatorKey, decoration: BoxDecoration( color: KrowPalette.blue, borderRadius: BorderRadius.circular(10), boxShadow: [ BoxShadow( color: KrowPalette.blue.withValues(alpha: 0.28), blurRadius: 12, offset: const Offset(0, 4), ), ], ), ), ), Row( children: [ for (var i = 0; i < items.length; i++) Expanded( child: _NavItem( item: items[i], // 1 when the pill is centred on this tab. proximity: (1 - (position - i).abs()).clamp( 0.0, 1.0, ), onTap: () { if (i != currentIndex) { HapticFeedback.selectionClick(); } onSelect(i); }, ), ), ], ), ], ); }, ); }, ), ), ); } } class _NavItem extends StatelessWidget { const _NavItem({ required this.item, required this.proximity, required this.onTap, }); final KrowNavItem item; final double proximity; final VoidCallback onTap; @override Widget build(BuildContext context) { // The icon only turns white once the pill is actually under it — a linear // ramp would leave it half-white over bare white while the pill is still // in transit. easeInCubic holds it grey, then flips on arrival. final onPill = Curves.easeInCubic.transform(proximity); final iconColor = Color.lerp(KrowPalette.mute, Colors.white, onPill)!; // The label is never covered, so it can ramp gently the whole way. final labelColor = Color.lerp( KrowPalette.mute, KrowPalette.blue, Curves.easeIn.transform(proximity), )!; // Material's NavigationBar announces each tab and its selected state; // rolling our own means saying so explicitly. container + excludeSemantics // collapse the icon, dot and label into a single node — otherwise the // annotation is split across the children and never reaches the reader. return Semantics( container: true, excludeSemantics: true, button: true, selected: proximity > 0.5, label: item.showDot ? '${item.label}, needs attention' : item.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( // Started from the top rather than centred, so the icon sits exactly // where the pill is drawn. mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: KrowNavBar._topPadding), SizedBox( height: KrowNavBar._pillHeight, child: Center(child: Icon(item.icon, size: 21, color: iconColor)), ), const SizedBox(height: 3), // The dot's slot is always reserved, so tabs with and without one // keep their labels on the same line. SizedBox( height: KrowNavBar._dotSlot, child: Center( child: AnimatedScale( scale: item.showDot ? 1 : 0, duration: const Duration(milliseconds: 220), curve: Curves.easeOutBack, child: Container( width: 6, height: 6, decoration: BoxDecoration( color: Theme.of(context).colorScheme.error, shape: BoxShape.circle, ), ), ), ), ), const SizedBox(height: 2), Text( item.label, style: TextStyle( fontSize: KrowNavBar._labelSize, fontWeight: proximity > 0.5 ? FontWeight.w600 : FontWeight.w500, color: labelColor, ), ), const SizedBox(height: 4), ], ), ), ); } }