Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/src/app/shell/pages/thunder_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -377,7 +377,7 @@ class _ThunderState extends State<Thunder> {
duration: Duration(milliseconds: reduceAnimations ? 0 : 150),
curve: Curves.easeOut,
opacity: (hideBottomBarOnScroll && !context.select<ShellChromeCubit, bool>((cubit) => cubit.state.isBottomNavBarVisible)) ? 0.0 : 1.0,
child: CustomBottomNavigationBar(
child: BottomNavigationBar(
feedActionController: _rootFeedActionController,
selectedPageIndex: selectedPageIndex,
onPageChange: (int index) {
Expand Down
230 changes: 102 additions & 128 deletions lib/src/app/shell/widgets/bottom_nav_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ 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';
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;
Expand All @@ -25,147 +26,120 @@ class CustomBottomNavigationBar extends StatefulWidget {
/// Optional controller for the root feed page.
final FeedActionController? feedActionController;

@override
State<CustomBottomNavigationBar> createState() => _CustomBottomNavigationBarState();
}

class _CustomBottomNavigationBarState extends State<CustomBottomNavigationBar> {
/// 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<GesturePreferencesCubit>().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<ShellChromeCubit>().state.isFeedFabOpen) {
context.read<ShellChromeCubit>().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<SearchBloc>().add(SearchFocusRequested());
}

bool bottomNavBarDoubleTapGestures = context.read<GesturePreferencesCubit>().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<InboxBloc>().add(const GetInboxEvent(reset: true));
}
}

@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final l10n = GlobalContext.l10n;

final showNavigationLabels = context.select<ThunderCubit, bool>((bloc) => bloc.state.showNavigationLabels);
final bottomNavBarSwipeGestures = context.select<GesturePreferencesCubit, bool>((cubit) => cubit.state.bottomNavBarSwipeGestures);
final bottomNavBarDoubleTapGestures = context.select<GesturePreferencesCubit, bool>((cubit) => cubit.state.bottomNavBarDoubleTapGestures);
final inboxState = context.watch<InboxBloc>().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<InboxBloc, int>((bloc) => bloc.state.totalUnreadCount);

return ThunderBottomNavigationBar(
selectedIndex: selectedPageIndex,
labelBehavior: showNavigationLabels ? NavigationDestinationLabelBehavior.alwaysShow : NavigationDestinationLabelBehavior.alwaysHide,
longPressTimeout: const Duration(milliseconds: 300),
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<ShellChromeCubit>().state.isFeedFabOpen) {
context.read<ShellChromeCubit>().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<SearchBloc>().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<InboxBloc>().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),
);
}
}
Loading
Loading