feat(navbar): add scaling animation #1

Changed files
+172 -130
lib
+172 -130
lib/widgets/bottom_nav_bar.dart
···
+
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.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;
···
});
@override
-
Widget build(BuildContext context, WidgetRef ref) {
+
ConsumerState<BottomNavBar> createState() => _BottomNavBarState();
+
}
+
+
class _BottomNavBarState extends ConsumerState<BottomNavBar> {
+
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))
···
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,
),
),
],
···
);
}
}
+
+
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,
+
),
+
),
+
);
+
}
+
}