From 6231dfe0fa42556deb1feb6feca8f7c03dbfce64 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 17 Mar 2026 08:48:50 -0700 Subject: [PATCH 1/2] fix: fix account bottom nav bar not triggering profile selector --- lib/src/app/shell/pages/thunder_page.dart | 4 +- lib/src/app/shell/widgets/bottom_nav_bar.dart | 229 ++++++++---------- .../shell/widgets/thunder_bottom_nav_bar.dart | 203 ++++++++++++++++ 3 files changed, 306 insertions(+), 130 deletions(-) create mode 100644 lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart diff --git a/lib/src/app/shell/pages/thunder_page.dart b/lib/src/app/shell/pages/thunder_page.dart index 36df859a1..74d476c80 100644 --- a/lib/src/app/shell/pages/thunder_page.dart +++ b/lib/src/app/shell/pages/thunder_page.dart @@ -3,7 +3,7 @@ import 'dart:io'; // Flutter import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide BottomNavigationBar; import 'package:flutter/services.dart'; // Packages @@ -377,7 +377,7 @@ class _ThunderState extends State { duration: Duration(milliseconds: reduceAnimations ? 0 : 150), curve: Curves.easeOut, opacity: (hideBottomBarOnScroll && !context.select((cubit) => cubit.state.isBottomNavBarVisible)) ? 0.0 : 1.0, - child: CustomBottomNavigationBar( + child: BottomNavigationBar( feedActionController: _rootFeedActionController, selectedPageIndex: selectedPageIndex, onPageChange: (int index) { diff --git a/lib/src/app/shell/widgets/bottom_nav_bar.dart b/lib/src/app/shell/widgets/bottom_nav_bar.dart index 4611f86d4..60651d7b0 100644 --- a/lib/src/app/shell/widgets/bottom_nav_bar.dart +++ b/lib/src/app/shell/widgets/bottom_nav_bar.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:thunder/l10n/generated/app_localizations.dart'; import 'package:thunder/src/app/shell/state/shell_chrome_cubit.dart'; import 'package:thunder/src/features/feed/api.dart'; import 'package:thunder/src/features/account/account.dart'; @@ -11,10 +10,12 @@ import 'package:thunder/src/features/inbox/inbox.dart'; import 'package:thunder/src/features/search/search.dart'; import 'package:thunder/src/app/state/thunder/thunder_bloc.dart'; import 'package:thunder/src/features/settings/api.dart'; +import 'package:thunder/src/app/shell/widgets/thunder_bottom_nav_bar.dart'; +import 'package:thunder/src/foundation/config/global_context.dart'; -/// A custom bottom navigation bar that enables tap/swipe gestures -class CustomBottomNavigationBar extends StatefulWidget { - const CustomBottomNavigationBar({super.key, required this.selectedPageIndex, required this.onPageChange, this.feedActionController}); +/// Defines the bottom navigation bar for Thunder. Uses a custom [ThunderBottomNavigationBar] to handle additional gestures and long-press behavior. +class BottomNavigationBar extends StatelessWidget { + const BottomNavigationBar({super.key, required this.selectedPageIndex, required this.onPageChange, this.feedActionController}); /// The index of the currently selected page final int selectedPageIndex; @@ -25,147 +26,119 @@ class CustomBottomNavigationBar extends StatefulWidget { /// Optional controller for the root feed page. final FeedActionController? feedActionController; - @override - State createState() => _CustomBottomNavigationBarState(); -} - -class _CustomBottomNavigationBarState extends State { - /// This is used for the swipe drag gesture on the bottom nav bar - double _dragStartX = 0.0; - double _dragEndX = 0.0; + /// The index of the account tab in the navigation bar. Used to trigger the profile modal sheet on long-press. + static const int _accountTabIndex = 2; - // Handles drag on bottom nav bar to open the drawer - void _handleDragStart(DragStartDetails details) { - _dragStartX = details.globalPosition.dx; - } - - // Handles drag on bottom nav bar to open the drawer - void _handleDragUpdate(DragUpdateDetails details) async { - _dragEndX = details.globalPosition.dx; - } - - // Handles drag on bottom nav bar to open the drawer - void _handleDragEnd(DragEndDetails details, BuildContext context) async { - if (widget.selectedPageIndex != 0) return; - - bool bottomNavBarSwipeGestures = context.read().state.bottomNavBarSwipeGestures; - if (bottomNavBarSwipeGestures == false) return; - - double delta = _dragEndX - _dragStartX; - - // Set some threshold to also allow for swipe up to reveal FAB - if (delta > 20) { - if (context.mounted) Scaffold.of(context).openDrawer(); - } else if (delta < 0) { - if (context.mounted) Scaffold.of(context).closeDrawer(); + void _handleDestinationSelected(BuildContext context, int index) { + if (context.read().state.isFeedFabOpen) { + context.read().setFeedFabOpen(false); } - _dragStartX = 0.0; - } + if (selectedPageIndex == 0 && index == 0) { + feedActionController?.scrollToTop(); + } - // Handles double-tap to open the drawer - void _handleDoubleTap(BuildContext context) async { - if (widget.selectedPageIndex != 0) return; + if (selectedPageIndex == 1 && index != 1) { + FocusManager.instance.primaryFocus?.unfocus(); + } else if (selectedPageIndex == 1 && index == 1) { + context.read().add(SearchFocusRequested()); + } - bool bottomNavBarDoubleTapGestures = context.read().state.bottomNavBarDoubleTapGestures; - if (bottomNavBarDoubleTapGestures == false) return; + if (selectedPageIndex == 3 && index == 3) { + return; + } - bool isDrawerOpen = context.mounted ? Scaffold.of(context).isDrawerOpen : false; + if (selectedPageIndex != index) { + onPageChange(index); + } - if (isDrawerOpen) { - if (context.mounted) Scaffold.of(context).closeDrawer(); - } else { - if (context.mounted) Scaffold.of(context).openDrawer(); + // TODO: Change this from integer to enum or some other type + if (index == 3) { + context.read().add(const GetInboxEvent(reset: true)); } } @override Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; + final l10n = GlobalContext.l10n; final showNavigationLabels = context.select((bloc) => bloc.state.showNavigationLabels); + final bottomNavBarSwipeGestures = context.select((cubit) => cubit.state.bottomNavBarSwipeGestures); final bottomNavBarDoubleTapGestures = context.select((cubit) => cubit.state.bottomNavBarDoubleTapGestures); - final inboxState = context.watch().state; - - return GestureDetector( - onHorizontalDragStart: _handleDragStart, - onHorizontalDragUpdate: _handleDragUpdate, - onHorizontalDragEnd: (DragEndDetails dragEndDetails) => _handleDragEnd(dragEndDetails, context), - onDoubleTap: bottomNavBarDoubleTapGestures == true ? () => _handleDoubleTap(context) : null, - child: NavigationBar( - selectedIndex: widget.selectedPageIndex, - labelBehavior: showNavigationLabels ? NavigationDestinationLabelBehavior.alwaysShow : NavigationDestinationLabelBehavior.alwaysHide, - destinations: [ - NavigationDestination( - icon: const Icon(Icons.dashboard_outlined), - selectedIcon: const Icon(Icons.dashboard_rounded), - label: l10n.feed, - ), - NavigationDestination( - icon: const Icon(Icons.search_outlined), - selectedIcon: const Icon(Icons.search_rounded), - label: l10n.search, - ), - GestureDetector( - onLongPress: () { - HapticFeedback.mediumImpact(); - showProfileModalSheet(context); - }, - child: NavigationDestination( - icon: const Icon(Icons.person_outline_rounded), - selectedIcon: const Icon(Icons.person_rounded), - label: l10n.account(1), - tooltip: '', // Disable tooltip so that gesture detector triggers properly - ), - ), - NavigationDestination( - icon: Badge( - isLabelVisible: inboxState.totalUnreadCount != 0, - label: Text(inboxState.totalUnreadCount > 99 ? '99+' : inboxState.totalUnreadCount.toString()), - child: const Icon(Icons.inbox_outlined), - ), - selectedIcon: Badge( - isLabelVisible: inboxState.totalUnreadCount != 0, - label: Text(inboxState.totalUnreadCount > 99 ? '99+' : inboxState.totalUnreadCount.toString()), - child: const Icon(Icons.inbox_rounded), - ), - label: l10n.inbox, + + final enableDrawerGestures = selectedPageIndex == 0 && bottomNavBarSwipeGestures; + final enableDrawerDoubleTap = selectedPageIndex == 0 && bottomNavBarDoubleTapGestures; + + final totalUnreadCount = context.select((bloc) => bloc.state.totalUnreadCount); + + return ThunderBottomNavigationBar( + selectedIndex: selectedPageIndex, + labelBehavior: showNavigationLabels ? NavigationDestinationLabelBehavior.alwaysShow : NavigationDestinationLabelBehavior.alwaysHide, + onHorizontalSwipeRight: enableDrawerGestures + ? () { + if (context.mounted) Scaffold.of(context).openDrawer(); + } + : null, + onHorizontalSwipeLeft: enableDrawerGestures + ? () { + if (context.mounted) Scaffold.of(context).closeDrawer(); + } + : null, + onDoubleTap: enableDrawerDoubleTap + ? () { + if (!context.mounted) return; + + final scaffold = Scaffold.of(context); + if (scaffold.isDrawerOpen) { + scaffold.closeDrawer(); + } else { + scaffold.openDrawer(); + } + } + : null, + onDestinationLongPresses: { + _accountTabIndex: () { + HapticFeedback.mediumImpact(); + showProfileModalSheet(context); + }, + }, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.dashboard_outlined), + selectedIcon: const Icon(Icons.dashboard_rounded), + label: l10n.feed, + ), + NavigationDestination( + icon: const Icon(Icons.search_outlined), + selectedIcon: const Icon(Icons.search_rounded), + label: l10n.search, + ), + NavigationDestination( + icon: const Icon(Icons.person_outline_rounded), + selectedIcon: const Icon(Icons.person_rounded), + label: l10n.account(1), + tooltip: '', // Keep tooltip disabled so long-press opens the profile selector instead. + ), + NavigationDestination( + icon: Badge( + isLabelVisible: totalUnreadCount != 0, + label: Text(totalUnreadCount > 99 ? '99+' : totalUnreadCount.toString()), + child: const Icon(Icons.inbox_outlined), ), - NavigationDestination( - icon: const Icon(Icons.settings_outlined), - selectedIcon: const Icon(Icons.settings_rounded), - label: l10n.settings, + selectedIcon: Badge( + isLabelVisible: totalUnreadCount != 0, + label: Text(totalUnreadCount > 99 ? '99+' : totalUnreadCount.toString()), + child: const Icon(Icons.inbox_rounded), ), - ], - onDestinationSelected: (index) { - if (context.read().state.isFeedFabOpen) { - context.read().setFeedFabOpen(false); - } - - if (widget.selectedPageIndex == 0 && index == 0) { - widget.feedActionController?.scrollToTop(); - } - - if (widget.selectedPageIndex == 1 && index != 1) { - FocusManager.instance.primaryFocus?.unfocus(); - } else if (widget.selectedPageIndex == 1 && index == 1) { - context.read().add(SearchFocusRequested()); - } - - if (widget.selectedPageIndex == 3 && index == 3) { - return; - } - - if (widget.selectedPageIndex != index) { - widget.onPageChange(index); - } - - // TODO: Change this from integer to enum or some other type - if (index == 3) { - context.read().add(const GetInboxEvent(reset: true)); - } - }, - ), + label: l10n.inbox, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings_rounded), + label: l10n.settings, + ), + ], + onDestinationSelected: (index) => _handleDestinationSelected(context, index), ); } } diff --git a/lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart b/lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart new file mode 100644 index 000000000..6880ea205 --- /dev/null +++ b/lib/src/app/shell/widgets/thunder_bottom_nav_bar.dart @@ -0,0 +1,203 @@ +import 'dart:async'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// A [NavigationBar] that handles custom gestures. +class ThunderBottomNavigationBar extends StatefulWidget { + const ThunderBottomNavigationBar({ + super.key, + required this.selectedIndex, + required this.destinations, + required this.onDestinationSelected, + this.labelBehavior, + this.onDestinationLongPresses = const {}, + this.longPressTimeout = kLongPressTimeout, + this.longPressSuppressionDuration = const Duration(seconds: 1), + this.onHorizontalSwipeLeft, + this.onHorizontalSwipeRight, + this.horizontalSwipeThreshold = 20, + this.onDoubleTap, + }); + + /// The index of the currently selected destination. + final int selectedIndex; + + /// The list of destinations to display in the navigation bar. + final List destinations; + + /// Callback invoked when a destination is selected (tapped). + final ValueChanged onDestinationSelected; + + /// Controls the visibility of destination labels. + final NavigationDestinationLabelBehavior? labelBehavior; + + /// Long-press callbacks keyed by destination index. + final Map onDestinationLongPresses; + + /// How long a press must be held before the destination long-press fires. + final Duration longPressTimeout; + + /// How long to suppress the next selection after a long-press fires. + final Duration longPressSuppressionDuration; + + /// Callback invoked when the bar is swiped left past the threshold. + final VoidCallback? onHorizontalSwipeLeft; + + /// Callback invoked when the bar is swiped right past the threshold. + final VoidCallback? onHorizontalSwipeRight; + + /// Minimum horizontal drag delta before a swipe callback is fired. + final double horizontalSwipeThreshold; + + /// Callback invoked when the bar is double-tapped. + final VoidCallback? onDoubleTap; + + @override + State createState() => _ThunderBottomNavigationBarState(); +} + +class _ThunderBottomNavigationBarState extends State { + /// Timer used to track long-press duration. + Timer? _longPressTimer; + + /// The pointer ID currently being tracked for a potential long-press. + int? _trackedPointer; + + /// The index of the destination currently being tracked for a long-press. + int? _trackedDestinationIndex; + + /// The original position where the pointer went down, used to detect movement. + Offset? _pressOrigin; + + /// The index of the destination for which the long-press was last triggered, used to suppress the next selection. + int? _suppressedDestinationIndex; + + /// The timestamp until which selection of the long-pressed destination should be suppressed. + DateTime? _suppressSelectionUntil; + + /// The starting X position of a horizontal drag, used to detect swipe gestures. + double _dragStartX = 0.0; + + /// The ending X position of a horizontal drag, used to detect swipe gestures. + double _dragEndX = 0.0; + + @override + void dispose() { + _cancelLongPressTracking(); + super.dispose(); + } + + int _destinationIndexForOffset(double dx, double maxWidth) { + if (maxWidth <= 0 || widget.destinations.isEmpty) return -1; + + final clampedDx = dx.clamp(0.0, maxWidth - 0.001); + return (clampedDx / (maxWidth / widget.destinations.length)).floor(); + } + + void _cancelLongPressTracking() { + _longPressTimer?.cancel(); + _longPressTimer = null; + _trackedPointer = null; + _trackedDestinationIndex = null; + _pressOrigin = null; + } + + void _handlePointerDown(PointerDownEvent event, double maxWidth) { + _cancelLongPressTracking(); + + final destinationIndex = _destinationIndexForOffset(event.localPosition.dx, maxWidth); + if (!widget.onDestinationLongPresses.containsKey(destinationIndex)) return; + + _trackedPointer = event.pointer; + _trackedDestinationIndex = destinationIndex; + _pressOrigin = event.localPosition; + _longPressTimer = Timer(widget.longPressTimeout, () { + if (!mounted) return; + + final callback = widget.onDestinationLongPresses[destinationIndex]; + if (callback == null) return; + + _cancelLongPressTracking(); + _suppressedDestinationIndex = destinationIndex; + _suppressSelectionUntil = DateTime.now().add(widget.longPressSuppressionDuration); + callback(); + }); + } + + void _handlePointerMove(PointerMoveEvent event, double maxWidth) { + if (event.pointer != _trackedPointer || _pressOrigin == null || _trackedDestinationIndex == null) return; + + final movedTooFar = (event.localPosition - _pressOrigin!).distance > kTouchSlop; + final leftTrackedDestination = _destinationIndexForOffset(event.localPosition.dx, maxWidth) != _trackedDestinationIndex; + + if (movedTooFar || leftTrackedDestination) { + _cancelLongPressTracking(); + } + } + + void _handlePointerEnd(PointerEvent event) { + if (event.pointer != _trackedPointer) return; + _cancelLongPressTracking(); + } + + void _handleDestinationSelected(int index) { + final shouldSuppressSelection = index == _suppressedDestinationIndex && _suppressSelectionUntil != null && DateTime.now().isBefore(_suppressSelectionUntil!); + + if (shouldSuppressSelection) { + _suppressedDestinationIndex = null; + _suppressSelectionUntil = null; + return; + } + + _suppressedDestinationIndex = null; + _suppressSelectionUntil = null; + widget.onDestinationSelected(index); + } + + void _handleHorizontalDragStart(DragStartDetails details) { + _dragStartX = details.globalPosition.dx; + _dragEndX = details.globalPosition.dx; + } + + void _handleHorizontalDragUpdate(DragUpdateDetails details) { + _dragEndX = details.globalPosition.dx; + } + + void _handleHorizontalDragEnd(DragEndDetails details) { + final delta = _dragEndX - _dragStartX; + + if (delta > widget.horizontalSwipeThreshold) { + widget.onHorizontalSwipeRight?.call(); + } else if (delta < -widget.horizontalSwipeThreshold) { + widget.onHorizontalSwipeLeft?.call(); + } + + _dragStartX = 0.0; + _dragEndX = 0.0; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: widget.onHorizontalSwipeLeft != null || widget.onHorizontalSwipeRight != null ? _handleHorizontalDragStart : null, + onHorizontalDragUpdate: widget.onHorizontalSwipeLeft != null || widget.onHorizontalSwipeRight != null ? _handleHorizontalDragUpdate : null, + onHorizontalDragEnd: widget.onHorizontalSwipeLeft != null || widget.onHorizontalSwipeRight != null ? _handleHorizontalDragEnd : null, + onDoubleTap: widget.onDoubleTap, + child: LayoutBuilder( + builder: (context, constraints) => Listener( + onPointerDown: (event) => _handlePointerDown(event, constraints.maxWidth), + onPointerMove: (event) => _handlePointerMove(event, constraints.maxWidth), + onPointerUp: _handlePointerEnd, + onPointerCancel: _handlePointerEnd, + child: NavigationBar( + selectedIndex: widget.selectedIndex, + labelBehavior: widget.labelBehavior, + destinations: widget.destinations, + onDestinationSelected: _handleDestinationSelected, + ), + ), + ), + ); + } +} From 39c69178b881ed93e27ac1fcebdd45e3238b9045 Mon Sep 17 00:00:00 2001 From: Hamlet Jiang Su Date: Tue, 17 Mar 2026 10:00:49 -0700 Subject: [PATCH 2/2] feat: reduce long-press duration --- lib/src/app/shell/widgets/bottom_nav_bar.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/src/app/shell/widgets/bottom_nav_bar.dart b/lib/src/app/shell/widgets/bottom_nav_bar.dart index 60651d7b0..fc766961d 100644 --- a/lib/src/app/shell/widgets/bottom_nav_bar.dart +++ b/lib/src/app/shell/widgets/bottom_nav_bar.dart @@ -74,6 +74,7 @@ class BottomNavigationBar extends StatelessWidget { return ThunderBottomNavigationBar( selectedIndex: selectedPageIndex, labelBehavior: showNavigationLabels ? NavigationDestinationLabelBehavior.alwaysShow : NavigationDestinationLabelBehavior.alwaysHide, + longPressTimeout: const Duration(milliseconds: 300), onHorizontalSwipeRight: enableDrawerGestures ? () { if (context.mounted) Scaffold.of(context).openDrawer();