From e549375178d174d64b60d2f6c95b075acfe7ac97 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 22 Dec 2025 10:05:17 +0100 Subject: [PATCH] rework tab bar; added contextual bar and quick switcher; tab abr dismissable; reorder now opt in; consolidated controllers and menu buttons; --- .../features/browser/domain/providers.dart | 30 + .../features/browser/domain/providers.g.dart | 49 + .../controllers/tab_bar_dismissable.dart | 21 + ...ions.g.dart => tab_bar_dismissable.g.dart} | 27 +- ...estions.dart => tab_view_controllers.dart} | 46 +- .../controllers/tab_view_controllers.g.dart | 172 +++ .../presentation/controllers/tree_view.dart | 40 - .../presentation/controllers/tree_view.g.dart | 64 - .../browser/presentation/screens/browser.dart | 130 +- .../presentation/screens/tab_view.dart | 40 +- .../browser_modules/bottom_app_bar.dart | 1201 +++++++++++------ .../widgets/browser_modules/browser_fab.dart} | 17 +- .../widgets/menu_item_buttons.dart | 150 ++ .../widgets/tab_creation_menu.dart | 2 - .../presentation/widgets/tab_menu.dart | 80 +- .../presentation/widgets/view_tabs.dart | 491 +++++-- .../features/tabs/data/database/daos/tab.dart | 6 + .../features/tabs/domain/providers.dart | 7 + .../features/tabs/domain/providers.g.dart | 39 + .../tabs/domain/repositories/tab.dart | 15 +- .../tabs/domain/repositories/tab.g.dart | 2 +- .../screens/general_settings.dart | 32 + .../user/data/models/general_settings.dart | 12 +- .../user/data/models/general_settings.g.dart | 115 +- .../domain/repositories/general_settings.dart | 6 + .../repositories/general_settings.g.dart | 2 +- 26 files changed, 1903 insertions(+), 893 deletions(-) create mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart rename app/lib/features/geckoview/features/browser/presentation/controllers/{tab_suggestions.g.dart => tab_bar_dismissable.g.dart} (60%) rename app/lib/features/geckoview/features/browser/presentation/controllers/{tab_suggestions.dart => tab_view_controllers.dart} (56%) create mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart delete mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart delete mode 100644 app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart rename app/lib/features/geckoview/features/{readerview/presentation/widgets/reader_appearance_button.dart => browser/presentation/widgets/browser_modules/browser_fab.dart} (73%) create mode 100644 app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index 35ea6fc6..0cbe693c 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -31,6 +31,7 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/features/search/domain/entities/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; @@ -38,6 +39,8 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart part 'providers.g.dart'; +typedef FifoTab = (TabState, ContainerData?); + @Riverpod(keepAlive: true) class SelectedBangTrigger extends _$SelectedBangTrigger { // ignore: document_ignores api decision @@ -111,6 +114,33 @@ EquatableValue> availableTabStates( }); } +@Riverpod(keepAlive: true) +EquatableValue> fifoTabStates(Ref ref) { + final containerData = ref + .watch(watchContainersWithCountProvider.select((value) => value.value)) + .mapNotNull( + (value) => Map.fromEntries(value.map((c) => MapEntry(c.id, c))), + ); + + final sortedTabs = ref.watch( + watchgetTabsFifoProvider.select((value) => value.value), + ); + + final tabStates = ref.watch(tabStatesProvider); + + return EquatableValue([ + if (sortedTabs != null) + for (final tab in sortedTabs) + if (tabStates.containsKey(tab.id)) + ( + tabStates[tab.id]!, + tab.containerId.mapNotNull( + (containerId) => containerData?[containerId], + ), + ), + ]); +} + @Riverpod() EquatableValue> suggestedTabEntities( Ref ref, diff --git a/app/lib/features/geckoview/features/browser/domain/providers.g.dart b/app/lib/features/geckoview/features/browser/domain/providers.g.dart index e747c01f..901b2c00 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -382,6 +382,55 @@ final class AvailableTabStatesFamily extends $Family String toString() => r'availableTabStatesProvider'; } +@ProviderFor(fifoTabStates) +const fifoTabStatesProvider = FifoTabStatesProvider._(); + +final class FifoTabStatesProvider + extends + $FunctionalProvider< + EquatableValue>, + EquatableValue>, + EquatableValue> + > + with $Provider>> { + const FifoTabStatesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'fifoTabStatesProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$fifoTabStatesHash(); + + @$internal + @override + $ProviderElement>> $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + EquatableValue> create(Ref ref) { + return fifoTabStates(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(EquatableValue> value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>>( + value, + ), + ); + } +} + +String _$fifoTabStatesHash() => r'0320c28b328d814ed358ec6a10ec770a1ed634ad'; + @ProviderFor(suggestedTabEntities) const suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily._(); diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart new file mode 100644 index 00000000..f72ea7e6 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart @@ -0,0 +1,21 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tab_bar_dismissable.g.dart'; + +@Riverpod() +class TabBarDismissableController extends _$TabBarDismissableController { + void show() { + state = false; + } + + void dismiss() { + if (!state) { + state = true; + } + } + + @override + bool build() { + return false; + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.g.dart similarity index 60% rename from app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart rename to app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.g.dart index 0472be63..2b6161e1 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.g.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'tab_suggestions.dart'; +part of 'tab_bar_dismissable.dart'; // ************************************************************************** // RiverpodGenerator @@ -9,28 +9,29 @@ part of 'tab_suggestions.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -@ProviderFor(TabSuggestionsController) -const tabSuggestionsControllerProvider = TabSuggestionsControllerProvider._(); +@ProviderFor(TabBarDismissableController) +const tabBarDismissableControllerProvider = + TabBarDismissableControllerProvider._(); -final class TabSuggestionsControllerProvider - extends $NotifierProvider { - const TabSuggestionsControllerProvider._() +final class TabBarDismissableControllerProvider + extends $NotifierProvider { + const TabBarDismissableControllerProvider._() : super( from: null, argument: null, retry: null, - name: r'tabSuggestionsControllerProvider', - isAutoDispose: false, + name: r'tabBarDismissableControllerProvider', + isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$tabSuggestionsControllerHash(); + String debugGetCreateSourceHash() => _$tabBarDismissableControllerHash(); @$internal @override - TabSuggestionsController create() => TabSuggestionsController(); + TabBarDismissableController create() => TabBarDismissableController(); /// {@macro riverpod.override_with_value} Override overrideWithValue(bool value) { @@ -41,10 +42,10 @@ final class TabSuggestionsControllerProvider } } -String _$tabSuggestionsControllerHash() => - r'5ce7f385b8aba14d432912cf0acb06aad04433fc'; +String _$tabBarDismissableControllerHash() => + r'2bb0055f5525a21982ba3d7cb6443bcad823b64f'; -abstract class _$TabSuggestionsController extends $Notifier { +abstract class _$TabBarDismissableController extends $Notifier { bool build(); @$mustCallSuper @override diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart similarity index 56% rename from app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart rename to app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart index d93a96fe..3d043542 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart @@ -17,9 +17,11 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -part 'tab_suggestions.g.dart'; +part 'tab_view_controllers.g.dart'; @Riverpod(keepAlive: true) class TabSuggestionsController extends _$TabSuggestionsController { @@ -38,3 +40,45 @@ class TabSuggestionsController extends _$TabSuggestionsController { return false; } } + +enum TabsViewMode { + grid(MdiIcons.table, 'Grid'), + tree(MdiIcons.familyTree, 'Tree'); + + final IconData icon; + final String label; + + const TabsViewMode(this.icon, this.label); +} + +@Riverpod(keepAlive: true) +class TabsViewModeController extends _$TabsViewModeController { + void set(TabsViewMode mode) { + if (mode != state) { + state = mode; + } + } + + @override + TabsViewMode build() { + return TabsViewMode.grid; + } +} + +@Riverpod() +class TabsReorderableController extends _$TabsReorderableController { + void toggle() { + state = !state; + } + + void hide() { + if (state) { + state = false; + } + } + + @override + bool build() { + return false; + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart new file mode 100644 index 00000000..c0c4cb39 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.g.dart @@ -0,0 +1,172 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab_view_controllers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(TabSuggestionsController) +const tabSuggestionsControllerProvider = TabSuggestionsControllerProvider._(); + +final class TabSuggestionsControllerProvider + extends $NotifierProvider { + const TabSuggestionsControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabSuggestionsControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabSuggestionsControllerHash(); + + @$internal + @override + TabSuggestionsController create() => TabSuggestionsController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tabSuggestionsControllerHash() => + r'5ce7f385b8aba14d432912cf0acb06aad04433fc'; + +abstract class _$TabSuggestionsController extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +@ProviderFor(TabsViewModeController) +const tabsViewModeControllerProvider = TabsViewModeControllerProvider._(); + +final class TabsViewModeControllerProvider + extends $NotifierProvider { + const TabsViewModeControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabsViewModeControllerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabsViewModeControllerHash(); + + @$internal + @override + TabsViewModeController create() => TabsViewModeController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(TabsViewMode value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tabsViewModeControllerHash() => + r'9c574458f6d5a4c6a8b5aabf96ad7f5dcd043dd0'; + +abstract class _$TabsViewModeController extends $Notifier { + TabsViewMode build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + TabsViewMode, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +@ProviderFor(TabsReorderableController) +const tabsReorderableControllerProvider = TabsReorderableControllerProvider._(); + +final class TabsReorderableControllerProvider + extends $NotifierProvider { + const TabsReorderableControllerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'tabsReorderableControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$tabsReorderableControllerHash(); + + @$internal + @override + TabsReorderableController create() => TabsReorderableController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$tabsReorderableControllerHash() => + r'f169e9dc04055ef611f17ebe121f0f51f6a294cb'; + +abstract class _$TabsReorderableController extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart deleted file mode 100644 index 6e915433..00000000 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.dart +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 . - */ -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'tree_view.g.dart'; - -@Riverpod(keepAlive: true) -class TreeViewController extends _$TreeViewController { - void toggle() { - state = !state; - } - - void hide() { - if (state) { - state = false; - } - } - - @override - bool build() { - return false; - } -} diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart deleted file mode 100644 index 4eb9a43a..00000000 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tree_view.g.dart +++ /dev/null @@ -1,64 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'tree_view.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning - -@ProviderFor(TreeViewController) -const treeViewControllerProvider = TreeViewControllerProvider._(); - -final class TreeViewControllerProvider - extends $NotifierProvider { - const TreeViewControllerProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'treeViewControllerProvider', - isAutoDispose: false, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$treeViewControllerHash(); - - @$internal - @override - TreeViewController create() => TreeViewController(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(bool value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$treeViewControllerHash() => - r'1fac2a133d369e3bd3f8f75cdd2d0c862a6ab942'; - -abstract class _$TreeViewController extends $Notifier { - bool build(); - @$mustCallSuper - @override - void runBuild() { - final created = build(); - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - bool, - Object?, - Object? - >; - element.handleValue(ref, created); - } -} diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart index 4db51593..fa209eb1 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -40,23 +40,31 @@ import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_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/presentation/controllers/tree_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart'; +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/sheets/view_tab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; -import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; class _TabBar extends HookConsumerWidget { + final bool showContextualToolbar; + final bool showQuickTabSwitcherBar; final ValueNotifier showAppBar; final ValueNotifier sheetController; - const _TabBar({required this.showAppBar, required this.sheetController}); + const _TabBar({ + required this.showContextualToolbar, + required this.showQuickTabSwitcherBar, + required this.showAppBar, + required this.sheetController, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -76,7 +84,11 @@ class _TabBar extends HookConsumerWidget { if (!autoHideTabBar) { return Visibility( visible: !tabInFullScreen, - child: BrowserBottomAppBar(displayedSheet: displayedSheet), + child: BrowserBottomAppBar( + displayedSheet: displayedSheet, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), ); } @@ -84,7 +96,10 @@ class _TabBar extends HookConsumerWidget { final diffAcc = useRef(0.0); void resetHiddenState() { - showAppBar.value = true; + if (!ref.read(tabBarDismissableControllerProvider)) { + showAppBar.value = true; + } + diffAcc.value = 0.0; } @@ -153,7 +168,12 @@ class _TabBar extends HookConsumerWidget { return Visibility( visible: sheetController.value != null || (!tabInFullScreen && appBarVisible), - child: BrowserBottomAppBar(displayedSheet: displayedSheet), + maintainState: true, + child: BrowserBottomAppBar( + displayedSheet: displayedSheet, + showContextualToolbar: showContextualToolbar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), ); } } @@ -171,8 +191,24 @@ class BrowserScreen extends HookConsumerWidget { final overlayController = useOverlayPortalController(); + final showContextualAppBar = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarShowContextualBar, + ), + ); + + final showQuickTabSwitcherBar = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarShowQuickTabSwitcherBar, + ), + ); + final showAppBar = useValueNotifier(true); + ref.listen(tabBarDismissableControllerProvider, (previous, next) { + showAppBar.value = !next; + }); + ref.listen(overlayControllerProvider, (previous, next) { if (next != null) { overlayController.show(); @@ -220,17 +256,25 @@ class BrowserScreen extends HookConsumerWidget { //This causes issues with a non dismissable barrier pushed, we ahve our own barrier and this does seem to have issues when dismissing, so disable it completely return null; }, - bottomNavigationBar: _TabBar( - showAppBar: showAppBar, - sheetController: sheetController, + body: Column( + children: [ + _TabBar( + showAppBar: showAppBar, + sheetController: sheetController, + showContextualToolbar: showContextualAppBar, + showQuickTabSwitcherBar: showQuickTabSwitcherBar, + ), + Expanded( + child: _Browser( + overlayController: overlayController, + sheetController: sheetController, + showAppBar: showAppBar, + tabInFullScreen: tabInFullScreen, + ), + ), + ], ), - body: _Browser( - overlayController: overlayController, - sheetController: sheetController, - showAppBar: showAppBar, - tabInFullScreen: tabInFullScreen, - ), - floatingActionButton: ReaderAppearanceButton(), + floatingActionButton: BrowserFab(), ), ), ); @@ -408,7 +452,9 @@ class _Browser extends HookConsumerWidget { } //Make sure app bar is visible - showAppBar.value = true; + if (!ref.read(tabBarDismissableControllerProvider)) { + showAppBar.value = true; + } if (tabState?.isLoading == true) { lastBackButtonPress.value = null; @@ -634,11 +680,15 @@ class _ViewTabsSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final draggableScrollableController = useDraggableScrollableController(); + final tabsViewMode = ref.watch(tabsViewModeControllerProvider); + final tabsReorderable = ref.watch(tabsReorderableControllerProvider); - final treeModeEnabled = ref.watch(treeViewControllerProvider); + final draggableScrollableController = useDraggableScrollableController( + keys: [tabsReorderable], + ); return DraggableScrollableSheet( + key: ValueKey(tabsReorderable), controller: draggableScrollableController, expand: false, minChildSize: 0.1, @@ -649,26 +699,28 @@ class _ViewTabsSheet extends HookConsumerWidget { topLeft: Radius.circular(28), topRight: Radius.circular(28), ), - child: treeModeEnabled - ? ViewTabTreesWidget( - scrollController: scrollController, - showNewTabFab: true, - onClose: () { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); - }, - ) - : ViewTabsWidget( - scrollController: scrollController, - showNewTabFab: true, - draggableScrollableController: draggableScrollableController, - onClose: () { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); - }, - ), + child: switch (tabsViewMode) { + TabsViewMode.grid => ViewTabsWidget( + scrollController: scrollController, + showNewTabFab: true, + tabsReorderable: tabsReorderable, + draggableScrollableController: draggableScrollableController, + onClose: () { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); + }, + ), + TabsViewMode.tree => ViewTabTreesWidget( + scrollController: scrollController, + showNewTabFab: true, + onClose: () { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); + }, + ), + }, ); }, ); diff --git a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart index 5f048262..16d62841 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/tab_view.dart @@ -22,7 +22,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; @@ -31,28 +31,32 @@ class TabViewScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final treeModeEnabled = ref.watch(treeViewControllerProvider); + final tabsViewMode = ref.watch(tabsViewModeControllerProvider); + final tabsReorderable = ref.watch(tabsReorderableControllerProvider); - final scrollController = useScrollController(); + final scrollController = useScrollController(keys: [tabsReorderable]); return Dialog.fullscreen( child: Scaffold( body: SafeArea( - child: treeModeEnabled - ? ViewTabTreesWidget( - scrollController: scrollController, - showNewTabFab: false, - onClose: () { - const BrowserRoute().go(context); - }, - ) - : ViewTabsWidget( - scrollController: scrollController, - showNewTabFab: false, - onClose: () { - const BrowserRoute().go(context); - }, - ), + child: switch (tabsViewMode) { + TabsViewMode.grid => ViewTabsWidget( + key: ValueKey(tabsReorderable), + scrollController: scrollController, + tabsReorderable: tabsReorderable, + showNewTabFab: false, + onClose: () { + const BrowserRoute().go(context); + }, + ), + TabsViewMode.tree => ViewTabTreesWidget( + scrollController: scrollController, + showNewTabFab: false, + onClose: () { + const BrowserRoute().go(context); + }, + ), + }, ), floatingActionButton: FloatingActionButton( onPressed: () async { diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart index 0dd26fd2..27d32bf8 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/bottom_app_bar.dart @@ -17,6 +17,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ + import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -34,9 +35,12 @@ 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/tab_creation_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_menu.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tabs_action_button.dart'; @@ -48,20 +52,52 @@ import 'package:weblibre/features/user/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.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/ui_helper.dart' as ui_helper; class BrowserBottomAppBar extends HookConsumerWidget { - const BrowserBottomAppBar({required this.displayedSheet}); + final bool showContextualToolbar; + final bool showQuickTabSwitcherBar; + + const BrowserBottomAppBar({ + required this.displayedSheet, + required this.showContextualToolbar, + required this.showQuickTabSwitcherBar, + }); final Sheet? displayedSheet; + static const contextualToolabarHeight = 54.0; + static const quickTabSwitcherHeight = 48.0; + + bool get displayAppBar => + !showContextualToolbar || displayedSheet is! ViewTabsSheet; + + bool get displayQuickTabSwitcher => + showQuickTabSwitcherBar && displayedSheet is! ViewTabsSheet; + + double getToolbarHeight() { + var height = 0.0; + + if (displayAppBar) { + height += kToolbarHeight; + } + + if (showContextualToolbar) { + height += contextualToolabarHeight; + } + + if (displayQuickTabSwitcher) { + height += quickTabSwitcherHeight; + } + + return height; + } + @override Widget build(BuildContext context, WidgetRef ref) { - final addonService = ref.watch(addonServiceProvider); - - final tabMenuController = useMenuController(); final extensionMenuController = useMenuController(); - final hamburgerMenuController = useMenuController(); final trippleDotMenuController = useMenuController(); final selectedTabId = ref.watch(selectedTabProvider); @@ -78,7 +114,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { final dragStartPosition = useRef(Offset.zero); return BottomAppBar( - height: kToolbarHeight, + height: getToolbarHeight(), padding: EdgeInsets.zero, child: GestureDetector( onTap: () { @@ -101,7 +137,7 @@ class BrowserBottomAppBar extends HookConsumerWidget { onHorizontalDragEnd: (details) async { final distance = dragStartPosition.value - details.globalPosition; - if (distance.dx.abs() > 50) { + if (distance.dx.abs() > 50 && distance.dy.abs() < 20) { final selectedTab = ref.read(selectedTabProvider); final setting = await ref .read(generalSettingsRepositoryProvider.notifier) @@ -125,464 +161,729 @@ class BrowserBottomAppBar extends HookConsumerWidget { } } } + } else if (distance.dy < 20 && distance.dx.abs() < 15) { + ref.read(tabBarDismissableControllerProvider.notifier).dismiss(); } }, - child: AppBar( - automaticallyImplyLeading: false, - titleSpacing: 8.0, - backgroundColor: (isPrivateTab && displayedSheet is! ViewTabsSheet) - ? const Color(0x648000D7) - : null, - title: (selectedTabId != null && displayedSheet is! ViewTabsSheet) - ? const AppBarTitle() - : null, - actions: [ - if (selectedTabId != null && displayedSheet is! ViewTabsSheet) - Consumer( - builder: (context, ref, child) { - final tabBarReaderView = ref.watch( - generalSettingsWithDefaultsProvider.select( - (value) => value.tabBarReaderView, - ), - ); - - final readerabilityStateActive = ref.watch( - selectedTabStateProvider.select( - (state) => - (state?.readerableState ?? ReaderableState.$default()) - .active, - ), - ); - - return Visibility( - visible: tabBarReaderView || readerabilityStateActive, - child: ReaderButton( - buttonBuilder: (isLoading, readerActive, icon) => InkWell( - onTap: isLoading - ? null - : () async { - await ref - .read( - readerableScreenControllerProvider - .notifier, - ) - .toggleReaderView(!readerActive); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 15.0, - horizontal: 8.0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (showQuickTabSwitcherBar) + Visibility( + visible: displayQuickTabSwitcher, + maintainState: true, + child: QuickTabSwitcher(), + ), + Visibility( + visible: displayAppBar, + maintainState: true, + child: AppBar( + automaticallyImplyLeading: false, + titleSpacing: 8.0, + backgroundColor: + (isPrivateTab && displayedSheet is! ViewTabsSheet) + ? const Color(0x648000D7) + : null, + title: + (selectedTabId != null && displayedSheet is! ViewTabsSheet) + ? const AppBarTitle() + : null, + actions: [ + if (selectedTabId != null && displayedSheet is! ViewTabsSheet) + Consumer( + builder: (context, ref, child) { + final tabBarReaderView = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarReaderView, ), - child: icon, - ), - ), - ), - ); - }, - ), - if (showExtensionShortcut) - ExtensionShortcutMenu( - controller: extensionMenuController, - child: IconButton( - onPressed: () { - if (extensionMenuController.isOpen) { - extensionMenuController.close(); - } else { - extensionMenuController.open(); - } - }, - icon: const Icon(MdiIcons.puzzle), - ), - ), - if (selectedTabId != null) - TabMenu( - controller: trippleDotMenuController, - selectedTabId: selectedTabId, - child: InkWell( - onTap: () { - if (trippleDotMenuController.isOpen) { - trippleDotMenuController.close(); - } else { - trippleDotMenuController.open(); - } - }, - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 15.0, - ), - child: Icon(MdiIcons.dotsVertical), - ), - ), - ), - TabCreationMenu( - controller: tabMenuController, - selectedTabId: selectedTabId, - child: TabsActionButton( - isActive: displayedSheet is ViewTabsSheet, - onTap: () async { - final tabViewBottomSheet = ref - .read(generalSettingsWithDefaultsProvider) - .tabViewBottomSheet; + ); - if (tabViewBottomSheet) { - if (displayedSheet case ViewTabsSheet()) { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); - } else { - ref - .read(bottomSheetControllerProvider.notifier) - .show(ViewTabsSheet()); - } - } else { - await const TabViewRoute().push(context); - } - }, - onLongPress: () { - if (tabMenuController.isOpen) { - tabMenuController.close(); - } else { - tabMenuController.open(); - } - }, - ), - ), - 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), - ), - ), - ); - }, - 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'); - }, - ), - ), - 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), - ), + final readerabilityStateActive = ref.watch( + selectedTabStateProvider.select( + (state) => + (state?.readerableState ?? + ReaderableState.$default()) + .active, ), - ), - ], - ); - }, - ), - 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), - ); - }, - 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 != null, - ), - ); - - return Badge( - isLabelVisible: torConnected, - backgroundColor: const Color(0xFF68B030), - 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.mobile.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'), - ), - const Divider(), - if (selectedTabId != null) - MenuItemButton( - onPressed: () async { - final controller = ref.read( - tabSessionProvider(tabId: selectedTabId).notifier, - ); - - await controller.reload(); - hamburgerMenuController.close(); - }, - leadingIcon: const Icon(Icons.refresh), - child: const Text('Reload'), - ), - if (selectedTabId != null) const Divider(), - if (selectedTabId != null) - Consumer( - builder: (context, ref, child) { - final history = ref.watch( - tabStateProvider( - selectedTabId, - ).select((value) => value?.historyState), - ); - - final isLoading = ref.watch( - selectedTabStateProvider.select( - (state) => state?.isLoading ?? false, - ), - ); - - return Row( - children: [ - Expanded( - child: (history?.canGoBack == true || isLoading) - ? IconButton( - onPressed: () async { - final controller = ref.read( - tabSessionProvider( - tabId: selectedTabId, - ).notifier, - ); - - final isReaderActive = ref.read( - selectedTabStateProvider.select( - (state) => - state?.readerableState.active ?? - false, - ), - ); - - if (isLoading) { - await controller.stopLoading(); - } else if (isReaderActive) { - await ref - .read( - readerableScreenControllerProvider - .notifier, - ) - .toggleReaderView(false); - } else { - await controller.goBack(); - } - - hamburgerMenuController.close(); - }, - icon: const Icon(Icons.arrow_back), - ) - : IconButton( - onPressed: () async { - await ref - .read(tabRepositoryProvider.notifier) - .closeTab(selectedTabId); - - hamburgerMenuController.close(); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref + return Visibility( + visible: tabBarReaderView || readerabilityStateActive, + child: ReaderButton( + buttonBuilder: (isLoading, readerActive, icon) => + InkWell( + onTap: isLoading + ? null + : () async { + await ref .read( - tabRepositoryProvider.notifier, + readerableScreenControllerProvider + .notifier, ) - .undoClose, - ); - } - }, - icon: const Icon(Icons.close), + .toggleReaderView(!readerActive); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 15.0, + horizontal: 8.0, + ), + child: icon, ), + ), ), - const SizedBox(height: 48, child: VerticalDivider()), - Expanded( - child: IconButton( - onPressed: (history?.canGoForward == true) - ? () async { - final controller = ref.read( - tabSessionProvider( - tabId: selectedTabId, - ).notifier, - ); - - await controller.goForward(); - hamburgerMenuController.close(); - } - : null, - icon: const Icon(Icons.arrow_forward), - ), + ); + }, + ), + if (showExtensionShortcut) + ExtensionShortcutMenu( + controller: extensionMenuController, + child: IconButton( + onPressed: () { + if (extensionMenuController.isOpen) { + extensionMenuController.close(); + } else { + extensionMenuController.open(); + } + }, + icon: const Icon(MdiIcons.puzzle), + ), + ), + if (selectedTabId != null) + TabMenu( + controller: trippleDotMenuController, + selectedTabId: selectedTabId, + child: InkWell( + onTap: () { + if (trippleDotMenuController.isOpen) { + trippleDotMenuController.close(); + } else { + trippleDotMenuController.open(); + } + }, + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 15.0, ), - ], - ); - }, - ), - ], + child: Icon(MdiIcons.dotsVertical), + ), + ), + ), + if (!showContextualToolbar) + TabsCountButton( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + showLongPressMenu: true, + ), + if (!showContextualToolbar) + NavigationMenuButton(selectedTabId: selectedTabId), + ], + ), ), + if (showContextualToolbar) + ContextualToolbar( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + ), ], ), ), ); } } + +class ContextualToolbar extends HookConsumerWidget { + const ContextualToolbar({ + super.key, + required this.selectedTabId, + required this.displayedSheet, + }); + + final String? selectedTabId; + final Sheet? displayedSheet; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(tabStateProvider(selectedTabId)); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (tabState?.historyState.canGoBack == true) + NavigateBackButton( + selectedTabId: selectedTabId, + isLoading: tabState?.isLoading ?? false, + ) + else + IconButton( + onPressed: () async { + await BookmarkListRoute( + entryGuid: BookmarkRoot.mobile.id, + ).push(context); + }, + icon: const Icon(MdiIcons.bookmarkMultiple), + ), + if (tabState?.historyState.canGoForward == true) + NavigateForwardButton(selectedTabId: selectedTabId) + else + ShareMenuButton(selectedTabId: selectedTabId), + AddTabButton(), + TabsCountButton( + selectedTabId: selectedTabId, + displayedSheet: displayedSheet, + showLongPressMenu: false, + ), + NavigationMenuButton(selectedTabId: selectedTabId), + ], + ); + } +} + +class QuickTabSwitcher extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabStates = ref.watch(fifoTabStatesProvider).value; + + final chipScrollController = useScrollController(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: SizedBox( + height: 48, + width: double.maxFinite, + child: SelectableChips( + enableDelete: false, + scrollController: chipScrollController, + itemId: (item) => item.$1.id, + itemLabel: (item) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 64), + child: Text(item.$1.titleOrAuthority), + ), + if (item.$1.isPrivate) + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon( + MdiIcons.dominoMask, + color: Color(0xFF8000D7), + size: 20, + ), + ), + ], + ); + }, + itemAvatar: (item) => UrlIcon([item.$1.url], iconSize: 16), + itemBackgroundColor: (item) => item.$2?.color.withValues(alpha: 0.5), + onSelected: (item) async { + final animation = chipScrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + ); + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(item.$1.id); + await animation; + }, + availableItems: tabStates.skip(1), + ), + ), + ); + } +} + +class ShareMenuButton extends HookConsumerWidget { + final String? selectedTabId; + + const ShareMenuButton({super.key, required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final menuController = useMenuController(); + + return MenuAnchor( + controller: menuController, + menuChildren: [ + CopyAddressMenuItemButton(selectedTabId: selectedTabId), + LaunchExternalMenuItemButton(selectedTabId: selectedTabId), + ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), + ShareMenuItemButton(selectedTabId: selectedTabId), + ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), + ], + builder: (context, controller, child) { + return IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(Icons.share), + ); + }, + ); + } +} + +class NavigationMenuButton extends HookConsumerWidget { + const NavigationMenuButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final addonService = ref.watch(addonServiceProvider); + final showContextualAppBar = ref.watch( + generalSettingsWithDefaultsProvider.select( + (value) => value.tabBarShowContextualBar, + ), + ); + + 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), + ), + ), + ); + }, + 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'); + }, + ), + ), + 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), + ); + }, + 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 != null), + ); + + return Badge( + isLabelVisible: torConnected, + backgroundColor: const Color(0xFF68B030), + 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.mobile.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'), + ), + const Divider(), + if (selectedTabId != null) + MenuItemButton( + onPressed: () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.reload(); + hamburgerMenuController.close(); + }, + leadingIcon: const Icon(Icons.refresh), + child: const Text('Reload'), + ), + if (selectedTabId != null) const Divider(), + if (selectedTabId != null && !showContextualAppBar) + Consumer( + builder: (context, ref, child) { + final history = ref.watch( + tabStateProvider( + selectedTabId, + ).select((value) => value?.historyState), + ); + + final isLoading = ref.watch( + selectedTabStateProvider.select( + (state) => state?.isLoading ?? false, + ), + ); + + return Row( + children: [ + Expanded( + child: (history?.canGoBack == true || isLoading) + ? NavigateBackButton( + selectedTabId: selectedTabId, + isLoading: isLoading, + menuControllerToClose: hamburgerMenuController, + ) + : IconButton( + onPressed: () async { + await ref + .read(tabRepositoryProvider.notifier) + .closeTab(selectedTabId!); + + hamburgerMenuController.close(); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read(tabRepositoryProvider.notifier) + .undoClose, + ); + } + }, + icon: const Icon(Icons.close), + ), + ), + const SizedBox(height: 48, child: VerticalDivider()), + Expanded( + child: NavigateForwardButton( + selectedTabId: selectedTabId, + menuControllerToClose: hamburgerMenuController, + ), + ), + ], + ); + }, + ), + ], + ); + } +} + +class NavigateForwardButton extends HookConsumerWidget { + const NavigateForwardButton({ + super.key, + required this.selectedTabId, + this.menuControllerToClose, + }); + + final String? selectedTabId; + final MenuController? menuControllerToClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + onPressed: () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + await controller.goForward(); + menuControllerToClose?.close(); + }, + icon: const Icon(Icons.arrow_forward), + ); + } +} + +class NavigateBackButton extends HookConsumerWidget { + const NavigateBackButton({ + super.key, + required this.selectedTabId, + required this.isLoading, + this.menuControllerToClose, + }); + + final String? selectedTabId; + final bool isLoading; + final MenuController? menuControllerToClose; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return IconButton( + onPressed: () async { + final controller = ref.read( + tabSessionProvider(tabId: selectedTabId).notifier, + ); + + final isReaderActive = ref.read( + selectedTabStateProvider.select( + (state) => state?.readerableState.active ?? false, + ), + ); + + if (isLoading) { + await controller.stopLoading(); + } else if (isReaderActive) { + await ref + .read(readerableScreenControllerProvider.notifier) + .toggleReaderView(false); + } else { + await controller.goBack(); + } + + menuControllerToClose?.close(); + }, + icon: const Icon(Icons.arrow_back), + ); + } +} + +class AddTabButton extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabMenuController = useMenuController(); + + return TabCreationMenu( + controller: tabMenuController, + child: IconButton( + onPressed: () async { + final settings = ref.read(generalSettingsWithDefaultsProvider); + + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.defaultCreateTabType, + ).push(context); + + if (context.mounted) { + const BrowserRoute().go(context); + } + }, + icon: const Icon(Icons.add), + onLongPress: () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + }, + ), + ); + } +} + +class TabsCountButton extends HookConsumerWidget { + const TabsCountButton({ + super.key, + required this.selectedTabId, + required this.displayedSheet, + required this.showLongPressMenu, + }); + + final String? selectedTabId; + final Sheet? displayedSheet; + final bool showLongPressMenu; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabMenuController = useMenuController(); + + return TabCreationMenu( + controller: tabMenuController, + child: TabsActionButton( + isActive: displayedSheet is ViewTabsSheet, + onTap: () async { + final tabViewBottomSheet = ref + .read(generalSettingsWithDefaultsProvider) + .tabViewBottomSheet; + + if (tabViewBottomSheet) { + if (displayedSheet case ViewTabsSheet()) { + ref.read(bottomSheetControllerProvider.notifier).requestDismiss(); + } else { + ref + .read(bottomSheetControllerProvider.notifier) + .show(ViewTabsSheet()); + } + } else { + await const TabViewRoute().push(context); + } + }, + onLongPress: showLongPressMenu + ? () { + if (tabMenuController.isOpen) { + tabMenuController.close(); + } else { + tabMenuController.open(); + } + } + : null, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart similarity index 73% rename from app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart rename to app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart index 6cc6113e..b88ad5ee 100644 --- a/app/lib/features/geckoview/features/readerview/presentation/widgets/reader_appearance_button.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart @@ -22,12 +22,15 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/readerable.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_bar_dismissable.dart'; import 'package:weblibre/features/geckoview/features/readerview/domain/providers/readerable.dart'; -class ReaderAppearanceButton extends HookConsumerWidget { +class BrowserFab extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final buttonVisible = ref.watch( + final tabBarDismissed = ref.watch(tabBarDismissableControllerProvider); + + final appearanceButtonVisible = ref.watch( appearanceButtonVisibilityProvider.select( (value) => value.value ?? false, ), @@ -40,7 +43,15 @@ class ReaderAppearanceButton extends HookConsumerWidget { ); return Visibility( - visible: readerabilityState.active && buttonVisible, + visible: readerabilityState.active && appearanceButtonVisible, + replacement: tabBarDismissed + ? FloatingActionButton( + child: const Icon(MdiIcons.dockBottom), + onPressed: () { + ref.read(tabBarDismissableControllerProvider.notifier).show(); + }, + ) + : const SizedBox.shrink(), child: FloatingActionButton( onPressed: () async { await ref.read(readerableServiceProvider).onAppearanceButtonTap(); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart new file mode 100644 index 00000000..2b553504 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart @@ -0,0 +1,150 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class ShareMenuItemButton extends HookConsumerWidget { + const ShareMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(Icons.share), + closeOnActivate: false, + onPressed: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + + await SharePlus.instance.share(ShareParams(uri: tabState.url)); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + child: const Text('Share Link'), + ); + } +} + +class ShowQrCodeMenuItemButton extends HookConsumerWidget { + const ShowQrCodeMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(Icons.qr_code), + closeOnActivate: false, + onPressed: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + + await showQrCode(context, tabState.url.toString()); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + child: const Text('Show QR Code'), + ); + } +} + +class ShareScreenshotMenuItemButton extends HookConsumerWidget { + const ShareScreenshotMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(Icons.mobile_screen_share), + closeOnActivate: false, + child: const Text('Share Screenshot'), + onPressed: () async { + final screenshot = await ref + .read(selectedTabSessionProvider) + .requestScreenshot(); + + final tabState = ref.read(tabStateProvider(selectedTabId))!; + + if (screenshot != null) { + ui.decodeImageFromList(screenshot, (result) async { + final png = await result.toByteData(format: ui.ImageByteFormat.png); + + if (png != null) { + final file = XFile.fromData( + png.buffer.asUint8List(), + mimeType: 'image/png', + ); + + await SharePlus.instance.share( + ShareParams(files: [file], subject: tabState.titleOrAuthority), + ); + } + }); + } + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + ); + } +} + +class LaunchExternalMenuItemButton extends HookConsumerWidget { + const LaunchExternalMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(Icons.open_in_browser), + closeOnActivate: false, + child: const Text('Launch External'), + onPressed: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + + await ui_helper.launchUrlFeedback(context, tabState.url); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + ); + } +} + +class CopyAddressMenuItemButton extends HookConsumerWidget { + const CopyAddressMenuItemButton({super.key, required this.selectedTabId}); + + final String? selectedTabId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MenuItemButton( + leadingIcon: const Icon(MdiIcons.contentCopy), + closeOnActivate: false, + child: const Text('Copy Address'), + onPressed: () async { + final tabState = ref.read(tabStateProvider(selectedTabId))!; + + await Clipboard.setData(ClipboardData(text: tabState.url.toString())); + + if (context.mounted) { + MenuController.maybeOf(context)?.close(); + } + }, + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart index 20af1cc6..f8066f45 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_creation_menu.dart @@ -26,13 +26,11 @@ import 'package:weblibre/features/user/domain/repositories/general_settings.dart class TabCreationMenu extends HookConsumerWidget { final Widget child; final MenuController controller; - final String? selectedTabId; const TabCreationMenu({ super.key, required this.child, required this.controller, - required this.selectedTabId, }); @override diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index a4267ba3..cedbd107 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -18,23 +18,19 @@ * along with this program. If not, see . */ import 'dart:convert'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter/services.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:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; -import 'package:share_plus/share_plus.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; -import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/dialogs/qr_code.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/menu_item_buttons.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; @@ -315,75 +311,11 @@ class TabMenu extends HookConsumerWidget { ), SubmenuButton( menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.contentCopy), - child: const Text('Copy Address'), - onPressed: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - - await Clipboard.setData( - ClipboardData(text: tabState.url.toString()), - ); - }, - ), - MenuItemButton( - leadingIcon: const Icon(Icons.open_in_browser), - child: const Text('Launch External'), - onPressed: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - - await ui_helper.launchUrlFeedback(context, tabState.url); - }, - ), - MenuItemButton( - leadingIcon: const Icon(Icons.mobile_screen_share), - child: const Text('Share Screenshot'), - onPressed: () async { - final screenshot = await ref - .read(selectedTabSessionProvider) - .requestScreenshot(); - - final tabState = ref.read(tabStateProvider(selectedTabId))!; - - if (screenshot != null) { - ui.decodeImageFromList(screenshot, (result) async { - final png = await result.toByteData( - format: ui.ImageByteFormat.png, - ); - - if (png != null) { - final file = XFile.fromData( - png.buffer.asUint8List(), - mimeType: 'image/png', - ); - - await SharePlus.instance.share( - ShareParams(files: [file], subject: tabState.title), - ); - } - }); - } - }, - ), - MenuItemButton( - leadingIcon: const Icon(Icons.share), - onPressed: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - - await SharePlus.instance.share(ShareParams(uri: tabState.url)); - }, - child: const Text('Share Link'), - ), - MenuItemButton( - leadingIcon: const Icon(Icons.qr_code), - onPressed: () async { - final tabState = ref.read(tabStateProvider(selectedTabId))!; - - await showQrCode(context, tabState.url.toString()); - controller.close(); - }, - child: const Text('Show QR Code'), - ), + CopyAddressMenuItemButton(selectedTabId: selectedTabId), + LaunchExternalMenuItemButton(selectedTabId: selectedTabId), + ShareScreenshotMenuItemButton(selectedTabId: selectedTabId), + ShareMenuItemButton(selectedTabId: selectedTabId), + ShowQrCodeMenuItemButton(selectedTabId: selectedTabId), ], leadingIcon: const Icon(Icons.share), child: const Text('Share'), diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/view_tabs.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/view_tabs.dart index baf49091..9dcab860 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/view_tabs.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/view_tabs.dart @@ -26,6 +26,7 @@ import 'package:fast_equatable/fast_equatable.dart'; 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:flutter_reorderable_grid_view/widgets/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; @@ -36,8 +37,7 @@ 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/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_suggestions.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tree_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/draggable_scrollable_header.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; @@ -50,6 +50,7 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/c import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/listenable_callback.dart'; +import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/speech_to_text_button.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; @@ -67,7 +68,7 @@ int calculateCrossAxisItemCount({ return crossAxisCount; } -double calculateItemHeight({ +Size calculateItemSize({ required double screenWidth, required double childAspectRatio, required double horizontalPadding, @@ -81,9 +82,8 @@ double calculateItemHeight({ screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; final itemWidth = availableWidth / crossAxisCount; final itemHeight = itemWidth / childAspectRatio; - final totalItemHeight = itemHeight + mainAxisSpacing; - return totalItemHeight; + return Size(itemWidth, itemHeight + mainAxisSpacing); } class _TabDraggable extends HookConsumerWidget { @@ -165,10 +165,10 @@ class _TabDraggable extends HookConsumerWidget { class _TabViewHeader extends HookConsumerWidget { static const headerSize = 124.0; - final bool treeViewEnabled; + final TabsViewMode tabsViewMode; final VoidCallback onClose; - const _TabViewHeader({required this.onClose, required this.treeViewEnabled}); + const _TabViewHeader({required this.onClose, required this.tabsViewMode}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -176,6 +176,9 @@ class _TabViewHeader extends HookConsumerWidget { final searchTextFocus = useFocusNode(); final searchTextController = useTextEditingController(); + final viewModeMenuController = useMenuController(); + final tabsActionMenuController = useMenuController(); + final hasSearchText = useListenableSelector( searchTextController, () => searchTextController.text.isNotEmpty, @@ -218,8 +221,8 @@ class _TabViewHeader extends HookConsumerWidget { padding: EdgeInsets.zero, onPressed: () { ref - .read(treeViewControllerProvider.notifier) - .hide(); + .read(tabsViewModeControllerProvider.notifier) + .set(TabsViewMode.grid); searchMode.value = true; searchTextFocus.requestFocus(); @@ -229,17 +232,40 @@ class _TabViewHeader extends HookConsumerWidget { height: 32, child: VerticalDivider(indent: 4, endIndent: 4), ), - IconButton( - icon: const Icon(MdiIcons.familyTree), - selectedIcon: const Icon(MdiIcons.table), - isSelected: treeViewEnabled, - iconSize: 18, - padding: EdgeInsets.zero, - onPressed: () { - ref - .read(treeViewControllerProvider.notifier) - .toggle(); - }, + MenuAnchor( + controller: viewModeMenuController, + menuChildren: TabsViewMode.values + .map( + (mode) => MenuItemButton( + leadingIcon: Icon(mode.icon), + child: Text(mode.label), + onPressed: () { + ref + .read( + tabsViewModeControllerProvider + .notifier, + ) + .set(mode); + }, + ), + ) + .toList(), + child: IconButton( + onPressed: () { + if (viewModeMenuController.isOpen) { + viewModeMenuController.close(); + } else { + viewModeMenuController.open(); + } + }, + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tabsViewMode.icon, size: 18), + const Icon(Icons.arrow_drop_down, size: 18), + ], + ), + ), ), if (enableAiFeatures) Consumer( @@ -264,26 +290,122 @@ class _TabViewHeader extends HookConsumerWidget { ); }, ), + Consumer( + builder: (context, ref, child) { + final tabsReorderabe = ref.watch( + tabsReorderableControllerProvider, + ); + + return IconButton.filledTonal( + icon: const Icon(MdiIcons.orderNumericAscending), + isSelected: tabsReorderabe, + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + ref + .read( + tabsReorderableControllerProvider + .notifier, + ) + .toggle(); + }, + ); + }, + ), ], ), - TextButton.icon( - onPressed: () async { - final container = ref.read(selectedContainerProvider); + MenuAnchor( + controller: tabsActionMenuController, + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), + child: const Text('Bookmark all'), + onPressed: () async { + final containerId = ref.read( + selectedContainerProvider, + ); - final count = await ref - .read(tabDataRepositoryProvider.notifier) - .closeAllTabsByContainer(container); + final tabData = await ref + .read(tabDataRepositoryProvider.notifier) + .getContainerTabsData(containerId); - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: count, - ); - } - }, - icon: const Icon(MdiIcons.closeBoxMultiple), - label: const Text('Close All'), + for (final tab in tabData) { + if (context.mounted) { + await BookmarkEntryAddRoute( + bookmarkInfo: jsonEncode( + BookmarkInfo( + title: tab.title, + url: tab.url.toString(), + ).encode(), + ), + ).push(context); + } + } + }, + ), + MenuItemButton( + leadingIcon: const Icon(MdiIcons.closeBoxMultiple), + child: const Text('Close All'), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Close All Tabs'), + content: const Text( + 'Are you sure you want to close all displayed tabs?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Close'), + ), + ], + ); + }, + ); + + if (result == true) { + final container = ref.read( + selectedContainerProvider, + ); + + final count = await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByContainer(container); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref + .read(tabRepositoryProvider.notifier) + .undoClose, + count: count, + ); + } + } + }, + ), + ], + child: IconButton( + onPressed: () { + if (tabsActionMenuController.isOpen) { + tabsActionMenuController.close(); + } else { + tabsActionMenuController.open(); + } + }, + icon: const Icon(MdiIcons.dotsVertical), + ), ), ], ) @@ -318,7 +440,7 @@ class _TabViewHeader extends HookConsumerWidget { ), ), ), - if (!treeViewEnabled) + if (tabsViewMode == TabsViewMode.grid) Consumer( builder: (context, ref, child) { final selectedContainer = ref.watch( @@ -372,9 +494,14 @@ class _TabViewHeader extends HookConsumerWidget { class _TabView extends HookConsumerWidget { final ScrollController scrollController; + final bool tabsReorderable; final VoidCallback onClose; - const _TabView({required this.scrollController, required this.onClose}); + const _TabView({ + required this.scrollController, + required this.tabsReorderable, + required this.onClose, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -415,8 +542,8 @@ class _TabView extends HookConsumerWidget { return math.max(math.min(calculatedCount, itemCount), 2); }, [screenWidth, itemCount]); - final itemHeight = useMemoized( - () => calculateItemHeight( + final itemSize = useMemoized( + () => calculateItemSize( screenWidth: screenWidth, childAspectRatio: 0.75, horizontalPadding: 4.0, @@ -438,7 +565,7 @@ class _TabView extends HookConsumerWidget { ); if (index > -1) { - final offset = (index ~/ 2) * itemHeight; + final offset = (index ~/ 2) * itemSize.height; if (offset != scrollController.offset) { lastScroll.value = activeTab; @@ -465,117 +592,192 @@ class _TabView extends HookConsumerWidget { fadingSize: 5, controller: scrollController, builder: (context, controller) { - return ReorderableBuilder.builder( - //Rebuild when cross axis count changes - key: ValueKey(crossAxisCount), - scrollController: controller, - itemCount: itemCount, - onDragStarted: (index) { - ref.read(willAcceptDropProvider.notifier).clear(); - }, - onReorderPositions: (positions) async { - assert(positions.length == 1, 'Not ready for multiple reorders'); - - final oldIndex = positions.first.oldIndex; - final newIndex = positions.first.newIndex; - - final containerRepository = ref.read( - containerRepositoryProvider.notifier, - ); - - //Suggestions are at the end and not reorderable, so skip - if (oldIndex >= filteredTabEntities.value.length) { - return; - } - - final tabId = filteredTabEntities.value[oldIndex].tabId; - final containerId = await ref - .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabId); - - final String key; - if (newIndex <= 0) { - key = await containerRepository.getLeadingOrderKey(containerId); - } else if (newIndex >= filteredTabEntities.value.length - 1) { - key = await containerRepository.getTrailingOrderKey( - containerId, - ); - } else { - if (newIndex < oldIndex) { - key = (await containerRepository.getOrderKeyAfterTab( - filteredTabEntities.value[newIndex - 1].tabId, - containerId, - ))!; - } else { - key = await containerRepository.getOrderKeyBeforeTab( - filteredTabEntities.value[newIndex + 1].tabId, - containerId, - ); - } - } - - await ref - .read(tabDataRepositoryProvider.notifier) - .assignOrderKey(tabId, key); - }, - childBuilder: (itemBuilder) { - return GridView.builder( - controller: controller, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, + return !tabsReorderable + ? _TabGrid( + key: ValueKey(crossAxisCount), crossAxisCount: crossAxisCount, - ), - itemCount: itemCount, - itemBuilder: (context, index) { - final Widget tab; - if (index < filteredTabEntities.value.length) { - final entity = filteredTabEntities.value[index]; - tab = CustomDraggable( - key: Key(entity.tabId), - data: TabDragData(entity.tabId), - child: _TabDraggable(entity: entity, onClose: onClose), - ); - } else { - final suggestedIndex = - index - filteredTabEntities.value.length; - final entity = suggestedTabEntities.value[suggestedIndex]; - - tab = CustomDraggable( - key: Key('suggested_${entity.tabId}'), - child: _TabDraggable( - entity: entity, - onClose: onClose, - suggestedContainerId: ref.watch( - selectedContainerProvider, + itemCount: itemCount, + scrollController: controller, + itemBuilder: (widget, _) { + if (widget is CustomDraggable) { + return LongPressDraggable( + feedback: Material( + color: Colors + .transparent, // removes white corners when having shadow + child: Transform.scale( + scale: 1.05, + child: SizedBox( + height: itemSize.height, + width: itemSize.width, + child: widget.child, + ), + ), ), - ), - ); - } + data: widget.data, + child: widget.child, + ); + } - return itemBuilder(tab, index); - }, - ); - }, - ); + return widget; + }, + suggestedContainerId: containerId, + filteredTabEntities: filteredTabEntities, + suggestedTabEntities: suggestedTabEntities, + onClose: onClose, + ) + : ReorderableBuilder.builder( + //Rebuild when cross axis count changes + key: ValueKey(crossAxisCount), + scrollController: controller, + itemCount: itemCount, + onDragStarted: (index) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onReorderPositions: (positions) async { + assert( + positions.length == 1, + 'Not ready for multiple reorders', + ); + + final oldIndex = positions.first.oldIndex; + final newIndex = positions.first.newIndex; + + final containerRepository = ref.read( + containerRepositoryProvider.notifier, + ); + + //Suggestions are at the end and not reorderable, so skip + if (oldIndex >= filteredTabEntities.value.length) { + return; + } + + final tabId = filteredTabEntities.value[oldIndex].tabId; + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .getContainerTabId(tabId); + + final String key; + if (newIndex <= 0) { + key = await containerRepository.getLeadingOrderKey( + containerId, + ); + } else if (newIndex >= + filteredTabEntities.value.length - 1) { + key = await containerRepository.getTrailingOrderKey( + containerId, + ); + } else { + if (newIndex < oldIndex) { + key = (await containerRepository.getOrderKeyAfterTab( + filteredTabEntities.value[newIndex - 1].tabId, + containerId, + ))!; + } else { + key = await containerRepository.getOrderKeyBeforeTab( + filteredTabEntities.value[newIndex + 1].tabId, + containerId, + ); + } + } + + await ref + .read(tabDataRepositoryProvider.notifier) + .assignOrderKey(tabId, key); + }, + childBuilder: (itemBuilder) { + return _TabGrid( + crossAxisCount: crossAxisCount, + itemCount: itemCount, + scrollController: controller, + itemBuilder: itemBuilder, + suggestedContainerId: containerId, + filteredTabEntities: filteredTabEntities, + suggestedTabEntities: suggestedTabEntities, + onClose: onClose, + ); + }, + ); }, ), ); } } +class _TabGrid extends StatelessWidget { + const _TabGrid({ + super.key, + required this.crossAxisCount, + required this.scrollController, + required this.itemCount, + required this.itemBuilder, + required this.suggestedContainerId, + required this.filteredTabEntities, + required this.suggestedTabEntities, + required this.onClose, + }); + + final int crossAxisCount; + final int itemCount; + final ScrollController? scrollController; + final String? suggestedContainerId; + final EquatableValue> filteredTabEntities; + final EquatableValue> suggestedTabEntities; + final Widget Function(Widget, int)? itemBuilder; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return GridView.builder( + controller: scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + final Widget tab; + if (index < filteredTabEntities.value.length) { + final entity = filteredTabEntities.value[index]; + tab = CustomDraggable( + key: Key(entity.tabId), + data: TabDragData(entity.tabId), + child: _TabDraggable(entity: entity, onClose: onClose), + ); + } else { + final suggestedIndex = index - filteredTabEntities.value.length; + final entity = suggestedTabEntities.value[suggestedIndex]; + + tab = CustomDraggable( + key: Key('suggested_${entity.tabId}'), + child: _TabDraggable( + entity: entity, + onClose: onClose, + suggestedContainerId: suggestedContainerId, + ), + ); + } + + return (itemBuilder != null) ? itemBuilder!(tab, index) : tab; + }, + ); + } +} + class ViewTabsWidget extends HookConsumerWidget { final ScrollController scrollController; final DraggableScrollableController? draggableScrollableController; - final VoidCallback onClose; final bool showNewTabFab; + final bool tabsReorderable; + final VoidCallback onClose; const ViewTabsWidget({ required this.onClose, required this.scrollController, this.draggableScrollableController, + required this.tabsReorderable, required this.showNewTabFab, super.key, }); @@ -596,14 +798,21 @@ class ViewTabsWidget extends HookConsumerWidget { controller: draggableScrollableController, child: _TabViewHeader( onClose: onClose, - treeViewEnabled: false, + tabsViewMode: TabsViewMode.grid, ), ), ) ?? - _TabViewHeader(onClose: onClose, treeViewEnabled: false), + _TabViewHeader( + onClose: onClose, + tabsViewMode: TabsViewMode.grid, + ), ), ], - body: _TabView(scrollController: scrollController, onClose: onClose), + body: _TabView( + scrollController: scrollController, + tabsReorderable: tabsReorderable, + onClose: onClose, + ), ), if (showNewTabFab) Padding( @@ -650,7 +859,7 @@ class ViewTabTreesWidget extends HookConsumerWidget { children: [ Column( children: [ - _TabViewHeader(onClose: onClose, treeViewEnabled: true), + _TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree), Expanded( child: HookConsumer( builder: (context, ref, child) { @@ -684,8 +893,8 @@ class ViewTabTreesWidget extends HookConsumerWidget { ); }, [screenWidth, filteredTabEntities.value.length]); - final itemHeight = useMemoized( - () => calculateItemHeight( + final itemSize = useMemoized( + () => calculateItemSize( screenWidth: screenWidth, childAspectRatio: 0.75, horizontalPadding: 4.0, @@ -702,7 +911,7 @@ class ViewTabTreesWidget extends HookConsumerWidget { ); if (index > -1) { - final offset = (index ~/ 2) * itemHeight; + final offset = (index ~/ 2) * itemSize.height; if (offset != scrollController.offset) { unawaited( diff --git a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart index 4b9565bc..7d6ddba1 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/daos/tab.dart @@ -51,6 +51,12 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { return query.map((row) => row.read(db.tab.id)!); } + Selectable getTabsFifo({int limit = 25}) { + return select(db.tab) + ..limit(limit) + ..orderBy([(t) => OrderingTerm.desc(t.timestamp)]); + } + SingleOrNullSelectable getTabContainerId(String tabId) { final query = selectOnly(db.tab) ..addColumns([db.tab.containerId]) diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index b69c1fb5..2e0e23c9 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.dart @@ -74,6 +74,13 @@ Stream> watchContainerTabIds( } } +@Riverpod(keepAlive: true) +Stream> watchgetTabsFifo(Ref ref) { + final db = ref.watch(tabDatabaseProvider); + + return db.tabDao.getTabsFifo().watch(); +} + @Riverpod() Future containerTabCount(Ref ref, ContainerFilter containerFilter) { return ref.watch( diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart index fa1faf7e..d60feb2d 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -221,6 +221,45 @@ final class WatchContainerTabIdsFamily extends $Family String toString() => r'watchContainerTabIdsProvider'; } +@ProviderFor(watchgetTabsFifo) +const watchgetTabsFifoProvider = WatchgetTabsFifoProvider._(); + +final class WatchgetTabsFifoProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + Stream> + > + with $FutureModifier>, $StreamProvider> { + const WatchgetTabsFifoProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'watchgetTabsFifoProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchgetTabsFifoHash(); + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + return watchgetTabsFifo(ref); + } +} + +String _$watchgetTabsFifoHash() => r'b0f58ca1688003ba46a55de1ca29d8253fc3de3a'; + @ProviderFor(containerTabCount) const containerTabCountProvider = ContainerTabCountFamily._(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart index c9811e32..5bea0577 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -20,6 +20,7 @@ import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/database/definitions.drift.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; @@ -87,11 +88,7 @@ class TabDataRepository extends _$TabDataRepository { } Future closeAllTabsByHost(String? containerId, String host) async { - final tabs = await ref - .read(tabDatabaseProvider) - .containerDao - .getContainerTabsData(containerId) - .get(); + final tabs = await getContainerTabsData(containerId); final filtered = tabs .where((tab) => tab.url?.host == host) @@ -105,6 +102,14 @@ class TabDataRepository extends _$TabDataRepository { return filtered.length; } + Future> getContainerTabsData(String? containerId) { + return ref + .read(tabDatabaseProvider) + .containerDao + .getContainerTabsData(containerId) + .get(); + } + Future getContainerTabId(String tabId) { return ref .read(tabDatabaseProvider) diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart index 76e7e9d5..0a56d0cc 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabDataRepositoryProvider } } -String _$tabDataRepositoryHash() => r'8f8a1dff5e5be5cbe59562c86b2e57c229b97f8a'; +String _$tabDataRepositoryHash() => r'e12750bc1962878a5bf5140d97521d642001994f'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/app/lib/features/settings/presentation/screens/general_settings.dart b/app/lib/features/settings/presentation/screens/general_settings.dart index e3e5272f..b5223507 100644 --- a/app/lib/features/settings/presentation/screens/general_settings.dart +++ b/app/lib/features/settings/presentation/screens/general_settings.dart @@ -362,6 +362,38 @@ class GeneralSettingsScreen extends HookConsumerWidget { ); }, ), + SwitchListTile.adaptive( + title: const Text('Show Contextual Tab Bar'), + subtitle: const Text( + 'Show additional bottom toolbar for navigation and actions', + ), + secondary: const Icon(MdiIcons.dockBottom), + value: generalSettings.tabBarShowContextualBar, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .tabBarShowContextualBar(value), + ); + }, + ), + SwitchListTile.adaptive( + title: const Text('Show Quick Tab Switcher Bar'), + subtitle: const Text( + 'Show additional toolbar to quickly switch to recently used tabs', + ), + secondary: const Icon(MdiIcons.dockBottom), + value: generalSettings.tabBarShowQuickTabSwitcherBar, + onChanged: (value) async { + await ref + .read(saveGeneralSettingsControllerProvider.notifier) + .save( + (currentSettings) => currentSettings.copyWith + .tabBarShowQuickTabSwitcherBar(value), + ); + }, + ), SwitchListTile.adaptive( title: const Text('Create Child Tabs'), subtitle: const Text( diff --git a/app/lib/features/user/data/models/general_settings.dart b/app/lib/features/user/data/models/general_settings.dart index 01186d5a..35cd79cf 100644 --- a/app/lib/features/user/data/models/general_settings.dart +++ b/app/lib/features/user/data/models/general_settings.dart @@ -72,6 +72,8 @@ class GeneralSettings with FastEquatable { final Duration historyAutoCleanInterval; final bool tabViewBottomSheet; final bool tabBarReaderView; + final bool tabBarShowContextualBar; + final bool tabBarShowQuickTabSwitcherBar; GeneralSettings({ required this.themeMode, @@ -90,6 +92,8 @@ class GeneralSettings with FastEquatable { required this.historyAutoCleanInterval, required this.tabViewBottomSheet, required this.tabBarReaderView, + required this.tabBarShowContextualBar, + required this.tabBarShowQuickTabSwitcherBar, }); GeneralSettings.withDefaults({ @@ -109,6 +113,8 @@ class GeneralSettings with FastEquatable { Duration? historyAutoCleanInterval, bool? tabViewBottomSheet, bool? tabBarReaderView, + bool? tabBarShowContextualBar, + bool? tabBarShowQuickTabSwitcherBar, }) : themeMode = themeMode ?? ThemeMode.dark, enableReadability = enableReadability ?? true, enforceReadability = enforceReadability ?? false, @@ -126,7 +132,9 @@ class GeneralSettings with FastEquatable { historyAutoCleanInterval = historyAutoCleanInterval ?? const Duration(days: 90), tabViewBottomSheet = tabViewBottomSheet ?? false, - tabBarReaderView = tabBarReaderView ?? false; + tabBarReaderView = tabBarReaderView ?? false, + tabBarShowContextualBar = tabBarShowContextualBar ?? true, + tabBarShowQuickTabSwitcherBar = tabBarShowQuickTabSwitcherBar ?? true; factory GeneralSettings.fromJson(Map json) => _$GeneralSettingsFromJson(json); @@ -151,5 +159,7 @@ class GeneralSettings with FastEquatable { historyAutoCleanInterval, tabViewBottomSheet, tabBarReaderView, + tabBarShowContextualBar, + tabBarShowQuickTabSwitcherBar, ]; } diff --git a/app/lib/features/user/data/models/general_settings.g.dart b/app/lib/features/user/data/models/general_settings.g.dart index 7f7d5faf..15512747 100644 --- a/app/lib/features/user/data/models/general_settings.g.dart +++ b/app/lib/features/user/data/models/general_settings.g.dart @@ -45,6 +45,12 @@ abstract class _$GeneralSettingsCWProxy { GeneralSettings tabBarReaderView(bool tabBarReaderView); + GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar); + + GeneralSettings tabBarShowQuickTabSwitcherBar( + bool tabBarShowQuickTabSwitcherBar, + ); + /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. /// @@ -69,6 +75,8 @@ abstract class _$GeneralSettingsCWProxy { Duration historyAutoCleanInterval, bool tabViewBottomSheet, bool tabBarReaderView, + bool tabBarShowContextualBar, + bool tabBarShowQuickTabSwitcherBar, }); } @@ -145,6 +153,15 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { GeneralSettings tabBarReaderView(bool tabBarReaderView) => call(tabBarReaderView: tabBarReaderView); + @override + GeneralSettings tabBarShowContextualBar(bool tabBarShowContextualBar) => + call(tabBarShowContextualBar: tabBarShowContextualBar); + + @override + GeneralSettings tabBarShowQuickTabSwitcherBar( + bool tabBarShowQuickTabSwitcherBar, + ) => call(tabBarShowQuickTabSwitcherBar: tabBarShowQuickTabSwitcherBar); + @override /// Creates a new instance with the provided field values. /// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `GeneralSettings(...).copyWith.fieldName(value)`. @@ -170,6 +187,8 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { Object? historyAutoCleanInterval = const $CopyWithPlaceholder(), Object? tabViewBottomSheet = const $CopyWithPlaceholder(), Object? tabBarReaderView = const $CopyWithPlaceholder(), + Object? tabBarShowContextualBar = const $CopyWithPlaceholder(), + Object? tabBarShowQuickTabSwitcherBar = const $CopyWithPlaceholder(), }) { return GeneralSettings( themeMode: themeMode == const $CopyWithPlaceholder() || themeMode == null @@ -264,6 +283,18 @@ class _$GeneralSettingsCWProxyImpl implements _$GeneralSettingsCWProxy { ? _value.tabBarReaderView // ignore: cast_nullable_to_non_nullable : tabBarReaderView as bool, + tabBarShowContextualBar: + tabBarShowContextualBar == const $CopyWithPlaceholder() || + tabBarShowContextualBar == null + ? _value.tabBarShowContextualBar + // ignore: cast_nullable_to_non_nullable + : tabBarShowContextualBar as bool, + tabBarShowQuickTabSwitcherBar: + tabBarShowQuickTabSwitcherBar == const $CopyWithPlaceholder() || + tabBarShowQuickTabSwitcherBar == null + ? _value.tabBarShowQuickTabSwitcherBar + // ignore: cast_nullable_to_non_nullable + : tabBarShowQuickTabSwitcherBar as bool, ); } } @@ -279,46 +310,48 @@ extension $GeneralSettingsCopyWith on GeneralSettings { // JsonSerializableGenerator // ************************************************************************** -GeneralSettings _$GeneralSettingsFromJson(Map json) => - GeneralSettings.withDefaults( - themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), - enableReadability: json['enableReadability'] as bool?, - enforceReadability: json['enforceReadability'] as bool?, - deleteBrowsingDataOnQuit: - (json['deleteBrowsingDataOnQuit'] as List?) - ?.map((e) => $enumDecode(_$DeleteBrowsingDataTypeEnumMap, e)) - .toSet(), - defaultSearchProvider: const BangKeyConverter().fromJson( - json['defaultSearchProvider'] as String?, - ), - defaultSearchSuggestionsProvider: $enumDecodeNullable( - _$SearchSuggestionProvidersEnumMap, - json['defaultSearchSuggestionsProvider'], - ), - createChildTabsOption: json['createChildTabsOption'] as bool?, - showExtensionShortcut: json['showExtensionShortcut'] as bool?, - enableLocalAiFeatures: json['enableLocalAiFeatures'] as bool?, - defaultCreateTabType: $enumDecodeNullable( - _$TabTypeEnumMap, - json['defaultCreateTabType'], - ), - tabIntentOpenSetting: $enumDecodeNullable( - _$TabIntentOpenSettingEnumMap, - json['tabIntentOpenSetting'], - ), - autoHideTabBar: json['autoHideTabBar'] as bool?, - tabBarSwipeAction: $enumDecodeNullable( - _$TabBarSwipeActionEnumMap, - json['tabBarSwipeAction'], - ), - historyAutoCleanInterval: json['historyAutoCleanInterval'] == null - ? null - : Duration( - microseconds: (json['historyAutoCleanInterval'] as num).toInt(), - ), - tabViewBottomSheet: json['tabViewBottomSheet'] as bool?, - tabBarReaderView: json['tabBarReaderView'] as bool?, - ); +GeneralSettings _$GeneralSettingsFromJson( + Map json, +) => GeneralSettings.withDefaults( + themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']), + enableReadability: json['enableReadability'] as bool?, + enforceReadability: json['enforceReadability'] as bool?, + deleteBrowsingDataOnQuit: (json['deleteBrowsingDataOnQuit'] as List?) + ?.map((e) => $enumDecode(_$DeleteBrowsingDataTypeEnumMap, e)) + .toSet(), + defaultSearchProvider: const BangKeyConverter().fromJson( + json['defaultSearchProvider'] as String?, + ), + defaultSearchSuggestionsProvider: $enumDecodeNullable( + _$SearchSuggestionProvidersEnumMap, + json['defaultSearchSuggestionsProvider'], + ), + createChildTabsOption: json['createChildTabsOption'] as bool?, + showExtensionShortcut: json['showExtensionShortcut'] as bool?, + enableLocalAiFeatures: json['enableLocalAiFeatures'] as bool?, + defaultCreateTabType: $enumDecodeNullable( + _$TabTypeEnumMap, + json['defaultCreateTabType'], + ), + tabIntentOpenSetting: $enumDecodeNullable( + _$TabIntentOpenSettingEnumMap, + json['tabIntentOpenSetting'], + ), + autoHideTabBar: json['autoHideTabBar'] as bool?, + tabBarSwipeAction: $enumDecodeNullable( + _$TabBarSwipeActionEnumMap, + json['tabBarSwipeAction'], + ), + historyAutoCleanInterval: json['historyAutoCleanInterval'] == null + ? null + : Duration( + microseconds: (json['historyAutoCleanInterval'] as num).toInt(), + ), + tabViewBottomSheet: json['tabViewBottomSheet'] as bool?, + tabBarReaderView: json['tabBarReaderView'] as bool?, + tabBarShowContextualBar: json['tabBarShowContextualBar'] as bool?, + tabBarShowQuickTabSwitcherBar: json['tabBarShowQuickTabSwitcherBar'] as bool?, +); Map _$GeneralSettingsToJson( GeneralSettings instance, @@ -346,6 +379,8 @@ Map _$GeneralSettingsToJson( 'historyAutoCleanInterval': instance.historyAutoCleanInterval.inMicroseconds, 'tabViewBottomSheet': instance.tabViewBottomSheet, 'tabBarReaderView': instance.tabBarReaderView, + 'tabBarShowContextualBar': instance.tabBarShowContextualBar, + 'tabBarShowQuickTabSwitcherBar': instance.tabBarShowQuickTabSwitcherBar, }; const _$ThemeModeEnumMap = { diff --git a/app/lib/features/user/domain/repositories/general_settings.dart b/app/lib/features/user/domain/repositories/general_settings.dart index b06966f6..199da009 100644 --- a/app/lib/features/user/domain/repositories/general_settings.dart +++ b/app/lib/features/user/domain/repositories/general_settings.dart @@ -108,6 +108,12 @@ class GeneralSettingsRepository extends _$GeneralSettingsRepository { DriftSqlType.bool, db.typeMapping, ), + 'tabBarShowContextualBar': settings['tabBarShowContextualBar']?.readAs( + DriftSqlType.bool, + db.typeMapping, + ), + 'tabBarShowQuickTabSwitcherBar': settings['tabBarShowQuickTabSwitcherBar'] + ?.readAs(DriftSqlType.bool, db.typeMapping), }); } diff --git a/app/lib/features/user/domain/repositories/general_settings.g.dart b/app/lib/features/user/domain/repositories/general_settings.g.dart index dd111f19..cfabf09a 100644 --- a/app/lib/features/user/domain/repositories/general_settings.g.dart +++ b/app/lib/features/user/domain/repositories/general_settings.g.dart @@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider } String _$generalSettingsRepositoryHash() => - r'e929fd7a8bf315b08ea7f5a4df2bd5bf066bc84f'; + r'4014986c9510d6d7210c5c16d814f6b018a1f93c'; abstract class _$GeneralSettingsRepository extends $StreamNotifier {