merge navigation and tab menu into bottom sheet

This commit is contained in:
Fabian Freund
2026-02-28 18:37:35 +01:00
parent 9be77d00e5
commit b7edd95093
19 changed files with 2095 additions and 536 deletions
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2024-2026 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 'dart:convert';
import 'package:riverpod/experimental/persist.dart';
import 'package:riverpod_annotation/experimental/persist.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/user/data/providers.dart';
part 'extensions_expanded.g.dart';
@Riverpod(keepAlive: true)
class ExtensionsExpanded extends _$ExtensionsExpanded {
void toggle() {
state = !state;
}
@override
bool build() {
persist(
ref.watch(riverpodDatabaseStorageProvider),
key: 'ExtensionsExpanded',
encode: (state) => jsonEncode([state]),
decode: (encoded) => (jsonDecode(encoded) as List<dynamic>).first as bool,
);
return stateOrNull ?? false;
}
}
@@ -0,0 +1,63 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'extensions_expanded.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(ExtensionsExpanded)
final extensionsExpandedProvider = ExtensionsExpandedProvider._();
final class ExtensionsExpandedProvider
extends $NotifierProvider<ExtensionsExpanded, bool> {
ExtensionsExpandedProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'extensionsExpandedProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$extensionsExpandedHash();
@$internal
@override
ExtensionsExpanded create() => ExtensionsExpanded();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(bool value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<bool>(value),
);
}
}
String _$extensionsExpandedHash() =>
r'c07d67f3d00a8c374b598664f7f54c1bcc6de5b7';
abstract class _$ExtensionsExpanded extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -47,7 +47,6 @@ 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';
@@ -292,11 +291,6 @@ class BrowserScreen extends HookConsumerWidget {
),
);
final drawerGestureEnabled = ref.watch(
generalSettingsWithDefaultsProvider.select(
(value) => value.drawerGestureEnabled,
),
);
ref.listen(overlayControllerProvider, (previous, next) {
if (next != null) {
@@ -567,8 +561,6 @@ class BrowserScreen extends HookConsumerWidget {
child: Scaffold(
// Minimal scaffold - only for Material overlay support (SnackBars)
resizeToAvoidBottomInset: false,
endDrawer: const BrowserNavigationDrawer(),
endDrawerEnableOpenDragGesture: drawerGestureEnabled,
body: Stack(
children: [
// Layer 0: Browser content
@@ -879,6 +871,13 @@ class _Browser extends HookConsumerWidget {
return false;
}
// Dismiss modal routes (e.g. showModalBottomSheet)
final rootNavigator = Navigator.of(context, rootNavigator: true);
if (rootNavigator.canPop()) {
rootNavigator.pop();
return true;
}
if (ref.read(bottomSheetControllerProvider) != null) {
ref
.read(bottomSheetControllerProvider.notifier)
@@ -38,6 +38,7 @@ 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/toolbar_visibility.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.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_shortcut_menu.dart';
import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart';
@@ -55,7 +56,6 @@ import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors
import 'package:weblibre/features/user/data/models/general_settings.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/menu_controller.dart';
import 'package:weblibre/presentation/icons/weblibre_icons.dart';
import 'package:weblibre/presentation/widgets/selectable_chips.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
@@ -80,7 +80,6 @@ class BrowserTopAppBar extends HookConsumerWidget {
showQuickTabSwitcherBar: false,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
showMainToolbarTabActionButton: !showContextualToolbar,
);
}
@@ -117,7 +116,6 @@ class BrowserBottomAppBar extends HookConsumerWidget {
showQuickTabSwitcherBar: showQuickTabSwitcherBar,
showMainToolbarNavigationButton: !showContextualToolbar,
showMainToolbarTabsCount: !showContextualToolbar,
showMainToolbarTabActionButton: !showContextualToolbar,
);
}
@@ -147,7 +145,6 @@ class BrowserTabBar extends HookConsumerWidget {
final bool showMainToolbarTabsCount;
final bool showMainToolbarNavigationButton;
final bool showMainToolbarTabActionButton;
const BrowserTabBar({
super.key,
@@ -157,7 +154,6 @@ class BrowserTabBar extends HookConsumerWidget {
required this.showQuickTabSwitcherBar,
required this.showMainToolbarTabsCount,
required this.showMainToolbarNavigationButton,
required this.showMainToolbarTabActionButton,
});
static const contextualToolabarHeight = 54.0;
@@ -191,7 +187,6 @@ class BrowserTabBar extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extensionMenuController = useMenuController();
final trippleDotMenuController = useMenuController();
final selectedTabId = ref.watch(selectedTabProvider);
final settings = ref.watch(generalSettingsWithDefaultsProvider);
@@ -360,23 +355,6 @@ class BrowserTabBar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showLongPressMenu: true,
),
if (selectedTabId != null && showMainToolbarTabActionButton)
TabMenu(
controller: trippleDotMenuController,
selectedTabId: selectedTabId,
builder: (context, controller, child) {
return ToolbarButton(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(WebLibreIcons.tabOptions),
);
},
),
],
),
),
@@ -444,24 +422,6 @@ class ContextualToolbar extends HookConsumerWidget {
displayedSheet: displayedSheet,
showLongPressMenu: false,
),
if (selectedTabId != null)
TabMenu(
controller: useMenuController(),
selectedTabId: selectedTabId!,
enableNavigationButtons: false,
builder: (context, controller, child) {
return ToolbarButton(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: const Icon(WebLibreIcons.tabOptions),
);
},
),
],
);
}
@@ -679,8 +639,8 @@ class NavigationMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ToolbarButton(
onTap: () {
Scaffold.of(context).openEndDrawer();
onTap: () async {
await showBrowserMenuSheet(context);
},
child: const Icon(Icons.menu),
);
@@ -1,422 +0,0 @@
/*
* Copyright (c) 2024-2026 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 'dart:async';
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/entities/tab_container_selection.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/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
import 'package:weblibre/features/sync/domain/repositories/sync.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';
import 'package:weblibre/utils/ui_helper.dart' as ui_helper;
/// 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;
final showContainerUi = ref.watch(
generalSettingsWithDefaultsProvider.select((s) => s.showContainerUi),
);
return NavigationDrawer(
backgroundColor: colorScheme.surface,
header: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [_ProfileHeader(), _SyncTile(), const Divider()],
),
children: [
// 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.fileDownload),
title: const Text('Downloads'),
onTap: () async {
Navigator.of(context).pop();
await const HistoryDownloadsRoute().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);
},
),
if (showContainerUi)
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 tabMode = TabMode.fromTabType(
ref
.read(generalSettingsWithDefaultsProvider)
.effectiveDefaultCreateTabType,
);
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: Uri.parse('https://addons.mozilla.org'),
tabMode: tabMode,
containerSelection:
const TabContainerSelection.unassigned(),
selectTab: true,
);
},
),
],
),
),
],
);
}
}
/// Sync tile widget shown in navigation drawer when sync is active.
/// Displays sync status and allows manual sync trigger.
class _SyncTile extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAuthenticated = ref.watch(syncIsAuthenticatedProvider);
// Only show if sync is active
if (!isAuthenticated) {
return const SizedBox.shrink();
}
final syncInfo = ref.watch(
syncRepositoryProvider.select((value) => value.value?.account),
);
final syncStarted = ref.watch(
syncEventProvider.select(
(value) => value.isLoading || value.value?.$1 == SyncEvent.started,
),
);
final isSyncing = syncStarted || syncInfo?.syncing == true;
final controller = useAnimationController(
duration: const Duration(seconds: 2),
);
useEffect(() {
if (isSyncing) {
unawaited(controller.repeat());
} else {
controller.stop();
controller.reset();
}
return null;
}, [isSyncing]);
return ListTile(
leading: RotationTransition(
turns: Tween<double>(begin: 0, end: -1).animate(controller),
child: const Icon(Icons.sync),
),
title: const Text('Sync Now'),
onTap: () async {
await ref.read(syncRepositoryProvider.notifier).syncNow();
final openedTabs = await ref
.read(syncRepositoryProvider.notifier)
.pollIncomingTabsAndOpen();
if (context.mounted) {
if (openedTabs > 0) {
ui_helper.showOpenedTabsFromAnotherDeviceMessage(
context,
openedTabs,
);
} else {
ui_helper.showInfoMessage(
context,
'Synchronization complete',
duration: const Duration(seconds: 2),
);
}
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
);
}
}