diff --git a/app/lib/features/geckoview/domain/entities/tab_container_selection.dart b/app/lib/features/geckoview/domain/entities/tab_container_selection.dart new file mode 100644 index 00000000..7bf0283f --- /dev/null +++ b/app/lib/features/geckoview/domain/entities/tab_container_selection.dart @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; + +sealed class TabContainerSelection { + const TabContainerSelection(); + + const factory TabContainerSelection.useSelected() = + UseSelectedContainerTabSelection; + + const factory TabContainerSelection.unassigned() = + UnassignedContainerTabSelection; + + const factory TabContainerSelection.specific(ContainerData container) = + SpecificContainerTabSelection; +} + +final class UseSelectedContainerTabSelection extends TabContainerSelection { + const UseSelectedContainerTabSelection(); +} + +final class UnassignedContainerTabSelection extends TabContainerSelection { + const UnassignedContainerTabSelection(); +} + +final class SpecificContainerTabSelection extends TabContainerSelection { + final ContainerData container; + + const SpecificContainerTabSelection(this.container); +} diff --git a/app/lib/features/geckoview/domain/repositories/tab.dart b/app/lib/features/geckoview/domain/repositories/tab.dart index 94b4c29c..29260590 100644 --- a/app/lib/features/geckoview/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/domain/repositories/tab.dart @@ -26,6 +26,7 @@ import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/features/geckoview/domain/entities/states/tab.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; @@ -87,20 +88,22 @@ class TabRepository extends _$TabRepository { required bool private, HistoryMetadataKey? historyMetadata, Map? additionalHeaders, - Value? container, + TabContainerSelection containerSelection = + const TabContainerSelection.useSelected(), bool launchedFromIntent = false, }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; - final assingedContainer = - container ?? - Value( - await ref.read(selectedContainerProvider.notifier).fetchData(), - ); + final assignedContainer = switch (containerSelection) { + UseSelectedContainerTabSelection() => + await ref.read(selectedContainerProvider.notifier).fetchData(), + UnassignedContainerTabSelection() => null, + SpecificContainerTabSelection(:final container) => container, + }; final validatedParentId = await _resolveParentIdForContext( parentId: parentId, - targetContextId: assingedContainer.value?.metadata.contextualIdentity, + targetContextId: assignedContainer?.metadata.contextualIdentity, ); final newTabId = await tabDao.upsertTabTransactional( @@ -111,7 +114,7 @@ class TabRepository extends _$TabRepository { startLoading: startLoading, parentId: validatedParentId, flags: flags, - contextId: assingedContainer.value?.metadata.contextualIdentity, + contextId: assignedContainer?.metadata.contextualIdentity, source: source, private: private, historyMetadata: historyMetadata, @@ -119,7 +122,7 @@ class TabRepository extends _$TabRepository { ); }, parentId: Value(validatedParentId), - containerId: Value(assingedContainer.value?.id), + containerId: Value(assignedContainer?.id), isPrivate: Value(private), url: Value(url), ); @@ -134,10 +137,17 @@ class TabRepository extends _$TabRepository { Future> addMultipleTabs({ required List tabs, String? selectTabId, - Value? container, + TabContainerSelection containerSelection = + const TabContainerSelection.unassigned(), }) async { final tabDao = ref.read(tabDatabaseProvider).tabDao; final db = ref.read(tabDatabaseProvider); + final assignedContainer = switch (containerSelection) { + UseSelectedContainerTabSelection() => + await ref.read(selectedContainerProvider.notifier).fetchData(), + UnassignedContainerTabSelection() => null, + SpecificContainerTabSelection(:final container) => container, + }; return await db.transaction(() async { final createdTabIds = await _tabsService.addMultipleTabs( @@ -177,7 +187,7 @@ class TabRepository extends _$TabRepository { tabId, parentId: Value(validatedParentId), source: TabSource.manual, - containerId: Value(container?.value?.id), + containerId: Value(assignedContainer?.id), isPrivate: Value(tab.private), url: Value(Uri.tryParse(tab.url)), ); @@ -466,7 +476,9 @@ class TabRepository extends _$TabRepository { await addTab( url: uri, private: tabState.isPrivate, - container: Value(containerData), + containerSelection: TabContainerSelection.specific( + containerData, + ), parentId: tabState.id, selectTab: true, ); diff --git a/app/lib/features/geckoview/domain/repositories/tab.g.dart b/app/lib/features/geckoview/domain/repositories/tab.g.dart index 02d93dc8..03d07fee 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'0a676c0513917e8853677ebd592954bde0f15f57'; +String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c'; abstract class _$TabRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart index 543e2989..5858bece 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/navigation_drawer.dart @@ -20,7 +20,6 @@ import 'dart:async'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -29,6 +28,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; @@ -329,7 +329,8 @@ class _ExtensionsSection extends HookConsumerWidget { .addTab( url: Uri.parse('https://addons.mozilla.org'), private: isPrivate, - container: const Value(null), + containerSelection: + const TabContainerSelection.unassigned(), selectTab: true, ); }, diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart index 083123a3..dfc7042b 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/tab_menu.dart @@ -19,7 +19,6 @@ */ import 'dart:convert'; -import 'package:drift/drift.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -30,6 +29,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/desktop_mode.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; @@ -42,6 +42,7 @@ import 'package:weblibre/features/geckoview/features/pwa/presentation/widgets/pw import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart'; import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; @@ -239,7 +240,11 @@ class TabMenu extends HookConsumerWidget { .addTab( url: tabState.url, private: false, - container: Value(containerData), + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific( + containerData, + ), selectTab: false, ) : await ref @@ -278,7 +283,11 @@ class TabMenu extends HookConsumerWidget { .addTab( url: tabState.url, private: true, - container: Value(containerData), + containerSelection: containerData == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific( + containerData, + ), selectTab: false, ) : await ref @@ -313,25 +322,34 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline), child: const Text('Assign Container'), onPressed: () async { - final targetContainerId = - await const ContainerSelectionRoute().push( - context, - ); + final selection = await const ContainerSelectionRoute() + .push(context); - if (targetContainerId != null) { - final containerData = await ref - .read(containerRepositoryProvider.notifier) - .getContainerData(targetContainerId); + switch (selection) { + case ContainerSelectionSelected(:final containerId): + final containerData = await ref + .read(containerRepositoryProvider.notifier) + .getContainerData(containerId); - if (containerData != null) { + if (containerData != null) { + final tabState = ref.read( + tabStateProvider(selectedTabId), + )!; + + await ref + .read(tabDataRepositoryProvider.notifier) + .assignContainer(tabState.id, containerData); + } + case ContainerSelectionUnassigned(): final tabState = ref.read( tabStateProvider(selectedTabId), )!; await ref .read(tabDataRepositoryProvider.notifier) - .assignContainer(tabState.id, containerData); - } + .unassignContainer(tabState.id); + case null: + break; } }, ), @@ -340,15 +358,15 @@ class TabMenu extends HookConsumerWidget { leadingIcon: const Icon(MdiIcons.webPlus), child: const Text('URL relation'), onPressed: () async { - final targetContainerId = - await const ContainerSelectionRoute().push( - context, - ); + final selection = await const ContainerSelectionRoute() + .push(context); - if (targetContainerId != null) { + if (selection case ContainerSelectionSelected( + :final containerId, + )) { final containerData = await ref .read(containerRepositoryProvider.notifier) - .getContainerData(targetContainerId); + .getContainerData(containerId); if (containerData != null) { final tabState = ref.read( 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 index b6aeeb9d..19cc3912 100644 --- 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 @@ -20,7 +20,6 @@ import 'dart:convert'; import 'package:collection/collection.dart'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -28,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; @@ -662,9 +662,10 @@ class TabViewHeader extends HookConsumerWidget { .contextualIdentity, ); }).toList(), - container: Value( - selectedContainer, - ), + containerSelection: + TabContainerSelection.specific( + selectedContainer, + ), ); } diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart index 8f584c84..fa7ce518 100644 --- a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart +++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart @@ -19,7 +19,6 @@ */ import 'dart:convert'; -import 'package:drift/drift.dart' hide Column; import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -29,6 +28,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:uuid/enums.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; @@ -103,7 +103,9 @@ class OpenInContainer extends HookConsumerWidget { parentId: currentTab?.id, selectTab: false, private: isPrivate, - container: Value(selectedContainer), + containerSelection: TabContainerSelection.specific( + selectedContainer, + ), ); if (context.mounted) { diff --git a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart index 365eb8ee..4ce38ee7 100644 --- a/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart +++ b/app/lib/features/geckoview/features/open_link_tools/presentation/dialogs/open_shared_content.dart @@ -19,7 +19,6 @@ */ import 'dart:async'; -import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -28,6 +27,7 @@ import 'package:flutter_mozilla_components/flutter_mozilla_components.dart' import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/entities/url_cleaner_result.dart'; import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.dart'; @@ -167,7 +167,9 @@ class OpenSharedContent extends HookConsumerWidget { .addTab( url: Uri.parse(textController.text), private: isPrivate, - container: Value(selectedContainer.value), + containerSelection: selectedContainer.value == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(selectedContainer.value!), launchedFromIntent: true, selectTab: true, ); diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart index 839f85a6..e8889b75 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.dart @@ -75,6 +75,7 @@ AsyncValue> engineHistorySuggestions(Ref ref) { ) != null), ) + .take(25) .toList(), ), ), diff --git a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart index f0513b2c..9f272af3 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/engine_suggestions.g.dart @@ -107,4 +107,4 @@ final class EngineHistorySuggestionsProvider } String _$engineHistorySuggestionsHash() => - r'7e6c17e6a2df98098ca1a683a438d1c261ccabdf'; + r'e78f87f67da9184115991ee93a25da8039e36293'; diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart new file mode 100644 index 00000000..c641b302 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_modules_view.g.dart'; + +enum SearchModuleType { tabs, articles, history } + +enum SearchModuleDisplayState { preview, expanded, collapsed } + +@Riverpod() +class SearchModuleDisplayStateController + extends _$SearchModuleDisplayStateController { + void cycle() { + state = switch (state) { + SearchModuleDisplayState.preview => SearchModuleDisplayState.expanded, + SearchModuleDisplayState.expanded => SearchModuleDisplayState.collapsed, + SearchModuleDisplayState.collapsed => SearchModuleDisplayState.preview, + }; + } + + void toggleCollapse() { + state = switch (state) { + SearchModuleDisplayState.collapsed => SearchModuleDisplayState.preview, + _ => SearchModuleDisplayState.collapsed, + }; + } + + void toggleExpansion() { + state = switch (state) { + SearchModuleDisplayState.preview => SearchModuleDisplayState.expanded, + SearchModuleDisplayState.expanded => SearchModuleDisplayState.preview, + _ => state, + }; + } + + @override + SearchModuleDisplayState build(SearchModuleType module) { + return SearchModuleDisplayState.preview; + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart new file mode 100644 index 00000000..37a5f4ff --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart @@ -0,0 +1,121 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_modules_view.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SearchModuleDisplayStateController) +final searchModuleDisplayStateControllerProvider = + SearchModuleDisplayStateControllerFamily._(); + +final class SearchModuleDisplayStateControllerProvider + extends + $NotifierProvider< + SearchModuleDisplayStateController, + SearchModuleDisplayState + > { + SearchModuleDisplayStateControllerProvider._({ + required SearchModuleDisplayStateControllerFamily super.from, + required SearchModuleType super.argument, + }) : super( + retry: null, + name: r'searchModuleDisplayStateControllerProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => + _$searchModuleDisplayStateControllerHash(); + + @override + String toString() { + return r'searchModuleDisplayStateControllerProvider' + '' + '($argument)'; + } + + @$internal + @override + SearchModuleDisplayStateController create() => + SearchModuleDisplayStateController(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SearchModuleDisplayState value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is SearchModuleDisplayStateControllerProvider && + other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$searchModuleDisplayStateControllerHash() => + r'c3f1c93b618eec76e86c1c9cf0bf8fbdfdcb1430'; + +final class SearchModuleDisplayStateControllerFamily extends $Family + with + $ClassFamilyOverride< + SearchModuleDisplayStateController, + SearchModuleDisplayState, + SearchModuleDisplayState, + SearchModuleDisplayState, + SearchModuleType + > { + SearchModuleDisplayStateControllerFamily._() + : super( + retry: null, + name: r'searchModuleDisplayStateControllerProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + SearchModuleDisplayStateControllerProvider call(SearchModuleType module) => + SearchModuleDisplayStateControllerProvider._( + argument: module, + from: this, + ); + + @override + String toString() => r'searchModuleDisplayStateControllerProvider'; +} + +abstract class _$SearchModuleDisplayStateController + extends $Notifier { + late final _$args = ref.$arg as SearchModuleType; + SearchModuleType get module => _$args; + + SearchModuleDisplayState build(SearchModuleType module); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + SearchModuleDisplayState, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.dart new file mode 100644 index 00000000..5e1962ca --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.dart @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:riverpod/experimental/persist.dart'; +import 'package:riverpod_annotation/experimental/persist.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/user/data/providers.dart'; + +part 'search_suggestions_view.g.dart'; + +@Riverpod(keepAlive: true) +class SearchSuggestionsExpanded extends _$SearchSuggestionsExpanded { + void toggle() { + state = !state; + } + + @override + bool build() { + persist( + ref.watch(riverpodDatabaseStorageProvider), + key: 'SearchSuggestionsExpanded', + encode: (state) => jsonEncode([state]), + decode: (encoded) => (jsonDecode(encoded) as List).first as bool, + ); + + return stateOrNull ?? true; + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.g.dart new file mode 100644 index 00000000..dd9d7a5a --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_suggestions_view.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'search_suggestions_view.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SearchSuggestionsExpanded) +final searchSuggestionsExpandedProvider = SearchSuggestionsExpandedProvider._(); + +final class SearchSuggestionsExpandedProvider + extends $NotifierProvider { + SearchSuggestionsExpandedProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'searchSuggestionsExpandedProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$searchSuggestionsExpandedHash(); + + @$internal + @override + SearchSuggestionsExpanded create() => SearchSuggestionsExpanded(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$searchSuggestionsExpandedHash() => + r'520bcdb9ef624c2ebe5a570d4dab034de5b4f475'; + +abstract class _$SearchSuggestionsExpanded extends $Notifier { + bool build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index dff4484b..4fa38e28 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -19,22 +19,21 @@ */ import 'dart:async'; -import 'package:drift/drift.dart' hide Column; -import 'package:fading_scroll/fading_scroll.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:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/search.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_session.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart'; @@ -42,11 +41,10 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; -import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart'; -import 'package:weblibre/presentation/icons/weblibre_icons.dart'; import 'package:weblibre/utils/text_field_line_count.dart'; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; @@ -248,7 +246,9 @@ class SearchScreen extends HookConsumerWidget { : null, launchedFromIntent: launchedFromIntent, selectTab: true, - container: Value(selectedContainer), + containerSelection: selectedContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(selectedContainer), ); } @@ -264,252 +264,213 @@ class SearchScreen extends HookConsumerWidget { body: SafeArea( child: Form( key: formKey, - child: FadingScroll( - builder: (context, controller) { - return CustomScrollView( - controller: controller, - slivers: [ - SliverAppBar( - floating: true, - pinned: true, - automaticallyImplyLeading: false, - toolbarHeight: isEditMode ? 0 : kToolbarHeight + 56, - titleSpacing: 0.0, - title: isEditMode - ? null - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - Focus( - canRequestFocus: false, - child: SegmentedButton( - showSelectedIcon: false, - segments: [ - const ButtonSegment( - value: TabType.regular, - label: Text('Regular'), - icon: Icon(MdiIcons.tab), - ), - const ButtonSegment( - value: TabType.private, - label: Text('Private'), - icon: Icon(WebLibreIcons.privateTab), - ), - if (createChildTabsOption) - const ButtonSegment( - value: TabType.child, - label: Text('Child'), - icon: Icon(MdiIcons.fileTree), - ), - ], - selected: {selectedTabType.value}, - onSelectionChanged: (value) { - selectedTabType.value = value.first; - // Restore focus to search field after segment change - WidgetsBinding.instance - .addPostFrameCallback((_) { - searchFocusNode.requestFocus(); - }); - }, - style: switch (selectedTabType.value) { - TabType.regular => null, - TabType.private => - SegmentedButton.styleFrom( - selectedBackgroundColor: + child: CustomScrollView( + slivers: [ + SliverAppBar( + floating: true, + pinned: true, + automaticallyImplyLeading: false, + toolbarHeight: isEditMode ? 0 : kToolbarHeight, + titleSpacing: 0.0, + title: isEditMode + ? null + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + flex: 3, + child: Align( + alignment: Alignment.centerLeft, + child: Focus( + canRequestFocus: false, + child: AnimatedTabTypeSwitcher( + selected: selectedTabType.value, + onChanged: (value) { + selectedTabType.value = value; + // Restore focus to search field after segment change + WidgetsBinding.instance + .addPostFrameCallback((_) { + searchFocusNode.requestFocus(); + }); + }, + showChildOption: createChildTabsOption, + selectedBackgroundColor: + switch (selectedTabType.value) { + TabType.regular => null, + TabType.private => appColors.privateSelectionOverlay, - ), - TabType.child => - (currentTabTabType == TabType.private) - ? SegmentedButton.styleFrom( - selectedBackgroundColor: appColors - .privateSelectionOverlay, - ) - : null, - }, - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: SizedBox( - height: 48, - child: ContainerChips( - selectedContainer: selectedContainer, - onSelected: (container) async { - if (container != null) { - await ref - .read( - selectedContainerProvider - .notifier, - ) - .setContainerId(container.id); - } else { - ref - .read( - selectedContainerProvider - .notifier, - ) - .clearContainer(); - } - }, - onDeleted: (container) { - ref - .read( - selectedContainerProvider.notifier, - ) - .clearContainer(); - }, + TabType.child => + (currentTabTabType == + TabType.private) + ? appColors + .privateSelectionOverlay + : null, + }, ), ), ), - ], - ), - bottom: PreferredSize( - preferredSize: Size.fromHeight(preferredHeight.value), - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: SearchField( - textFieldKey: textFieldKey, - showBangIcon: showBangIcon, - textEditingController: searchTextController, - focusNode: searchFocusNode, - maxLines: isEditMode ? 3 : 1, - autofocus: true, - label: (activeBang != null) - ? const Text('Search') - : const Text('Address / Search'), - unfocusOnTapOutside: false, - onSubmitted: (value) async { - if (value.isNotEmpty) { - var newUrl = uri_parser.tryParseUrl( - value, - eagerParsing: true, - ); - - if (newUrl == null) { - // Read from both providers - use site if set, otherwise global - final siteBang = isEditMode - ? ref.read( - selectedBangDataProvider( - domain: existingTabState.url.host, - ), - ) - : null; - final globalBang = ref.read( - selectedBangDataProvider(), - ); - final bang = - siteBang ?? - globalBang ?? - await ref.read( - defaultSearchBangDataProvider.future, - ); - - if (bang != null) { - newUrl = bang.getTemplateUrl(value); - - if (!privateTabMode) { - await ref - .read(bangSearchProvider.notifier) - .triggerBangSearch(bang, value); - } - } - } - - if (newUrl != null) { - if (isEditMode) { - // Load into existing tab - await ref - .read( - tabSessionProvider( - tabId: tabId, - ).notifier, - ) - .loadUrl(url: newUrl); - } else { - // Create new tab - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: newUrl, - private: privateTabMode, - parentId: - (selectedTabType.value == - TabType.child) - ? ref.read(selectedTabProvider) - : null, - launchedFromIntent: launchedFromIntent, - selectTab: true, - container: Value(selectedContainer), - ); - } - - if (context.mounted) { - ref - .read( - bottomSheetControllerProvider.notifier, - ) - .requestDismiss(); - - const BrowserRoute().go(context); - } - } - } - }, - activeBang: activeBang, - showSuggestions: true, + ), + const SizedBox(width: 16), + Flexible( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: CompactContainerSelector( + selectedContainer: selectedContainer, + ), + ), + ), + ], ), ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(preferredHeight.value), + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: SearchField( + textFieldKey: textFieldKey, + showBangIcon: showBangIcon, + textEditingController: searchTextController, + focusNode: searchFocusNode, + maxLines: isEditMode ? 3 : 1, + autofocus: true, + label: (activeBang != null) + ? const Text('Search') + : const Text('Address / Search'), + unfocusOnTapOutside: false, + onSubmitted: (value) async { + if (value.isNotEmpty) { + var newUrl = uri_parser.tryParseUrl( + value, + eagerParsing: true, + ); + + if (newUrl == null) { + // Read from both providers - use site if set, otherwise global + final siteBang = isEditMode + ? ref.read( + selectedBangDataProvider( + domain: existingTabState.url.host, + ), + ) + : null; + final globalBang = ref.read( + selectedBangDataProvider(), + ); + final bang = + siteBang ?? + globalBang ?? + await ref.read( + defaultSearchBangDataProvider.future, + ); + + if (bang != null) { + newUrl = bang.getTemplateUrl(value); + + if (!privateTabMode) { + await ref + .read(bangSearchProvider.notifier) + .triggerBangSearch(bang, value); + } + } + } + + if (newUrl != null) { + if (isEditMode) { + // Load into existing tab + await ref + .read( + tabSessionProvider(tabId: tabId).notifier, + ) + .loadUrl(url: newUrl); + } else { + // Create new tab + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: newUrl, + private: privateTabMode, + parentId: + (selectedTabType.value == TabType.child) + ? ref.read(selectedTabProvider) + : null, + launchedFromIntent: launchedFromIntent, + selectTab: true, + containerSelection: + selectedContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific( + selectedContainer, + ), + ); + } + + if (context.mounted) { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); + + const BrowserRoute().go(context); + } + } + } + }, + activeBang: activeBang, + showSuggestions: true, ), ), - SliverToBoxAdapter( - child: ClipboardFillLink(controller: searchTextController), - ), - const SliverToBoxAdapter(child: Divider()), - FullSearchTermSuggestions( - searchTextController: searchTextController, - activeBang: activeBang, - submitSearch: submitSearch, - domain: isEditMode ? existingTabState.url.host : null, - ), - TabSearch(searchTextListenable: sampledSearchText), - FeedSearch(searchTextNotifier: sampledSearchText), - HistorySuggestions( - searchTextListenable: sampledSearchText, - onUriSelected: (uri) async { - if (isEditMode) { - // Load into existing tab - await ref - .read(tabSessionProvider(tabId: tabId).notifier) - .loadUrl(url: uri); - } else { - // Create new tab - await ref - .read(tabRepositoryProvider.notifier) - .addTab( - url: uri, - private: privateTabMode, - parentId: (selectedTabType.value == TabType.child) - ? ref.read(selectedTabProvider) - : null, - launchedFromIntent: launchedFromIntent, - selectTab: true, - container: Value(selectedContainer), - ); - } + ), + ), + SliverToBoxAdapter( + child: ClipboardFillLink(controller: searchTextController), + ), + FullSearchTermSuggestions( + searchTextController: searchTextController, + activeBang: activeBang, + submitSearch: submitSearch, + domain: isEditMode ? existingTabState.url.host : null, + ), + TabSearch(searchTextListenable: sampledSearchText), + FeedSearch(searchTextNotifier: sampledSearchText), + HistorySuggestions( + searchTextListenable: sampledSearchText, + onUriSelected: (uri) async { + if (isEditMode) { + // Load into existing tab + await ref + .read(tabSessionProvider(tabId: tabId).notifier) + .loadUrl(url: uri); + } else { + // Create new tab + await ref + .read(tabRepositoryProvider.notifier) + .addTab( + url: uri, + private: privateTabMode, + parentId: (selectedTabType.value == TabType.child) + ? ref.read(selectedTabProvider) + : null, + launchedFromIntent: launchedFromIntent, + selectTab: true, + containerSelection: selectedContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific( + selectedContainer, + ), + ); + } - if (context.mounted) { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); + if (context.mounted) { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); - const BrowserRoute().go(context); - } - }, - ), - ], - ); - }, + const BrowserRoute().go(context); + } + }, + ), + ], ), ), ), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart b/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart new file mode 100644 index 00000000..b1b82747 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/presentation/icons/weblibre_icons.dart'; + +/// An animated tab type switcher that only shows the label for the currently +/// active option. Inactive options collapse to show only their icon. +class AnimatedTabTypeSwitcher extends StatelessWidget { + final TabType selected; + final ValueChanged onChanged; + final bool showChildOption; + final Color? selectedBackgroundColor; + + const AnimatedTabTypeSwitcher({ + super.key, + required this.selected, + required this.onChanged, + this.showChildOption = false, + this.selectedBackgroundColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final borderColor = colorScheme.outline; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(24), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(23), + child: IntrinsicHeight( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Segment( + tabType: TabType.regular, + icon: MdiIcons.tab, + label: 'Regular', + isSelected: selected == TabType.regular, + selectedBackgroundColor: selectedBackgroundColor, + onTap: () => onChanged(TabType.regular), + ), + _divider(borderColor), + _Segment( + tabType: TabType.private, + icon: WebLibreIcons.privateTab, + label: 'Private', + isSelected: selected == TabType.private, + selectedBackgroundColor: selectedBackgroundColor, + onTap: () => onChanged(TabType.private), + ), + if (showChildOption) ...[ + _divider(borderColor), + _Segment( + tabType: TabType.child, + icon: MdiIcons.fileTree, + label: 'Child', + isSelected: selected == TabType.child, + selectedBackgroundColor: selectedBackgroundColor, + onTap: () => onChanged(TabType.child), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _divider(Color color) { + return VerticalDivider(width: 1, thickness: 1, color: color); + } +} + +class _Segment extends StatelessWidget { + final TabType tabType; + final IconData icon; + final String label; + final bool isSelected; + final Color? selectedBackgroundColor; + final VoidCallback onTap; + + const _Segment({ + required this.tabType, + required this.icon, + required this.label, + required this.isSelected, + required this.selectedBackgroundColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + final bgColor = isSelected + ? (selectedBackgroundColor ?? colorScheme.secondaryContainer) + : Colors.transparent; + final fgColor = isSelected + ? colorScheme.onSecondaryContainer + : colorScheme.onSurfaceVariant; + + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + color: bgColor, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: fgColor, size: 18), + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + child: isSelected + ? Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: fgColor, + ), + maxLines: 1, + overflow: TextOverflow.clip, + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart index 15fc3c06..9cbe1ba3 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart @@ -23,10 +23,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:skeletonizer/skeletonizer.dart'; -import 'package:sliver_tools/sliver_tools.dart'; import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/extensions/uri.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; import 'package:weblibre/features/web_feed/data/models/feed_article_query_result.dart'; import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; import 'package:weblibre/features/web_feed/domain/providers.dart'; @@ -49,6 +50,7 @@ class FeedSearch extends HookConsumerWidget { final theme = Theme.of(context); final articlesAsync = ref.watch(articleSearchProvider(null)); + final totalResults = articlesAsync.value?.length ?? 0; useOnListenableChange(searchTextNotifier, () async { await ref @@ -66,153 +68,159 @@ class FeedSearch extends HookConsumerWidget { return const SliverToBoxAdapter(child: SizedBox.shrink()); } - return MultiSliver( - children: [ - const SliverToBoxAdapter(child: Divider()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Articles', style: Theme.of(context).textTheme.labelSmall), - ], - ), - ), - ), - SliverSkeletonizer( - enabled: articlesAsync.isLoading, - child: articlesAsync.when( - skipLoadingOnReload: true, - data: (articles) => SliverList.builder( - itemCount: articles.length, - itemBuilder: (context, index) { - final article = articles[index]; + return SearchModuleSection( + title: 'Articles', + moduleType: SearchModuleType.articles, + totalCount: totalResults, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverSkeletonizer( + enabled: articlesAsync.isLoading, + child: articlesAsync.when( + skipLoadingOnReload: true, + data: (articles) { + return SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final article = articles[index]; - final titleHighlight = switch (article) { - final FeedArticleQueryResult result => - result.titleHighlight.whenNotEmpty, - _ => null, - }; + final titleHighlight = switch (article) { + final FeedArticleQueryResult result => + result.titleHighlight.whenNotEmpty, + _ => null, + }; - final searchSnippet = switch (article) { - final FeedArticleQueryResult result => - result.summarySnippet.whenNotEmpty ?? - result.contentSnippet.whenNotEmpty, - _ => null, - }; + final searchSnippet = switch (article) { + final FeedArticleQueryResult result => + result.summarySnippet.whenNotEmpty ?? + result.contentSnippet.whenNotEmpty, + _ => null, + }; - final articleDate = article.updated ?? article.created; + final articleDate = article.updated ?? article.created; - return ListTile( - leading: RepaintBoundary( - child: UrlIcon([ - article.icon ?? - article.links - ?.getRelation(FeedLinkRelation.alternate) - ?.uri ?? - article.siteLink ?? - article.feedId.base, - ], iconSize: 24.0), - ), - title: (titleHighlight.isNotEmpty) - ? Text.rich( - buildHighlightedText( - titleHighlight!, - Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - _matchPrefix, - _matchSuffix, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - : Text( - article.displayTitle, - style: theme.textTheme.titleMedium, - maxLines: 2, - overflow: TextOverflow.ellipsis, + return ListTile( + leading: RepaintBoundary( + child: UrlIcon([ + article.icon ?? + article.links + ?.getRelation(FeedLinkRelation.alternate) + ?.uri ?? + article.siteLink ?? + article.feedId.base, + ], iconSize: 24.0), ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (searchSnippet.isNotEmpty) - Text.rich( - buildHighlightedText( - searchSnippet!, - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - _matchPrefix, - _matchSuffix, - normalizeWhitespaces: true, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) - else - (article.summaryPlain != null) - ? Text( - article.summaryPlain!, - style: theme.textTheme.bodySmall, + title: (titleHighlight.isNotEmpty) + ? Text.rich( + buildHighlightedText( + titleHighlight!, + Theme.of( + context, + ).textTheme.titleMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + ), + Theme.of( + context, + ).textTheme.titleMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + : Text( + article.displayTitle, + style: theme.textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (searchSnippet.isNotEmpty) + Text.rich( + buildHighlightedText( + searchSnippet!, + Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + Theme.of( + context, + ).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, + ), maxLines: 3, overflow: TextOverflow.ellipsis, ) - : const SizedBox.shrink(), - if (articleDate != null) - Align( - alignment: Alignment.topRight, - child: Text( - ref - .read(formatProvider.notifier) - .fullDateTime(articleDate), - style: theme.textTheme.bodySmall?.copyWith( - fontStyle: FontStyle.italic, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), + else + (article.summaryPlain != null) + ? Text( + article.summaryPlain!, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : const SizedBox.shrink(), + if (articleDate != null) + Align( + alignment: Alignment.topRight, + child: Text( + ref + .read(formatProvider.notifier) + .fullDateTime(articleDate), + style: theme.textTheme.bodySmall?.copyWith( + fontStyle: FontStyle.italic, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), - ], - ), - onTap: () { - FeedArticleRoute( - articleId: article.id, - ).pushReplacement(context); + onTap: () { + FeedArticleRoute( + articleId: article.id, + ).pushReplacement(context); + }, + ); + }, + ); + }, + error: (error, stackTrace) { + return SliverToBoxAdapter( + child: FailureWidget( + title: 'Failed searching Articles', + exception: error, + ), + ); + }, + loading: () => SliverList.builder( + itemCount: isCollapsed ? 0 : previewItemsPerModule, + itemBuilder: (context, index) { + return const ListTile(title: Bone.text()); }, - ); - }, - ), - error: (error, stackTrace) { - return SliverToBoxAdapter( - child: FailureWidget( - title: 'Failed searching Articles', - exception: error, ), - ); - }, - loading: () => SliverList.builder( - itemCount: articlesAsync.value?.length ?? 3, - itemBuilder: (context, index) { - return const ListTile(title: Bone.text()); - }, + ), ), - ), - ), - ], + ], ); } } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart index 368cb35b..0fa1651f 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/fixed_search_suggestions.dart @@ -87,7 +87,7 @@ class FixedSearchTermSuggestions extends HookConsumerWidget { searchTextController.text = query; }, child: InputChip( - // avatar: const Icon(Icons.search), + avatar: const Icon(Icons.search), label: Text(query), onSelected: (value) async { if (value) { diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart index 605819f9..2925bd2d 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart @@ -28,6 +28,7 @@ import 'package:weblibre/features/bangs/data/models/bang_data.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/repositories/data.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_suggestions_view.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart'; class FullSearchTermSuggestions extends HookConsumerWidget { @@ -55,8 +56,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget { ); final searchSuggestions = ref.watch(searchSuggestionsProvider()); - final searchHistory = ref.watch(searchHistoryProvider); + final expanded = ref.watch(searchSuggestionsExpandedProvider); useOnListenableChange(searchTextController, () { ref @@ -64,46 +65,50 @@ class FullSearchTermSuggestions extends HookConsumerWidget { .addQuery(searchTextController.text); }); - final MultiSliver listSliver; + Widget buildSuggestionChip( + String query, { + Widget? avatar, + Future Function()? onDelete, + }) { + return InkWell( + onLongPress: () { + searchTextController.text = query; + }, + child: InputChip( + avatar: avatar ?? const Icon(Icons.search), + label: Text(query), + onSelected: (value) async { + if (value) { + await submitSearch(query); + } + }, + onDeleted: onDelete == null + ? null + : () async { + await onDelete(); + }, + ), + ); + } + + final List suggestionChips; if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) { final entries = searchHistory.value!; - listSliver = MultiSliver( - children: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Wrap( - spacing: 8.0, - children: entries.map((entry) { - final query = entry.searchQuery; + suggestionChips = entries.map((entry) { + final query = entry.searchQuery; - return InkWell( - onLongPress: () { - searchTextController.text = query; - }, - child: InputChip( - avatar: const Icon(Icons.history), - label: Text(query), - onSelected: (value) async { - if (value) { - await submitSearch(query); - } - }, - onDeleted: () async { - await ref - .read(bangDataRepositoryProvider.notifier) - .removeSearchEntry(query); - }, - ), - ); - }).toList(), - ), - ), - ), - ], - ); + return buildSuggestionChip( + query, + avatar: const Icon(Icons.history), + onDelete: () async { + await ref + .read(bangDataRepositoryProvider.notifier) + .removeSearchEntry(query); + }, + ); + }).toList(); } else { final prioritizedSuggestions = [ if (searchTextIsNotEmpty) searchTextController.text, @@ -113,59 +118,20 @@ class FullSearchTermSuggestions extends HookConsumerWidget { ), ]; - listSliver = MultiSliver( - children: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Wrap( - spacing: 8.0, - children: prioritizedSuggestions.map((query) { - return InkWell( - onLongPress: () { - searchTextController.text = query; - }, - child: InputChip( - // avatar: const Icon(Icons.search), - label: Text(query), - onSelected: (value) async { - if (value) { - await submitSearch(query); - } - }, - ), - ); - }).toList(), - ), - ), - ), - ], - ); + suggestionChips = prioritizedSuggestions.map((query) { + return buildSuggestionChip(query); + }).toList(); } - return MultiSliver( - children: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (domain == null) - Text( - 'Search Provider', - style: Theme.of(context).textTheme.labelSmall, - ), - SmartBangSelector( - domain: domain, - searchTextController: searchTextController, - ), - ], - ), - ), - ), - SliverToBoxAdapter( - child: Padding( + final toggleButton = IconButton( + onPressed: () { + ref.read(searchSuggestionsExpandedProvider.notifier).toggle(); + }, + icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more), + ); + + final suggestionsContent = expanded + ? Padding( padding: const EdgeInsets.only(top: 8.0), child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 150), @@ -175,11 +141,60 @@ class FullSearchTermSuggestions extends HookConsumerWidget { return CustomScrollView( shrinkWrap: true, controller: controller, - slivers: [listSliver], + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Wrap(spacing: 8.0, children: suggestionChips), + ), + ), + ], ); }, ), ), + ) + : Padding( + padding: const EdgeInsets.only(left: 16.0, top: 8.0), + child: SizedBox( + height: 44, + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView.separated( + controller: controller, + scrollDirection: Axis.horizontal, + itemCount: suggestionChips.length, + separatorBuilder: (context, index) => + const SizedBox(width: 8), + itemBuilder: (context, index) => suggestionChips[index], + ); + }, + ), + ), + ); + + return MultiSliver( + children: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: SmartBangSelector( + domain: domain, + searchTextController: searchTextController, + ), + ), + ), + SliverToBoxAdapter( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: suggestionsContent), + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: toggleButton, + ), + ], ), ), ], diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart index 7a7691e7..d0a9743e 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart @@ -24,8 +24,9 @@ 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:sliver_tools/sliver_tools.dart'; import 'package:weblibre/features/geckoview/features/search/domain/providers/engine_suggestions.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; import 'package:weblibre/features/geckoview/utils/image_helper.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -45,6 +46,7 @@ class HistorySuggestions extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider); + final totalResults = historySuggestionsAsync.value?.length ?? 0; useOnListenableChange(searchTextListenable, () async { if (ref.exists(engineSuggestionsProvider)) { @@ -59,90 +61,89 @@ class HistorySuggestions extends HookConsumerWidget { return const SliverToBoxAdapter(child: SizedBox.shrink()); } - return MultiSliver( - children: [ - const SliverToBoxAdapter(child: Divider()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text( - 'History', - style: Theme.of(context).textTheme.labelSmall, - ), - ), - ), - SliverSkeletonizer( - enabled: historySuggestionsAsync.isLoading, - child: historySuggestionsAsync.when( - skipLoadingOnReload: true, - data: (historySuggestions) { - return SliverList.builder( - itemCount: historySuggestions.length, - itemBuilder: (context, index) { - final suggestion = historySuggestions[index]; - final uri = suggestion.description.mapNotNull(Uri.tryParse); - - return HookBuilder( - key: ValueKey(suggestion.id), - builder: (context) { - final icon = useCachedFuture( - () async => suggestion.icon.mapNotNull(tryDecodeImage), - [suggestion.description, suggestion.icon], + return SearchModuleSection( + title: 'History', + moduleType: SearchModuleType.history, + totalCount: totalResults, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + SliverSkeletonizer( + enabled: historySuggestionsAsync.isLoading, + child: historySuggestionsAsync.when( + skipLoadingOnReload: true, + data: (historySuggestions) { + return SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final suggestion = historySuggestions[index]; + final uri = suggestion.description.mapNotNull( + Uri.tryParse, ); - return ListTile( - leading: RepaintBoundary( - child: SafeRawImage( - image: icon.data, - height: 24, - width: 24, - fallback: const Icon(MdiIcons.web, size: 24), - ), - ), - title: suggestion.title.mapNotNull( - (title) => Text( - title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - subtitle: - uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ?? - suggestion.description.mapNotNull( - (description) => Text( - description, + return HookBuilder( + key: ValueKey(suggestion.id), + builder: (context) { + final icon = useCachedFuture( + () async => + suggestion.icon.mapNotNull(tryDecodeImage), + [suggestion.description, suggestion.icon], + ); + + return ListTile( + leading: RepaintBoundary( + child: SafeRawImage( + image: icon.data, + height: 24, + width: 24, + fallback: const Icon(MdiIcons.web, size: 24), + ), + ), + title: suggestion.title.mapNotNull( + (title) => Text( + title, maxLines: 2, overflow: TextOverflow.ellipsis, ), ), - onTap: () { - if (uri != null) { - onUriSelected(uri); - } + subtitle: + uri.mapNotNull( + (uri) => UriBreadcrumb(uri: uri), + ) ?? + suggestion.description.mapNotNull( + (description) => Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + onTap: () { + if (uri != null) { + onUriSelected(uri); + } + }, + ); }, ); }, ); }, - ); - }, - error: (error, stackTrace) { - return SliverToBoxAdapter( - child: FailureWidget( - title: 'Could not load history', - exception: error, + error: (error, stackTrace) { + return SliverToBoxAdapter( + child: FailureWidget( + title: 'Could not load history', + exception: error, + ), + ); + }, + loading: () => SliverList.builder( + itemCount: isCollapsed ? 0 : previewItemsPerModule, + itemBuilder: (context, index) { + return const ListTile(title: Bone.text()); + }, ), - ); - }, - loading: () => SliverList.builder( - itemCount: historySuggestionsAsync.value?.length ?? 3, - itemBuilder: (context, index) { - return const ListTile(title: Bone.text()); - }, + ), ), - ), - ), - ], + ], ); } } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart new file mode 100644 index 00000000..c15f06bb --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; + +/// A reusable header widget for search modules that displays a collapse/expand +/// chevron on the left, the section title, and a "Show all N" / "Show less" +/// button on the right. +class SearchModuleHeader extends StatelessWidget { + final String title; + final int totalCount; + final SearchModuleDisplayState displayState; + final VoidCallback onToggleCollapse; + final VoidCallback onToggleExpansion; + + /// The maximum number of items shown in preview mode. + /// The trailing button is hidden when totalCount <= this value. + final int previewLimit; + + const SearchModuleHeader({ + super.key, + required this.title, + required this.totalCount, + required this.displayState, + required this.onToggleCollapse, + required this.onToggleExpansion, + this.previewLimit = 3, + }); + + @override + Widget build(BuildContext context) { + final isCollapsed = displayState == SearchModuleDisplayState.collapsed; + final isExpanded = displayState == SearchModuleDisplayState.expanded; + final showTrailing = !isCollapsed && totalCount > previewLimit; + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: onToggleCollapse, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 8), + AnimatedRotation( + turns: isCollapsed ? -0.25 : 0, + duration: const Duration(milliseconds: 200), + child: Icon( + Icons.expand_more, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 8), + Text( + title.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ), + ), + if (showTrailing) + TextButton( + onPressed: onToggleExpansion, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: BorderSide( + color: Theme.of(context).colorScheme.outline, + width: 0.5, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isExpanded + ? 'Show less' + : 'Show all $totalCount', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 4), + Icon( + isExpanded + ? Icons.expand_less + : Icons.expand_more, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart new file mode 100644 index 00000000..085304b1 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sliver_tools/sliver_tools.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart'; + +const previewItemsPerModule = 3; + +/// A reusable wrapper for search module sections that handles: +/// - Display state management (preview/expanded/collapsed) +/// - Pinned header with collapse/expand and show-all/show-less controls +/// - Visible item count calculation +class SearchModuleSection extends ConsumerWidget { + final String title; + final SearchModuleType moduleType; + final int totalCount; + + /// Builds the content slivers for this module. + /// + /// [isCollapsed] indicates whether the section is fully collapsed (no items). + /// [visibleCount] is the number of items to display (0 when collapsed, + /// limited in preview, or all when expanded). + final List Function({ + required bool isCollapsed, + required int visibleCount, + }) + contentSliverBuilder; + + const SearchModuleSection({ + super.key, + required this.title, + required this.moduleType, + required this.totalCount, + required this.contentSliverBuilder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final displayState = ref.watch( + searchModuleDisplayStateControllerProvider(moduleType), + ); + + final isCollapsed = displayState == SearchModuleDisplayState.collapsed; + final showAllItems = + displayState == SearchModuleDisplayState.expanded || + totalCount <= previewItemsPerModule; + final visibleCount = isCollapsed + ? 0 + : (showAllItems ? totalCount : previewItemsPerModule); + + return MultiSliver( + pushPinnedChildren: true, + children: [ + const SliverToBoxAdapter(child: Divider()), + SliverPinnedHeader( + child: ColoredBox( + color: Theme.of(context).canvasColor, + child: SearchModuleHeader( + title: title, + totalCount: totalCount, + displayState: displayState, + onToggleCollapse: () => ref + .read( + searchModuleDisplayStateControllerProvider( + moduleType, + ).notifier, + ) + .toggleCollapse(), + onToggleExpansion: () => ref + .read( + searchModuleDisplayStateControllerProvider( + moduleType, + ).notifier, + ) + .toggleExpansion(), + ), + ), + ), + ...contentSliverBuilder( + isCollapsed: isCollapsed, + visibleCount: visibleCount, + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart index 531cddf3..84f8ba43 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.dart @@ -26,13 +26,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; -import 'package:sliver_tools/sliver_tools.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/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/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/entities/container_filter.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; @@ -128,126 +129,137 @@ class TabSearch extends HookConsumerWidget { return const SliverToBoxAdapter(child: SizedBox.shrink()); } - return MultiSliver( - children: [ - const SliverToBoxAdapter(child: Divider()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Tabs', style: Theme.of(context).textTheme.labelSmall), - ContainerChips( - displayMenu: false, - selectedContainer: selectedContainer.value, - showUnassignedChip: containerIdsWithResults.value.containsKey( - null, + final filteredResultCount = filteredTabs.length; + + return SearchModuleSection( + title: 'Tabs', + moduleType: SearchModuleType.tabs, + totalCount: filteredResultCount, + contentSliverBuilder: ({ + required bool isCollapsed, + required int visibleCount, + }) => [ + if (!isCollapsed) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ContainerChips( + displayMenu: false, + selectedContainer: selectedContainer.value, + showUnassignedChip: containerIdsWithResults.value + .containsKey(null), + onSelected: (container) { + selectedContainer.value = container; + }, + onDeleted: (container) { + selectedContainer.value = null; + }, + containerFilter: (container) => + containerIdsWithResults.value.containsKey(container.id), + containerBadgeCount: (container) => + containerIdsWithResults.value[container?.id] ?? 0, + searchTextListenable: searchTextListenable, ), - onSelected: (container) { - selectedContainer.value = container; - }, - onDeleted: (container) { - selectedContainer.value = null; - }, - containerFilter: (container) => - containerIdsWithResults.value.containsKey(container.id), - containerBadgeCount: (container) => - containerIdsWithResults.value[container?.id] ?? 0, - searchTextListenable: searchTextListenable, - ), - ], + ], + ), ), ), - ), - SliverList.builder( - itemCount: filteredTabs.length, - itemBuilder: (context, index) { - final result = filteredTabs[index]; + if (!isCollapsed) + SliverList.builder( + itemCount: visibleCount, + itemBuilder: (context, index) { + final result = filteredTabs[index]; - final content = - (result.extractedContent?.contains(_matchPrefix) == true) - ? result.extractedContent - : result.fullContent; + final content = + (result.extractedContent?.contains(_matchPrefix) == true) + ? result.extractedContent + : result.fullContent; - final titleHasMatch = result.title.contains(_matchPrefix); - final urlHasMatch = - result.highlightedUrl?.contains(_matchPrefix) ?? false; - final bodyHasMatch = content?.contains(_matchPrefix) ?? false; + final titleHasMatch = result.title.contains(_matchPrefix); + final urlHasMatch = + result.highlightedUrl?.contains(_matchPrefix) ?? false; + final bodyHasMatch = content?.contains(_matchPrefix) ?? false; - return ListTile( - leading: RepaintBoundary( - child: - result.icon.mapNotNull( - (icon) => SafeRawImage( - image: icon, - height: 24, - width: 24, - fallback: UrlIcon([result.url], iconSize: 24), - ), - ) ?? - UrlIcon([result.url], iconSize: 24), - ), - title: result.title.mapNotNull( - (title) => Text.rich( - buildHighlightedText( - title, - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - ), - Theme.of(context).textTheme.bodyLarge?.copyWith( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold, - ), - _matchPrefix, - _matchSuffix, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + return ListTile( + leading: RepaintBoundary( + child: + result.icon.mapNotNull( + (icon) => SafeRawImage( + image: icon, + height: 24, + width: 24, + fallback: UrlIcon([result.url], iconSize: 24), + ), + ) ?? + UrlIcon([result.url], iconSize: 24), ), - ), - subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) - ? Text.rich( - buildHighlightedText( - (bodyHasMatch ? content! : result.highlightedUrl!), - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.bold, - ), - _matchPrefix, - _matchSuffix, - normalizeWhitespaces: true, + title: result.title.mapNotNull( + (title) => Text.rich( + buildHighlightedText( + title, + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ) - : UriBreadcrumb(uri: result.url), - onTap: () async { - await ref - .read(tabRepositoryProvider.notifier) - .selectTab(result.id); - if (result.sourceSearchQuery.isNotEmpty && - ref.read(findInPageControllerProvider(result.id)) == - FindInPageState.hidden()) { + Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) + ? Text.rich( + buildHighlightedText( + (bodyHasMatch ? content! : result.highlightedUrl!), + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.bold, + ), + _matchPrefix, + _matchSuffix, + normalizeWhitespaces: true, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ) + : UriBreadcrumb(uri: result.url), + onTap: () async { await ref - .read(findInPageControllerProvider(result.id).notifier) - .findAll(text: result.sourceSearchQuery!); - } + .read(tabRepositoryProvider.notifier) + .selectTab(result.id); + if (result.sourceSearchQuery.isNotEmpty && + ref.read(findInPageControllerProvider(result.id)) == + FindInPageState.hidden()) { + await ref + .read(findInPageControllerProvider(result.id).notifier) + .findAll(text: result.sourceSearchQuery!); + } - if (context.mounted) { - ref - .read(bottomSheetControllerProvider.notifier) - .requestDismiss(); + if (context.mounted) { + ref + .read(bottomSheetControllerProvider.notifier) + .requestDismiss(); - const BrowserRoute().go(context); - } - }, - ); - }, - ), + const BrowserRoute().go(context); + } + }, + ); + }, + ), ], ); } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart b/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart index bf7b9eba..1d105323 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart @@ -206,6 +206,35 @@ class _TabbedBangSelector extends HookConsumerWidget { } } +/// Displays the default search provider as a chip. +class _DefaultSearchProviderChip extends ConsumerWidget { + const _DefaultSearchProviderChip(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final defaultBang = ref.watch(defaultSearchBangDataProvider); + + return defaultBang.when( + data: (bang) { + if (bang == null) { + return const SizedBox.shrink(); + } + + return ActionChip( + avatar: UrlIcon([bang.getDefaultUrl()], iconSize: 20), + label: Text(bang.websiteName), + onPressed: () async { + // Open bang search when tapped + await const BangSearchRoute().push(context); + }, + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + ); + } +} + /// The actual chip list with selection handling. class _BangChipsList extends HookConsumerWidget { /// The domain for this list's selection provider. @@ -253,17 +282,10 @@ class _BangChipsList extends HookConsumerWidget { onDeleted: (bang) => _handleDeletion(context, ref, bang), ), ) - else if (displayMenu) - Expanded( - child: Text( - "Press '>' to search Bangs.", - style: TextStyle( - color: Theme.of(context).hintColor, - fontStyle: FontStyle.italic, - ), - ), - ) - else + else if (displayMenu) ...[ + const _DefaultSearchProviderChip(), + const Spacer(), + ] else const Spacer(), if (displayMenu) IconButton( 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 3af92311..05cf8b03 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 @@ -370,6 +370,7 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { required String ellipsis, required int snippetLength, required String searchString, + int limit = 25, }) { final ftsQuery = db.buildFtsQuery(searchString); @@ -380,10 +381,12 @@ class TabDao extends DatabaseAccessor with $TabDaoMixin { beforeMatch: matchPrefix, afterMatch: matchSuffix, ellipsis: ellipsis, + limit: limit, ); } else { return db.definitionsDrift.queryTabsBasic( query: db.buildLikeQuery(searchString), + limit: limit, ); } } 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 d7f9319d..7a9f0ef8 100644 --- a/app/lib/features/geckoview/features/tabs/data/database/definitions.drift +++ b/app/lib/features/geckoview/features/tabs/data/database/definitions.drift @@ -163,8 +163,9 @@ queryTabsBasic WITH TabQueryResult: fts.title LIKE :query OR fts.url LIKE :query ORDER BY - weighted_rank ASC, - t.timestamp DESC; + weighted_rank ASC, + t.timestamp DESC + LIMIT :limit; queryTabsFullContent WITH TabQueryResult: WITH weights AS ( @@ -194,7 +195,8 @@ queryTabsFullContent WITH TabQueryResult: CROSS JOIN weights ORDER BY weighted_rank ASC, - t.timestamp DESC; + t.timestamp DESC + LIMIT :limit; tabTrees: WITH RECURSIVE descendants AS ( 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 7977b6e7..f855544b 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 @@ -2284,10 +2284,13 @@ class DefinitionsDrift extends i8.ModularAccessor { ).map((i0.QueryRow row) => row.read('_c0')); } - i0.Selectable queryTabsBasic({required String query}) { + i0.Selectable queryTabsBasic({ + required String query, + required int limit, + }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.is_private, 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)], + 'WITH weights AS (SELECT 10.0 AS title_weight, 5.0 AS url_weight) SELECT t.id, t.container_id, t.is_private, 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 LIMIT ?2', + variables: [i0.Variable(query), i0.Variable(limit)], readsFrom: {tab, tabFts}, ).map( (i0.QueryRow row) => i9.TabQueryResult( @@ -2310,15 +2313,17 @@ class DefinitionsDrift extends i8.ModularAccessor { required String ellipsis, required int snippetLength, required String query, + required int limit, }) { 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, t.container_id, t.is_private, 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, t.is_private, 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 LIMIT ?6', variables: [ i0.Variable(beforeMatch), i0.Variable(afterMatch), i0.Variable(ellipsis), i0.Variable(snippetLength), i0.Variable(query), + i0.Variable(limit), ], readsFrom: {tab, tabFts}, ).map( diff --git a/app/lib/features/geckoview/features/tabs/domain/entities/container_selection_result.dart b/app/lib/features/geckoview/features/tabs/domain/entities/container_selection_result.dart new file mode 100644 index 00000000..2d75dbe1 --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/domain/entities/container_selection_result.dart @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +sealed class ContainerSelectionResult { + const ContainerSelectionResult(); + + const factory ContainerSelectionResult.selected(String containerId) = + ContainerSelectionSelected; + + const factory ContainerSelectionResult.unassigned() = + ContainerSelectionUnassigned; +} + +final class ContainerSelectionSelected extends ContainerSelectionResult { + final String containerId; + + const ContainerSelectionSelected(this.containerId); +} + +final class ContainerSelectionUnassigned extends ContainerSelectionResult { + const ContainerSelectionUnassigned(); +} 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 26bcc65b..41f02c7f 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab.dart @@ -17,8 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:drift/drift.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.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'; @@ -57,7 +57,9 @@ class TabDataRepository extends _$TabDataRepository { .addTab( url: tabState.url, private: tabState.isPrivate, - container: Value(targetContainer), + containerSelection: TabContainerSelection.specific( + targetContainer, + ), // parentId defaults to null - breaks parent chain when changing contextual identity selectTab: selectedTabId == tabState.id, ); @@ -85,7 +87,7 @@ class TabDataRepository extends _$TabDataRepository { .addTab( url: tabState.url, private: tabState.isPrivate, - container: const Value(null), + containerSelection: const TabContainerSelection.unassigned(), // parentId defaults to null - breaks parent chain when removing contextual identity selectTab: selectedTabId == tabState.id, ); 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 cea300d3..2ea17f4f 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'c872509a70e4c0c029b468457e15c5e5bff10c65'; +String _$tabDataRepositoryHash() => r'c1bdfbb576379a7d2ac7b2fe310dbec090e7fd4d'; abstract class _$TabDataRepository extends $Notifier { void build(); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart index 21b63db1..5cbcb286 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.dart @@ -32,6 +32,7 @@ class TabSearchRepository extends _$TabSearchRepository { Future addQuery( String input, { int snippetLength = 120, + int maxResults = 25, String matchPrefix = '***', String matchSuffix = '***', String ellipsis = '…', @@ -49,6 +50,7 @@ class TabSearchRepository extends _$TabSearchRepository { ellipsis: ellipsis, snippetLength: snippetLength, searchString: input, + limit: maxResults, ) .get(), ); diff --git a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart index df366b4c..1268fa35 100644 --- a/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart +++ b/app/lib/features/geckoview/features/tabs/domain/repositories/tab_search.g.dart @@ -55,7 +55,7 @@ final class TabSearchRepositoryProvider } String _$tabSearchRepositoryHash() => - r'ac2381c692b9caf93f26f302d15e0160c098917a'; + r'473057d6a9f1e76d6b7fec13f710189d758bc924'; final class TabSearchRepositoryFamily extends $Family with diff --git a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart index bde4c003..79ef06a0 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/screens/container_selection.dart @@ -21,13 +21,16 @@ import 'dart:convert'; import 'package:fading_scroll/fading_scroll.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:uuid/enums.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/providers.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/presentation/widgets/container_list_tile.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; @@ -39,6 +42,8 @@ class ContainerSelectionScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final containersAsync = ref.watch(watchContainersWithCountProvider); + final selectedContainerId = ref.watch(selectedContainerProvider); + return Scaffold( appBar: AppBar(title: const Text('Select Container')), body: Skeletonizer( @@ -53,14 +58,42 @@ class ContainerSelectionScreen extends HookConsumerWidget { padding: EdgeInsets.only( bottom: floatingActionButtonBottomInset(context), ), - itemCount: containers.length, + itemCount: containers.length + 1, itemBuilder: (context, index) { - final container = containers[index]; + if (index == 0) { + return ListTileTheme( + selectedColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + selectedTileColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: ListTile( + selected: selectedContainerId == null, + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(MdiIcons.folderHidden), + ), + title: const Text('Unassigned'), + onTap: () { + context.pop( + const ContainerSelectionResult.unassigned(), + ); + }, + ), + ); + } + + final container = containers[index - 1]; return ContainerListTile( container, - isSelected: false, + isSelected: container.id == selectedContainerId, onTap: () { - context.pop(container.id); + context.pop( + ContainerSelectionResult.selected(container.id), + ); }, ); }, diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart new file mode 100644 index 00000000..d7046cea --- /dev/null +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/entities/container_selection_result.dart'; +import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart'; +import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart'; +import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart'; + +/// A compact container selector that displays only the currently selected +/// container (or "unassigned" if none selected) without counts. +/// Tapping opens the container selection screen. +/// Long pressing opens the edit screen for the selected container. +class CompactContainerSelector extends ConsumerWidget { + final ContainerData? selectedContainer; + + const CompactContainerSelector({super.key, this.selectedContainer}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final isSelected = selectedContainer != null; + + return GestureDetector( + onLongPress: isSelected + ? () async { + await ContainerEditRoute( + containerData: jsonEncode(selectedContainer!.toJson()), + ).push(context); + } + : null, + child: ActionChip( + avatar: isSelected ? null : const Icon(MdiIcons.folderHidden), + label: isSelected + ? ContainerTitle(container: selectedContainer!) + : const Text('Unassigned'), + backgroundColor: isSelected + ? ContainerColors.forChip(selectedContainer!.color) + : null, + side: isSelected ? BorderSide(color: theme.colorScheme.primary) : null, + onPressed: () async { + final selection = await const ContainerSelectionRoute() + .push(context); + + switch (selection) { + case ContainerSelectionSelected(:final containerId): + await ref + .read(selectedContainerProvider.notifier) + .setContainerId(containerId); + case ContainerSelectionUnassigned(): + ref.read(selectedContainerProvider.notifier).clearContainer(); + case null: + break; + } + }, + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart index 9314f17b..2e3f82c9 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart @@ -47,7 +47,6 @@ class ContainerListTile extends HookWidget { ), title: ContainerTitle(container: container), onTap: onTap, - trailing: const Icon(Icons.chevron_right), ), ); } diff --git a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart index 8425af02..70c2c2ed 100644 --- a/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart +++ b/app/lib/features/geckoview/features/tabs/presentation/widgets/container_title.dart @@ -36,7 +36,11 @@ class ContainerTitle extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (container.name.isNotEmpty) { - return Text(container.name!); + return Text( + container.name!, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); } final topicAsync = ref.watch(containerTopicProvider(container.id)); @@ -59,6 +63,8 @@ class ContainerTitle extends HookConsumerWidget { const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)), ], ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ) ?? Text( diff --git a/app/lib/features/onboarding/presentation/onboarding.dart b/app/lib/features/onboarding/presentation/onboarding.dart index 532304d9..0e479947 100644 --- a/app/lib/features/onboarding/presentation/onboarding.dart +++ b/app/lib/features/onboarding/presentation/onboarding.dart @@ -17,13 +17,13 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/router.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart'; @@ -148,7 +148,8 @@ class OnboardingScreen extends HookConsumerWidget { .addTab( url: ref.read(docsUriProvider), private: false, - container: const Value(null), + containerSelection: + const TabContainerSelection.unassigned(), selectTab: true, ); diff --git a/app/lib/features/sync/domain/repositories/sync.dart b/app/lib/features/sync/domain/repositories/sync.dart index fcf74cf5..d6928107 100644 --- a/app/lib/features/sync/domain/repositories/sync.dart +++ b/app/lib/features/sync/domain/repositories/sync.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:drift/drift.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/core/logger.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; @@ -282,7 +282,9 @@ class SyncRepository extends _$SyncRepository { url: uri, selectTab: true, private: false, - container: Value(assignedContainer), + containerSelection: assignedContainer == null + ? const TabContainerSelection.unassigned() + : TabContainerSelection.specific(assignedContainer), ); } diff --git a/app/lib/features/sync/domain/repositories/sync.g.dart b/app/lib/features/sync/domain/repositories/sync.g.dart index bb663e4f..3ac6be6c 100644 --- a/app/lib/features/sync/domain/repositories/sync.g.dart +++ b/app/lib/features/sync/domain/repositories/sync.g.dart @@ -336,7 +336,7 @@ final class SyncRepositoryProvider SyncRepository create() => SyncRepository(); } -String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312'; +String _$syncRepositoryHash() => r'5bfe6deacfea9b14981a0b9769857f23b7dd478c'; abstract class _$SyncRepository extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/features/web_feed/data/database/daos/article.dart b/app/lib/features/web_feed/data/database/daos/article.dart index 309c837a..47a30acb 100644 --- a/app/lib/features/web_feed/data/database/daos/article.dart +++ b/app/lib/features/web_feed/data/database/daos/article.dart @@ -148,6 +148,7 @@ class ArticleDao extends DatabaseAccessor with $ArticleDaoMixin { required int snippetLength, required String searchString, required Uri? feedId, + int limit = 25, }) { final ftsQuery = db.buildFtsQuery(searchString); @@ -159,11 +160,13 @@ class ArticleDao extends DatabaseAccessor with $ArticleDaoMixin { beforeMatch: matchPrefix, afterMatch: matchSuffix, ellipsis: ellipsis, + limit: limit, ); } else { return db.definitionsDrift.queryArticlesBasic( feedId: feedId?.toString(), query: db.buildLikeQuery(searchString), + limit: limit, ); } } diff --git a/app/lib/features/web_feed/data/database/definitions.drift b/app/lib/features/web_feed/data/database/definitions.drift index 445bdf38..5304aa93 100644 --- a/app/lib/features/web_feed/data/database/definitions.drift +++ b/app/lib/features/web_feed/data/database/definitions.drift @@ -102,7 +102,8 @@ queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: (:feed_id IS NULL OR a.feed_id = :feed_id) ORDER BY weighted_rank ASC, - a.created DESC NULLS LAST; + a.created DESC NULLS LAST + LIMIT :limit; queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: WITH weights AS ( @@ -132,4 +133,5 @@ queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: :feed_id IS NULL OR a.feed_id = :feed_id ORDER BY weighted_rank ASC, - a.created DESC NULLS LAST; \ No newline at end of file + a.created DESC NULLS LAST + LIMIT :limit; diff --git a/app/lib/features/web_feed/data/database/definitions.drift.dart b/app/lib/features/web_feed/data/database/definitions.drift.dart index 69b36ad0..51dcdb84 100644 --- a/app/lib/features/web_feed/data/database/definitions.drift.dart +++ b/app/lib/features/web_feed/data/database/definitions.drift.dart @@ -2644,10 +2644,15 @@ class DefinitionsDrift extends i10.ModularAccessor { i0.Selectable queryArticlesBasic({ required String query, String? feedId, + required int limit, }) { return customSelect( - 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', - variables: [i0.Variable(query), i0.Variable(feedId)], + 'WITH weights AS (SELECT 1.0 AS title_weight) SELECT a.*, f.icon,(bm25(article_fts, weights.title_weight))AS weighted_rank FROM article_fts AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE fts.title LIKE ?1 AND(?2 IS NULL OR a.feed_id = ?2)ORDER BY weighted_rank ASC, a.created DESC NULLS LAST LIMIT ?3', + variables: [ + i0.Variable(query), + i0.Variable(feedId), + i0.Variable(limit), + ], readsFrom: {feed, articleFts, article}, ).map( (i0.QueryRow row) => i11.FeedArticleQueryResult( @@ -2691,9 +2696,10 @@ class DefinitionsDrift extends i10.ModularAccessor { required int snippetLength, required String query, String? feedId, + required int limit, }) { return customSelect( - 'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST', + 'WITH weights AS (SELECT 10.0 AS title_weight, 3.0 AS summary_weight, 1.0 AS content_weight) SELECT a.*, f.icon, highlight(article_fts, 0, ?1, ?2) AS title_highlight, snippet(article_fts, 1, ?1, ?2, ?3, ?4) AS summary_snippet, snippet(article_fts, 2, ?1, ?2, ?3, ?4) AS content_snippet,(bm25(article_fts, weights.title_weight, weights.summary_weight, weights.content_weight))AS weighted_rank FROM article_fts(?5)AS fts INNER JOIN article AS a ON a."rowid" = fts."rowid" INNER JOIN feed AS f ON f.url = a.feed_id CROSS JOIN weights WHERE ?6 IS NULL OR a.feed_id = ?6 ORDER BY weighted_rank ASC, a.created DESC NULLS LAST LIMIT ?7', variables: [ i0.Variable(beforeMatch), i0.Variable(afterMatch), @@ -2701,6 +2707,7 @@ class DefinitionsDrift extends i10.ModularAccessor { i0.Variable(snippetLength), i0.Variable(query), i0.Variable(feedId), + i0.Variable(limit), ], readsFrom: {feed, articleFts, article}, ).map( diff --git a/app/lib/features/web_feed/domain/providers.dart b/app/lib/features/web_feed/domain/providers.dart index 2607f4f5..123bc11f 100644 --- a/app/lib/features/web_feed/domain/providers.dart +++ b/app/lib/features/web_feed/domain/providers.dart @@ -39,6 +39,7 @@ class ArticleSearch extends _$ArticleSearch { Future search( String input, { int snippetLength = 120, + int maxResults = 25, String matchPrefix = '***', String matchSuffix = '***', String ellipsis = '…', @@ -54,6 +55,7 @@ class ArticleSearch extends _$ArticleSearch { snippetLength: snippetLength, searchString: input, feedId: feedId, + limit: maxResults, ) .get() .then((value) { diff --git a/app/lib/features/web_feed/domain/providers.g.dart b/app/lib/features/web_feed/domain/providers.g.dart index 8046a171..79b6dc6b 100644 --- a/app/lib/features/web_feed/domain/providers.g.dart +++ b/app/lib/features/web_feed/domain/providers.g.dart @@ -50,7 +50,7 @@ final class ArticleSearchProvider } } -String _$articleSearchHash() => r'cc59a5d3c4b926db9f03ed96db592965ff925b04'; +String _$articleSearchHash() => r'48ed3baa560c0e731626f79a8f6ff3dab1e9bc95'; final class ArticleSearchFamily extends $Family with diff --git a/app/lib/features/web_feed/presentation/screens/feed_article.dart b/app/lib/features/web_feed/presentation/screens/feed_article.dart index 4eae946a..4cf3c5b1 100644 --- a/app/lib/features/web_feed/presentation/screens/feed_article.dart +++ b/app/lib/features/web_feed/presentation/screens/feed_article.dart @@ -18,7 +18,6 @@ * along with this program. If not, see . */ import 'package:collection/collection.dart'; -import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; @@ -26,6 +25,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nullability/nullability.dart'; import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/geckoview/domain/entities/tab_container_selection.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; @@ -206,7 +206,8 @@ class FeedArticleScreen extends HookConsumerWidget { .addTab( url: articleLink.uri, private: isPrivate, - container: const Value(null), + containerSelection: + const TabContainerSelection.unassigned(), selectTab: true, );