refactor container selection pattern and improve search UI

This commit is contained in:
Fabian Freund
2026-02-25 15:47:38 +01:00
parent 702b4b27ca
commit dc5f7902ab
45 changed files with 1713 additions and 735 deletions
@@ -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 <http://www.gnu.org/licenses/>.
*/
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);
}
@@ -26,6 +26,7 @@ import 'package:nullability/nullability.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/features/geckoview/domain/entities/states/tab.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.dart';
import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart'; import 'package:weblibre/features/geckoview/domain/providers/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart'; import 'package:weblibre/features/geckoview/domain/providers/tab_list.dart';
@@ -87,20 +88,22 @@ class TabRepository extends _$TabRepository {
required bool private, required bool private,
HistoryMetadataKey? historyMetadata, HistoryMetadataKey? historyMetadata,
Map<String, String>? additionalHeaders, Map<String, String>? additionalHeaders,
Value<ContainerData?>? container, TabContainerSelection containerSelection =
const TabContainerSelection.useSelected(),
bool launchedFromIntent = false, bool launchedFromIntent = false,
}) async { }) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao; final tabDao = ref.read(tabDatabaseProvider).tabDao;
final assingedContainer = final assignedContainer = switch (containerSelection) {
container ?? UseSelectedContainerTabSelection() =>
Value<ContainerData?>( await ref.read(selectedContainerProvider.notifier).fetchData(),
await ref.read(selectedContainerProvider.notifier).fetchData(), UnassignedContainerTabSelection() => null,
); SpecificContainerTabSelection(:final container) => container,
};
final validatedParentId = await _resolveParentIdForContext( final validatedParentId = await _resolveParentIdForContext(
parentId: parentId, parentId: parentId,
targetContextId: assingedContainer.value?.metadata.contextualIdentity, targetContextId: assignedContainer?.metadata.contextualIdentity,
); );
final newTabId = await tabDao.upsertTabTransactional( final newTabId = await tabDao.upsertTabTransactional(
@@ -111,7 +114,7 @@ class TabRepository extends _$TabRepository {
startLoading: startLoading, startLoading: startLoading,
parentId: validatedParentId, parentId: validatedParentId,
flags: flags, flags: flags,
contextId: assingedContainer.value?.metadata.contextualIdentity, contextId: assignedContainer?.metadata.contextualIdentity,
source: source, source: source,
private: private, private: private,
historyMetadata: historyMetadata, historyMetadata: historyMetadata,
@@ -119,7 +122,7 @@ class TabRepository extends _$TabRepository {
); );
}, },
parentId: Value(validatedParentId), parentId: Value(validatedParentId),
containerId: Value(assingedContainer.value?.id), containerId: Value(assignedContainer?.id),
isPrivate: Value(private), isPrivate: Value(private),
url: Value(url), url: Value(url),
); );
@@ -134,10 +137,17 @@ class TabRepository extends _$TabRepository {
Future<List<String>> addMultipleTabs({ Future<List<String>> addMultipleTabs({
required List<AddTabParams> tabs, required List<AddTabParams> tabs,
String? selectTabId, String? selectTabId,
Value<ContainerData?>? container, TabContainerSelection containerSelection =
const TabContainerSelection.unassigned(),
}) async { }) async {
final tabDao = ref.read(tabDatabaseProvider).tabDao; final tabDao = ref.read(tabDatabaseProvider).tabDao;
final db = ref.read(tabDatabaseProvider); 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 { return await db.transaction(() async {
final createdTabIds = await _tabsService.addMultipleTabs( final createdTabIds = await _tabsService.addMultipleTabs(
@@ -177,7 +187,7 @@ class TabRepository extends _$TabRepository {
tabId, tabId,
parentId: Value(validatedParentId), parentId: Value(validatedParentId),
source: TabSource.manual, source: TabSource.manual,
containerId: Value(container?.value?.id), containerId: Value(assignedContainer?.id),
isPrivate: Value(tab.private), isPrivate: Value(tab.private),
url: Value(Uri.tryParse(tab.url)), url: Value(Uri.tryParse(tab.url)),
); );
@@ -466,7 +476,9 @@ class TabRepository extends _$TabRepository {
await addTab( await addTab(
url: uri, url: uri,
private: tabState.isPrivate, private: tabState.isPrivate,
container: Value(containerData), containerSelection: TabContainerSelection.specific(
containerData,
),
parentId: tabState.id, parentId: tabState.id,
selectTab: true, selectTab: true,
); );
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
} }
} }
String _$tabRepositoryHash() => r'0a676c0513917e8853677ebd592954bde0f15f57'; String _$tabRepositoryHash() => r'd77e4e74bb7ee1b466172bca245b05c6bf03196c';
abstract class _$TabRepository extends $Notifier<void> { abstract class _$TabRepository extends $Notifier<void> {
void build(); void build();
@@ -20,7 +20,6 @@
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.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.dart';
import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart'; import 'package:weblibre/features/geckoview/domain/providers/web_extensions_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
@@ -329,7 +329,8 @@ class _ExtensionsSection extends HookConsumerWidget {
.addTab( .addTab(
url: Uri.parse('https://addons.mozilla.org'), url: Uri.parse('https://addons.mozilla.org'),
private: isPrivate, private: isPrivate,
container: const Value(null), containerSelection:
const TabContainerSelection.unassigned(),
selectTab: true, selectTab: true,
); );
}, },
@@ -19,7 +19,6 @@
*/ */
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -30,6 +29,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/desktop_mode.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/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/controllers/readerable.dart';
import 'package:weblibre/features/geckoview/features/readerview/presentation/widgets/reader_button.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/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.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
@@ -239,7 +240,11 @@ class TabMenu extends HookConsumerWidget {
.addTab( .addTab(
url: tabState.url, url: tabState.url,
private: false, private: false,
container: Value(containerData), containerSelection: containerData == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(
containerData,
),
selectTab: false, selectTab: false,
) )
: await ref : await ref
@@ -278,7 +283,11 @@ class TabMenu extends HookConsumerWidget {
.addTab( .addTab(
url: tabState.url, url: tabState.url,
private: true, private: true,
container: Value(containerData), containerSelection: containerData == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(
containerData,
),
selectTab: false, selectTab: false,
) )
: await ref : await ref
@@ -313,25 +322,34 @@ class TabMenu extends HookConsumerWidget {
leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline), leadingIcon: const Icon(MdiIcons.folderArrowUpDownOutline),
child: const Text('Assign Container'), child: const Text('Assign Container'),
onPressed: () async { onPressed: () async {
final targetContainerId = final selection = await const ContainerSelectionRoute()
await const ContainerSelectionRoute().push<String?>( .push<ContainerSelectionResult?>(context);
context,
);
if (targetContainerId != null) { switch (selection) {
final containerData = await ref case ContainerSelectionSelected(:final containerId):
.read(containerRepositoryProvider.notifier) final containerData = await ref
.getContainerData(targetContainerId); .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( final tabState = ref.read(
tabStateProvider(selectedTabId), tabStateProvider(selectedTabId),
)!; )!;
await ref await ref
.read(tabDataRepositoryProvider.notifier) .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), leadingIcon: const Icon(MdiIcons.webPlus),
child: const Text('URL relation'), child: const Text('URL relation'),
onPressed: () async { onPressed: () async {
final targetContainerId = final selection = await const ContainerSelectionRoute()
await const ContainerSelectionRoute().push<String?>( .push<ContainerSelectionResult?>(context);
context,
);
if (targetContainerId != null) { if (selection case ContainerSelectionSelected(
:final containerId,
)) {
final containerData = await ref final containerData = await ref
.read(containerRepositoryProvider.notifier) .read(containerRepositoryProvider.notifier)
.getContainerData(targetContainerId); .getContainerData(containerId);
if (containerData != null) { if (containerData != null) {
final tabState = ref.read( final tabState = ref.read(
@@ -20,7 +20,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/logger.dart'; import 'package:weblibre/core/logger.dart';
import 'package:weblibre/core/routing/routes.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.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
@@ -662,9 +662,10 @@ class TabViewHeader extends HookConsumerWidget {
.contextualIdentity, .contextualIdentity,
); );
}).toList(), }).toList(),
container: Value( containerSelection:
selectedContainer, TabContainerSelection.specific(
), selectedContainer,
),
); );
} }
@@ -19,7 +19,6 @@
*/ */
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart' hide Column;
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:skeletonizer/skeletonizer.dart';
import 'package:uuid/enums.dart'; import 'package:uuid/enums.dart';
import 'package:weblibre/core/routing/routes.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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart'; import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
@@ -103,7 +103,9 @@ class OpenInContainer extends HookConsumerWidget {
parentId: currentTab?.id, parentId: currentTab?.id,
selectTab: false, selectTab: false,
private: isPrivate, private: isPrivate,
container: Value(selectedContainer), containerSelection: TabContainerSelection.specific(
selectedContainer,
),
); );
if (context.mounted) { if (context.mounted) {
@@ -19,7 +19,6 @@
*/ */
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_material_design_icons/flutter_material_design_icons.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.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/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/entities/url_cleaner_result.dart';
import 'package:weblibre/features/geckoview/features/open_link_tools/domain/services/url_cleaner_catalog_service.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( .addTab(
url: Uri.parse(textController.text), url: Uri.parse(textController.text),
private: isPrivate, private: isPrivate,
container: Value(selectedContainer.value), containerSelection: selectedContainer.value == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(selectedContainer.value!),
launchedFromIntent: true, launchedFromIntent: true,
selectTab: true, selectTab: true,
); );
@@ -75,6 +75,7 @@ AsyncValue<List<GeckoSuggestion>> engineHistorySuggestions(Ref ref) {
) != ) !=
null), null),
) )
.take(25)
.toList(), .toList(),
), ),
), ),
@@ -107,4 +107,4 @@ final class EngineHistorySuggestionsProvider
} }
String _$engineHistorySuggestionsHash() => String _$engineHistorySuggestionsHash() =>
r'7e6c17e6a2df98098ca1a683a438d1c261ccabdf'; r'e78f87f67da9184115991ee93a25da8039e36293';
@@ -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 <http://www.gnu.org/licenses/>.
*/
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;
}
}
@@ -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<SearchModuleDisplayState>(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<SearchModuleDisplayState> {
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<SearchModuleDisplayState, SearchModuleDisplayState>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<SearchModuleDisplayState, SearchModuleDisplayState>,
SearchModuleDisplayState,
Object?,
Object?
>;
element.handleCreate(ref, () => build(_$args));
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<dynamic>).first as bool,
);
return stateOrNull ?? true;
}
}
@@ -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<SearchSuggestionsExpanded, bool> {
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<bool>(value),
);
}
}
String _$searchSuggestionsExpandedHash() =>
r'520bcdb9ef624c2ebe5a570d4dab034de5b4f475';
abstract class _$SearchSuggestionsExpanded extends $Notifier<bool> {
bool build();
@$mustCallSuper
@override
void runBuild() {
final ref = this.ref as $Ref<bool, bool>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<bool, bool>,
bool,
Object?,
Object?
>;
element.handleCreate(ref, build);
}
}
@@ -19,22 +19,21 @@
*/ */
import 'dart:async'; 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.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:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/core/design/app_colors.dart'; import 'package:weblibre/core/design/app_colors.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/bangs/domain/providers/bangs.dart'; import 'package:weblibre/features/bangs/domain/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/providers/search.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/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/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_session.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/providers/tab_state.dart';
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart'; import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/browser/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/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_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.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/history_suggestions.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/tab_search.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/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/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart'; import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
import 'package:weblibre/presentation/hooks/sampled_value_notifier.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/text_field_line_count.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser; import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
@@ -248,7 +246,9 @@ class SearchScreen extends HookConsumerWidget {
: null, : null,
launchedFromIntent: launchedFromIntent, launchedFromIntent: launchedFromIntent,
selectTab: true, selectTab: true,
container: Value(selectedContainer), containerSelection: selectedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(selectedContainer),
); );
} }
@@ -264,252 +264,213 @@ class SearchScreen extends HookConsumerWidget {
body: SafeArea( body: SafeArea(
child: Form( child: Form(
key: formKey, key: formKey,
child: FadingScroll( child: CustomScrollView(
builder: (context, controller) { slivers: [
return CustomScrollView( SliverAppBar(
controller: controller, floating: true,
slivers: [ pinned: true,
SliverAppBar( automaticallyImplyLeading: false,
floating: true, toolbarHeight: isEditMode ? 0 : kToolbarHeight,
pinned: true, titleSpacing: 0.0,
automaticallyImplyLeading: false, title: isEditMode
toolbarHeight: isEditMode ? 0 : kToolbarHeight + 56, ? null
titleSpacing: 0.0, : Padding(
title: isEditMode padding: const EdgeInsets.symmetric(horizontal: 16.0),
? null child: Row(
: Column( children: [
mainAxisSize: MainAxisSize.min, Expanded(
children: [ flex: 3,
Focus( child: Align(
canRequestFocus: false, alignment: Alignment.centerLeft,
child: SegmentedButton( child: Focus(
showSelectedIcon: false, canRequestFocus: false,
segments: [ child: AnimatedTabTypeSwitcher(
const ButtonSegment( selected: selectedTabType.value,
value: TabType.regular, onChanged: (value) {
label: Text('Regular'), selectedTabType.value = value;
icon: Icon(MdiIcons.tab), // Restore focus to search field after segment change
), WidgetsBinding.instance
const ButtonSegment( .addPostFrameCallback((_) {
value: TabType.private, searchFocusNode.requestFocus();
label: Text('Private'), });
icon: Icon(WebLibreIcons.privateTab), },
), showChildOption: createChildTabsOption,
if (createChildTabsOption) selectedBackgroundColor:
const ButtonSegment( switch (selectedTabType.value) {
value: TabType.child, TabType.regular => null,
label: Text('Child'), TabType.private =>
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:
appColors.privateSelectionOverlay, appColors.privateSelectionOverlay,
), TabType.child =>
TabType.child => (currentTabTabType ==
(currentTabTabType == TabType.private) TabType.private)
? SegmentedButton.styleFrom( ? appColors
selectedBackgroundColor: appColors .privateSelectionOverlay
.privateSelectionOverlay, : null,
) },
: 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();
},
), ),
), ),
), ),
], ),
), const SizedBox(width: 16),
bottom: PreferredSize( Flexible(
preferredSize: Size.fromHeight(preferredHeight.value), flex: 2,
child: Padding( child: Align(
padding: const EdgeInsets.only(left: 16.0), alignment: Alignment.centerRight,
child: SearchField( child: CompactContainerSelector(
textFieldKey: textFieldKey, selectedContainer: selectedContainer,
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,
), ),
), ),
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), ),
), SliverToBoxAdapter(
const SliverToBoxAdapter(child: Divider()), child: ClipboardFillLink(controller: searchTextController),
FullSearchTermSuggestions( ),
searchTextController: searchTextController, FullSearchTermSuggestions(
activeBang: activeBang, searchTextController: searchTextController,
submitSearch: submitSearch, activeBang: activeBang,
domain: isEditMode ? existingTabState.url.host : null, submitSearch: submitSearch,
), domain: isEditMode ? existingTabState.url.host : null,
TabSearch(searchTextListenable: sampledSearchText), ),
FeedSearch(searchTextNotifier: sampledSearchText), TabSearch(searchTextListenable: sampledSearchText),
HistorySuggestions( FeedSearch(searchTextNotifier: sampledSearchText),
searchTextListenable: sampledSearchText, HistorySuggestions(
onUriSelected: (uri) async { searchTextListenable: sampledSearchText,
if (isEditMode) { onUriSelected: (uri) async {
// Load into existing tab if (isEditMode) {
await ref // Load into existing tab
.read(tabSessionProvider(tabId: tabId).notifier) await ref
.loadUrl(url: uri); .read(tabSessionProvider(tabId: tabId).notifier)
} else { .loadUrl(url: uri);
// Create new tab } else {
await ref // Create new tab
.read(tabRepositoryProvider.notifier) await ref
.addTab( .read(tabRepositoryProvider.notifier)
url: uri, .addTab(
private: privateTabMode, url: uri,
parentId: (selectedTabType.value == TabType.child) private: privateTabMode,
? ref.read(selectedTabProvider) parentId: (selectedTabType.value == TabType.child)
: null, ? ref.read(selectedTabProvider)
launchedFromIntent: launchedFromIntent, : null,
selectTab: true, launchedFromIntent: launchedFromIntent,
container: Value(selectedContainer), selectTab: true,
); containerSelection: selectedContainer == null
} ? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(
selectedContainer,
),
);
}
if (context.mounted) { if (context.mounted) {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.requestDismiss(); .requestDismiss();
const BrowserRoute().go(context); const BrowserRoute().go(context);
} }
}, },
), ),
], ],
);
},
), ),
), ),
), ),
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<TabType> 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(),
),
],
),
),
);
}
}
@@ -23,10 +23,11 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/core/providers/format.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/extensions/uri.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_article_query_result.dart';
import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
import 'package:weblibre/features/web_feed/domain/providers.dart'; import 'package:weblibre/features/web_feed/domain/providers.dart';
@@ -49,6 +50,7 @@ class FeedSearch extends HookConsumerWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final articlesAsync = ref.watch(articleSearchProvider(null)); final articlesAsync = ref.watch(articleSearchProvider(null));
final totalResults = articlesAsync.value?.length ?? 0;
useOnListenableChange(searchTextNotifier, () async { useOnListenableChange(searchTextNotifier, () async {
await ref await ref
@@ -66,153 +68,159 @@ class FeedSearch extends HookConsumerWidget {
return const SliverToBoxAdapter(child: SizedBox.shrink()); return const SliverToBoxAdapter(child: SizedBox.shrink());
} }
return MultiSliver( return SearchModuleSection(
children: [ title: 'Articles',
const SliverToBoxAdapter(child: Divider()), moduleType: SearchModuleType.articles,
SliverToBoxAdapter( totalCount: totalResults,
child: Padding( contentSliverBuilder:
padding: const EdgeInsets.only(left: 16.0), ({required bool isCollapsed, required int visibleCount}) => [
child: Column( SliverSkeletonizer(
crossAxisAlignment: CrossAxisAlignment.start, enabled: articlesAsync.isLoading,
children: [ child: articlesAsync.when(
Text('Articles', style: Theme.of(context).textTheme.labelSmall), skipLoadingOnReload: true,
], data: (articles) {
), return SliverList.builder(
), itemCount: visibleCount,
), itemBuilder: (context, index) {
SliverSkeletonizer( final article = articles[index];
enabled: articlesAsync.isLoading,
child: articlesAsync.when(
skipLoadingOnReload: true,
data: (articles) => SliverList.builder(
itemCount: articles.length,
itemBuilder: (context, index) {
final article = articles[index];
final titleHighlight = switch (article) { final titleHighlight = switch (article) {
final FeedArticleQueryResult result => final FeedArticleQueryResult result =>
result.titleHighlight.whenNotEmpty, result.titleHighlight.whenNotEmpty,
_ => null, _ => null,
}; };
final searchSnippet = switch (article) { final searchSnippet = switch (article) {
final FeedArticleQueryResult result => final FeedArticleQueryResult result =>
result.summarySnippet.whenNotEmpty ?? result.summarySnippet.whenNotEmpty ??
result.contentSnippet.whenNotEmpty, result.contentSnippet.whenNotEmpty,
_ => null, _ => null,
}; };
final articleDate = article.updated ?? article.created; final articleDate = article.updated ?? article.created;
return ListTile( return ListTile(
leading: RepaintBoundary( leading: RepaintBoundary(
child: UrlIcon([ child: UrlIcon([
article.icon ?? article.icon ??
article.links article.links
?.getRelation(FeedLinkRelation.alternate) ?.getRelation(FeedLinkRelation.alternate)
?.uri ?? ?.uri ??
article.siteLink ?? article.siteLink ??
article.feedId.base, article.feedId.base,
], iconSize: 24.0), ], 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,
), ),
subtitle: Column( title: (titleHighlight.isNotEmpty)
crossAxisAlignment: CrossAxisAlignment.start, ? Text.rich(
children: [ buildHighlightedText(
if (searchSnippet.isNotEmpty) titleHighlight!,
Text.rich( Theme.of(
buildHighlightedText( context,
searchSnippet!, ).textTheme.titleMedium?.copyWith(
Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(
color: Theme.of( context,
context, ).colorScheme.onSurface,
).colorScheme.onSurfaceVariant, ),
), Theme.of(
Theme.of(context).textTheme.bodyMedium?.copyWith( context,
color: Theme.of( ).textTheme.titleMedium?.copyWith(
context, color: Theme.of(
).colorScheme.onSurfaceVariant, context,
fontWeight: FontWeight.bold, ).colorScheme.onSurface,
), fontWeight: FontWeight.bold,
_matchPrefix, ),
_matchSuffix, _matchPrefix,
normalizeWhitespaces: true, _matchSuffix,
), ),
maxLines: 3, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
else : Text(
(article.summaryPlain != null) article.displayTitle,
? Text( style: theme.textTheme.titleMedium,
article.summaryPlain!, maxLines: 2,
style: theme.textTheme.bodySmall, 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, maxLines: 3,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
) )
: const SizedBox.shrink(), else
if (articleDate != null) (article.summaryPlain != null)
Align( ? Text(
alignment: Alignment.topRight, article.summaryPlain!,
child: Text( style: theme.textTheme.bodySmall,
ref maxLines: 3,
.read(formatProvider.notifier) overflow: TextOverflow.ellipsis,
.fullDateTime(articleDate), )
style: theme.textTheme.bodySmall?.copyWith( : const SizedBox.shrink(),
fontStyle: FontStyle.italic, if (articleDate != null)
), Align(
maxLines: 1, alignment: Alignment.topRight,
overflow: TextOverflow.ellipsis, child: Text(
), ref
.read(formatProvider.notifier)
.fullDateTime(articleDate),
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
), ),
], onTap: () {
), FeedArticleRoute(
onTap: () { articleId: article.id,
FeedArticleRoute( ).pushReplacement(context);
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());
},
), ),
), ],
),
],
); );
} }
} }
@@ -87,7 +87,7 @@ class FixedSearchTermSuggestions extends HookConsumerWidget {
searchTextController.text = query; searchTextController.text = query;
}, },
child: InputChip( child: InputChip(
// avatar: const Icon(Icons.search), avatar: const Icon(Icons.search),
label: Text(query), label: Text(query),
onSelected: (value) async { onSelected: (value) async {
if (value) { if (value) {
@@ -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/providers/bangs.dart';
import 'package:weblibre/features/bangs/domain/repositories/data.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.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'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/smart_bang_selector.dart';
class FullSearchTermSuggestions extends HookConsumerWidget { class FullSearchTermSuggestions extends HookConsumerWidget {
@@ -55,8 +56,8 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
); );
final searchSuggestions = ref.watch(searchSuggestionsProvider()); final searchSuggestions = ref.watch(searchSuggestionsProvider());
final searchHistory = ref.watch(searchHistoryProvider); final searchHistory = ref.watch(searchHistoryProvider);
final expanded = ref.watch(searchSuggestionsExpandedProvider);
useOnListenableChange(searchTextController, () { useOnListenableChange(searchTextController, () {
ref ref
@@ -64,46 +65,50 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
.addQuery(searchTextController.text); .addQuery(searchTextController.text);
}); });
final MultiSliver listSliver; Widget buildSuggestionChip(
String query, {
Widget? avatar,
Future<void> 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<Widget> suggestionChips;
if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) { if (!searchTextIsNotEmpty && (searchHistory.value.isNotEmpty)) {
final entries = searchHistory.value!; final entries = searchHistory.value!;
listSliver = MultiSliver( suggestionChips = entries.map((entry) {
children: [ final query = entry.searchQuery;
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Wrap(
spacing: 8.0,
children: entries.map((entry) {
final query = entry.searchQuery;
return InkWell( return buildSuggestionChip(
onLongPress: () { query,
searchTextController.text = query; avatar: const Icon(Icons.history),
}, onDelete: () async {
child: InputChip( await ref
avatar: const Icon(Icons.history), .read(bangDataRepositoryProvider.notifier)
label: Text(query), .removeSearchEntry(query);
onSelected: (value) async { },
if (value) { );
await submitSearch(query); }).toList();
}
},
onDeleted: () async {
await ref
.read(bangDataRepositoryProvider.notifier)
.removeSearchEntry(query);
},
),
);
}).toList(),
),
),
),
],
);
} else { } else {
final prioritizedSuggestions = [ final prioritizedSuggestions = [
if (searchTextIsNotEmpty) searchTextController.text, if (searchTextIsNotEmpty) searchTextController.text,
@@ -113,59 +118,20 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
), ),
]; ];
listSliver = MultiSliver( suggestionChips = prioritizedSuggestions.map((query) {
children: [ return buildSuggestionChip(query);
SliverToBoxAdapter( }).toList();
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(),
),
),
),
],
);
} }
return MultiSliver( final toggleButton = IconButton(
children: [ onPressed: () {
SliverToBoxAdapter( ref.read(searchSuggestionsExpandedProvider.notifier).toggle();
child: Padding( },
padding: const EdgeInsets.only(left: 16.0), icon: Icon(expanded ? Icons.unfold_less : Icons.unfold_more),
child: Column( );
crossAxisAlignment: CrossAxisAlignment.start,
children: [ final suggestionsContent = expanded
if (domain == null) ? Padding(
Text(
'Search Provider',
style: Theme.of(context).textTheme.labelSmall,
),
SmartBangSelector(
domain: domain,
searchTextController: searchTextController,
),
],
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 150), constraints: const BoxConstraints(maxHeight: 150),
@@ -175,11 +141,60 @@ class FullSearchTermSuggestions extends HookConsumerWidget {
return CustomScrollView( return CustomScrollView(
shrinkWrap: true, shrinkWrap: true,
controller: controller, 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,
),
],
), ),
), ),
], ],
@@ -24,8 +24,9 @@ import 'package:flutter_material_design_icons/flutter_material_design_icons.dart
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:skeletonizer/skeletonizer.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/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/features/geckoview/utils/image_helper.dart';
import 'package:weblibre/presentation/hooks/cached_future.dart'; import 'package:weblibre/presentation/hooks/cached_future.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart';
@@ -45,6 +46,7 @@ class HistorySuggestions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider); final historySuggestionsAsync = ref.watch(engineHistorySuggestionsProvider);
final totalResults = historySuggestionsAsync.value?.length ?? 0;
useOnListenableChange(searchTextListenable, () async { useOnListenableChange(searchTextListenable, () async {
if (ref.exists(engineSuggestionsProvider)) { if (ref.exists(engineSuggestionsProvider)) {
@@ -59,90 +61,89 @@ class HistorySuggestions extends HookConsumerWidget {
return const SliverToBoxAdapter(child: SizedBox.shrink()); return const SliverToBoxAdapter(child: SizedBox.shrink());
} }
return MultiSliver( return SearchModuleSection(
children: [ title: 'History',
const SliverToBoxAdapter(child: Divider()), moduleType: SearchModuleType.history,
SliverToBoxAdapter( totalCount: totalResults,
child: Padding( contentSliverBuilder:
padding: const EdgeInsets.only(left: 16.0), ({required bool isCollapsed, required int visibleCount}) => [
child: Text( SliverSkeletonizer(
'History', enabled: historySuggestionsAsync.isLoading,
style: Theme.of(context).textTheme.labelSmall, child: historySuggestionsAsync.when(
), skipLoadingOnReload: true,
), data: (historySuggestions) {
), return SliverList.builder(
SliverSkeletonizer( itemCount: visibleCount,
enabled: historySuggestionsAsync.isLoading, itemBuilder: (context, index) {
child: historySuggestionsAsync.when( final suggestion = historySuggestions[index];
skipLoadingOnReload: true, final uri = suggestion.description.mapNotNull(
data: (historySuggestions) { Uri.tryParse,
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 ListTile( return HookBuilder(
leading: RepaintBoundary( key: ValueKey(suggestion.id),
child: SafeRawImage( builder: (context) {
image: icon.data, final icon = useCachedFuture(
height: 24, () async =>
width: 24, suggestion.icon.mapNotNull(tryDecodeImage),
fallback: const Icon(MdiIcons.web, size: 24), [suggestion.description, suggestion.icon],
), );
),
title: suggestion.title.mapNotNull( return ListTile(
(title) => Text( leading: RepaintBoundary(
title, child: SafeRawImage(
maxLines: 2, image: icon.data,
overflow: TextOverflow.ellipsis, height: 24,
), width: 24,
), fallback: const Icon(MdiIcons.web, size: 24),
subtitle: ),
uri.mapNotNull((uri) => UriBreadcrumb(uri: uri)) ?? ),
suggestion.description.mapNotNull( title: suggestion.title.mapNotNull(
(description) => Text( (title) => Text(
description, title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
onTap: () { subtitle:
if (uri != null) { uri.mapNotNull(
onUriSelected(uri); (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(
error: (error, stackTrace) { child: FailureWidget(
return SliverToBoxAdapter( title: 'Could not load history',
child: FailureWidget( exception: error,
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());
},
), ),
), ],
),
],
); );
} }
} }
@@ -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 <http://www.gnu.org/licenses/>.
*/
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,
),
],
),
),
],
),
);
}
}
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<Widget> 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,
),
],
);
}
}
@@ -26,13 +26,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nullability/nullability.dart'; import 'package:nullability/nullability.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/core/routing/routes.dart';
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.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/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.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/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/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/entities/container_filter.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.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'; 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 const SliverToBoxAdapter(child: SizedBox.shrink());
} }
return MultiSliver( final filteredResultCount = filteredTabs.length;
children: [
const SliverToBoxAdapter(child: Divider()), return SearchModuleSection(
SliverToBoxAdapter( title: 'Tabs',
child: Padding( moduleType: SearchModuleType.tabs,
padding: const EdgeInsets.only(left: 16.0), totalCount: filteredResultCount,
child: Column( contentSliverBuilder: ({
crossAxisAlignment: CrossAxisAlignment.start, required bool isCollapsed,
children: [ required int visibleCount,
Text('Tabs', style: Theme.of(context).textTheme.labelSmall), }) => [
ContainerChips( if (!isCollapsed)
displayMenu: false, SliverToBoxAdapter(
selectedContainer: selectedContainer.value, child: Padding(
showUnassignedChip: containerIdsWithResults.value.containsKey( padding: const EdgeInsets.symmetric(horizontal: 16.0),
null, 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,
),
],
), ),
), ),
), if (!isCollapsed)
SliverList.builder( SliverList.builder(
itemCount: filteredTabs.length, itemCount: visibleCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final result = filteredTabs[index]; final result = filteredTabs[index];
final content = final content =
(result.extractedContent?.contains(_matchPrefix) == true) (result.extractedContent?.contains(_matchPrefix) == true)
? result.extractedContent ? result.extractedContent
: result.fullContent; : result.fullContent;
final titleHasMatch = result.title.contains(_matchPrefix); final titleHasMatch = result.title.contains(_matchPrefix);
final urlHasMatch = final urlHasMatch =
result.highlightedUrl?.contains(_matchPrefix) ?? false; result.highlightedUrl?.contains(_matchPrefix) ?? false;
final bodyHasMatch = content?.contains(_matchPrefix) ?? false; final bodyHasMatch = content?.contains(_matchPrefix) ?? false;
return ListTile( return ListTile(
leading: RepaintBoundary( leading: RepaintBoundary(
child: child:
result.icon.mapNotNull( result.icon.mapNotNull(
(icon) => SafeRawImage( (icon) => SafeRawImage(
image: icon, image: icon,
height: 24, height: 24,
width: 24, width: 24,
fallback: UrlIcon([result.url], iconSize: 24), fallback: UrlIcon([result.url], iconSize: 24),
), ),
) ?? ) ??
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,
), ),
), title: result.title.mapNotNull(
subtitle: (bodyHasMatch || (urlHasMatch && !titleHasMatch)) (title) => Text.rich(
? Text.rich( buildHighlightedText(
buildHighlightedText( title,
(bodyHasMatch ? content! : result.highlightedUrl!), Theme.of(context).textTheme.bodyLarge?.copyWith(
Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface,
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, Theme.of(context).textTheme.bodyLarge?.copyWith(
overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.onSurface,
) fontWeight: FontWeight.bold,
: UriBreadcrumb(uri: result.url), ),
onTap: () async { _matchPrefix,
await ref _matchSuffix,
.read(tabRepositoryProvider.notifier) ),
.selectTab(result.id); maxLines: 2,
if (result.sourceSearchQuery.isNotEmpty && overflow: TextOverflow.ellipsis,
ref.read(findInPageControllerProvider(result.id)) == ),
FindInPageState.hidden()) { ),
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 await ref
.read(findInPageControllerProvider(result.id).notifier) .read(tabRepositoryProvider.notifier)
.findAll(text: result.sourceSearchQuery!); .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) { if (context.mounted) {
ref ref
.read(bottomSheetControllerProvider.notifier) .read(bottomSheetControllerProvider.notifier)
.requestDismiss(); .requestDismiss();
const BrowserRoute().go(context); const BrowserRoute().go(context);
} }
}, },
); );
}, },
), ),
], ],
); );
} }
@@ -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. /// The actual chip list with selection handling.
class _BangChipsList extends HookConsumerWidget { class _BangChipsList extends HookConsumerWidget {
/// The domain for this list's selection provider. /// The domain for this list's selection provider.
@@ -253,17 +282,10 @@ class _BangChipsList extends HookConsumerWidget {
onDeleted: (bang) => _handleDeletion(context, ref, bang), onDeleted: (bang) => _handleDeletion(context, ref, bang),
), ),
) )
else if (displayMenu) else if (displayMenu) ...[
Expanded( const _DefaultSearchProviderChip(),
child: Text( const Spacer(),
"Press '>' to search Bangs.", ] else
style: TextStyle(
color: Theme.of(context).hintColor,
fontStyle: FontStyle.italic,
),
),
)
else
const Spacer(), const Spacer(),
if (displayMenu) if (displayMenu)
IconButton( IconButton(
@@ -370,6 +370,7 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
required String ellipsis, required String ellipsis,
required int snippetLength, required int snippetLength,
required String searchString, required String searchString,
int limit = 25,
}) { }) {
final ftsQuery = db.buildFtsQuery(searchString); final ftsQuery = db.buildFtsQuery(searchString);
@@ -380,10 +381,12 @@ class TabDao extends DatabaseAccessor<TabDatabase> with $TabDaoMixin {
beforeMatch: matchPrefix, beforeMatch: matchPrefix,
afterMatch: matchSuffix, afterMatch: matchSuffix,
ellipsis: ellipsis, ellipsis: ellipsis,
limit: limit,
); );
} else { } else {
return db.definitionsDrift.queryTabsBasic( return db.definitionsDrift.queryTabsBasic(
query: db.buildLikeQuery(searchString), query: db.buildLikeQuery(searchString),
limit: limit,
); );
} }
} }
@@ -163,8 +163,9 @@ queryTabsBasic WITH TabQueryResult:
fts.title LIKE :query OR fts.title LIKE :query OR
fts.url LIKE :query fts.url LIKE :query
ORDER BY ORDER BY
weighted_rank ASC, weighted_rank ASC,
t.timestamp DESC; t.timestamp DESC
LIMIT :limit;
queryTabsFullContent WITH TabQueryResult: queryTabsFullContent WITH TabQueryResult:
WITH weights AS ( WITH weights AS (
@@ -194,7 +195,8 @@ queryTabsFullContent WITH TabQueryResult:
CROSS JOIN weights CROSS JOIN weights
ORDER BY ORDER BY
weighted_rank ASC, weighted_rank ASC,
t.timestamp DESC; t.timestamp DESC
LIMIT :limit;
tabTrees: tabTrees:
WITH RECURSIVE descendants AS ( WITH RECURSIVE descendants AS (
@@ -2284,10 +2284,13 @@ class DefinitionsDrift extends i8.ModularAccessor {
).map((i0.QueryRow row) => row.read<String>('_c0')); ).map((i0.QueryRow row) => row.read<String>('_c0'));
} }
i0.Selectable<i9.TabQueryResult> queryTabsBasic({required String query}) { i0.Selectable<i9.TabQueryResult> queryTabsBasic({
required String query,
required int limit,
}) {
return customSelect( 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', '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<String>(query)], variables: [i0.Variable<String>(query), i0.Variable<int>(limit)],
readsFrom: {tab, tabFts}, readsFrom: {tab, tabFts},
).map( ).map(
(i0.QueryRow row) => i9.TabQueryResult( (i0.QueryRow row) => i9.TabQueryResult(
@@ -2310,15 +2313,17 @@ class DefinitionsDrift extends i8.ModularAccessor {
required String ellipsis, required String ellipsis,
required int snippetLength, required int snippetLength,
required String query, required String query,
required int limit,
}) { }) {
return customSelect( 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: [ variables: [
i0.Variable<String>(beforeMatch), i0.Variable<String>(beforeMatch),
i0.Variable<String>(afterMatch), i0.Variable<String>(afterMatch),
i0.Variable<String>(ellipsis), i0.Variable<String>(ellipsis),
i0.Variable<int>(snippetLength), i0.Variable<int>(snippetLength),
i0.Variable<String>(query), i0.Variable<String>(query),
i0.Variable<int>(limit),
], ],
readsFrom: {tab, tabFts}, readsFrom: {tab, tabFts},
).map( ).map(
@@ -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 <http://www.gnu.org/licenses/>.
*/
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();
}
@@ -17,8 +17,8 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:drift/drift.dart';
import 'package:riverpod_annotation/riverpod_annotation.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/selected_tab.dart';
import 'package:weblibre/features/geckoview/domain/providers/tab_state.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/domain/repositories/tab.dart';
@@ -57,7 +57,9 @@ class TabDataRepository extends _$TabDataRepository {
.addTab( .addTab(
url: tabState.url, url: tabState.url,
private: tabState.isPrivate, private: tabState.isPrivate,
container: Value(targetContainer), containerSelection: TabContainerSelection.specific(
targetContainer,
),
// parentId defaults to null - breaks parent chain when changing contextual identity // parentId defaults to null - breaks parent chain when changing contextual identity
selectTab: selectedTabId == tabState.id, selectTab: selectedTabId == tabState.id,
); );
@@ -85,7 +87,7 @@ class TabDataRepository extends _$TabDataRepository {
.addTab( .addTab(
url: tabState.url, url: tabState.url,
private: tabState.isPrivate, private: tabState.isPrivate,
container: const Value(null), containerSelection: const TabContainerSelection.unassigned(),
// parentId defaults to null - breaks parent chain when removing contextual identity // parentId defaults to null - breaks parent chain when removing contextual identity
selectTab: selectedTabId == tabState.id, selectTab: selectedTabId == tabState.id,
); );
@@ -41,7 +41,7 @@ final class TabDataRepositoryProvider
} }
} }
String _$tabDataRepositoryHash() => r'c872509a70e4c0c029b468457e15c5e5bff10c65'; String _$tabDataRepositoryHash() => r'c1bdfbb576379a7d2ac7b2fe310dbec090e7fd4d';
abstract class _$TabDataRepository extends $Notifier<void> { abstract class _$TabDataRepository extends $Notifier<void> {
void build(); void build();
@@ -32,6 +32,7 @@ class TabSearchRepository extends _$TabSearchRepository {
Future<void> addQuery( Future<void> addQuery(
String input, { String input, {
int snippetLength = 120, int snippetLength = 120,
int maxResults = 25,
String matchPrefix = '***', String matchPrefix = '***',
String matchSuffix = '***', String matchSuffix = '***',
String ellipsis = '', String ellipsis = '',
@@ -49,6 +50,7 @@ class TabSearchRepository extends _$TabSearchRepository {
ellipsis: ellipsis, ellipsis: ellipsis,
snippetLength: snippetLength, snippetLength: snippetLength,
searchString: input, searchString: input,
limit: maxResults,
) )
.get(), .get(),
); );
@@ -55,7 +55,7 @@ final class TabSearchRepositoryProvider
} }
String _$tabSearchRepositoryHash() => String _$tabSearchRepositoryHash() =>
r'ac2381c692b9caf93f26f302d15e0160c098917a'; r'473057d6a9f1e76d6b7fec13f710189d758bc924';
final class TabSearchRepositoryFamily extends $Family final class TabSearchRepositoryFamily extends $Family
with with
@@ -21,13 +21,16 @@ import 'dart:convert';
import 'package:fading_scroll/fading_scroll.dart'; import 'package:fading_scroll/fading_scroll.dart';
import 'package:flutter/material.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:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:uuid/enums.dart'; import 'package:uuid/enums.dart';
import 'package:weblibre/core/routing/routes.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/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.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart'; import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_list_tile.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart';
@@ -39,6 +42,8 @@ class ContainerSelectionScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final containersAsync = ref.watch(watchContainersWithCountProvider); final containersAsync = ref.watch(watchContainersWithCountProvider);
final selectedContainerId = ref.watch(selectedContainerProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Select Container')), appBar: AppBar(title: const Text('Select Container')),
body: Skeletonizer( body: Skeletonizer(
@@ -53,14 +58,42 @@ class ContainerSelectionScreen extends HookConsumerWidget {
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: floatingActionButtonBottomInset(context), bottom: floatingActionButtonBottomInset(context),
), ),
itemCount: containers.length, itemCount: containers.length + 1,
itemBuilder: (context, index) { 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<ContainerSelectionResult>(
const ContainerSelectionResult.unassigned(),
);
},
),
);
}
final container = containers[index - 1];
return ContainerListTile( return ContainerListTile(
container, container,
isSelected: false, isSelected: container.id == selectedContainerId,
onTap: () { onTap: () {
context.pop<String?>(container.id); context.pop<ContainerSelectionResult>(
ContainerSelectionResult.selected(container.id),
);
}, },
); );
}, },
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<ContainerSelectionResult?>(context);
switch (selection) {
case ContainerSelectionSelected(:final containerId):
await ref
.read(selectedContainerProvider.notifier)
.setContainerId(containerId);
case ContainerSelectionUnassigned():
ref.read(selectedContainerProvider.notifier).clearContainer();
case null:
break;
}
},
),
);
}
}
@@ -47,7 +47,6 @@ class ContainerListTile extends HookWidget {
), ),
title: ContainerTitle(container: container), title: ContainerTitle(container: container),
onTap: onTap, onTap: onTap,
trailing: const Icon(Icons.chevron_right),
), ),
); );
} }
@@ -36,7 +36,11 @@ class ContainerTitle extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
if (container.name.isNotEmpty) { if (container.name.isNotEmpty) {
return Text(container.name!); return Text(
container.name!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
} }
final topicAsync = ref.watch(containerTopicProvider(container.id)); final topicAsync = ref.watch(containerTopicProvider(container.id));
@@ -59,6 +63,8 @@ class ContainerTitle extends HookConsumerWidget {
const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)), const WidgetSpan(child: Icon(MdiIcons.creation, size: 16)),
], ],
), ),
overflow: TextOverflow.ellipsis,
maxLines: 1,
), ),
) ?? ) ??
Text( Text(
@@ -17,13 +17,13 @@
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart'; import 'package:smooth_page_indicator/smooth_page_indicator.dart';
import 'package:weblibre/core/providers/defaults.dart'; import 'package:weblibre/core/providers/defaults.dart';
import 'package:weblibre/core/providers/router.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/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart'; import 'package:weblibre/features/onboarding/presentation/pages/abstract/i_form_page.dart';
import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart'; import 'package:weblibre/features/onboarding/presentation/pages/ai_configuration.dart';
@@ -148,7 +148,8 @@ class OnboardingScreen extends HookConsumerWidget {
.addTab( .addTab(
url: ref.read(docsUriProvider), url: ref.read(docsUriProvider),
private: false, private: false,
container: const Value(null), containerSelection:
const TabContainerSelection.unassigned(),
selectTab: true, selectTab: true,
); );
@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/core/logger.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/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
@@ -282,7 +282,9 @@ class SyncRepository extends _$SyncRepository {
url: uri, url: uri,
selectTab: true, selectTab: true,
private: false, private: false,
container: Value(assignedContainer), containerSelection: assignedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(assignedContainer),
); );
} }
@@ -336,7 +336,7 @@ final class SyncRepositoryProvider
SyncRepository create() => SyncRepository(); SyncRepository create() => SyncRepository();
} }
String _$syncRepositoryHash() => r'5312aed1abf60ce3e04afa7d2f60b38367c09312'; String _$syncRepositoryHash() => r'5bfe6deacfea9b14981a0b9769857f23b7dd478c';
abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> { abstract class _$SyncRepository extends $AsyncNotifier<SyncRepositoryState> {
FutureOr<SyncRepositoryState> build(); FutureOr<SyncRepositoryState> build();
@@ -148,6 +148,7 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with $ArticleDaoMixin {
required int snippetLength, required int snippetLength,
required String searchString, required String searchString,
required Uri? feedId, required Uri? feedId,
int limit = 25,
}) { }) {
final ftsQuery = db.buildFtsQuery(searchString); final ftsQuery = db.buildFtsQuery(searchString);
@@ -159,11 +160,13 @@ class ArticleDao extends DatabaseAccessor<FeedDatabase> with $ArticleDaoMixin {
beforeMatch: matchPrefix, beforeMatch: matchPrefix,
afterMatch: matchSuffix, afterMatch: matchSuffix,
ellipsis: ellipsis, ellipsis: ellipsis,
limit: limit,
); );
} else { } else {
return db.definitionsDrift.queryArticlesBasic( return db.definitionsDrift.queryArticlesBasic(
feedId: feedId?.toString(), feedId: feedId?.toString(),
query: db.buildLikeQuery(searchString), query: db.buildLikeQuery(searchString),
limit: limit,
); );
} }
} }
@@ -102,7 +102,8 @@ queryArticlesBasic(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
(:feed_id IS NULL OR a.feed_id = :feed_id) (:feed_id IS NULL OR a.feed_id = :feed_id)
ORDER BY ORDER BY
weighted_rank ASC, weighted_rank ASC,
a.created DESC NULLS LAST; a.created DESC NULLS LAST
LIMIT :limit;
queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult: queryArticlesFullContent(:feed_id AS TEXT OR NULL) WITH FeedArticleQueryResult:
WITH weights AS ( 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 :feed_id IS NULL OR a.feed_id = :feed_id
ORDER BY ORDER BY
weighted_rank ASC, weighted_rank ASC,
a.created DESC NULLS LAST; a.created DESC NULLS LAST
LIMIT :limit;
@@ -2644,10 +2644,15 @@ class DefinitionsDrift extends i10.ModularAccessor {
i0.Selectable<i11.FeedArticleQueryResult> queryArticlesBasic({ i0.Selectable<i11.FeedArticleQueryResult> queryArticlesBasic({
required String query, required String query,
String? feedId, String? feedId,
required int limit,
}) { }) {
return customSelect( 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', '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<String>(query), i0.Variable<String>(feedId)], variables: [
i0.Variable<String>(query),
i0.Variable<String>(feedId),
i0.Variable<int>(limit),
],
readsFrom: {feed, articleFts, article}, readsFrom: {feed, articleFts, article},
).map( ).map(
(i0.QueryRow row) => i11.FeedArticleQueryResult( (i0.QueryRow row) => i11.FeedArticleQueryResult(
@@ -2691,9 +2696,10 @@ class DefinitionsDrift extends i10.ModularAccessor {
required int snippetLength, required int snippetLength,
required String query, required String query,
String? feedId, String? feedId,
required int limit,
}) { }) {
return customSelect( 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: [ variables: [
i0.Variable<String>(beforeMatch), i0.Variable<String>(beforeMatch),
i0.Variable<String>(afterMatch), i0.Variable<String>(afterMatch),
@@ -2701,6 +2707,7 @@ class DefinitionsDrift extends i10.ModularAccessor {
i0.Variable<int>(snippetLength), i0.Variable<int>(snippetLength),
i0.Variable<String>(query), i0.Variable<String>(query),
i0.Variable<String>(feedId), i0.Variable<String>(feedId),
i0.Variable<int>(limit),
], ],
readsFrom: {feed, articleFts, article}, readsFrom: {feed, articleFts, article},
).map( ).map(
@@ -39,6 +39,7 @@ class ArticleSearch extends _$ArticleSearch {
Future<void> search( Future<void> search(
String input, { String input, {
int snippetLength = 120, int snippetLength = 120,
int maxResults = 25,
String matchPrefix = '***', String matchPrefix = '***',
String matchSuffix = '***', String matchSuffix = '***',
String ellipsis = '', String ellipsis = '',
@@ -54,6 +55,7 @@ class ArticleSearch extends _$ArticleSearch {
snippetLength: snippetLength, snippetLength: snippetLength,
searchString: input, searchString: input,
feedId: feedId, feedId: feedId,
limit: maxResults,
) )
.get() .get()
.then((value) { .then((value) {
@@ -50,7 +50,7 @@ final class ArticleSearchProvider
} }
} }
String _$articleSearchHash() => r'cc59a5d3c4b926db9f03ed96db592965ff925b04'; String _$articleSearchHash() => r'48ed3baa560c0e731626f79a8f6ff3dab1e9bc95';
final class ArticleSearchFamily extends $Family final class ArticleSearchFamily extends $Family
with with
@@ -18,7 +18,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
*/ */
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_markdown/flutter_markdown.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:nullability/nullability.dart';
import 'package:weblibre/core/providers/format.dart'; import 'package:weblibre/core/providers/format.dart';
import 'package:weblibre/core/routing/routes.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/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/features/web_feed/data/models/feed_link.dart'; import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
@@ -206,7 +206,8 @@ class FeedArticleScreen extends HookConsumerWidget {
.addTab( .addTab(
url: articleLink.uri, url: articleLink.uri,
private: isPrivate, private: isPrivate,
container: const Value(null), containerSelection:
const TabContainerSelection.unassigned(),
selectTab: true, selectTab: true,
); );