feat(navbar): add scaling animation #1

Changed files
+172 -130
lib
+172 -130
lib/widgets/bottom_nav_bar.dart
··· 1 + import 'dart:async'; 1 2 import 'package:flutter/material.dart'; 2 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 4 import 'package:font_awesome_flutter/font_awesome_flutter.dart'; ··· 9 10 import 'package:grain/providers/profile_provider.dart'; 10 11 import 'package:grain/widgets/app_image.dart'; 11 12 12 - class BottomNavBar extends ConsumerWidget { 13 + class BottomNavBar extends ConsumerStatefulWidget { 13 14 final int navIndex; 14 15 final VoidCallback onHome; 15 16 final VoidCallback onExplore; ··· 26 27 }); 27 28 28 29 @override 29 - Widget build(BuildContext context, WidgetRef ref) { 30 + ConsumerState<BottomNavBar> createState() => _BottomNavBarState(); 31 + } 32 + 33 + class _BottomNavBarState extends ConsumerState<BottomNavBar> { 34 + int _pressedIndex = -1; 35 + 36 + void _onHoldTap(int index, VoidCallback callback) { 37 + setState(() => _pressedIndex = index); 38 + callback(); 39 + Future.delayed(const Duration(milliseconds: 200), () { 40 + setState(() => _pressedIndex = -1); 41 + }); 42 + } 43 + 44 + Widget _buildNavItem({ 45 + required int index, 46 + required Widget icon, 47 + required VoidCallback onHoldComplete, 48 + }) { 49 + return Expanded( 50 + child: _NavItem( 51 + index: index, 52 + isPressed: _pressedIndex == index, 53 + icon: icon, 54 + onHoldComplete: () => _onHoldTap(index, onHoldComplete), 55 + ), 56 + ); 57 + } 58 + 59 + @override 60 + Widget build(BuildContext context) { 61 + final theme = Theme.of(context); 30 62 final did = apiService.currentUser?.did; 31 63 final asyncProfile = did != null 32 64 ? ref.watch(profileNotifierProvider(did)) ··· 37 69 orElse: () => null, 38 70 ); 39 71 40 - final theme = Theme.of(context); 41 - 42 - // Get unread notifications count 43 72 final notifications = ref.watch(notificationsProvider); 44 73 final unreadCount = notifications.maybeWhen( 45 - data: (list) => list.where((n) => n.isRead == false).length, 74 + data: (list) => list.where((n) => !n.isRead).length, 46 75 orElse: () => 0, 47 76 ); 48 77 49 78 return Container( 50 79 decoration: BoxDecoration( 51 - color: Theme.of(context).scaffoldBackgroundColor, 52 - border: Border(top: BorderSide(color: Theme.of(context).dividerColor, width: 1)), 80 + color: theme.scaffoldBackgroundColor, 81 + border: Border(top: BorderSide(color: theme.dividerColor, width: 1)), 53 82 ), 54 - height: 42 + MediaQuery.of(context).padding.bottom, 83 + height: 56 + MediaQuery.of(context).padding.bottom, 84 + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom), 55 85 child: Row( 56 - mainAxisAlignment: MainAxisAlignment.spaceAround, 57 86 children: [ 58 - Expanded( 59 - child: GestureDetector( 60 - behavior: HitTestBehavior.opaque, 61 - onTap: onHome, 62 - child: SizedBox( 63 - height: 42 + MediaQuery.of(context).padding.bottom, 64 - child: Transform.translate( 65 - offset: const Offset(0, -10), 66 - child: Center( 67 - child: FaIcon( 68 - AppIcons.house, 69 - size: 20, 70 - color: navIndex == 0 71 - ? AppTheme.primaryColor 72 - : Theme.of(context).colorScheme.onSurfaceVariant, 73 - ), 74 - ), 75 - ), 76 - ), 87 + _buildNavItem( 88 + index: 0, 89 + onHoldComplete: widget.onHome, 90 + icon: FaIcon( 91 + AppIcons.house, 92 + size: 20, 93 + color: widget.navIndex == 0 94 + ? AppTheme.primaryColor 95 + : theme.colorScheme.onSurfaceVariant, 77 96 ), 78 97 ), 79 - Expanded( 80 - child: GestureDetector( 81 - behavior: HitTestBehavior.opaque, 82 - onTap: onExplore, 83 - child: SizedBox( 84 - height: 42 + MediaQuery.of(context).padding.bottom, 85 - child: Transform.translate( 86 - offset: const Offset(0, -10), 87 - child: Center( 88 - child: FaIcon( 89 - AppIcons.magnifyingGlass, 90 - size: 20, 91 - color: navIndex == 1 92 - ? AppTheme.primaryColor 93 - : Theme.of(context).colorScheme.onSurfaceVariant, 94 - ), 95 - ), 96 - ), 97 - ), 98 + _buildNavItem( 99 + index: 1, 100 + onHoldComplete: widget.onExplore, 101 + icon: FaIcon( 102 + AppIcons.magnifyingGlass, 103 + size: 20, 104 + color: widget.navIndex == 1 105 + ? AppTheme.primaryColor 106 + : theme.colorScheme.onSurfaceVariant, 98 107 ), 99 108 ), 100 - Expanded( 101 - child: GestureDetector( 102 - behavior: HitTestBehavior.opaque, 103 - onTap: onNotifications, 104 - child: SizedBox( 105 - height: 42 + MediaQuery.of(context).padding.bottom, 106 - child: Transform.translate( 107 - offset: const Offset(0, -10), 108 - child: Stack( 109 - alignment: Alignment.center, 110 - children: [ 111 - Center( 112 - child: FaIcon( 113 - AppIcons.solidBell, 114 - size: 20, 115 - color: navIndex == 2 116 - ? AppTheme.primaryColor 117 - : Theme.of(context).colorScheme.onSurfaceVariant, 109 + _buildNavItem( 110 + index: 2, 111 + onHoldComplete: widget.onNotifications, 112 + icon: Stack( 113 + alignment: Alignment.center, 114 + children: [ 115 + FaIcon( 116 + AppIcons.solidBell, 117 + size: 20, 118 + color: widget.navIndex == 2 119 + ? AppTheme.primaryColor 120 + : theme.colorScheme.onSurfaceVariant, 121 + ), 122 + if (unreadCount > 0) 123 + Positioned( 124 + right: 0, 125 + top: 0, 126 + child: Container( 127 + padding: 128 + const EdgeInsets.symmetric(horizontal: 4, vertical: 1), 129 + decoration: BoxDecoration( 130 + color: theme.colorScheme.primary, 131 + borderRadius: BorderRadius.circular(10), 132 + border: Border.all( 133 + color: theme.scaffoldBackgroundColor, 134 + width: 1, 118 135 ), 119 136 ), 120 - if (unreadCount > 0) 121 - Align( 122 - alignment: Alignment.center, 123 - child: Transform.translate( 124 - offset: const Offset(10, -10), 125 - child: Container( 126 - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), 127 - decoration: BoxDecoration( 128 - color: theme.colorScheme.primary, 129 - borderRadius: BorderRadius.circular(10), 130 - border: Border.all(color: theme.scaffoldBackgroundColor, width: 1), 131 - ), 132 - constraints: const BoxConstraints(minWidth: 16, minHeight: 16), 133 - child: Text( 134 - unreadCount > 99 ? '99+' : unreadCount.toString(), 135 - style: const TextStyle( 136 - color: Colors.white, 137 - fontSize: 10, 138 - fontWeight: FontWeight.bold, 139 - ), 140 - textAlign: TextAlign.center, 141 - ), 142 - ), 143 - ), 137 + constraints: 138 + const BoxConstraints(minWidth: 16, minHeight: 16), 139 + child: Text( 140 + unreadCount > 99 ? '99+' : unreadCount.toString(), 141 + style: const TextStyle( 142 + color: Colors.white, 143 + fontSize: 10, 144 + fontWeight: FontWeight.bold, 144 145 ), 145 - ], 146 + textAlign: TextAlign.center, 147 + ), 148 + ), 146 149 ), 147 - ), 148 - ), 150 + ], 149 151 ), 150 152 ), 151 - Expanded( 152 - child: GestureDetector( 153 - behavior: HitTestBehavior.opaque, 154 - onTap: onProfile, 155 - child: SizedBox( 156 - height: 42 + MediaQuery.of(context).padding.bottom, 157 - child: Transform.translate( 158 - offset: const Offset(0, -10), 159 - child: Center( 160 - child: avatarUrl != null && avatarUrl.isNotEmpty 161 - ? Container( 162 - width: 28, 163 - height: 28, 164 - alignment: Alignment.center, 165 - decoration: navIndex == 3 166 - ? BoxDecoration( 167 - shape: BoxShape.circle, 168 - border: Border.all(color: AppTheme.primaryColor, width: 2.2), 169 - ) 170 - : null, 171 - child: ClipOval( 172 - child: AppImage( 173 - url: avatarUrl, 174 - width: 24, 175 - height: 24, 176 - fit: BoxFit.cover, 177 - ), 178 - ), 179 - ) 180 - : FaIcon( 181 - navIndex == 3 ? AppIcons.solidUser : AppIcons.user, 182 - size: 16, 183 - color: navIndex == 3 184 - ? AppTheme.primaryColor 185 - : Theme.of(context).colorScheme.onSurfaceVariant, 186 - ), 187 - ), 153 + _buildNavItem( 154 + index: 3, 155 + onHoldComplete: widget.onProfile, 156 + icon: avatarUrl != null && avatarUrl.isNotEmpty 157 + ? Container( 158 + width: 28, 159 + height: 28, 160 + alignment: Alignment.center, 161 + decoration: widget.navIndex == 3 162 + ? BoxDecoration( 163 + shape: BoxShape.circle, 164 + border: Border.all( 165 + color: AppTheme.primaryColor, width: 2.2), 166 + ) 167 + : null, 168 + child: ClipOval( 169 + child: AppImage( 170 + url: avatarUrl, 171 + width: 24, 172 + height: 24, 173 + fit: BoxFit.cover, 188 174 ), 189 175 ), 176 + ) 177 + : FaIcon( 178 + widget.navIndex == 3 179 + ? AppIcons.solidUser 180 + : AppIcons.user, 181 + size: 16, 182 + color: widget.navIndex == 3 183 + ? AppTheme.primaryColor 184 + : theme.colorScheme.onSurfaceVariant, 190 185 ), 191 186 ), 192 187 ], ··· 194 189 ); 195 190 } 196 191 } 192 + 193 + class _NavItem extends StatefulWidget { 194 + final Widget icon; 195 + final VoidCallback onHoldComplete; 196 + final int index; 197 + final bool isPressed; 198 + 199 + const _NavItem({ 200 + required this.icon, 201 + required this.onHoldComplete, 202 + required this.index, 203 + required this.isPressed, 204 + }); 205 + 206 + @override 207 + State<_NavItem> createState() => _NavItemState(); 208 + } 209 + 210 + class _NavItemState extends State<_NavItem> { 211 + bool _pressed = false; 212 + 213 + @override 214 + Widget build(BuildContext context) { 215 + return GestureDetector( 216 + onTapDown: (_) { 217 + setState(() => _pressed = true); 218 + }, 219 + onTapUp: (_) { 220 + Future.delayed(const Duration(milliseconds: 200), () { 221 + setState(() => _pressed = false); 222 + }); 223 + }, 224 + onTapCancel: () { 225 + setState(() => _pressed = false); 226 + }, 227 + onTap: widget.onHoldComplete, 228 + behavior: HitTestBehavior.opaque, 229 + child: Center( 230 + child: AnimatedScale( 231 + scale: _pressed ? 0.85 : 1.0, 232 + duration: const Duration(milliseconds: 150), 233 + child: widget.icon, 234 + ), 235 + ), 236 + ); 237 + } 238 + }