Files
jamshalat-diary/lib/core/widgets/bottom_nav_bar.dart
2026-03-18 00:07:10 +07:00

708 lines
21 KiB
Dart

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<int> onTap;
@override
ConsumerState<AppBottomNavBar> createState() => _AppBottomNavBarState();
}
class _AppBottomNavBarState extends ConsumerState<AppBottomNavBar>
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<double> _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<void> _toggleThemeMode(BuildContext context) async {
final isDarkNow = Theme.of(context).brightness == Brightness.dark;
final nextMode = isDarkNow ? ThemeMode.light : ThemeMode.dark;
final box = Hive.box<AppSettings>(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<Box<AppSettings>>(
valueListenable: Hive.box<AppSettings>(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<double> animation;
final ValueChanged<int> 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<double> 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
? <BoxShadow>[
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),
),
]
: <BoxShadow>[
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;
}
}