rework tab bar; added contextual bar and quick switcher; tab abr dismissable; reorder now opt in; consolidated controllers and menu buttons;

This commit is contained in:
Fabian Freund
2025-12-22 10:05:17 +01:00
parent 2b1400eb76
commit e549375178
26 changed files with 1903 additions and 893 deletions
@@ -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<Map<String, TabState>> availableTabStates(
});
}
@Riverpod(keepAlive: true)
EquatableValue<List<FifoTab>> 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<List<TabEntity>> suggestedTabEntities(
Ref ref,
@@ -382,6 +382,55 @@ final class AvailableTabStatesFamily extends $Family
String toString() => r'availableTabStatesProvider';
}
@ProviderFor(fifoTabStates)
const fifoTabStatesProvider = FifoTabStatesProvider._();
final class FifoTabStatesProvider
extends
$FunctionalProvider<
EquatableValue<List<FifoTab>>,
EquatableValue<List<FifoTab>>,
EquatableValue<List<FifoTab>>
>
with $Provider<EquatableValue<List<FifoTab>>> {
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<EquatableValue<List<FifoTab>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
EquatableValue<List<FifoTab>> create(Ref ref) {
return fifoTabStates(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(EquatableValue<List<FifoTab>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<EquatableValue<List<FifoTab>>>(
value,
),
);
}
}
String _$fifoTabStatesHash() => r'0320c28b328d814ed358ec6a10ec770a1ed634ad';
@ProviderFor(suggestedTabEntities)
const suggestedTabEntitiesProvider = SuggestedTabEntitiesFamily._();
@@ -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;
}
}
@@ -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<TabSuggestionsController, bool> {
const TabSuggestionsControllerProvider._()
final class TabBarDismissableControllerProvider
extends $NotifierProvider<TabBarDismissableController, bool> {
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<bool> {
abstract class _$TabBarDismissableController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
@@ -17,9 +17,11 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import 'package: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;
}
}
@@ -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<TabSuggestionsController, bool> {
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<bool>(value),
);
}
}
String _$tabSuggestionsControllerHash() =>
r'5ce7f385b8aba14d432912cf0acb06aad04433fc';
abstract class _$TabSuggestionsController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@ProviderFor(TabsViewModeController)
const tabsViewModeControllerProvider = TabsViewModeControllerProvider._();
final class TabsViewModeControllerProvider
extends $NotifierProvider<TabsViewModeController, TabsViewMode> {
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<TabsViewMode>(value),
);
}
}
String _$tabsViewModeControllerHash() =>
r'9c574458f6d5a4c6a8b5aabf96ad7f5dcd043dd0';
abstract class _$TabsViewModeController extends $Notifier<TabsViewMode> {
TabsViewMode build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<TabsViewMode, TabsViewMode>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<TabsViewMode, TabsViewMode>,
TabsViewMode,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@ProviderFor(TabsReorderableController)
const tabsReorderableControllerProvider = TabsReorderableControllerProvider._();
final class TabsReorderableControllerProvider
extends $NotifierProvider<TabsReorderableController, bool> {
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<bool>(value),
);
}
}
String _$tabsReorderableControllerHash() =>
r'f169e9dc04055ef611f17ebe121f0f51f6a294cb';
abstract class _$TabsReorderableController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
@@ -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<TreeViewController, bool> {
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<bool>(value),
);
}
}
String _$treeViewControllerHash() =>
r'1fac2a133d369e3bd3f8f75cdd2d0c862a6ab942';
abstract class _$TreeViewController extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@@ -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<bool> showAppBar;
final ValueNotifier<PersistentBottomSheetController?> 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();
},
),
},
);
},
);
@@ -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 {
@@ -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();
@@ -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();
}
},
);
}
}
@@ -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
@@ -18,23 +18,19 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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'),
@@ -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<bool?>(
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: <Widget>[
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<List<TabEntity>> filteredTabEntities;
final EquatableValue<List<TabEntity>> 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(
@@ -51,6 +51,12 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
return query.map((row) => row.read(db.tab.id)!);
}
Selectable<TabData> getTabsFifo({int limit = 25}) {
return select(db.tab)
..limit(limit)
..orderBy([(t) => OrderingTerm.desc(t.timestamp)]);
}
SingleOrNullSelectable<String?> getTabContainerId(String tabId) {
final query = selectOnly(db.tab)
..addColumns([db.tab.containerId])
@@ -74,6 +74,13 @@ Stream<List<String>> watchContainerTabIds(
}
}
@Riverpod(keepAlive: true)
Stream<List<TabData>> watchgetTabsFifo(Ref ref) {
final db = ref.watch(tabDatabaseProvider);
return db.tabDao.getTabsFifo().watch();
}
@Riverpod()
Future<int> containerTabCount(Ref ref, ContainerFilter containerFilter) {
return ref.watch(
@@ -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<TabData>>,
List<TabData>,
Stream<List<TabData>>
>
with $FutureModifier<List<TabData>>, $StreamProvider<List<TabData>> {
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<List<TabData>> $createElement(
$ProviderPointer pointer,
) => $StreamProviderElement(pointer);
@override
Stream<List<TabData>> create(Ref ref) {
return watchgetTabsFifo(ref);
}
}
String _$watchgetTabsFifoHash() => r'b0f58ca1688003ba46a55de1ca29d8253fc3de3a';
@ProviderFor(containerTabCount)
const containerTabCountProvider = ContainerTabCountFamily._();
@@ -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<int> 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<List<TabData>> getContainerTabsData(String? containerId) {
return ref
.read(tabDatabaseProvider)
.containerDao
.getContainerTabsData(containerId)
.get();
}
Future<String?> getContainerTabId(String tabId) {
return ref
.read(tabDatabaseProvider)
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
}
}
String _$tabDataRepositoryHash() => r'8f8a1dff5e5be5cbe59562c86b2e57c229b97f8a';
String _$tabDataRepositoryHash() => r'e12750bc1962878a5bf5140d97521d642001994f';
abstract class _$TabDataRepository extends $Notifier<void> {
void build();
@@ -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(
@@ -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<String, dynamic> json) =>
_$GeneralSettingsFromJson(json);
@@ -151,5 +159,7 @@ class GeneralSettings with FastEquatable {
historyAutoCleanInterval,
tabViewBottomSheet,
tabBarReaderView,
tabBarShowContextualBar,
tabBarShowQuickTabSwitcherBar,
];
}
@@ -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<String, dynamic> json) =>
GeneralSettings.withDefaults(
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']),
enableReadability: json['enableReadability'] as bool?,
enforceReadability: json['enforceReadability'] as bool?,
deleteBrowsingDataOnQuit:
(json['deleteBrowsingDataOnQuit'] as List<dynamic>?)
?.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<String, dynamic> json,
) => GeneralSettings.withDefaults(
themeMode: $enumDecodeNullable(_$ThemeModeEnumMap, json['themeMode']),
enableReadability: json['enableReadability'] as bool?,
enforceReadability: json['enforceReadability'] as bool?,
deleteBrowsingDataOnQuit: (json['deleteBrowsingDataOnQuit'] as List<dynamic>?)
?.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<String, dynamic> _$GeneralSettingsToJson(
GeneralSettings instance,
@@ -346,6 +379,8 @@ Map<String, dynamic> _$GeneralSettingsToJson(
'historyAutoCleanInterval': instance.historyAutoCleanInterval.inMicroseconds,
'tabViewBottomSheet': instance.tabViewBottomSheet,
'tabBarReaderView': instance.tabBarReaderView,
'tabBarShowContextualBar': instance.tabBarShowContextualBar,
'tabBarShowQuickTabSwitcherBar': instance.tabBarShowQuickTabSwitcherBar,
};
const _$ThemeModeEnumMap = {
@@ -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),
});
}
@@ -35,7 +35,7 @@ final class GeneralSettingsRepositoryProvider
}
String _$generalSettingsRepositoryHash() =>
r'e929fd7a8bf315b08ea7f5a4df2bd5bf066bc84f';
r'4014986c9510d6d7210c5c16d814f6b018a1f93c';
abstract class _$GeneralSettingsRepository
extends $StreamNotifier<GeneralSettings> {