From 50b89218790147e465c06b8d44c1266677eaf9d1 Mon Sep 17 00:00:00 2001 From: Turtlepaw <81275769+Turtlepaw@users.noreply.github.com> Date: Thu, 24 Jul 2025 00:29:30 -0400 Subject: [PATCH] feat(navbar): add scaling animation --- lib/widgets/bottom_nav_bar.dart | 302 ++++++++++++++++++-------------- 1 file changed, 172 insertions(+), 130 deletions(-) diff --git a/lib/widgets/bottom_nav_bar.dart b/lib/widgets/bottom_nav_bar.dart index ab626a0..af37bf0 100644 --- a/lib/widgets/bottom_nav_bar.dart +++ b/lib/widgets/bottom_nav_bar.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -9,7 +10,7 @@ import 'package:grain/providers/notifications_provider.dart'; import 'package:grain/providers/profile_provider.dart'; import 'package:grain/widgets/app_image.dart'; -class BottomNavBar extends ConsumerWidget { +class BottomNavBar extends ConsumerStatefulWidget { final int navIndex; final VoidCallback onHome; final VoidCallback onExplore; @@ -26,7 +27,38 @@ class BottomNavBar extends ConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _BottomNavBarState(); +} + +class _BottomNavBarState extends ConsumerState { + int _pressedIndex = -1; + + void _onHoldTap(int index, VoidCallback callback) { + setState(() => _pressedIndex = index); + callback(); + Future.delayed(const Duration(milliseconds: 200), () { + setState(() => _pressedIndex = -1); + }); + } + + Widget _buildNavItem({ + required int index, + required Widget icon, + required VoidCallback onHoldComplete, + }) { + return Expanded( + child: _NavItem( + index: index, + isPressed: _pressedIndex == index, + icon: icon, + onHoldComplete: () => _onHoldTap(index, onHoldComplete), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); final did = apiService.currentUser?.did; final asyncProfile = did != null ? ref.watch(profileNotifierProvider(did)) @@ -37,156 +69,119 @@ class BottomNavBar extends ConsumerWidget { orElse: () => null, ); - final theme = Theme.of(context); - - // Get unread notifications count final notifications = ref.watch(notificationsProvider); final unreadCount = notifications.maybeWhen( - data: (list) => list.where((n) => n.isRead == false).length, + data: (list) => list.where((n) => !n.isRead).length, orElse: () => 0, ); return Container( decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - border: Border(top: BorderSide(color: Theme.of(context).dividerColor, width: 1)), + color: theme.scaffoldBackgroundColor, + border: Border(top: BorderSide(color: theme.dividerColor, width: 1)), ), - height: 42 + MediaQuery.of(context).padding.bottom, + height: 56 + MediaQuery.of(context).padding.bottom, + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onHome, - child: SizedBox( - height: 42 + MediaQuery.of(context).padding.bottom, - child: Transform.translate( - offset: const Offset(0, -10), - child: Center( - child: FaIcon( - AppIcons.house, - size: 20, - color: navIndex == 0 - ? AppTheme.primaryColor - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), + _buildNavItem( + index: 0, + onHoldComplete: widget.onHome, + icon: FaIcon( + AppIcons.house, + size: 20, + color: widget.navIndex == 0 + ? AppTheme.primaryColor + : theme.colorScheme.onSurfaceVariant, ), ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onExplore, - child: SizedBox( - height: 42 + MediaQuery.of(context).padding.bottom, - child: Transform.translate( - offset: const Offset(0, -10), - child: Center( - child: FaIcon( - AppIcons.magnifyingGlass, - size: 20, - color: navIndex == 1 - ? AppTheme.primaryColor - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ), + _buildNavItem( + index: 1, + onHoldComplete: widget.onExplore, + icon: FaIcon( + AppIcons.magnifyingGlass, + size: 20, + color: widget.navIndex == 1 + ? AppTheme.primaryColor + : theme.colorScheme.onSurfaceVariant, ), ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onNotifications, - child: SizedBox( - height: 42 + MediaQuery.of(context).padding.bottom, - child: Transform.translate( - offset: const Offset(0, -10), - child: Stack( - alignment: Alignment.center, - children: [ - Center( - child: FaIcon( - AppIcons.solidBell, - size: 20, - color: navIndex == 2 - ? AppTheme.primaryColor - : Theme.of(context).colorScheme.onSurfaceVariant, + _buildNavItem( + index: 2, + onHoldComplete: widget.onNotifications, + icon: Stack( + alignment: Alignment.center, + children: [ + FaIcon( + AppIcons.solidBell, + size: 20, + color: widget.navIndex == 2 + ? AppTheme.primaryColor + : theme.colorScheme.onSurfaceVariant, + ), + if (unreadCount > 0) + Positioned( + right: 0, + top: 0, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: theme.colorScheme.primary, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.scaffoldBackgroundColor, + width: 1, ), ), - if (unreadCount > 0) - Align( - alignment: Alignment.center, - child: Transform.translate( - offset: const Offset(10, -10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: theme.scaffoldBackgroundColor, width: 1), - ), - constraints: const BoxConstraints(minWidth: 16, minHeight: 16), - child: Text( - unreadCount > 99 ? '99+' : unreadCount.toString(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - ), - ), + constraints: + const BoxConstraints(minWidth: 16, minHeight: 16), + child: Text( + unreadCount > 99 ? '99+' : unreadCount.toString(), + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, ), - ], + textAlign: TextAlign.center, + ), + ), ), - ), - ), + ], ), ), - Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onProfile, - child: SizedBox( - height: 42 + MediaQuery.of(context).padding.bottom, - child: Transform.translate( - offset: const Offset(0, -10), - child: Center( - child: avatarUrl != null && avatarUrl.isNotEmpty - ? Container( - width: 28, - height: 28, - alignment: Alignment.center, - decoration: navIndex == 3 - ? BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppTheme.primaryColor, width: 2.2), - ) - : null, - child: ClipOval( - child: AppImage( - url: avatarUrl, - width: 24, - height: 24, - fit: BoxFit.cover, - ), - ), - ) - : FaIcon( - navIndex == 3 ? AppIcons.solidUser : AppIcons.user, - size: 16, - color: navIndex == 3 - ? AppTheme.primaryColor - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), + _buildNavItem( + index: 3, + onHoldComplete: widget.onProfile, + icon: avatarUrl != null && avatarUrl.isNotEmpty + ? Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: widget.navIndex == 3 + ? BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppTheme.primaryColor, width: 2.2), + ) + : null, + child: ClipOval( + child: AppImage( + url: avatarUrl, + width: 24, + height: 24, + fit: BoxFit.cover, ), ), + ) + : FaIcon( + widget.navIndex == 3 + ? AppIcons.solidUser + : AppIcons.user, + size: 16, + color: widget.navIndex == 3 + ? AppTheme.primaryColor + : theme.colorScheme.onSurfaceVariant, ), ), ], @@ -194,3 +189,50 @@ class BottomNavBar extends ConsumerWidget { ); } } + +class _NavItem extends StatefulWidget { + final Widget icon; + final VoidCallback onHoldComplete; + final int index; + final bool isPressed; + + const _NavItem({ + required this.icon, + required this.onHoldComplete, + required this.index, + required this.isPressed, + }); + + @override + State<_NavItem> createState() => _NavItemState(); +} + +class _NavItemState extends State<_NavItem> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) { + setState(() => _pressed = true); + }, + onTapUp: (_) { + Future.delayed(const Duration(milliseconds: 200), () { + setState(() => _pressed = false); + }); + }, + onTapCancel: () { + setState(() => _pressed = false); + }, + onTap: widget.onHoldComplete, + behavior: HitTestBehavior.opaque, + child: Center( + child: AnimatedScale( + scale: _pressed ? 0.85 : 1.0, + duration: const Duration(milliseconds: 150), + child: widget.icon, + ), + ), + ); + } +} -- 2.43.0