added many suggestion modules

This commit is contained in:
Fabian Freund
2026-03-09 03:10:02 +01:00
parent 84038c7b81
commit a7b4954da1
35 changed files with 4593 additions and 2077 deletions
@@ -0,0 +1,79 @@
/*
* 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_mozilla_components/flutter_mozilla_components.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/history/domain/repositories/history.dart';
import 'package:weblibre/features/web_feed/data/models/feed_article.dart';
import 'package:weblibre/features/web_feed/domain/providers.dart';
part 'empty_state_content.g.dart';
@Riverpod()
Future<List<HistoryHighlight>> searchEmptyHistoryHighlights(
Ref ref, {
int count = 25,
}) async {
final highlights = await ref
.read(historyRepositoryProvider.notifier)
.getHistoryHighlights(limit: count);
if (!ref.mounted) return [];
return highlights.where((h) => Uri.tryParse(h.url) != null).toList();
}
@Riverpod()
Future<List<VisitInfo>> searchEmptyRecentHistory(
Ref ref, {
int count = 25,
}) async {
final visits = await ref
.read(historyRepositoryProvider.notifier)
.getVisitsPaginated(count: count);
if (!ref.mounted) return [];
return visits.where((v) => Uri.tryParse(v.url) != null).toList();
}
@Riverpod()
AsyncValue<List<FeedArticle>> searchEmptyRecentFeedArticles(
Ref ref, {
int count = 25,
}) {
final feeds = ref.watch(feedListProvider);
final hasFeeds = feeds.whenOrNull(data: (list) => list.isNotEmpty) ?? false;
if (!hasFeeds) {
return const AsyncValue.data([]);
}
return ref
.watch(feedArticleListProvider(null))
.whenData((articles) => articles.take(count).toList());
}
@Riverpod()
List<TabStateWithContainer> searchEmptyRecentTabs(Ref ref, {int count = 25}) {
final tabs = ref.watch(fifoTabStatesProvider).value;
return tabs.take(count).toList();
}
@@ -0,0 +1,339 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'empty_state_content.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(searchEmptyHistoryHighlights)
final searchEmptyHistoryHighlightsProvider =
SearchEmptyHistoryHighlightsFamily._();
final class SearchEmptyHistoryHighlightsProvider
extends
$FunctionalProvider<
AsyncValue<List<HistoryHighlight>>,
List<HistoryHighlight>,
FutureOr<List<HistoryHighlight>>
>
with
$FutureModifier<List<HistoryHighlight>>,
$FutureProvider<List<HistoryHighlight>> {
SearchEmptyHistoryHighlightsProvider._({
required SearchEmptyHistoryHighlightsFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'searchEmptyHistoryHighlightsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchEmptyHistoryHighlightsHash();
@override
String toString() {
return r'searchEmptyHistoryHighlightsProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<HistoryHighlight>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<HistoryHighlight>> create(Ref ref) {
final argument = this.argument as int;
return searchEmptyHistoryHighlights(ref, count: argument);
}
@override
bool operator ==(Object other) {
return other is SearchEmptyHistoryHighlightsProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchEmptyHistoryHighlightsHash() =>
r'f12206cb05c5c03ecdc2b84e3ffd20706aa4c986';
final class SearchEmptyHistoryHighlightsFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<HistoryHighlight>>, int> {
SearchEmptyHistoryHighlightsFamily._()
: super(
retry: null,
name: r'searchEmptyHistoryHighlightsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchEmptyHistoryHighlightsProvider call({int count = 25}) =>
SearchEmptyHistoryHighlightsProvider._(argument: count, from: this);
@override
String toString() => r'searchEmptyHistoryHighlightsProvider';
}
@ProviderFor(searchEmptyRecentHistory)
final searchEmptyRecentHistoryProvider = SearchEmptyRecentHistoryFamily._();
final class SearchEmptyRecentHistoryProvider
extends
$FunctionalProvider<
AsyncValue<List<VisitInfo>>,
List<VisitInfo>,
FutureOr<List<VisitInfo>>
>
with $FutureModifier<List<VisitInfo>>, $FutureProvider<List<VisitInfo>> {
SearchEmptyRecentHistoryProvider._({
required SearchEmptyRecentHistoryFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'searchEmptyRecentHistoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchEmptyRecentHistoryHash();
@override
String toString() {
return r'searchEmptyRecentHistoryProvider'
''
'($argument)';
}
@$internal
@override
$FutureProviderElement<List<VisitInfo>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<VisitInfo>> create(Ref ref) {
final argument = this.argument as int;
return searchEmptyRecentHistory(ref, count: argument);
}
@override
bool operator ==(Object other) {
return other is SearchEmptyRecentHistoryProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchEmptyRecentHistoryHash() =>
r'bd7b9a67edfb1751d6d5cf6a8392516911796cb0';
final class SearchEmptyRecentHistoryFamily extends $Family
with $FunctionalFamilyOverride<FutureOr<List<VisitInfo>>, int> {
SearchEmptyRecentHistoryFamily._()
: super(
retry: null,
name: r'searchEmptyRecentHistoryProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchEmptyRecentHistoryProvider call({int count = 25}) =>
SearchEmptyRecentHistoryProvider._(argument: count, from: this);
@override
String toString() => r'searchEmptyRecentHistoryProvider';
}
@ProviderFor(searchEmptyRecentFeedArticles)
final searchEmptyRecentFeedArticlesProvider =
SearchEmptyRecentFeedArticlesFamily._();
final class SearchEmptyRecentFeedArticlesProvider
extends
$FunctionalProvider<
AsyncValue<List<FeedArticle>>,
AsyncValue<List<FeedArticle>>,
AsyncValue<List<FeedArticle>>
>
with $Provider<AsyncValue<List<FeedArticle>>> {
SearchEmptyRecentFeedArticlesProvider._({
required SearchEmptyRecentFeedArticlesFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'searchEmptyRecentFeedArticlesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchEmptyRecentFeedArticlesHash();
@override
String toString() {
return r'searchEmptyRecentFeedArticlesProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<AsyncValue<List<FeedArticle>>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
AsyncValue<List<FeedArticle>> create(Ref ref) {
final argument = this.argument as int;
return searchEmptyRecentFeedArticles(ref, count: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(AsyncValue<List<FeedArticle>> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<List<FeedArticle>>>(
value,
),
);
}
@override
bool operator ==(Object other) {
return other is SearchEmptyRecentFeedArticlesProvider &&
other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchEmptyRecentFeedArticlesHash() =>
r'ee6ac7336d58b78c6c9295cdbb8aeeca9a91c2ad';
final class SearchEmptyRecentFeedArticlesFamily extends $Family
with $FunctionalFamilyOverride<AsyncValue<List<FeedArticle>>, int> {
SearchEmptyRecentFeedArticlesFamily._()
: super(
retry: null,
name: r'searchEmptyRecentFeedArticlesProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchEmptyRecentFeedArticlesProvider call({int count = 25}) =>
SearchEmptyRecentFeedArticlesProvider._(argument: count, from: this);
@override
String toString() => r'searchEmptyRecentFeedArticlesProvider';
}
@ProviderFor(searchEmptyRecentTabs)
final searchEmptyRecentTabsProvider = SearchEmptyRecentTabsFamily._();
final class SearchEmptyRecentTabsProvider
extends
$FunctionalProvider<
List<TabStateWithContainer>,
List<TabStateWithContainer>,
List<TabStateWithContainer>
>
with $Provider<List<TabStateWithContainer>> {
SearchEmptyRecentTabsProvider._({
required SearchEmptyRecentTabsFamily super.from,
required int super.argument,
}) : super(
retry: null,
name: r'searchEmptyRecentTabsProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$searchEmptyRecentTabsHash();
@override
String toString() {
return r'searchEmptyRecentTabsProvider'
''
'($argument)';
}
@$internal
@override
$ProviderElement<List<TabStateWithContainer>> $createElement(
$ProviderPointer pointer,
) => $ProviderElement(pointer);
@override
List<TabStateWithContainer> create(Ref ref) {
final argument = this.argument as int;
return searchEmptyRecentTabs(ref, count: argument);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(List<TabStateWithContainer> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<List<TabStateWithContainer>>(value),
);
}
@override
bool operator ==(Object other) {
return other is SearchEmptyRecentTabsProvider && other.argument == argument;
}
@override
int get hashCode {
return argument.hashCode;
}
}
String _$searchEmptyRecentTabsHash() =>
r'6000970dd261f243326900555967956508b84660';
final class SearchEmptyRecentTabsFamily extends $Family
with $FunctionalFamilyOverride<List<TabStateWithContainer>, int> {
SearchEmptyRecentTabsFamily._()
: super(
retry: null,
name: r'searchEmptyRecentTabsProvider',
dependencies: null,
$allTransitiveDependencies: null,
isAutoDispose: true,
);
SearchEmptyRecentTabsProvider call({int count = 25}) =>
SearchEmptyRecentTabsProvider._(argument: count, from: this);
@override
String toString() => r'searchEmptyRecentTabsProvider';
}
@@ -21,7 +21,17 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'search_modules_view.g.dart';
enum SearchModuleType { tabs, articles, bookmarks, history }
enum SearchModuleType {
tabs,
articles,
bookmarks,
history,
historyHighlights,
recentHistory,
recentArticles,
recentTabs,
containers,
}
enum SearchModuleDisplayState { preview, expanded, collapsed }
@@ -35,6 +35,11 @@ import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
import 'package:weblibre/features/geckoview/features/browser/domain/providers.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/clipboard_fill.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/containers_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/recent_tabs_section.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
@@ -45,6 +50,8 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolatio
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
import 'package:weblibre/presentation/hooks/on_listenable_change_selector.dart';
import 'package:weblibre/presentation/hooks/sampled_value_notifier.dart';
@@ -135,6 +142,17 @@ class SearchScreen extends HookConsumerWidget {
source: searchTextController,
sampleDuration: const Duration(milliseconds: 150),
);
final hasUserProvidedInput = useState(
initialSearchText?.isNotEmpty == true,
);
useOnListenableChange(searchTextController, () {
hasUserProvidedInput.value = searchTextController.text.isNotEmpty;
});
final showNoInputSections =
!hasUserProvidedInput.value && searchTextController.text.isEmpty;
final searchFocusNode = useFocusNode();
final textFieldKey = useMemoized(() => GlobalKey());
final preferredHeight = useState<double>(kToolbarHeight);
@@ -484,86 +502,211 @@ class SearchScreen extends HookConsumerWidget {
SliverToBoxAdapter(
child: ClipboardFillLink(controller: searchTextController),
),
FullSearchTermSuggestions(
searchTextController: searchTextController,
activeBang: activeBang,
submitSearch: submitSearch,
domain: isEditMode ? existingTabState.url.host : null,
),
TabSearch(searchTextListenable: sampledSearchText),
BookmarkSearch(
searchTextListenable: sampledSearchText,
onUriSelected: (uri) async {
if (isEditMode) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
if (showNoInputSections) ...[
HistoryHighlightsSection(
onUriSelected: (uri) async {
if (isEditMode) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
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);
}
},
),
RecentFeedArticlesSection(
onArticleSelected: (article) {
FeedArticleRoute(articleId: article.id).go(context);
},
),
RecentTabsSection(
onTabSelected: (tabId) async {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
containerSelection: selectedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(
selectedContainer,
),
.selectTab(tabId);
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
const BrowserRoute().go(context);
}
},
),
RecentHistorySection(
onUriSelected: (uri) async {
if (isEditMode) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
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);
}
},
),
ContainersSection(
onContainerSelected: (container) async {
final result = await ref
.read(selectedContainerProvider.notifier)
.setContainerId(container.id);
if (!context.mounted) return;
if (result == SetContainerResult.successHasProxy) {
final shouldStartProxy = await ref
.read(startProxyControllerProvider.notifier)
.shouldPromptProxyStart();
if (context.mounted && shouldStartProxy) {
final dialogResult = await showDialog<bool>(
context: context,
builder: (_) => const TorDialog(),
);
}
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
if (dialogResult == true) {
await ref
.read(startProxyControllerProvider.notifier)
.startProxy();
}
}
}
const BrowserRoute().go(context);
}
},
),
FeedSearch(searchTextNotifier: sampledSearchText),
HistorySuggestions(
searchTextListenable: sampledSearchText,
onUriSelected: (uri) async {
if (isEditMode) {
// Load into existing tab
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
// Create new tab
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
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 &&
result != SetContainerResult.failed) {
const TabViewRoute().go(context);
}
},
),
],
if (!showNoInputSections) ...[
FullSearchTermSuggestions(
searchTextController: searchTextController,
activeBang: activeBang,
submitSearch: submitSearch,
domain: isEditMode ? existingTabState.url.host : null,
),
TabSearch(searchTextListenable: sampledSearchText),
BookmarkSearch(
searchTextListenable: sampledSearchText,
onUriSelected: (uri) async {
if (isEditMode) {
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
parentId: (selectedTabType.value == TabType.child)
? ref.read(selectedTabProvider)
: null,
launchedFromIntent: launchedFromIntent,
selectTab: true,
containerSelection: selectedContainer == null
? const TabContainerSelection.unassigned()
: TabContainerSelection.specific(
selectedContainer,
),
);
}
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
if (context.mounted) {
ref
.read(bottomSheetControllerProvider.notifier)
.requestDismiss();
const BrowserRoute().go(context);
}
},
),
const BrowserRoute().go(context);
}
},
),
FeedSearch(searchTextNotifier: sampledSearchText),
HistorySuggestions(
searchTextListenable: sampledSearchText,
onUriSelected: (uri) async {
if (isEditMode) {
// Load into existing tab
await ref
.read(tabSessionProvider(tabId: tabId).notifier)
.loadUrl(url: uri);
} else {
// Create new tab
await ref
.read(tabRepositoryProvider.notifier)
.addTab(
url: uri,
tabMode: effectiveTabMode,
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);
}
},
),
],
],
),
),
@@ -0,0 +1,60 @@
/*
* 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';
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart';
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
class ContainersSection extends StatelessWidget {
final void Function(ContainerDataWithCount container) onContainerSelected;
const ContainersSection({super.key, required this.onContainerSelected});
@override
Widget build(BuildContext context) {
return SearchModuleSection(
title: 'Containers',
moduleType: SearchModuleType.containers,
totalCount: 1,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ContainerChips(
selectedContainer: null,
onSelected: (container) {
if (container != null) {
onContainerSelected(container);
}
},
onDeleted: null,
displayMenu: false,
showUnassignedChip: false,
enableDragAndDrop: false,
),
),
),
],
);
}
}
@@ -0,0 +1,64 @@
/*
* 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:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.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/presentation/widgets/url_list_tile.dart';
class HistoryHighlightsSection extends ConsumerWidget {
final void Function(Uri uri) onUriSelected;
const HistoryHighlightsSection({super.key, required this.onUriSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final highlightsAsync = ref.watch(searchEmptyHistoryHighlightsProvider());
final highlights = highlightsAsync.value ?? [];
if (highlightsAsync.hasValue && highlights.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SearchModuleSection(
title: 'History Highlights',
moduleType: SearchModuleType.historyHighlights,
totalCount: highlights.length,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
SliverList.builder(
itemCount: visibleCount,
itemBuilder: (context, index) {
final highlight = highlights[index];
final uri = Uri.parse(highlight.url);
return UrlListTile(
title: highlight.title ?? uri.authority,
uri: uri,
onTap: () => onUriSelected(uri),
);
},
),
],
);
}
}
@@ -0,0 +1,109 @@
/*
* 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:weblibre/core/providers/format.dart';
import 'package:weblibre/extensions/uri.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.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.dart';
import 'package:weblibre/features/web_feed/data/models/feed_link.dart';
import 'package:weblibre/features/web_feed/extensions/atom.dart';
import 'package:weblibre/features/web_feed/extensions/feed_article.dart';
import 'package:weblibre/presentation/widgets/url_icon.dart';
class RecentFeedArticlesSection extends ConsumerWidget {
final void Function(FeedArticle article) onArticleSelected;
const RecentFeedArticlesSection({super.key, required this.onArticleSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final articlesAsync = ref.watch(searchEmptyRecentFeedArticlesProvider());
final articles = articlesAsync.value ?? [];
if (articlesAsync.hasValue && articles.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SearchModuleSection(
title: 'Recent Articles',
moduleType: SearchModuleType.recentArticles,
totalCount: articles.length,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
SliverList.builder(
itemCount: visibleCount,
itemBuilder: (context, index) {
final article = articles[index];
final articleDate = article.updated ?? article.created;
return ListTile(
leading: RepaintBoundary(
child: UrlIcon([
article.icon ??
article.links
?.getRelation(FeedLinkRelation.alternate)
?.uri ??
article.siteLink ??
article.feedId.base,
], iconSize: 24.0),
),
title: Text(
article.displayTitle,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (article.summaryPlain != null)
Text(
article.summaryPlain!,
style: Theme.of(context).textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (articleDate != null)
Align(
alignment: Alignment.topRight,
child: Text(
ref
.read(formatProvider.notifier)
.fullDateTime(articleDate),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(fontStyle: FontStyle.italic),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
onTap: () => onArticleSelected(article),
);
},
),
],
);
}
}
@@ -0,0 +1,64 @@
/*
* 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:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.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/presentation/widgets/url_list_tile.dart';
class RecentHistorySection extends ConsumerWidget {
final void Function(Uri uri) onUriSelected;
const RecentHistorySection({super.key, required this.onUriSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final historyAsync = ref.watch(searchEmptyRecentHistoryProvider());
final visits = historyAsync.value ?? [];
if (historyAsync.hasValue && visits.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SearchModuleSection(
title: 'Recent History',
moduleType: SearchModuleType.recentHistory,
totalCount: visits.length,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
SliverList.builder(
itemCount: visibleCount,
itemBuilder: (context, index) {
final visit = visits[index];
final uri = Uri.parse(visit.url);
return UrlListTile(
title: visit.title ?? uri.authority,
uri: uri,
onTap: () => onUriSelected(uri),
);
},
),
],
);
}
}
@@ -0,0 +1,68 @@
/*
* 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:weblibre/features/geckoview/features/browser/presentation/widgets/tab_icon.dart';
import 'package:weblibre/features/geckoview/features/search/domain/providers/empty_state_content.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/presentation/widgets/url_list_tile.dart';
class RecentTabsSection extends ConsumerWidget {
final void Function(String tabId) onTabSelected;
const RecentTabsSection({super.key, required this.onTabSelected});
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabs = ref.watch(searchEmptyRecentTabsProvider());
if (tabs.isEmpty) {
return const SliverToBoxAdapter(child: SizedBox.shrink());
}
return SearchModuleSection(
title: 'Recent Tabs',
moduleType: SearchModuleType.recentTabs,
totalCount: tabs.length,
contentSliverBuilder:
({required bool isCollapsed, required int visibleCount}) => [
if (!isCollapsed)
SliverList.builder(
itemCount: visibleCount,
itemBuilder: (context, index) {
final (tabState, containerData) = tabs[index];
return UrlListTile(
title: tabState.titleOrAuthority,
uri: tabState.url,
leading: TabIcon(
tabState: tabState,
iconSize: UrlListTile.iconSize,
),
borderColor: containerData?.color,
onTap: () => onTabSelected(tabState.id),
);
},
),
],
);
}
}