reorganize navigation into drawer; change button placement

This commit is contained in:
Fabian Freund
2026-02-01 09:23:33 +01:00
parent 0c2923fd82
commit dd19618002
5 changed files with 367 additions and 254 deletions
@@ -41,7 +41,7 @@ final class TabStatesProvider
}
}
String _$tabStatesHash() => r'985d2eb87c59c3a113b8bbcf9d96267c42a2f543';
String _$tabStatesHash() => r'2d18c0c2105ce53bd13e6a6dff1d33c1b531919d';
abstract class _$TabStates extends $Notifier<Map<String, TabState>> {
Map<String, TabState> build();
@@ -46,6 +46,7 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart';
@@ -428,6 +429,7 @@ class BrowserScreen extends HookConsumerWidget {
child: Scaffold(
// Minimal scaffold - only for Material overlay support (SnackBars)
resizeToAvoidBottomInset: false,
drawer: const BrowserNavigationDrawer(),
body: Stack(
children: [
// Layer 0: Browser content
@@ -179,6 +179,7 @@ class AppBarTitle extends HookConsumerWidget {
),
),
),
const SizedBox(width: 8.0),
],
);
}
@@ -18,27 +18,22 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/entities/sheet.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/app_bar_title.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/navigation_buttons.dart';
@@ -51,17 +46,12 @@ import 'package:weblibre/features/geckoview/features/readerview/presentation/con
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
import 'package:weblibre/utils/exit_app.dart';
class BrowserTopAppBar extends HookConsumerWidget {
final bool showMainToolbar;
@@ -286,11 +276,15 @@ class BrowserTabBar extends HookConsumerWidget {
primary: false,
automaticallyImplyLeading: false,
titleSpacing: 0.0,
leadingWidth: 40.0,
toolbarHeight: kToolbarHeight,
backgroundColor:
(containerColor != null && displayedSheet is! ViewTabsSheet)
? ContainerColors.forAppBar(containerColor)
: null,
leading: showMainToolbarNavigationButton
? NavigationMenuButton(selectedTabId: selectedTabId)
: null,
title:
(selectedTabId != null && displayedSheet is! ViewTabsSheet)
? const AppBarTitle()
@@ -349,6 +343,12 @@ class BrowserTabBar extends HookConsumerWidget {
child: const Icon(MdiIcons.puzzle),
),
),
if (showMainToolbarTabsCount)
TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
if (selectedTabId != null)
TabMenu(
controller: trippleDotMenuController,
@@ -366,14 +366,6 @@ class BrowserTabBar extends HookConsumerWidget {
);
},
),
if (showMainToolbarTabsCount)
TabsCountButton(
selectedTabId: selectedTabId,
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
if (showMainToolbarNavigationButton)
NavigationMenuButton(selectedTabId: selectedTabId),
],
),
),
@@ -414,6 +406,7 @@ class ContextualToolbar extends HookConsumerWidget {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
NavigationMenuButton(selectedTabId: selectedTabId),
if (tabState?.historyState.canGoBack == true ||
tabState?.isLoading == true)
NavigateBackButton(
@@ -439,7 +432,23 @@ class ContextualToolbar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
NavigationMenuButton(selectedTabId: selectedTabId),
if (selectedTabId != null)
TabMenu(
controller: useMenuController(),
selectedTabId: selectedTabId!,
builder: (context, controller, child) {
return ToolbarButton(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(MdiIcons.dotsVertical),
);
},
),
],
);
}
@@ -616,246 +625,18 @@ class ShareMenuButton extends HookConsumerWidget {
}
}
class NavigationMenuButton extends HookConsumerWidget {
class NavigationMenuButton extends StatelessWidget {
final String? selectedTabId;
const NavigationMenuButton({super.key, required this.selectedTabId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonService = ref.watch(addonServiceProvider);
final hamburgerMenuController = useMenuController();
return MenuAnchor(
controller: hamburgerMenuController,
builder: (context, controller, child) {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: InkWell(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Padding(
padding: EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0),
child: Icon(Icons.menu),
),
),
);
Widget build(BuildContext context) {
return ToolbarButton(
onTap: () {
Scaffold.of(context).openDrawer();
},
menuChildren: [
MenuItemButton(
onPressed: () async {
await const SelectProfileRoute().push(context);
},
leadingIcon: const Icon(Icons.person),
child: Consumer(
builder: (context, ref, child) {
final profile = ref.watch(selectedProfileProvider);
return Text(profile.value?.name ?? 'User');
},
),
),
MenuItemButton(
onPressed: () async {
final result = await showQuitBrowserDialog(context);
if (result == true && context.mounted) {
await exitApp(ProviderScope.containerOf(context));
}
},
leadingIcon: const Icon(MdiIcons.power),
child: const Text('Quit Browser'),
),
const Divider(),
Consumer(
builder: (context, childRef, child) {
final pageExtensions = childRef.watch(
webExtensionsStateProvider(
WebExtensionActionType.page,
).select((value) => value.values.toList()),
);
return Wrap(
alignment: WrapAlignment.center,
children: [
...pageExtensions.map(
(extension) => IconButton(
onPressed: () async {
//Use parents .ref because after onPressed this consumer gets disposed already
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.page,
);
},
icon: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: ExtensionBadgeIcon(extension),
),
),
),
],
);
},
),
MenuItemButton(
onPressed: () async {
await AboutRoute().push(context);
},
leadingIcon: const Icon(Icons.info),
child: const Text('About'),
),
// MenuItemButton(
// onPressed: () async {
// final isPrivate =
// ref
// .read(generalSettingsWithDefaultsProvider)
// .defaultCreateTabType ==
// TabType.private;
// await ref
// .read(tabRepositoryProvider.notifier)
// .addTab(
// url: ref.read(docsUriProvider),
// private: isPrivate,
// container: const Value(null),
// );
// },
// leadingIcon: const Icon(Icons.help),
// child: const Text('Help and feedback'),
// ),
const Divider(),
MenuItemButton(
onPressed: () async {
await SettingsRoute().push(context);
},
leadingIcon: const Icon(Icons.settings),
child: const Text('Settings'),
),
Consumer(
builder: (context, childRef, child) {
final browserExtensions = childRef.watch(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
return SubmenuButton(
menuChildren: [
...browserExtensions.map(
(extension) => MenuItemButton(
onPressed: () async {
//Use parents .ref because after onPressed this consumer gets disposed already
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
leadingIcon: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: ExtensionBadgeIcon(extension),
),
child: Text(extension.title ?? ''),
),
),
MenuItemButton(
onPressed: () async {
await addonService.startAddonManagerActivity();
},
leadingIcon: const Icon(MdiIcons.puzzleEdit),
child: const Text('Manage Extension'),
),
MenuItemButton(
onPressed: () async {
final isPrivate =
ref
.read(generalSettingsWithDefaultsProvider)
.defaultCreateTabType ==
TabType.private;
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse('https://addons.mozilla.org'),
private: isPrivate,
container: const Value(null),
selectTab: true,
);
},
leadingIcon: const Icon(MdiIcons.puzzlePlus),
child: const Text('Get Extensions'),
),
],
leadingIcon: const Icon(MdiIcons.puzzle),
child: const Text('Extensions'),
);
},
),
MenuItemButton(
onPressed: () async {
await const TorProxyRoute().push(context);
},
leadingIcon: const Icon(TorIcons.onionAlt),
child: Consumer(
child: const Text('Tor™ Proxy'),
builder: (context, ref, child) {
final torConnected = ref.watch(
torProxyServiceProvider.select(
(value) => value.value?.isRunning == true,
),
);
return Badge(
isLabelVisible: torConnected,
backgroundColor: AppColors.of(context).torActiveGreen,
child: child,
);
},
),
),
const Divider(),
MenuItemButton(
onPressed: () async {
await const HistoryRoute().push(context);
},
leadingIcon: const Icon(Icons.history),
child: const Text('History'),
),
MenuItemButton(
onPressed: () async {
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
leadingIcon: const Icon(MdiIcons.bookmarkMultiple),
child: const Text('Bookmarks'),
),
MenuItemButton(
onPressed: () async {
await const BangMenuRoute().push(context);
},
leadingIcon: const Icon(MdiIcons.exclamationThick),
child: const Text('Bangs'),
),
MenuItemButton(
onPressed: () async {
await const ContainerListRoute().push(context);
},
leadingIcon: const Icon(MdiIcons.folder),
child: const Text('Containers'),
),
MenuItemButton(
onPressed: () async {
await context.push(FeedListRoute().location);
},
leadingIcon: const Icon(Icons.rss_feed),
child: const Text('Feeds'),
),
],
child: const Icon(Icons.menu),
);
}
}
@@ -0,0 +1,329 @@
/*
* Copyright (c) 2024-2025 Fabian Freund.
*
* This file is part of WebLibre
* (see https://weblibre.eu).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/providers.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/extension_badge_icon.dart';
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
import 'package:weblibre/features/user/domain/providers.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/icons/tor_icons.dart';
import 'package:weblibre/utils/exit_app.dart';
/// Navigation drawer for the browser screen.
/// Contains all navigation destinations and settings.
class BrowserNavigationDrawer extends HookConsumerWidget {
const BrowserNavigationDrawer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
return NavigationDrawer(
backgroundColor: colorScheme.surface,
children: [
// Profile Header
_ProfileHeader(),
const Divider(),
// Section 1: Tools & Configuration
_ExtensionsSection(),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () async {
Navigator.of(context).pop();
await SettingsRoute().push(context);
},
),
Consumer(
builder: (context, ref, child) {
final torConnected = ref.watch(
torProxyServiceProvider.select(
(value) => value.value?.isRunning == true,
),
);
return ListTile(
leading: Badge(
isLabelVisible: torConnected,
backgroundColor: AppColors.of(context).torActiveGreen,
child: const Icon(TorIcons.onionAlt),
),
title: const Text('Tor™ Proxy'),
onTap: () async {
Navigator.of(context).pop();
await const TorProxyRoute().push(context);
},
);
},
),
const Divider(),
// Section 2: Data & Content
ListTile(
leading: const Icon(Icons.history),
title: const Text('History'),
onTap: () async {
Navigator.of(context).pop();
await const HistoryRoute().push(context);
},
),
ListTile(
leading: const Icon(MdiIcons.bookmarkMultiple),
title: const Text('Bookmarks'),
onTap: () async {
Navigator.of(context).pop();
await BookmarkListRoute(
entryGuid: BookmarkRoot.root.id,
).push(context);
},
),
ListTile(
leading: const Icon(MdiIcons.exclamationThick),
title: const Text('Bangs'),
onTap: () async {
Navigator.of(context).pop();
await const BangMenuRoute().push(context);
},
),
ListTile(
leading: const Icon(MdiIcons.folder),
title: const Text('Containers'),
onTap: () async {
Navigator.of(context).pop();
await const ContainerListRoute().push(context);
},
),
ListTile(
leading: const Icon(Icons.rss_feed),
title: const Text('Feeds'),
onTap: () async {
Navigator.of(context).pop();
await context.push(FeedListRoute().location);
},
),
const Divider(),
// Section 3: App
ListTile(
leading: const Icon(Icons.info),
title: const Text('About'),
onTap: () async {
Navigator.of(context).pop();
await AboutRoute().push(context);
},
),
// Quit Browser - styled as destructive action
ListTile(
leading: Icon(MdiIcons.power, color: colorScheme.error),
title: Text(
'Quit Browser',
style: TextStyle(color: colorScheme.error),
),
onTap: () async {
final result = await showQuitBrowserDialog(context);
if (result == true && context.mounted) {
await exitApp(ProviderScope.containerOf(context));
}
},
),
],
);
}
}
/// Profile header widget showing current profile information.
class _ProfileHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final profile = ref.watch(selectedProfileProvider);
return InkWell(
onTap: () async {
Navigator.of(context).pop(); // Close drawer
await const SelectProfileRoute().push(context);
},
child: Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 28,
backgroundColor: theme.colorScheme.primaryContainer,
child: Icon(
Icons.person,
size: 28,
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 12),
Text(
profile.value?.name ?? 'User',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Tap to switch profile',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
/// Extensions section with expandable list of installed extensions.
class _ExtensionsSection extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final addonService = ref.watch(addonServiceProvider);
final pageExtensions = ref.watch(
webExtensionsStateProvider(
WebExtensionActionType.page,
).select((value) => value.values.toList()),
);
final browserExtensions = ref.watch(
webExtensionsStateProvider(
WebExtensionActionType.browser,
).select((value) => value.values.toList()),
);
final isExpanded = useState(false);
return Column(
children: [
ListTile(
leading: const Icon(MdiIcons.puzzle),
title: const Text('Extensions'),
trailing: Icon(
isExpanded.value ? Icons.expand_less : Icons.expand_more,
),
onTap: () => isExpanded.value = !isExpanded.value,
),
if (isExpanded.value)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Column(
children: [
// Page extensions
if (pageExtensions.isNotEmpty) ...[
...pageExtensions.map(
(extension) => ListTile(
leading: ExtensionBadgeIcon(extension),
title: Text(extension.title ?? 'Extension'),
dense: true,
onTap: () async {
Navigator.of(context).pop(); // Close drawer
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.page,
);
},
),
),
const Divider(indent: 16, endIndent: 16),
],
// Browser extensions
if (browserExtensions.isNotEmpty) ...[
...browserExtensions.map(
(extension) => ListTile(
leading: ExtensionBadgeIcon(extension),
title: Text(extension.title ?? 'Extension'),
dense: true,
onTap: () async {
Navigator.of(context).pop(); // Close drawer
await addonService.invokeAddonAction(
extension.extensionId,
WebExtensionActionType.browser,
);
},
),
),
const Divider(indent: 16, endIndent: 16),
],
// Management options
ListTile(
leading: const Icon(MdiIcons.puzzleEdit),
title: const Text('Manage Extensions'),
dense: true,
onTap: () async {
Navigator.of(context).pop(); // Close drawer
await addonService.startAddonManagerActivity();
},
),
ListTile(
leading: const Icon(MdiIcons.puzzlePlus),
title: const Text('Get Extensions'),
dense: true,
onTap: () async {
Navigator.of(context).pop(); // Close drawer
final isPrivate =
ref
.read(generalSettingsWithDefaultsProvider)
.defaultCreateTabType ==
TabType.private;
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse('https://addons.mozilla.org'),
private: isPrivate,
container: const Value(null),
selectTab: true,
);
},
),
],
),
),
],
);
}
}