diff --git a/app/lib/features/geckoview/domain/providers/tab_session.g.dart b/app/lib/features/geckoview/domain/providers/tab_session.g.dart index 39d5d595..4a18f854 100644 --- a/app/lib/features/geckoview/domain/providers/tab_session.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_session.g.dart @@ -57,7 +57,7 @@ final class TabSessionProvider extends $NotifierProvider { } } -String _$tabSessionHash() => r'f0a3aca70719afd37cad260c5d36621455252bfb'; +String _$tabSessionHash() => r'4b7206863e205dfeee6201ba6072e742c43ab6f3'; final class TabSessionFamily extends $Family with $ClassFamilyOverride { diff --git a/app/lib/features/geckoview/domain/providers/tab_state.dart b/app/lib/features/geckoview/domain/providers/tab_state.dart index 4a804a4f..639c263d 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.dart @@ -255,7 +255,7 @@ Future isTabTunneled(Ref ref, String? tabId) async { case TorRegularTabProxyMode.container: final containerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabState.id); + .getTabContainerId(tabState.id); final containerData = await containerId.mapNotNull( (containerId) => ref diff --git a/app/lib/features/geckoview/domain/providers/tab_state.g.dart b/app/lib/features/geckoview/domain/providers/tab_state.g.dart index 4a21879c..cf25b701 100644 --- a/app/lib/features/geckoview/domain/providers/tab_state.g.dart +++ b/app/lib/features/geckoview/domain/providers/tab_state.g.dart @@ -188,7 +188,7 @@ final class IsTabTunneledProvider } } -String _$isTabTunneledHash() => r'fd8ca366b0835eef8aea7ad64453e6a6f2e69e82'; +String _$isTabTunneledHash() => r'2e1a911c26345951cf259ca4547d236a71607667'; final class IsTabTunneledFamily extends $Family with $FunctionalFamilyOverride, String?> { diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 71b03d82..ca674853 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -188,7 +188,7 @@ class TabRepository extends _$TabRepository { Future selectTab(String tabId) async { final containerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabId); + .getTabContainerId(tabId); if (!ref.mounted) return false; @@ -234,7 +234,7 @@ class TabRepository extends _$TabRepository { final currentContainerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabId); + .getTabContainerId(tabId); if (!ref.mounted) return; @@ -419,7 +419,7 @@ class TabRepository extends _$TabRepository { } else { final tabContainerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabState.id); + .getTabContainerId(tabState.id); if (targetContainerId != tabContainerId) { if (originUri == null) { diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index 0537411b..1dea4bdf 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.g.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.g.dart @@ -41,7 +41,7 @@ final class TabRepositoryProvider } } -String _$tabRepositoryHash() => r'ef1116cbf09f456deae7a7d3f5cee3ba843d7430'; +String _$tabRepositoryHash() => r'7af24e759044d1b1b471cdf90a3eb38eef06f61b'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/domain/providers.dart b/app/lib/features/geckoview/features/browser/domain/providers.dart index 0cbe693c..bb1cc726 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.dart @@ -83,7 +83,7 @@ class SelectedBangData extends _$SelectedBangData { } @Riverpod() -EquatableValue> availableTabIds( +EquatableValue> availableTabIds( Ref ref, ContainerFilter containerFilter, ) { @@ -94,10 +94,37 @@ EquatableValue> availableTabIds( ); final tabList = ref.watch(tabListProvider); - return EquatableValue( - containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ?? - [], - ); + final availableTabs = + containerTabs?.where((tabId) => tabList.value.contains(tabId)).toList() ?? + []; + + switch (containerFilter) { + case ContainerFilterById(): + return EquatableValue( + availableTabs + .map( + (t) => DefaultTabEntity( + tabId: t, + containerId: containerFilter.containerId, + ), + ) + .toList(), + ); + case ContainerFilterDisabled(): + return ref.watch( + watchTabsContainerIdProvider(EquatableValue(availableTabs)).select( + (value) => EquatableValue( + value.value?.entries + .map( + (e) => + DefaultTabEntity(tabId: e.key, containerId: e.value), + ) + .toList() ?? + [], + ), + ), + ); + } } @Riverpod() @@ -109,8 +136,9 @@ EquatableValue> availableTabStates( final tabStates = ref.watch(tabStatesProvider); return EquatableValue({ - for (final tabId in availableTabs.value) - if (tabStates.containsKey(tabId)) tabId: tabStates[tabId]!, + for (final tabEntity in availableTabs.value) + if (tabStates.containsKey(tabEntity.tabId)) + tabEntity.tabId: tabStates[tabEntity.tabId]!, }); } @@ -171,7 +199,12 @@ EquatableValue> suggestedTabEntities( .whereNot( (tabId) => excludedTabIds.value?.contains(tabId) ?? false, ) - .map((tabId) => DefaultTabEntity(tabId: tabId)) + .map( + (tabId) => DefaultTabEntity( + tabId: tabId, + containerId: containerId, + ), + ) .toList(), ) ?? const [], @@ -198,6 +231,7 @@ EquatableValue> seamlessFilteredTabEntities( .map( (tab) => SearchResultTabEntity( tabId: tab.id, + containerId: tab.containerId, searchQuery: result.query, ), ) @@ -208,13 +242,7 @@ EquatableValue> seamlessFilteredTabEntities( ) .value; - final availableTabs = ref.watch( - availableTabIdsProvider(containerFilter).select( - (value) => EquatableValue( - value.value.map((tab) => DefaultTabEntity(tabId: tab)).toList(), - ), - ), - ); + final availableTabs = ref.watch(availableTabIdsProvider(containerFilter)); if (tabSearchResults == null) { if (groupTrees) { 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 901b2c00..1778cbfd 100644 --- a/app/lib/features/geckoview/features/browser/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/browser/domain/providers.g.dart @@ -212,11 +212,11 @@ const availableTabIdsProvider = AvailableTabIdsFamily._(); final class AvailableTabIdsProvider extends $FunctionalProvider< - EquatableValue>, - EquatableValue>, - EquatableValue> + EquatableValue>, + EquatableValue>, + EquatableValue> > - with $Provider>> { + with $Provider>> { const AvailableTabIdsProvider._({ required AvailableTabIdsFamily super.from, required ContainerFilter super.argument, @@ -240,21 +240,22 @@ final class AvailableTabIdsProvider @$internal @override - $ProviderElement>> $createElement( + $ProviderElement>> $createElement( $ProviderPointer pointer, ) => $ProviderElement(pointer); @override - EquatableValue> create(Ref ref) { + EquatableValue> create(Ref ref) { final argument = this.argument as ContainerFilter; return availableTabIds(ref, argument); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(EquatableValue> value) { + Override overrideWithValue(EquatableValue> value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider>>(value), + providerOverride: + $SyncValueProvider>>(value), ); } @@ -269,12 +270,12 @@ final class AvailableTabIdsProvider } } -String _$availableTabIdsHash() => r'aa166b471d45913df5166a3126abeb7c0d791412'; +String _$availableTabIdsHash() => r'fa7280a7a5f9c277faa71a30f5dd50afa30a20b1'; final class AvailableTabIdsFamily extends $Family with $FunctionalFamilyOverride< - EquatableValue>, + EquatableValue>, ContainerFilter > { const AvailableTabIdsFamily._() @@ -358,7 +359,7 @@ final class AvailableTabStatesProvider } String _$availableTabStatesHash() => - r'f506b050e1dc4f50fbd2699d97af5a16a037bc4b'; + r'32f15b0f739495e11025bdc10d324764465b542d'; final class AvailableTabStatesFamily extends $Family with @@ -497,7 +498,7 @@ final class SuggestedTabEntitiesProvider } String _$suggestedTabEntitiesHash() => - r'6fb58bf4b7377f826377c88110c8e4aca9f6d6f9'; + r'1c3d9a4f85db60301fe5c5b55fb55512d1b59696'; final class SuggestedTabEntitiesFamily extends $Family with $FunctionalFamilyOverride>, String?> { @@ -601,7 +602,7 @@ final class SeamlessFilteredTabEntitiesProvider } String _$seamlessFilteredTabEntitiesHash() => - r'eb6573514f55b598fbc1a91370079b4b8372c55e'; + r'f2bf14737bc03f71fc61252359979b4e901e5df7'; final class SeamlessFilteredTabEntitiesFamily extends $Family with diff --git a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart index 3d043542..de2b98f9 100644 --- a/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart +++ b/app/lib/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart @@ -17,9 +17,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/experimental/persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/providers.dart'; part 'tab_view_controllers.g.dart'; @@ -37,11 +42,19 @@ class TabSuggestionsController extends _$TabSuggestionsController { @override bool build() { - return false; + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'TabSuggestions', + encode: (state) => jsonEncode([state]), + decode: (encoded) => (jsonDecode(encoded) as List).first as bool, + ); + + return stateOrNull ?? false; } } enum TabsViewMode { + list(MdiIcons.folderTable, 'List'), grid(MdiIcons.table, 'Grid'), tree(MdiIcons.familyTree, 'Tree'); @@ -61,7 +74,17 @@ class TabsViewModeController extends _$TabsViewModeController { @override TabsViewMode build() { - return TabsViewMode.grid; + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'TabsViewMode', + encode: (state) => jsonEncode([state.name]), + decode: (encoded) { + final name = (jsonDecode(encoded) as List).first as String; + return TabsViewMode.values.firstWhere((e) => e.name == name); + }, + ); + + return stateOrNull ?? TabsViewMode.list; } } 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 index c0c4cb39..273c0819 100644 --- 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 @@ -42,7 +42,7 @@ final class TabSuggestionsControllerProvider } String _$tabSuggestionsControllerHash() => - r'5ce7f385b8aba14d432912cf0acb06aad04433fc'; + r'c60912e474177f2a0f15167743db439d157cd852'; abstract class _$TabSuggestionsController extends $Notifier { bool build(); @@ -96,7 +96,7 @@ final class TabsViewModeControllerProvider } String _$tabsViewModeControllerHash() => - r'9c574458f6d5a4c6a8b5aabf96ad7f5dcd043dd0'; + r'e013b174218fcad81981f7d939cf161c3b002adc'; abstract class _$TabsViewModeController extends $Notifier { TabsViewMode build(); diff --git a/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart b/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart index 5b9bc7cc..a2af91e5 100644 --- a/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart +++ b/app/lib/features/geckoview/features/browser/presentation/dialogs/tab_tree.dart @@ -30,7 +30,7 @@ import 'package:skeletonizer/skeletonizer.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/selected_tab.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/utils/ui_helper.dart'; @@ -123,7 +123,7 @@ class TabTreeDialog extends HookConsumerWidget { final id = node.key!.value as String; return SizedBox.fromSize( size: childSize, - child: SingleTabPreview( + child: SingleGridTabPreview( key: ValueKey(id), tabId: id, activeTabId: selectedTabId, 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 abb58d1d..7d2f94e3 100644 --- a/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart +++ b/app/lib/features/geckoview/features/browser/presentation/screens/browser.dart @@ -46,7 +46,9 @@ import 'package:weblibre/features/geckoview/features/browser/presentation/widget import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_view.dart'; import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/sheets/view_tab.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.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'; @@ -780,7 +782,18 @@ class _ViewTabsSheet extends HookConsumerWidget { topRight: Radius.circular(28), ), child: switch (tabsViewMode) { - TabsViewMode.grid => ViewTabsWidget( + TabsViewMode.list => ViewTabListWidget( + scrollController: scrollController, + showNewTabFab: true, + tabsReorderable: tabsReorderable, + draggableScrollableController: draggableScrollableController, + onClose: () { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); + }, + ), + TabsViewMode.grid => ViewTabGridWidget( scrollController: scrollController, showNewTabFab: true, tabsReorderable: tabsReorderable, 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 16d62841..11ef3bf0 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 @@ -23,7 +23,9 @@ 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/tab_view_controllers.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; class TabViewScreen extends HookConsumerWidget { @@ -40,7 +42,16 @@ class TabViewScreen extends HookConsumerWidget { child: Scaffold( body: SafeArea( child: switch (tabsViewMode) { - TabsViewMode.grid => ViewTabsWidget( + TabsViewMode.list => ViewTabListWidget( + key: ValueKey(tabsReorderable), + scrollController: scrollController, + tabsReorderable: tabsReorderable, + showNewTabFab: false, + onClose: () { + const BrowserRoute().go(context); + }, + ), + TabsViewMode.grid => ViewTabGridWidget( key: ValueKey(tabsReorderable), scrollController: scrollController, tabsReorderable: tabsReorderable, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart new file mode 100644 index 00000000..e79aec99 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_grid_view.dart @@ -0,0 +1,457 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_reorderable_grid_view/widgets/custom_draggable.dart'; +import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/providers/global_drop.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.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_view/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.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/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +class _TabDraggable extends HookConsumerWidget { + final TabEntity entity; + final String? suggestedContainerId; + final VoidCallback onClose; + + const _TabDraggable({ + required this.entity, + required this.onClose, + this.suggestedContainerId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeTab = ref.watch(selectedTabProvider); + + final dragData = ref.watch( + willAcceptDropProvider.select((value) { + final dragTabId = switch (value) { + ContainerDropData() => value.tabId, + DeleteDropData() => value.tabId, + null => null, + }; + + return (dragTabId == entity.tabId) ? value : null; + }), + ); + + // Cache the tab widget to avoid rebuilding + final tab = useMemoized(() { + return (suggestedContainerId != null) + ? SuggestedSingleGridTabPreview( + key: ValueKey(entity.tabId), + tabId: entity.tabId, + activeTabId: activeTab, + onTap: () async { + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(suggestedContainerId!); + + if (containerData != null) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(entity.tabId, containerData); + } + }, + ) + : SingleGridTabPreview( + key: ValueKey(entity.tabId), + tabId: entity.tabId, + activeTabId: activeTab, + onClose: onClose, + sourceSearchQuery: switch (entity) { + DefaultTabEntity _ => null, + final SearchResultTabEntity entity => entity.searchQuery, + TabTreeEntity _ => throw UnimplementedError(), + }, + ); + }, [entity.tabId, activeTab, suggestedContainerId]); + + return switch (dragData) { + ContainerDropData() => Opacity( + opacity: 0.3, + child: Transform.scale(scale: 0.9, child: tab), + ), + DeleteDropData() => Opacity( + opacity: 0.3, + child: ColorFiltered( + colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate), + child: tab, + ), + ), + null => tab, + }; + } +} + +class _TabGridView extends HookConsumerWidget { + final ScrollController scrollController; + final bool tabsReorderable; + final VoidCallback onClose; + + const _TabGridView({ + required this.scrollController, + required this.tabsReorderable, + required this.onClose, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final screenWidth = MediaQuery.of(context).size.width; + + final containerId = ref.watch(selectedContainerProvider); + + final filteredTabEntities = ref.watch( + seamlessFilteredTabEntitiesProvider( + searchPartition: TabSearchPartition.preview, + // ignore: document_ignores using fast equatable + // ignore: provider_parameters + containerFilter: ContainerFilterById(containerId: containerId), + groupTrees: false, + ), + ); + + final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider); + + final suggestedTabEntities = tabSuggestionsEnabled + ? ref.watch(suggestedTabEntitiesProvider(containerId)) + : EquatableValue([]); + + final itemCount = + filteredTabEntities.value.length + + //Limit to 3 sugegstions for now + math.min(suggestedTabEntities.value.length, 3); + + final activeTab = ref.watch(selectedTabProvider); + + final crossAxisCount = useMemoized(() { + final calculatedCount = calculateCrossAxisItemCount( + screenWidth: screenWidth, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max(math.min(calculatedCount, itemCount), 2); + }, [screenWidth, itemCount]); + + final itemSize = useMemoized( + () => calculateItemSize( + screenWidth: screenWidth, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + [screenWidth, crossAxisCount], + ); + + final lastScroll = useRef(null); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + if (lastScroll.value != activeTab) { + final index = filteredTabEntities.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index > -1) { + final offset = (index ~/ 2) * itemSize.height; + + if (offset != scrollController.offset) { + lastScroll.value = activeTab; + + unawaited( + scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + } + } + }); + + return null; + }, [filteredTabEntities, activeTab]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FadingScroll( + fadingSize: 5, + controller: scrollController, + builder: (context, controller) { + return !tabsReorderable + ? _TabGrid( + key: ValueKey(crossAxisCount), + crossAxisCount: crossAxisCount, + 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, + childWhenDragging: SizedBox( + height: itemSize.height, + width: itemSize.width, + ), + child: widget.child, + ); + } + + 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) + .getTabContainerId(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 ViewTabGridWidget extends HookConsumerWidget { + final ScrollController scrollController; + final DraggableScrollableController? draggableScrollableController; + final bool showNewTabFab; + final bool tabsReorderable; + final VoidCallback onClose; + + const ViewTabGridWidget({ + required this.onClose, + required this.scrollController, + this.draggableScrollableController, + required this.tabsReorderable, + required this.showNewTabFab, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + NestedScrollView( + physics: const NeverScrollableScrollPhysics(), + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverToBoxAdapter( + child: + draggableScrollableController.mapNotNull( + (draggableScrollableController) => + DraggableScrollableHeader( + controller: draggableScrollableController, + child: TabViewHeader( + onClose: onClose, + tabsViewMode: TabsViewMode.grid, + ), + ), + ) ?? + TabViewHeader( + onClose: onClose, + tabsViewMode: TabsViewMode.grid, + ), + ), + ], + body: _TabGridView( + scrollController: scrollController, + tabsReorderable: tabsReorderable, + onClose: onClose, + ), + ), + if (showNewTabFab) + Padding( + padding: const EdgeInsets.only( + top: TabViewHeader.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + final settings = ref.read(generalSettingsWithDefaultsProvider); + + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.defaultCreateTabType, + ).push(context); + + onClose(); + }, + child: const Icon(Icons.add), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart new file mode 100644 index 00000000..c883d44d --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_list_view.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_reorderable_grid_view/widgets/custom_draggable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nullability/nullability.dart'; +import 'package:weblibre/core/providers/global_drop.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/data/models/drag_data.dart'; +import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; +import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; +import 'package:weblibre/features/geckoview/features/browser/domain/providers.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_view/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.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/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; + +class _TabDraggable extends HookConsumerWidget { + final TabEntity entity; + final String? suggestedContainerId; + final VoidCallback onClose; + final double height; + + const _TabDraggable({ + required this.entity, + required this.onClose, + required this.height, + this.suggestedContainerId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeTab = ref.watch(selectedTabProvider); + + final dragData = ref.watch( + willAcceptDropProvider.select((value) { + final dragTabId = switch (value) { + ContainerDropData() => value.tabId, + DeleteDropData() => value.tabId, + null => null, + }; + + return (dragTabId == entity.tabId) ? value : null; + }), + ); + + // Cache the tab widget to avoid rebuilding + final tab = useMemoized(() { + return (suggestedContainerId != null) + ? SuggestedSingleListTabPreview( + key: ValueKey(entity.tabId), + tabId: entity.tabId, + activeTabId: activeTab, + onTap: () async { + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(suggestedContainerId!); + + if (containerData != null) { + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(entity.tabId, containerData); + } + }, + ) + : SingleListTabPreview( + key: ValueKey(entity.tabId), + tabId: entity.tabId, + activeTabId: activeTab, + onClose: onClose, + sourceSearchQuery: switch (entity) { + DefaultTabEntity _ => null, + final SearchResultTabEntity entity => entity.searchQuery, + TabTreeEntity _ => throw UnimplementedError(), + }, + ); + }, [entity.tabId, activeTab, suggestedContainerId]); + + return switch (dragData) { + ContainerDropData() => Opacity( + opacity: 0.3, + child: Transform.scale( + scale: 0.9, + child: SizedBox( + height: height, + width: MediaQuery.of(context).size.width, + child: tab, + ), + ), + ), + DeleteDropData() => Opacity( + opacity: 0.3, + child: ColorFiltered( + colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate), + child: tab, + ), + ), + null => tab, + }; + } +} + +class _TabListView extends HookConsumerWidget { + final ScrollController scrollController; + final bool tabsReorderable; + final VoidCallback onClose; + + const _TabListView({ + required this.scrollController, + required this.tabsReorderable, + required this.onClose, + }); + + static const _itemHeight = 80.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + // final screenWidth = MediaQuery.of(context).size.width; + + final containerId = ref.watch(selectedContainerProvider); + + final filteredTabEntities = ref.watch( + seamlessFilteredTabEntitiesProvider( + searchPartition: TabSearchPartition.preview, + // ignore: document_ignores using fast equatable + // ignore: provider_parameters + containerFilter: ContainerFilterById(containerId: containerId), + groupTrees: false, + ), + ); + + final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider); + + final suggestedTabEntities = tabSuggestionsEnabled + ? ref.watch(suggestedTabEntitiesProvider(containerId)) + : EquatableValue([]); + + final itemCount = + filteredTabEntities.value.length + + //Limit to 3 sugegstions for now + math.min(suggestedTabEntities.value.length, 3); + + final activeTab = ref.watch(selectedTabProvider); + + final lastScroll = useRef(null); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (scrollController.hasClients) { + if (lastScroll.value != activeTab) { + final index = filteredTabEntities.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index > -1) { + final offset = index * _itemHeight; + + if (offset != scrollController.offset) { + lastScroll.value = activeTab; + + unawaited( + scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + } + } + }); + + return null; + }, [filteredTabEntities, activeTab]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: FadingScroll( + fadingSize: 5, + controller: scrollController, + builder: (context, controller) { + return !tabsReorderable + ? ListView.builder( + controller: scrollController, + itemCount: itemCount, + itemExtent: _itemHeight, + itemBuilder: (context, index) { + final CustomDraggable 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, + height: _itemHeight, + ), + ); + } 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: containerId, + height: _itemHeight, + ), + ); + } + + return LongPressDraggable( + feedback: Material( + color: Colors + .transparent, // removes white corners when having shadow + child: Transform.scale( + scale: 1.05, + child: SizedBox( + height: _itemHeight, + width: MediaQuery.of(context).size.width, + child: tab.child, + ), + ), + ), + data: tab.data, + childWhenDragging: const SizedBox(height: _itemHeight), + child: tab.child, + ); + }, + ) + : ReorderableListView.builder( + scrollController: controller, + itemCount: itemCount, + onReorderStart: (index) { + ref.read(willAcceptDropProvider.notifier).clear(); + }, + onReorder: (oldIndex, newIndex) async { + 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) + .getTabContainerId(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); + }, + itemBuilder: (context, index) { + final CustomDraggable 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, + height: _itemHeight, + ), + ); + } 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: containerId, + height: _itemHeight, + ), + ); + } + + // return LongPressDraggable( + // key: tab.key, + // feedback: Material( + // color: Colors + // .transparent, // removes white corners when having shadow + // child: Transform.scale( + // scale: 1.05, + // child: SizedBox( + // height: _itemHeight, + // width: MediaQuery.of(context).size.width, + // child: tab.child, + // ), + // ), + // ), + // data: tab.data, + // child: tab.child, + // ); + return tab; + }, + ); + }, + ), + ); + } +} + +class ViewTabListWidget extends HookConsumerWidget { + final ScrollController scrollController; + final DraggableScrollableController? draggableScrollableController; + final bool showNewTabFab; + final bool tabsReorderable; + final VoidCallback onClose; + + const ViewTabListWidget({ + required this.onClose, + required this.scrollController, + this.draggableScrollableController, + required this.tabsReorderable, + required this.showNewTabFab, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + NestedScrollView( + physics: const NeverScrollableScrollPhysics(), + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverToBoxAdapter( + child: + draggableScrollableController.mapNotNull( + (draggableScrollableController) => + DraggableScrollableHeader( + controller: draggableScrollableController, + child: TabViewHeader( + onClose: onClose, + tabsViewMode: TabsViewMode.list, + ), + ), + ) ?? + TabViewHeader( + onClose: onClose, + tabsViewMode: TabsViewMode.list, + ), + ), + ], + body: _TabListView( + scrollController: scrollController, + tabsReorderable: tabsReorderable, + onClose: onClose, + ), + ), + if (showNewTabFab) + Padding( + padding: const EdgeInsets.only( + top: TabViewHeader.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + final settings = ref.read(generalSettingsWithDefaultsProvider); + + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.defaultCreateTabType, + ).push(context); + + onClose(); + }, + child: const Icon(Icons.add), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart similarity index 57% rename from app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart rename to app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart index 3c7ff3ca..6d3f7f60 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_preview.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart @@ -24,24 +24,22 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; -import 'package:skeletonizer/skeletonizer.dart'; -import 'package:weblibre/core/routing/routes.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/widgets/tab_icon.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/domain/entities/find_in_page_state.dart'; import 'package:weblibre/features/geckoview/features/find_in_page/presentation/controllers/find_in_page.dart'; -import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; +import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; import 'package:weblibre/utils/ui_helper.dart' as ui_helper; -class TabContainer extends StatelessWidget { +class GridTabItemContainer extends StatelessWidget { final bool isActive; final bool isPrivate; final Widget? child; - const TabContainer({ + const GridTabItemContainer({ required this.isActive, required this.isPrivate, this.child, @@ -71,38 +69,7 @@ class TabContainer extends StatelessWidget { } } -class TabMiniPreview extends HookConsumerWidget { - final String tabId; - - const TabMiniPreview({super.key, required this.tabId}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final thumbnail = ref.watch( - tabStateProvider(tabId).select((value) => value?.thumbnail), - ); - - return TabContainer( - isActive: false, - isPrivate: false, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(14.0)), - child: Skeleton.replace( - replace: thumbnail == null, - replacement: const Bone.square(size: double.infinity), - child: SizedBox( - width: double.infinity, - child: RepaintBoundary( - child: RawImage(image: thumbnail?.value, fit: BoxFit.fitWidth), - ), - ), - ), - ), - ); - } -} - -class TabPreview extends HookConsumerWidget { +class GridTabPreview extends HookConsumerWidget { final String tabId; final bool isActive; @@ -114,7 +81,7 @@ class TabPreview extends HookConsumerWidget { final Widget? trailingChild; - const TabPreview({ + const GridTabPreview({ required this.tabId, required this.isActive, this.onTap, @@ -135,7 +102,7 @@ class TabPreview extends HookConsumerWidget { final extendedDeleteMenuController = useMenuController(); - return TabContainer( + return GridTabItemContainer( isActive: isActive, isPrivate: tabState.isPrivate, child: InkWell( @@ -258,7 +225,145 @@ class TabPreview extends HookConsumerWidget { } } -class SingleTabPreview extends HookConsumerWidget { +class ListTabPreview extends HookConsumerWidget { + final String tabId; + final bool isActive; + + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final VoidCallback? onDelete; + final void Function(String host)? onDeleteAll; + + final Widget? trailingChild; + + const ListTabPreview({ + required this.tabId, + required this.isActive, + this.onTap, + this.onLongPress, + this.onDelete, + this.onDeleteAll, + this.trailingChild, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + + final tabState = ref.watch(tabStateProvider(tabId)); + if (tabState == null) { + return const SizedBox.shrink(); + } + + final extendedDeleteMenuController = useMenuController(); + + return Container( + decoration: BoxDecoration( + color: tabState.isPrivate ? const Color(0xFF25003E) : null, + border: isActive ? Border.all(color: colorScheme.primary) : null, + // borderRadius: const BorderRadius.all(Radius.circular(16.0)), + ), + child: Row( + children: [ + Expanded( + child: ListTile( + key: ValueKey(tabState.id), + onTap: onTap, + onLongPress: onLongPress, + contentPadding: const EdgeInsets.only(left: 4), + leading: (tabState.thumbnail != null) + ? RepaintBoundary( + child: RawImage( + image: tabState.thumbnail!.value, + fit: BoxFit.fitHeight, + ), + ) + : TabIcon(tabState: tabState, iconSize: 32), + title: Text( + overflow: TextOverflow.ellipsis, + tabState.titleOrAuthority, + maxLines: 2, + style: tabState.isPrivate + ? const TextStyle(color: Colors.white) + : null, + ), + subtitle: Row( + children: [ + Expanded( + child: UriBreadcrumb( + uri: tabState.url, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: tabState.isPrivate ? Colors.white : null, + ), + ), + ), + if (tabState.isPrivate) ...[ + const SizedBox(width: 6.0), + const SizedBox( + height: 16, + width: 24, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned( + top: -4, + child: Icon( + MdiIcons.dominoMask, + color: Color(0xFF8000D7), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onDelete != null || onDeleteAll != null) + MenuAnchor( + controller: extendedDeleteMenuController, + builder: (context, controller, child) { + return child!; + }, + menuChildren: [ + MenuItemButton( + onPressed: () { + onDeleteAll?.call(tabState.url.host); + }, + + leadingIcon: const Icon(MdiIcons.closeBoxMultiple), + child: Text('Close all from ${tabState.url.host}'), + ), + ], + child: IconButton( + onPressed: onDelete, + onLongPress: onDeleteAll != null + ? () { + if (extendedDeleteMenuController.isOpen) { + extendedDeleteMenuController.close(); + } else { + extendedDeleteMenuController.open(); + } + } + : null, + icon: const Icon(Icons.close), + ), + ), + ?trailingChild, + ], + ), + ], + ), + ); + } +} + +class SingleGridTabPreview extends HookConsumerWidget { final String tabId; final String? activeTabId; final String? sourceSearchQuery; @@ -267,7 +372,7 @@ class SingleTabPreview extends HookConsumerWidget { final void Function() onClose; final void Function()? onBeforeDelete; - const SingleTabPreview({ + const SingleGridTabPreview({ required this.tabId, required this.activeTabId, required this.onClose, @@ -319,7 +424,7 @@ class SingleTabPreview extends HookConsumerWidget { }, child: Opacity( opacity: 1.0 - draggedDistance.value / deleteThreshold, - child: TabPreview( + child: GridTabPreview( tabId: tabId, isActive: tabId == activeTabId, onTap: () async { @@ -341,7 +446,7 @@ class SingleTabPreview extends HookConsumerWidget { onDeleteAll: (host) async { final containerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabId); + .getTabContainerId(tabId); final count = await ref .read(tabDataRepositoryProvider.notifier) @@ -382,14 +487,138 @@ class SingleTabPreview extends HookConsumerWidget { } } -class SuggestedSingleTabPreview extends StatelessWidget { +class SingleListTabPreview extends HookConsumerWidget { + final String tabId; + final String? activeTabId; + final String? sourceSearchQuery; + final double deleteThreshold; + + final void Function() onClose; + final void Function()? onBeforeDelete; + + const SingleListTabPreview({ + required this.tabId, + required this.activeTabId, + required this.onClose, + required this.sourceSearchQuery, + this.deleteThreshold = 100, + this.onBeforeDelete, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasTabState = ref.watch( + tabStateProvider(tabId).select((value) => value != null), + ); + + if (!hasTabState) { + return const SizedBox.shrink(); + } + + final dragStartPosition = useRef(Offset.zero); + final draggedDistance = useState(0.0); + + return GestureDetector( + onHorizontalDragStart: (details) { + dragStartPosition.value = details.globalPosition; + draggedDistance.value = 0.0; + }, + onHorizontalDragUpdate: (details) { + final cappedDistance = math.min( + (dragStartPosition.value - details.globalPosition).dx.abs(), + deleteThreshold, + ); + + draggedDistance.value = cappedDistance; + }, + onHorizontalDragEnd: (details) async { + if (draggedDistance.value >= deleteThreshold) { + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } + } + + draggedDistance.value = 0.0; + }, + child: Opacity( + opacity: 1.0 - draggedDistance.value / deleteThreshold, + child: ListTabPreview( + tabId: tabId, + isActive: tabId == activeTabId, + onTap: () async { + if (tabId != activeTabId) { + //Close first to avoid rebuilds + onClose(); + await ref.read(tabRepositoryProvider.notifier).selectTab(tabId); + if (sourceSearchQuery.isNotEmpty && + ref.read(findInPageControllerProvider(tabId)) == + FindInPageState.hidden()) { + await ref + .read(findInPageControllerProvider(tabId).notifier) + .findAll(text: sourceSearchQuery!); + } + } else { + onClose(); + } + }, + onDeleteAll: (host) async { + final containerId = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabContainerId(tabId); + + final count = await ref + .read(tabDataRepositoryProvider.notifier) + .closeAllTabsByHost(containerId, host); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: count, + ); + } + }, + // onDoubleTap: () { + // ref.read(overlayDialogControllerProvider.notifier).show( + // TabActionDialog( + // initialTab: tab, + // onDismiss: + // ref.read(overlayDialogControllerProvider.notifier).dismiss, + // ), + // ); + // }, + onDelete: () async { + onBeforeDelete?.call(); + + await ref.read(tabRepositoryProvider.notifier).closeTab(tabId); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + ); + } + }, + ), + ), + ); + } +} + +class SuggestedSingleGridTabPreview extends StatelessWidget { final String tabId; final String? activeTabId; final void Function()? onTap; - const SuggestedSingleTabPreview({ + const SuggestedSingleGridTabPreview({ required this.tabId, required this.activeTabId, this.onTap, @@ -400,7 +629,7 @@ class SuggestedSingleTabPreview extends StatelessWidget { Widget build(BuildContext context) { return Opacity( opacity: 0.5, - child: TabPreview( + child: GridTabPreview( tabId: tabId, isActive: tabId == activeTabId, onTap: onTap, @@ -414,133 +643,34 @@ class SuggestedSingleTabPreview extends StatelessWidget { } } -class TabTreePreview extends HookConsumerWidget { - final TabTreeEntity entity; +class SuggestedSingleListTabPreview extends StatelessWidget { + final String tabId; + final String? activeTabId; - final Offset stackPadding; + final void Function()? onTap; - final void Function() onClose; - - TabTreePreview({ - required this.entity, + const SuggestedSingleListTabPreview({ + required this.tabId, required this.activeTabId, - required this.onClose, - required this.stackPadding, - }) : super(key: ValueKey(entity)); - - Widget _addPadding(int stackCount, int totalCount, Widget child) { - final topPadding = stackPadding * stackCount.toDouble(); - final bottomPadding = stackPadding * (totalCount - stackCount).toDouble(); - - return Positioned( - top: topPadding.dy, - left: topPadding.dx, - right: bottomPadding.dx, - bottom: bottomPadding.dy, - child: child, - ); - } + this.onTap, + super.key, + }); @override - Widget build(BuildContext context, WidgetRef ref) { - final isTabPrivate = ref.watch( - tabStateProvider(entity.tabId).select((value) => value?.isPrivate), - ); - - if (isTabPrivate == null) { - return const SizedBox.shrink(); - } - - final stackCount = math.min(entity.totalTabs, 3) - 1; - - return Stack( - children: [ - for (var index = 0; index < stackCount; index++) - _addPadding( - index, - stackCount, - TabContainer( - isActive: entity.tabId == activeTabId, - isPrivate: isTabPrivate == true, - ), - ), - _addPadding( - stackCount, - stackCount, - Badge.count( - isLabelVisible: entity.totalTabs > 1, - count: entity.totalTabs, - alignment: AlignmentDirectional.bottomEnd, - offset: const Offset(-8, -24), - backgroundColor: Theme.of(context).colorScheme.primaryContainer, - textColor: Theme.of(context).colorScheme.onPrimaryContainer, - child: TabPreview( - tabId: entity.tabId, - isActive: entity.tabId == activeTabId, - onLongPress: () async { - if (entity.tabId != activeTabId) { - //Close first to avoid rebuilds - onClose(); - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(entity.tabId); - } else { - onClose(); - } - }, - onTap: () async { - if (entity.totalTabs > 1) { - await TabTreeRoute(entity.rootId).push(context); - } else if (entity.tabId != activeTabId) { - //Close first to avoid rebuilds - onClose(); - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(entity.tabId); - } else { - onClose(); - } - }, - // onDeleteAll: (host) async { - // final containerId = await ref - // .read(tabDataRepositoryProvider.notifier) - // .containerTabId(tab.id); - - // await ref - // .read(tabDataRepositoryProvider.notifier) - // .closeAllTabsByHost(containerId, host); - // }, - // onDoubleTap: () { - // ref.read(overlayDialogControllerProvider.notifier).show( - // TabActionDialog( - // initialTab: tab, - // onDismiss: - // ref.read(overlayDialogControllerProvider.notifier).dismiss, - // ), - // ); - // }, - onDelete: () async { - final tabs = await ref - .read(tabDataRepositoryProvider.notifier) - .getTabDescendants(entity.rootId); - - await ref - .read(tabRepositoryProvider.notifier) - .closeTabs(tabs.keys.toList()); - - if (context.mounted) { - ui_helper.showTabUndoClose( - context, - ref.read(tabRepositoryProvider.notifier).undoClose, - count: tabs.length, - ); - } - }, - ), - ), + Widget build(BuildContext context) { + return Opacity( + opacity: 0.5, + child: ListTabPreview( + tabId: tabId, + isActive: tabId == activeTabId, + onTap: onTap, + trailingChild: const IconButton( + visualDensity: VisualDensity(horizontal: -4.0, vertical: -4.0), + icon: Icon(MdiIcons.creation), + onPressed: null, ), - ], + ), ); } } diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart new file mode 100644 index 00000000..df4af09b --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_tree_view.dart @@ -0,0 +1,299 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +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/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_view_controllers.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart'; +import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.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/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +class _TabTreePreview extends HookConsumerWidget { + final TabTreeEntity entity; + final String? activeTabId; + + final Offset stackPadding; + + final void Function() onClose; + + _TabTreePreview({ + required this.entity, + required this.activeTabId, + required this.onClose, + required this.stackPadding, + }) : super(key: ValueKey(entity)); + + Widget _addPadding(int stackCount, int totalCount, Widget child) { + final topPadding = stackPadding * stackCount.toDouble(); + final bottomPadding = stackPadding * (totalCount - stackCount).toDouble(); + + return Positioned( + top: topPadding.dy, + left: topPadding.dx, + right: bottomPadding.dx, + bottom: bottomPadding.dy, + child: child, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isTabPrivate = ref.watch( + tabStateProvider(entity.tabId).select((value) => value?.isPrivate), + ); + + if (isTabPrivate == null) { + return const SizedBox.shrink(); + } + + final stackCount = math.min(entity.totalTabs, 3) - 1; + + return Stack( + children: [ + for (var index = 0; index < stackCount; index++) + _addPadding( + index, + stackCount, + GridTabItemContainer( + isActive: entity.tabId == activeTabId, + isPrivate: isTabPrivate == true, + ), + ), + _addPadding( + stackCount, + stackCount, + Badge.count( + isLabelVisible: entity.totalTabs > 1, + count: entity.totalTabs, + alignment: AlignmentDirectional.bottomEnd, + offset: const Offset(-8, -24), + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + textColor: Theme.of(context).colorScheme.onPrimaryContainer, + child: GridTabPreview( + tabId: entity.tabId, + isActive: entity.tabId == activeTabId, + onLongPress: () async { + if (entity.tabId != activeTabId) { + //Close first to avoid rebuilds + onClose(); + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(entity.tabId); + } else { + onClose(); + } + }, + onTap: () async { + if (entity.totalTabs > 1) { + await TabTreeRoute(entity.rootId).push(context); + } else if (entity.tabId != activeTabId) { + //Close first to avoid rebuilds + onClose(); + await ref + .read(tabRepositoryProvider.notifier) + .selectTab(entity.tabId); + } else { + onClose(); + } + }, + // onDeleteAll: (host) async { + // final containerId = await ref + // .read(tabDataRepositoryProvider.notifier) + // .containerTabId(tab.id); + + // await ref + // .read(tabDataRepositoryProvider.notifier) + // .closeAllTabsByHost(containerId, host); + // }, + // onDoubleTap: () { + // ref.read(overlayDialogControllerProvider.notifier).show( + // TabActionDialog( + // initialTab: tab, + // onDismiss: + // ref.read(overlayDialogControllerProvider.notifier).dismiss, + // ), + // ); + // }, + onDelete: () async { + final tabs = await ref + .read(tabDataRepositoryProvider.notifier) + .getTabDescendants(entity.rootId); + + await ref + .read(tabRepositoryProvider.notifier) + .closeTabs(tabs.keys.toList()); + + if (context.mounted) { + ui_helper.showTabUndoClose( + context, + ref.read(tabRepositoryProvider.notifier).undoClose, + count: tabs.length, + ); + } + }, + ), + ), + ), + ], + ); + } +} + +class ViewTabTreesWidget extends HookConsumerWidget { + final ScrollController scrollController; + final VoidCallback onClose; + final bool showNewTabFab; + + const ViewTabTreesWidget({ + required this.onClose, + required this.scrollController, + required this.showNewTabFab, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + Column( + children: [ + TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree), + Expanded( + child: HookConsumer( + builder: (context, ref, child) { + final screenWidth = MediaQuery.of(context).size.width; + + final filteredTabEntities = ref.watch( + seamlessFilteredTabEntitiesProvider( + searchPartition: TabSearchPartition.preview, + // ignore: document_ignores using fast equatable + // ignore: provider_parameters + containerFilter: ContainerFilterDisabled(), + groupTrees: true, + ), + ); + + final activeTab = ref.watch(selectedTabProvider); + + final crossAxisCount = useMemoized(() { + final calculatedCount = calculateCrossAxisItemCount( + screenWidth: screenWidth, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + ); + + return math.max( + math.min( + calculatedCount, + filteredTabEntities.value.length, + ), + 2, + ); + }, [screenWidth, filteredTabEntities.value.length]); + + final itemSize = useMemoized( + () => calculateItemSize( + screenWidth: screenWidth, + childAspectRatio: 0.75, + horizontalPadding: 4.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + mainAxisSpacing: 8.0, + ), + [screenWidth, crossAxisCount], + ); + + useEffect(() { + final index = filteredTabEntities.value.indexWhere( + (entity) => entity.tabId == activeTab, + ); + + if (index > -1) { + final offset = (index ~/ 2) * itemSize.height; + + if (offset != scrollController.offset) { + unawaited( + scrollController.animateTo( + offset, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + ), + ); + } + } + + return null; + }, [filteredTabEntities, activeTab]); + + final tabs = useMemoized(() { + return filteredTabEntities.value + .whereType() + .map((entity) { + return _TabTreePreview( + entity: entity, + activeTabId: activeTab, + onClose: onClose, + stackPadding: const Offset(8, 8), + ); + }) + .toList(); + }, [filteredTabEntities, activeTab]); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: GridView.builder( + controller: scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + //Sync values for itemHeight calculation _calculateItemHeight + childAspectRatio: 0.75, + mainAxisSpacing: 8.0, + crossAxisSpacing: 8.0, + crossAxisCount: crossAxisCount, + ), + itemCount: tabs.length, + itemBuilder: (context, index) => tabs[index], + ), + ); + }, + ), + ), + ], + ), + if (showNewTabFab) + Padding( + padding: const EdgeInsets.only( + top: TabViewHeader.headerSize + 4, + right: 4, + ), + child: FloatingActionButton.small( + onPressed: () async { + final settings = ref.read(generalSettingsWithDefaultsProvider); + + await SearchRoute( + tabType: + ref.read(selectedTabTypeProvider) ?? + settings.defaultCreateTabType, + ).push(context); + + onClose(); + }, + child: const Icon(Icons.add), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart new file mode 100644 index 00000000..359f00fd --- /dev/null +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_view/tab_view_header.dart @@ -0,0 +1,371 @@ +import 'dart:convert'; + +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:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/controllers/tab_view_controllers.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +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; + +class TabViewHeader extends HookConsumerWidget { + static const headerSize = 124.0; + + final TabsViewMode tabsViewMode; + final VoidCallback onClose; + + const TabViewHeader({required this.onClose, required this.tabsViewMode}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final searchMode = useState(false); + final searchTextFocus = useFocusNode(); + final searchTextController = useTextEditingController(); + + final viewModeMenuController = useMenuController(); + final tabsActionMenuController = useMenuController(); + + final hasSearchText = useListenableSelector( + searchTextController, + () => searchTextController.text.isNotEmpty, + ); + + final enableAiFeatures = ref.watch( + generalSettingsWithDefaultsProvider.select( + (settings) => settings.enableLocalAiFeatures, + ), + ); + + useListenableCallback(searchTextController, () async { + if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) { + await ref + .read( + tabSearchRepositoryProvider(TabSearchPartition.preview).notifier, + ) + .addQuery(searchTextController.text); + } + }); + + return Material( + //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 + child: Align( + child: Padding( + padding: const EdgeInsets.only(right: 8.0, left: 8.0, top: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!searchMode.value) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(MdiIcons.tabSearch), + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + switch (ref.read(tabsViewModeControllerProvider)) { + case TabsViewMode.tree: + case TabsViewMode.list: + break; + case TabsViewMode.grid: + ref + .read( + tabsViewModeControllerProvider.notifier, + ) + .set(TabsViewMode.list); + } + + searchMode.value = true; + searchTextFocus.requestFocus(); + }, + ), + const SizedBox( + height: 32, + child: VerticalDivider(indent: 4, endIndent: 4), + ), + 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 && + switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }) + Consumer( + builder: (context, ref, child) { + final tabSuggestionsEnabled = ref.watch( + tabSuggestionsControllerProvider, + ); + + return IconButton.filledTonal( + icon: const Icon(MdiIcons.imageAutoAdjust), + isSelected: tabSuggestionsEnabled, + iconSize: 18, + padding: EdgeInsets.zero, + onPressed: () { + ref + .read( + tabSuggestionsControllerProvider + .notifier, + ) + .toggle(); + }, + ); + }, + ), + if (switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }) + 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(); + }, + ); + }, + ), + ], + ), + MenuAnchor( + controller: tabsActionMenuController, + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), + child: const Text('Bookmark all'), + onPressed: () async { + final containerId = ref.read( + selectedContainerProvider, + ); + + final tabData = await ref + .read(tabDataRepositoryProvider.notifier) + .getContainerTabsData(containerId); + + 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), + ), + ), + ], + ) + else + TextField( + controller: searchTextController, + focusNode: searchTextFocus, + // enableIMEPersonalizedLearning: !incognitoEnabled, + decoration: InputDecoration( + // border: InputBorder.none, + prefixIcon: const Icon(MdiIcons.tabSearch, size: 18), + hintText: 'Search inside tabs...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!hasSearchText) + SpeechToTextButton( + onTextReceived: (data) { + searchTextController.text = data.toString(); + }, + ), + IconButton( + onPressed: () { + searchTextController.clear(); + searchTextFocus.requestFocus(); + searchMode.value = false; + }, + icon: const Icon(Icons.clear), + ), + ], + ), + ), + ), + if (switch (tabsViewMode) { + TabsViewMode.list || TabsViewMode.grid => true, + TabsViewMode.tree => false, + }) + Consumer( + builder: (context, ref, child) { + final selectedContainer = ref.watch( + selectedContainerDataProvider.select( + (value) => value.value, + ), + ); + + return ContainerChips( + showGroupSuggestions: true, + selectedContainer: selectedContainer, + onSelected: (container) async { + if (container != null) { + final result = await ref + .read(selectedContainerProvider.notifier) + .setContainerId(container.id); + + if (context.mounted && + result == SetContainerResult.successHasProxy) { + await ref + .read(startProxyControllerProvider.notifier) + .maybeStartProxy(context); + } + } else { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + } + }, + onDeleted: (container) { + ref + .read(selectedContainerProvider.notifier) + .clearContainer(); + }, + onLongPress: (container) async { + await ContainerEditRoute( + containerData: jsonEncode(container.toJson()), + ).push(context); + }, + ); + }, + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } +} 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 deleted file mode 100644 index 9dcab860..00000000 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/view_tabs.dart +++ /dev/null @@ -1,988 +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 'dart:async'; -import 'dart:convert'; -import 'dart:math' as math; - -import 'package:fading_scroll/fading_scroll.dart'; -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'; -import 'package:weblibre/core/providers/global_drop.dart'; -import 'package:weblibre/core/routing/routes.dart'; -import 'package:weblibre/data/models/drag_data.dart'; -import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; -import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; -import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/browser/domain/providers.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'; -import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_entity.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; -import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; -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; - -int calculateCrossAxisItemCount({ - required double screenWidth, - required double horizontalPadding, - required double crossAxisSpacing, -}) { - final totalHorizontalPadding = horizontalPadding * 2; - final availableWidth = - screenWidth - totalHorizontalPadding - crossAxisSpacing; - - final crossAxisCount = availableWidth ~/ 180.0; - - return crossAxisCount; -} - -Size calculateItemSize({ - required double screenWidth, - required double childAspectRatio, - required double horizontalPadding, - required double mainAxisSpacing, - required double crossAxisSpacing, - required int crossAxisCount, -}) { - final totalHorizontalPadding = horizontalPadding * 2; - final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1); - final availableWidth = - screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; - final itemWidth = availableWidth / crossAxisCount; - final itemHeight = itemWidth / childAspectRatio; - - return Size(itemWidth, itemHeight + mainAxisSpacing); -} - -class _TabDraggable extends HookConsumerWidget { - final TabEntity entity; - final String? suggestedContainerId; - final VoidCallback onClose; - - const _TabDraggable({ - required this.entity, - required this.onClose, - this.suggestedContainerId, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final activeTab = ref.watch(selectedTabProvider); - - final dragData = ref.watch( - willAcceptDropProvider.select((value) { - final dragTabId = switch (value) { - ContainerDropData() => value.tabId, - DeleteDropData() => value.tabId, - null => null, - }; - - return (dragTabId == entity.tabId) ? value : null; - }), - ); - - // Cache the tab widget to avoid rebuilding - final tab = useMemoized(() { - return (suggestedContainerId != null) - ? SuggestedSingleTabPreview( - key: ValueKey(entity.tabId), - tabId: entity.tabId, - activeTabId: activeTab, - onTap: () async { - final containerData = await ref - .read(containerRepositoryProvider.notifier) - .getContainerData(suggestedContainerId!); - - if (containerData != null) { - await ref - .read(tabDataRepositoryProvider.notifier) - .assignContainer(entity.tabId, containerData); - } - }, - ) - : SingleTabPreview( - key: ValueKey(entity.tabId), - tabId: entity.tabId, - activeTabId: activeTab, - onClose: onClose, - sourceSearchQuery: switch (entity) { - DefaultTabEntity _ => null, - final SearchResultTabEntity entity => entity.searchQuery, - TabTreeEntity _ => throw UnimplementedError(), - }, - ); - }, [entity.tabId, activeTab, suggestedContainerId]); - - return switch (dragData) { - ContainerDropData() => Opacity( - opacity: 0.3, - child: Transform.scale(scale: 0.9, child: tab), - ), - DeleteDropData() => Opacity( - opacity: 0.3, - child: ColorFiltered( - colorFilter: const ColorFilter.mode(Colors.red, BlendMode.modulate), - child: tab, - ), - ), - null => tab, - }; - } -} - -class _TabViewHeader extends HookConsumerWidget { - static const headerSize = 124.0; - - final TabsViewMode tabsViewMode; - final VoidCallback onClose; - - const _TabViewHeader({required this.onClose, required this.tabsViewMode}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final searchMode = useState(false); - final searchTextFocus = useFocusNode(); - final searchTextController = useTextEditingController(); - - final viewModeMenuController = useMenuController(); - final tabsActionMenuController = useMenuController(); - - final hasSearchText = useListenableSelector( - searchTextController, - () => searchTextController.text.isNotEmpty, - ); - - final enableAiFeatures = ref.watch( - generalSettingsWithDefaultsProvider.select( - (settings) => settings.enableLocalAiFeatures, - ), - ); - - useListenableCallback(searchTextController, () async { - if (ref.exists(tabSearchRepositoryProvider(TabSearchPartition.preview))) { - await ref - .read( - tabSearchRepositoryProvider(TabSearchPartition.preview).notifier, - ) - .addQuery(searchTextController.text); - } - }); - - return Material( - //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 - child: Align( - child: Padding( - padding: const EdgeInsets.only(right: 8.0, left: 8.0, top: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (!searchMode.value) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(MdiIcons.tabSearch), - iconSize: 18, - padding: EdgeInsets.zero, - onPressed: () { - ref - .read(tabsViewModeControllerProvider.notifier) - .set(TabsViewMode.grid); - - searchMode.value = true; - searchTextFocus.requestFocus(); - }, - ), - const SizedBox( - height: 32, - child: VerticalDivider(indent: 4, endIndent: 4), - ), - 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( - builder: (context, ref, child) { - final tabSuggestionsEnabled = ref.watch( - tabSuggestionsControllerProvider, - ); - - return IconButton.filledTonal( - icon: const Icon(MdiIcons.imageAutoAdjust), - isSelected: tabSuggestionsEnabled, - iconSize: 18, - padding: EdgeInsets.zero, - onPressed: () { - ref - .read( - tabSuggestionsControllerProvider - .notifier, - ) - .toggle(); - }, - ); - }, - ), - 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(); - }, - ); - }, - ), - ], - ), - MenuAnchor( - controller: tabsActionMenuController, - menuChildren: [ - MenuItemButton( - leadingIcon: const Icon(MdiIcons.bookmarkPlusOutline), - child: const Text('Bookmark all'), - onPressed: () async { - final containerId = ref.read( - selectedContainerProvider, - ); - - final tabData = await ref - .read(tabDataRepositoryProvider.notifier) - .getContainerTabsData(containerId); - - 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), - ), - ), - ], - ) - else - TextField( - controller: searchTextController, - focusNode: searchTextFocus, - // enableIMEPersonalizedLearning: !incognitoEnabled, - decoration: InputDecoration( - // border: InputBorder.none, - prefixIcon: const Icon(MdiIcons.tabSearch, size: 18), - hintText: 'Search inside tabs...', - floatingLabelBehavior: FloatingLabelBehavior.always, - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!hasSearchText) - SpeechToTextButton( - onTextReceived: (data) { - searchTextController.text = data.toString(); - }, - ), - IconButton( - onPressed: () { - searchTextController.clear(); - searchTextFocus.requestFocus(); - searchMode.value = false; - }, - icon: const Icon(Icons.clear), - ), - ], - ), - ), - ), - if (tabsViewMode == TabsViewMode.grid) - Consumer( - builder: (context, ref, child) { - final selectedContainer = ref.watch( - selectedContainerDataProvider.select( - (value) => value.value, - ), - ); - - return ContainerChips( - showGroupSuggestions: true, - selectedContainer: selectedContainer, - onSelected: (container) async { - if (container != null) { - final result = await ref - .read(selectedContainerProvider.notifier) - .setContainerId(container.id); - - if (context.mounted && - result == SetContainerResult.successHasProxy) { - await ref - .read(startProxyControllerProvider.notifier) - .maybeStartProxy(context); - } - } else { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - } - }, - onDeleted: (container) { - ref - .read(selectedContainerProvider.notifier) - .clearContainer(); - }, - onLongPress: (container) async { - await ContainerEditRoute( - containerData: jsonEncode(container.toJson()), - ).push(context); - }, - ); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ), - ); - } -} - -class _TabView extends HookConsumerWidget { - final ScrollController scrollController; - final bool tabsReorderable; - final VoidCallback onClose; - - const _TabView({ - required this.scrollController, - required this.tabsReorderable, - required this.onClose, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final screenWidth = MediaQuery.of(context).size.width; - - final containerId = ref.watch(selectedContainerProvider); - - final filteredTabEntities = ref.watch( - seamlessFilteredTabEntitiesProvider( - searchPartition: TabSearchPartition.preview, - // ignore: document_ignores using fast equatable - // ignore: provider_parameters - containerFilter: ContainerFilterById(containerId: containerId), - groupTrees: false, - ), - ); - - final tabSuggestionsEnabled = ref.watch(tabSuggestionsControllerProvider); - - final suggestedTabEntities = tabSuggestionsEnabled - ? ref.watch(suggestedTabEntitiesProvider(containerId)) - : EquatableValue([]); - - final itemCount = - filteredTabEntities.value.length + - //Limit to 3 sugegstions for now - math.min(suggestedTabEntities.value.length, 3); - - final activeTab = ref.watch(selectedTabProvider); - - final crossAxisCount = useMemoized(() { - final calculatedCount = calculateCrossAxisItemCount( - screenWidth: screenWidth, - horizontalPadding: 4.0, - crossAxisSpacing: 8.0, - ); - - return math.max(math.min(calculatedCount, itemCount), 2); - }, [screenWidth, itemCount]); - - final itemSize = useMemoized( - () => calculateItemSize( - screenWidth: screenWidth, - childAspectRatio: 0.75, - horizontalPadding: 4.0, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: crossAxisCount, - ), - [screenWidth, crossAxisCount], - ); - - final lastScroll = useRef(null); - - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (scrollController.hasClients) { - if (lastScroll.value != activeTab) { - final index = filteredTabEntities.value.indexWhere( - (entity) => entity.tabId == activeTab, - ); - - if (index > -1) { - final offset = (index ~/ 2) * itemSize.height; - - if (offset != scrollController.offset) { - lastScroll.value = activeTab; - - unawaited( - scrollController.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), - ); - } - } - } - } - }); - - return null; - }, [filteredTabEntities, activeTab]); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: FadingScroll( - fadingSize: 5, - controller: scrollController, - builder: (context, controller) { - return !tabsReorderable - ? _TabGrid( - key: ValueKey(crossAxisCount), - crossAxisCount: crossAxisCount, - 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 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 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, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Stack( - alignment: Alignment.bottomRight, - children: [ - NestedScrollView( - physics: const NeverScrollableScrollPhysics(), - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverToBoxAdapter( - child: - draggableScrollableController.mapNotNull( - (draggableScrollableController) => - DraggableScrollableHeader( - controller: draggableScrollableController, - child: _TabViewHeader( - onClose: onClose, - tabsViewMode: TabsViewMode.grid, - ), - ), - ) ?? - _TabViewHeader( - onClose: onClose, - tabsViewMode: TabsViewMode.grid, - ), - ), - ], - body: _TabView( - scrollController: scrollController, - tabsReorderable: tabsReorderable, - onClose: onClose, - ), - ), - if (showNewTabFab) - Padding( - padding: const EdgeInsets.only( - top: _TabViewHeader.headerSize + 4, - right: 4, - ), - child: FloatingActionButton.small( - onPressed: () async { - final settings = ref.read(generalSettingsWithDefaultsProvider); - - await SearchRoute( - tabType: - ref.read(selectedTabTypeProvider) ?? - settings.defaultCreateTabType, - ).push(context); - - onClose(); - }, - child: const Icon(Icons.add), - ), - ), - ], - ); - } -} - -class ViewTabTreesWidget extends HookConsumerWidget { - final ScrollController scrollController; - final VoidCallback onClose; - final bool showNewTabFab; - - const ViewTabTreesWidget({ - required this.onClose, - required this.scrollController, - required this.showNewTabFab, - super.key, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Stack( - alignment: Alignment.bottomRight, - children: [ - Column( - children: [ - _TabViewHeader(onClose: onClose, tabsViewMode: TabsViewMode.tree), - Expanded( - child: HookConsumer( - builder: (context, ref, child) { - final screenWidth = MediaQuery.of(context).size.width; - - final filteredTabEntities = ref.watch( - seamlessFilteredTabEntitiesProvider( - searchPartition: TabSearchPartition.preview, - // ignore: document_ignores using fast equatable - // ignore: provider_parameters - containerFilter: ContainerFilterDisabled(), - groupTrees: true, - ), - ); - - final activeTab = ref.watch(selectedTabProvider); - - final crossAxisCount = useMemoized(() { - final calculatedCount = calculateCrossAxisItemCount( - screenWidth: screenWidth, - horizontalPadding: 4.0, - crossAxisSpacing: 8.0, - ); - - return math.max( - math.min( - calculatedCount, - filteredTabEntities.value.length, - ), - 2, - ); - }, [screenWidth, filteredTabEntities.value.length]); - - final itemSize = useMemoized( - () => calculateItemSize( - screenWidth: screenWidth, - childAspectRatio: 0.75, - horizontalPadding: 4.0, - crossAxisSpacing: 8.0, - crossAxisCount: crossAxisCount, - mainAxisSpacing: 8.0, - ), - [screenWidth, crossAxisCount], - ); - - useEffect(() { - final index = filteredTabEntities.value.indexWhere( - (entity) => entity.tabId == activeTab, - ); - - if (index > -1) { - final offset = (index ~/ 2) * itemSize.height; - - if (offset != scrollController.offset) { - unawaited( - scrollController.animateTo( - offset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ), - ); - } - } - - return null; - }, [filteredTabEntities, activeTab]); - - final tabs = useMemoized(() { - return filteredTabEntities.value - .whereType() - .map((entity) { - return TabTreePreview( - entity: entity, - activeTabId: activeTab, - onClose: onClose, - stackPadding: const Offset(8, 8), - ); - }) - .toList(); - }, [filteredTabEntities, activeTab]); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: GridView.builder( - controller: scrollController, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - //Sync values for itemHeight calculation _calculateItemHeight - childAspectRatio: 0.75, - mainAxisSpacing: 8.0, - crossAxisSpacing: 8.0, - crossAxisCount: crossAxisCount, - ), - itemCount: tabs.length, - itemBuilder: (context, index) => tabs[index], - ), - ); - }, - ), - ), - ], - ), - if (showNewTabFab) - Padding( - padding: const EdgeInsets.only( - top: _TabViewHeader.headerSize + 4, - right: 4, - ), - child: FloatingActionButton.small( - onPressed: () async { - final settings = ref.read(generalSettingsWithDefaultsProvider); - - await SearchRoute( - tabType: - ref.read(selectedTabTypeProvider) ?? - settings.defaultCreateTabType, - ).push(context); - - onClose(); - }, - child: const Icon(Icons.add), - ), - ), - ], - ); - } -} diff --git a/app/lib/features/geckoview/features/browser/utils/grid_calculations.dart b/app/lib/features/geckoview/features/browser/utils/grid_calculations.dart new file mode 100644 index 00000000..bb3c7f01 --- /dev/null +++ b/app/lib/features/geckoview/features/browser/utils/grid_calculations.dart @@ -0,0 +1,33 @@ +import 'dart:ui'; + +int calculateCrossAxisItemCount({ + required double screenWidth, + required double horizontalPadding, + required double crossAxisSpacing, +}) { + final totalHorizontalPadding = horizontalPadding * 2; + final availableWidth = + screenWidth - totalHorizontalPadding - crossAxisSpacing; + + final crossAxisCount = availableWidth ~/ 180.0; + + return crossAxisCount; +} + +Size calculateItemSize({ + required double screenWidth, + required double childAspectRatio, + required double horizontalPadding, + required double mainAxisSpacing, + required double crossAxisSpacing, + required int crossAxisCount, +}) { + final totalHorizontalPadding = horizontalPadding * 2; + final totalCrossAxisSpacing = crossAxisSpacing * (crossAxisCount - 1); + final availableWidth = + screenWidth - totalHorizontalPadding - totalCrossAxisSpacing; + final itemWidth = availableWidth / crossAxisCount; + final itemHeight = itemWidth / childAspectRatio; + + return Size(itemWidth, itemHeight + mainAxisSpacing); +} diff --git a/app/lib/features/geckoview/features/history/domain/providers.dart b/app/lib/features/geckoview/features/history/domain/providers.dart index 9b639b57..466bc36a 100644 --- a/app/lib/features/geckoview/features/history/domain/providers.dart +++ b/app/lib/features/geckoview/features/history/domain/providers.dart @@ -17,11 +17,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'dart:convert'; - import 'package:flutter/material.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/experimental/json_persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/geckoview/features/history/domain/entities/history_filter_options.dart'; import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart'; @@ -30,6 +29,7 @@ import 'package:weblibre/features/user/data/providers.dart'; part 'providers.g.dart'; @Riverpod(keepAlive: true) +@JsonPersist() class HistoryFilter extends _$HistoryFilter { void updateVisitType(VisitType type, bool value) { if (value) { @@ -49,15 +49,9 @@ class HistoryFilter extends _$HistoryFilter { @override HistoryFilterOptions build() { - final riverpodStorage = ref.watch(riverpodDatabaseStorageProvider); - persist( - riverpodStorage, + ref.watch(riverpodDatabaseStorageProvider), key: 'HistoryFilterOptions', - decode: (encoded) => HistoryFilterOptions.fromJson( - jsonDecode(encoded) as Map, - ), - encode: (state) => jsonEncode(state.toJson()), ); return stateOrNull ?? HistoryFilterOptions.withDefaults(); diff --git a/app/lib/features/geckoview/features/history/domain/providers.g.dart b/app/lib/features/geckoview/features/history/domain/providers.g.dart index c3bedc44..971e91f4 100644 --- a/app/lib/features/geckoview/features/history/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/history/domain/providers.g.dart @@ -10,8 +10,10 @@ part of 'providers.dart'; // ignore_for_file: type=lint, type=warning @ProviderFor(HistoryFilter) +@JsonPersist() const historyFilterProvider = HistoryFilterProvider._(); +@JsonPersist() final class HistoryFilterProvider extends $NotifierProvider { const HistoryFilterProvider._() @@ -41,9 +43,10 @@ final class HistoryFilterProvider } } -String _$historyFilterHash() => r'1f8269e5f1227d6efde194e00c3d79a268ab1e4d'; +String _$historyFilterHash() => r'685b92590011ec450ef0c19820e4a6750d26b79d'; -abstract class _$HistoryFilter extends $Notifier { +@JsonPersist() +abstract class _$HistoryFilterBase extends $Notifier { HistoryFilterOptions build(); @$mustCallSuper @override @@ -100,3 +103,40 @@ final class BrowsingHistoryProvider } String _$browsingHistoryHash() => r'3b6ee5853387481d7544bc9733741a5519f84f40'; + +// ************************************************************************** +// JsonGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +abstract class _$HistoryFilter extends _$HistoryFilterBase { + /// The default key used by [persist]. + String get key { + const resolvedKey = "HistoryFilter"; + return resolvedKey; + } + + /// A variant of [persist], for JSON-specific encoding. + /// + /// You can override [key] to customize the key used for storage. + PersistResult persist( + FutureOr> storage, { + String? key, + String Function(HistoryFilterOptions state)? encode, + HistoryFilterOptions Function(String encoded)? decode, + StorageOptions options = const StorageOptions(), + }) { + return NotifierPersistX(this).persist( + storage, + key: key ?? this.key, + encode: encode ?? $jsonCodex.encode, + decode: + decode ?? + (encoded) { + final e = $jsonCodex.decode(encoded); + return HistoryFilterOptions.fromJson(e as Map); + }, + options: options, + ); + } +} 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 7d6ddba1..98cfaa01 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 @@ -65,6 +65,18 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { return query.map((row) => row.read(db.tab.containerId)); } + Selectable> getTabsContainerId( + Iterable tabIds, + ) { + final query = selectOnly(db.tab) + ..addColumns([db.tab.id, db.tab.containerId]) + ..where(db.tab.id.isIn(tabIds)); + + return query.map( + (row) => MapEntry(row.read(db.tab.id)!, row.read(db.tab.containerId)), + ); + } + Future _generateOrderKey({ required Value parentId, required Value containerId, diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift index 7e533922..49eea48a 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -140,6 +140,7 @@ queryTabsBasic WITH TabQueryResult: ) SELECT t.id, + t.container_id, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, @@ -166,6 +167,7 @@ queryTabsFullContent WITH TabQueryResult: ) SELECT t.id, + t.container_id, highlight(tab_fts, 0, :beforeMatch, :afterMatch) AS title, highlight(tab_fts, 1, :beforeMatch, :afterMatch) AS url, snippet(tab_fts, 2, :beforeMatch, :afterMatch, :ellipsis, :snippetLength) AS extracted_content, diff --git a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart index 405f4775..a29a7513 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift.dart @@ -2161,12 +2161,13 @@ class DefinitionsDrift extends i7.ModularAccessor { i0.Selectable queryTabsBasic({required String query}) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.title, CAST(t.url AS TEXT) AS url, t.url AS clean_url, bm25(tab_fts, weights.title_weight, weights.url_weight) AS weighted_rank FROM tab_fts AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights WHERE fts.title LIKE ?1 OR fts.url LIKE ?1 ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [i0.Variable(query)], readsFrom: {tab, tabFts}, ).map( (i0.QueryRow row) => i8.TabQueryResult( id: row.read('id'), + containerId: row.readNullable('container_id'), title: row.readNullable('title'), url: row.readNullable('url'), cleanUrl: i3.Tab.$converterurl.fromSql( @@ -2185,7 +2186,7 @@ class DefinitionsDrift extends i7.ModularAccessor { required String query, }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight, 3.0 AS extracted_weight, 1.0 AS full_weight) SELECT t.id, t.container_id, highlight(tab_fts, 0, ?1, ?2) AS title, highlight(tab_fts, 1, ?1, ?2) AS url, snippet(tab_fts, 2, ?1, ?2, ?3, ?4) AS extracted_content, snippet(tab_fts, 3, ?1, ?2, ?3, ?4) AS full_content, t.url AS clean_url,(bm25(tab_fts, weights.title_weight, weights.url_weight, weights.extracted_weight, weights.full_weight))AS weighted_rank FROM tab_fts(?5)AS fts INNER JOIN tab AS t ON t."rowid" = fts."rowid" CROSS JOIN weights ORDER BY weighted_rank ASC, t.timestamp DESC', variables: [ i0.Variable(beforeMatch), i0.Variable(afterMatch), @@ -2197,6 +2198,7 @@ class DefinitionsDrift extends i7.ModularAccessor { ).map( (i0.QueryRow row) => i8.TabQueryResult( id: row.read('id'), + containerId: row.readNullable('container_id'), title: row.readNullable('title'), url: row.readNullable('url'), cleanUrl: i3.Tab.$converterurl.fromSql( diff --git a/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart index 2b82623d..5e13f609 100644 --- a/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart +++ b/app/lib/features/geckoview/features/tabs/data/entities/tab_entity.dart @@ -26,23 +26,29 @@ sealed class TabEntity with FastEquatable { class DefaultTabEntity extends TabEntity { @override final String tabId; + final String? containerId; - DefaultTabEntity({required this.tabId}); + DefaultTabEntity({required this.tabId, required this.containerId}); @override - List get hashParameters => [tabId]; + List get hashParameters => [tabId, containerId]; } class SearchResultTabEntity extends TabEntity { @override final String tabId; + final String? containerId; final String searchQuery; - SearchResultTabEntity({required this.tabId, required this.searchQuery}); + SearchResultTabEntity({ + required this.tabId, + required this.searchQuery, + required this.containerId, + }); @override - List get hashParameters => [tabId, searchQuery]; + List get hashParameters => [tabId, searchQuery, containerId]; } class TabTreeEntity extends TabEntity { diff --git a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart index 61eea22c..b5b752c5 100644 --- a/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart +++ b/app/lib/features/geckoview/features/tabs/data/models/tab_query_result.dart @@ -21,6 +21,7 @@ import 'package:fast_equatable/fast_equatable.dart'; class TabQueryResult with FastEquatable { final String id; + final String? containerId; final String? title; final Uri? cleanUrl; @@ -33,6 +34,7 @@ class TabQueryResult with FastEquatable { TabQueryResult({ required this.id, + required this.containerId, required this.title, required this.url, required this.cleanUrl, @@ -44,6 +46,7 @@ class TabQueryResult with FastEquatable { @override List get hashParameters => [ id, + containerId, title, cleanUrl, url, diff --git a/app/lib/features/geckoview/features/tabs/domain/providers.dart b/app/lib/features/geckoview/features/tabs/domain/providers.dart index 2e0e23c9..5a36389a 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.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:fast_equatable/fast_equatable.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -129,6 +130,19 @@ Stream watchContainerTabId(Ref ref, String tabId) { .watchSingleOrNull(); } +@Riverpod() +Stream> watchTabsContainerId( + Ref ref, + EquatableValue> tabIds, +) { + return ref + .read(tabDatabaseProvider) + .tabDao + .getTabsContainerId(tabIds.value) + .watch() + .map(Map.fromEntries); +} + @Riverpod() Stream> watchAllAssignedSites(Ref ref) { return ref.read(tabDatabaseProvider).containerDao.allAssignedSites().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 d60feb2d..c8d64fa9 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers.g.dart @@ -671,6 +671,88 @@ final class WatchContainerTabIdFamily extends $Family String toString() => r'watchContainerTabIdProvider'; } +@ProviderFor(watchTabsContainerId) +const watchTabsContainerIdProvider = WatchTabsContainerIdFamily._(); + +final class WatchTabsContainerIdProvider + extends + $FunctionalProvider< + AsyncValue>, + Map, + Stream> + > + with + $FutureModifier>, + $StreamProvider> { + const WatchTabsContainerIdProvider._({ + required WatchTabsContainerIdFamily super.from, + required EquatableValue> super.argument, + }) : super( + retry: null, + name: r'watchTabsContainerIdProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$watchTabsContainerIdHash(); + + @override + String toString() { + return r'watchTabsContainerIdProvider' + '' + '($argument)'; + } + + @$internal + @override + $StreamProviderElement> $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream> create(Ref ref) { + final argument = this.argument as EquatableValue>; + return watchTabsContainerId(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is WatchTabsContainerIdProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$watchTabsContainerIdHash() => + r'ffdebb10f7118cdff93695c821ef5fbb0d434de1'; + +final class WatchTabsContainerIdFamily extends $Family + with + $FunctionalFamilyOverride< + Stream>, + EquatableValue> + > { + const WatchTabsContainerIdFamily._() + : super( + retry: null, + name: r'watchTabsContainerIdProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + WatchTabsContainerIdProvider call(EquatableValue> tabIds) => + WatchTabsContainerIdProvider._(argument: tabIds, from: this); + + @override + String toString() => r'watchTabsContainerIdProvider'; +} + @ProviderFor(watchAllAssignedSites) const watchAllAssignedSitesProvider = WatchAllAssignedSitesProvider._(); diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart index 2192eea9..6bafcbb7 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.dart @@ -125,7 +125,7 @@ class SelectedContainer extends _$SelectedContainer { if (next != null) { final tabContainerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(next); + .getTabContainerId(next); if (ref.mounted && tabContainerId != stateOrNull) { if (tabContainerId != null) { diff --git a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart index 14f5ad90..623345e8 100644 --- a/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/providers/selected_container.g.dart @@ -41,7 +41,7 @@ final class SelectedContainerProvider } } -String _$selectedContainerHash() => r'fd41c7d7ff0cfaff2829fd93e2afd41d883b8317'; +String _$selectedContainerHash() => r'35a8fc409e563758156a197bb8b114387d31b389'; abstract class _$SelectedContainer extends $Notifier { String? build(); 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 72bdcde3..4f987421 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -34,7 +34,7 @@ class TabDataRepository extends _$TabDataRepository { ContainerData targetContainer, { bool closeOldTab = true, }) async { - final currentContainerId = await getContainerTabId(tabId); + final currentContainerId = await getTabContainerId(tabId); final currentContainerData = await currentContainerId.mapNotNull( (containerId) => ref @@ -118,7 +118,7 @@ class TabDataRepository extends _$TabDataRepository { .get(); } - Future getContainerTabId(String tabId) { + Future getTabContainerId(String tabId) { return ref .read(tabDatabaseProvider) .tabDao @@ -126,6 +126,15 @@ class TabDataRepository extends _$TabDataRepository { .getSingleOrNull(); } + Future> getTabsContainerId(Iterable tabIds) { + return ref + .read(tabDatabaseProvider) + .tabDao + .getTabsContainerId(tabIds) + .get() + .then(Map.fromEntries); + } + Future> getTabDescendants(String tabId) async { final results = await 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 0a56d0cc..cec9b34e 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'e12750bc1962878a5bf5140d97521d642001994f'; +String _$tabDataRepositoryHash() => r'3d9149efb88f256cc826eb538f13331b49438a7c'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart index 945afb2d..f0d44837 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_draft_suggestions.dart @@ -30,8 +30,8 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/uuid.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_preview.dart'; -import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/view_tabs.dart'; +import 'package:weblibre/features/geckoview/features/browser/presentation/widgets/tab_view/tab_preview.dart'; +import 'package:weblibre/features/geckoview/features/browser/utils/grid_calculations.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart'; @@ -147,7 +147,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget { return (selectedContainerTabs.value[equatable] ?.contains(tabId) == true) - ? TabPreview( + ? GridTabPreview( tabId: tabId, isActive: false, onDelete: () { @@ -160,7 +160,7 @@ class ContainerDraftSuggestionsScreen extends HookConsumerWidget { }; }, ) - : SuggestedSingleTabPreview( + : SuggestedSingleGridTabPreview( tabId: tabId, activeTabId: null, onTap: () { diff --git a/app/lib/presentation/controllers/website_title.dart b/app/lib/presentation/controllers/website_title.dart index 940bd7d6..a7916041 100644 --- a/app/lib/presentation/controllers/website_title.dart +++ b/app/lib/presentation/controllers/website_title.dart @@ -87,7 +87,7 @@ Future pageInfo( if (tabState?.id != null) { final containerId = await ref .read(tabDataRepositoryProvider.notifier) - .getContainerTabId(tabState!.id); + .getTabContainerId(tabState!.id); final containerData = await containerId.mapNotNull( (containerId) => ref diff --git a/app/lib/presentation/controllers/website_title.g.dart b/app/lib/presentation/controllers/website_title.g.dart index 110c7a1c..80b0b9d0 100644 --- a/app/lib/presentation/controllers/website_title.g.dart +++ b/app/lib/presentation/controllers/website_title.g.dart @@ -163,7 +163,7 @@ final class PageInfoProvider } } -String _$pageInfoHash() => r'87d23da9b25c3557e7270d90c2dee6f37eaaf8e0'; +String _$pageInfoHash() => r'b756ee75e155bcee1e54658556eb8f4ef417d118'; final class PageInfoFamily extends $Family with