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,
);