import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter/services.dart'; import 'package:hive_flutter/hive_flutter.dart'; import '../../app/icons/app_icons.dart'; import '../../app/theme/app_colors.dart'; import '../../core/providers/theme_provider.dart'; import '../../data/local/hive_boxes.dart'; import '../../data/local/models/app_settings.dart'; /// 5-tab bottom navigation bar with luxury active treatment. class AppBottomNavBar extends ConsumerStatefulWidget { const AppBottomNavBar({ super.key, required this.currentIndex, required this.onTap, }); final int currentIndex; final ValueChanged onTap; @override ConsumerState createState() => _AppBottomNavBarState(); } class _AppBottomNavBarState extends ConsumerState with TickerProviderStateMixin { static const double _toggleRevealWidth = 88; static const double _dragThreshold = 38; static const double _inactiveIconSize = 27; static const double _activeIconSize = 22; static const double _navIconStrokeWidth = 1.9; late final AnimationController _shineController; late final AnimationController _revealController; late final Animation _revealAnimation; bool _isThemeToggleOpen = false; double _dragDeltaX = 0; @override void initState() { super.initState(); _shineController = AnimationController( vsync: this, duration: const Duration(milliseconds: 5200), )..repeat(); _revealController = AnimationController( vsync: this, duration: const Duration(milliseconds: 340), ); _revealAnimation = CurvedAnimation( parent: _revealController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); } void _syncAnimation({required bool reducedMotion}) { if (reducedMotion) { if (_shineController.isAnimating) { _shineController.stop(canceled: false); } return; } if (!_shineController.isAnimating) { _shineController.repeat(); } } void _openThemeToggle({bool withHaptics = true}) { if (_isThemeToggleOpen) return; _isThemeToggleOpen = true; if (withHaptics) { HapticFeedback.mediumImpact(); } _revealController.animateTo(1); } void _closeThemeToggle({bool withHaptics = true}) { if (!_isThemeToggleOpen) return; _isThemeToggleOpen = false; if (withHaptics) { HapticFeedback.lightImpact(); } _revealController.animateBack(0); } void _handleNavTap(int index) { _closeThemeToggle(withHaptics: false); widget.onTap(index); } void _handleHorizontalDragStart(DragStartDetails _) { _dragDeltaX = 0; } void _handleHorizontalDragUpdate(DragUpdateDetails details) { _dragDeltaX += details.delta.dx; } void _handleHorizontalDragEnd(DragEndDetails details) { final velocityX = details.primaryVelocity ?? 0; final shouldOpen = velocityX < -220 || _dragDeltaX <= -_dragThreshold; final shouldClose = velocityX > 220 || _dragDeltaX >= _dragThreshold; if (shouldOpen && !_isThemeToggleOpen) { _openThemeToggle(); } else if (shouldClose && _isThemeToggleOpen) { _closeThemeToggle(); } } Future _toggleThemeMode(BuildContext context) async { final isDarkNow = Theme.of(context).brightness == Brightness.dark; final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark; final box = Hive.box(HiveBoxes.settings); final settings = box.get('default') ?? AppSettings(); settings.themeModeIndex = nextMode == ThemeMode.dark ? 2 : 1; if (settings.isInBox) { await settings.save(); } else { await box.put('default', settings); } ref.read(themeProvider.notifier).state = nextMode; HapticFeedback.selectionClick(); if (mounted) { _closeThemeToggle(withHaptics: false); } } @override void dispose() { _shineController.dispose(); _revealController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final media = MediaQuery.maybeOf(context); final reducedMotion = (media?.disableAnimations ?? false) || (media?.accessibleNavigation ?? false); _syncAnimation(reducedMotion: reducedMotion); return ValueListenableBuilder>( valueListenable: Hive.box(HiveBoxes.settings).listenable(), builder: (context, box, _) { final isSimpleMode = box.get('default')?.simpleMode ?? false; final isDark = Theme.of(context).brightness == Brightness.dark; final systemBottomInset = MediaQueryData.fromView(View.of(context)).viewPadding.bottom; final items = isSimpleMode ? const [ _NavDef(AppIcons.home, 'Beranda'), _NavDef(AppIcons.calendar, 'Jadwal'), _NavDef(AppIcons.quran, "Al-Qur'an"), _NavDef(AppIcons.dzikir, 'Dzikir'), _NavDef(AppIcons.lainnya, 'Lainnya'), ] : const [ _NavDef(AppIcons.home, 'Beranda'), _NavDef(AppIcons.calendar, 'Jadwal'), _NavDef(AppIcons.ibadah, 'Ibadah'), _NavDef(AppIcons.laporan, 'Laporan'), _NavDef(AppIcons.lainnya, 'Lainnya'), ]; return ColoredBox( color: Theme.of(context).bottomNavigationBarTheme.backgroundColor ?? Colors.transparent, child: Padding( padding: EdgeInsets.fromLTRB(10, 6, 10, 6 + systemBottomInset), child: GestureDetector( behavior: HitTestBehavior.deferToChild, onHorizontalDragStart: _handleHorizontalDragStart, onHorizontalDragUpdate: _handleHorizontalDragUpdate, onHorizontalDragEnd: _handleHorizontalDragEnd, child: SizedBox( height: 56, child: Stack( clipBehavior: Clip.none, children: [ Positioned.fill( child: AnimatedBuilder( animation: _revealController, builder: (context, child) { final reveal = _revealAnimation.value; return IgnorePointer( ignoring: reveal < 0.55, child: _ThemeUnderlayBoard( isDark: isDark, reveal: reveal, onTap: () => _toggleThemeMode(context), ), ); }, ), ), AnimatedBuilder( animation: _revealController, builder: (context, child) { final reveal = _revealAnimation.value; return Transform.translate( offset: Offset(-_toggleRevealWidth * reveal, 0), child: _MainNavBoard( items: items, currentIndex: widget.currentIndex, isDark: isDark, reducedMotion: reducedMotion, animation: _shineController, onTap: _handleNavTap, ), ); }, ), ], ), ), ), ), ); }, ); } } class _ThemeUnderlayBoard extends StatelessWidget { const _ThemeUnderlayBoard({ required this.isDark, required this.reveal, required this.onTap, }); final bool isDark; final double reveal; final VoidCallback onTap; @override Widget build(BuildContext context) { final t = reveal.clamp(0.0, 1.0).toDouble(); return Container( height: 54, decoration: BoxDecoration( borderRadius: BorderRadius.circular(28), color: isDark ? const Color(0xFF090D14) : const Color(0xFFE6E6EA), border: Border.all( color: isDark ? const Color(0x1FFFFFFF) : const Color(0x140F172A), width: 0.8, ), ), child: Align( alignment: Alignment.centerRight, child: SizedBox( width: _AppBottomNavBarState._toggleRevealWidth, child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(22), child: Center( child: Opacity( opacity: t, child: Transform.translate( offset: Offset((1 - t) * 10, 0), child: Transform.scale( scale: 0.94 + (t * 0.06), child: AppIcon( glyph: isDark ? AppIcons.themeSun : AppIcons.themeMoon, size: 24, color: isDark ? AppColors.navActiveGoldPale : AppColors.textPrimaryLight, ), ), ), ), ), ), ), ), ), ); } } class _NavDef { const _NavDef(this.icon, this.label); final AppIconGlyph icon; final String label; } class _MainNavBoard extends StatelessWidget { const _MainNavBoard({ required this.items, required this.currentIndex, required this.isDark, required this.reducedMotion, required this.animation, required this.onTap, }); final List<_NavDef> items; final int currentIndex; final bool isDark; final bool reducedMotion; final Animation animation; final ValueChanged onTap; @override Widget build(BuildContext context) { final background = Theme.of(context).bottomNavigationBarTheme.backgroundColor ?? (isDark ? AppColors.surfaceDark : AppColors.surfaceLight); return DecoratedBox( decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(28), ), child: Row( children: List.generate(items.length, (index) { final item = items[index]; final isSelected = index == currentIndex; return Expanded( child: Material( color: Colors.transparent, child: InkWell( onTap: () => onTap(index), customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(22), ), child: SizedBox( height: 56, child: Center( child: isSelected ? _AnimatedLuxuryActiveIcon( animation: animation, icon: item.icon, isDark: isDark, reducedMotion: reducedMotion, iconSize: _AppBottomNavBarState._activeIconSize, ) : _InactiveNavIcon( glyph: item.icon, isDark: isDark, iconSize: _AppBottomNavBarState._inactiveIconSize, ), ), ), ), ), ); }), ), ); } } class _InactiveNavIcon extends StatelessWidget { const _InactiveNavIcon({ required this.glyph, required this.isDark, required this.iconSize, }); final AppIconGlyph glyph; final bool isDark; final double iconSize; @override Widget build(BuildContext context) { return SizedBox( width: 48, height: 48, child: Center( child: AppIcon( glyph: glyph, size: iconSize, strokeWidth: _AppBottomNavBarState._navIconStrokeWidth, color: isDark ? AppColors.textSecondaryDark : AppColors.textSecondaryLight, ), ), ); } } class _AnimatedLuxuryActiveIcon extends StatelessWidget { const _AnimatedLuxuryActiveIcon({ required this.animation, required this.icon, required this.isDark, required this.reducedMotion, required this.iconSize, }); final Animation animation; final AppIconGlyph icon; final bool isDark; final bool reducedMotion; final double iconSize; @override Widget build(BuildContext context) { return RepaintBoundary( child: AnimatedBuilder( animation: animation, builder: (_, __) { return _LuxuryActiveIcon( icon: icon, isDark: isDark, reducedMotion: reducedMotion, progress: reducedMotion ? 0.17 : animation.value, iconSize: iconSize, ); }, ), ); } } class _LuxuryActiveIcon extends StatelessWidget { const _LuxuryActiveIcon({ required this.icon, required this.isDark, required this.reducedMotion, required this.progress, required this.iconSize, }); final AppIconGlyph icon; final bool isDark; final bool reducedMotion; final double progress; final double iconSize; @override Widget build(BuildContext context) { final baseShadow = isDark ? [ const BoxShadow( color: Color(0xB3000000), blurRadius: 9, spreadRadius: 0.2, offset: Offset(0, 4), ), const BoxShadow( color: AppColors.navGlowDark, blurRadius: 8, spreadRadius: -0.8, offset: Offset(0, 1.5), ), ] : [ const BoxShadow( color: AppColors.navEmbossHighlight, blurRadius: 3.2, offset: Offset(-1.1, -1.1), ), const BoxShadow( color: AppColors.navEmbossShadow, blurRadius: 7, offset: Offset(2.3, 3.1), ), ]; return SizedBox( width: 48, height: 48, child: Container( decoration: BoxDecoration( color: isDark ? const Color(0xFF11161F) : const Color(0xFFF2F3F5), borderRadius: BorderRadius.circular(15), border: Border.all( color: isDark ? const Color(0x383D4C61) : const Color(0x2610172A), width: 0.85, ), boxShadow: baseShadow, ), child: Padding( padding: const EdgeInsets.all(2.0), child: DecoratedBox( decoration: BoxDecoration( color: isDark ? const Color(0xFF1A202A) : AppColors.navActiveSurfaceLight, borderRadius: BorderRadius.circular(12.5), border: Border.all( color: isDark ? const Color(0x2EFFFFFF) : const Color(0x1F0F172A), width: 0.8, ), ), child: CustomPaint( painter: _LuxuryRingPainter( progress: progress, reducedMotion: reducedMotion, isDark: isDark, ), child: Center( child: AppIcon( glyph: icon, size: iconSize, strokeWidth: _AppBottomNavBarState._navIconStrokeWidth, color: isDark ? AppColors.textPrimaryDark : AppColors.textPrimaryLight, ), ), ), ), ), ), ); } } class _LuxuryRingPainter extends CustomPainter { const _LuxuryRingPainter({ required this.progress, required this.reducedMotion, required this.isDark, }); final double progress; final bool reducedMotion; final bool isDark; @override void paint(Canvas canvas, Size size) { final rect = Offset.zero & size; final outerRect = rect.deflate(1.05); final ringRect = rect.deflate(2.5); final innerRect = rect.deflate(4.75); final outerRRect = RRect.fromRectAndRadius(outerRect, const Radius.circular(12.4)); final ringRRect = RRect.fromRectAndRadius(ringRect, const Radius.circular(10.8)); final innerRRect = RRect.fromRectAndRadius(innerRect, const Radius.circular(9.1)); final rotation = reducedMotion ? math.pi * 0.63 : progress * math.pi * 2; void drawEmboss( RRect target, { required Offset shadowOffset, required Offset highlightOffset, required Color shadowColor, required Color highlightColor, required double shadowBlur, required double highlightBlur, required double strokeWidth, }) { final shadowPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = shadowColor ..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur); final highlightPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = highlightColor ..maskFilter = MaskFilter.blur(BlurStyle.normal, highlightBlur); canvas.save(); canvas.translate(shadowOffset.dx, shadowOffset.dy); canvas.drawRRect(target, shadowPaint); canvas.restore(); canvas.save(); canvas.translate(highlightOffset.dx, highlightOffset.dy); canvas.drawRRect(target, highlightPaint); canvas.restore(); } drawEmboss( outerRRect, shadowOffset: const Offset(0.7, 1.0), highlightOffset: const Offset(-0.6, -0.7), shadowColor: isDark ? const Color(0xD6000000) : AppColors.navEmbossShadow.withValues(alpha: 0.72), highlightColor: isDark ? Colors.white.withValues(alpha: 0.22) : Colors.white.withValues(alpha: 0.92), shadowBlur: isDark ? 2.5 : 1.8, highlightBlur: isDark ? 1.6 : 1.1, strokeWidth: 1.05, ); drawEmboss( innerRRect, shadowOffset: const Offset(-0.45, -0.35), highlightOffset: const Offset(0.45, 0.55), shadowColor: isDark ? Colors.black.withValues(alpha: 0.58) : AppColors.navEmbossShadow.withValues(alpha: 0.58), highlightColor: isDark ? Colors.white.withValues(alpha: 0.14) : Colors.white.withValues(alpha: 0.78), shadowBlur: isDark ? 1.5 : 1.2, highlightBlur: isDark ? 1.1 : 0.9, strokeWidth: 0.88, ); final metallicRing = SweepGradient( startAngle: rotation, endAngle: rotation + math.pi * 2, colors: const [ AppColors.navActiveGoldDeep, AppColors.navActiveGold, AppColors.navActiveGoldBright, AppColors.navActiveGoldPale, AppColors.navActiveGoldBright, AppColors.navActiveGoldDeep, ], stops: const [0.0, 0.16, 0.34, 0.5, 0.68, 1.0], ); final metallicPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.9 ..shader = metallicRing.createShader(ringRect) ..isAntiAlias = true; final chromaStrength = isDark ? 0.92 : 0.74; final chromaSweep = SweepGradient( startAngle: (rotation * 1.3) + 0.42, endAngle: (rotation * 1.3) + 0.42 + math.pi * 2, colors: [ Colors.transparent, Colors.transparent, AppColors.navActiveGold.withValues(alpha: 0.52 * chromaStrength), Colors.white.withValues(alpha: 0.94 * chromaStrength), AppColors.navActiveGoldPale.withValues(alpha: 0.68 * chromaStrength), Colors.transparent, Colors.transparent, AppColors.navActiveGold.withValues(alpha: 0.44 * chromaStrength), Colors.white.withValues(alpha: 0.88 * chromaStrength), AppColors.navActiveGoldPale.withValues(alpha: 0.64 * chromaStrength), Colors.transparent, Colors.transparent, ], stops: const [ 0.0, 0.09, 0.112, 0.126, 0.14, 0.175, 0.45, 0.468, 0.484, 0.5, 0.528, 1.0, ], ); final chromaPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 1.15 ..shader = chromaSweep.createShader(ringRect) ..blendMode = BlendMode.screen; canvas.drawRRect(ringRRect, metallicPaint); canvas.drawRRect(ringRRect, chromaPaint); final ambientGold = Paint() ..style = PaintingStyle.stroke ..strokeWidth = isDark ? 1.0 : 0.85 ..color = isDark ? AppColors.navActiveGold.withValues(alpha: 0.18) : AppColors.navActiveGoldDeep.withValues(alpha: 0.16) ..maskFilter = MaskFilter.blur( BlurStyle.normal, isDark ? 2.6 : 1.2, ); canvas.drawRRect(ringRRect, ambientGold); final innerEdge = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 0.8 ..color = isDark ? Colors.white.withValues(alpha: 0.12) : const Color(0x330F172A); canvas.drawRRect(innerRRect, innerEdge); } @override bool shouldRepaint(covariant _LuxuryRingPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.reducedMotion != reducedMotion || oldDelegate.isDark != isDark; } }