search module reorder
This commit is contained in:
@@ -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 '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/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||||
|
import 'package:weblibre/features/user/data/providers.dart';
|
||||||
|
|
||||||
|
part 'search_module_order.g.dart';
|
||||||
|
|
||||||
|
typedef ModuleOrderEntry = ({SearchModuleType type, bool visible});
|
||||||
|
|
||||||
|
List<ModuleOrderEntry> _mergeWithDefaults(
|
||||||
|
List<ModuleOrderEntry>? persisted,
|
||||||
|
List<SearchModuleType> defaults,
|
||||||
|
) {
|
||||||
|
if (persisted == null) {
|
||||||
|
return defaults.map((type) => (type: type, visible: true)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final defaultSet = defaults.toSet();
|
||||||
|
// Keep persisted entries that are still valid
|
||||||
|
final result = persisted.where((e) => defaultSet.contains(e.type)).toList();
|
||||||
|
// Add any new defaults not in persisted
|
||||||
|
final persistedTypes = result.map((e) => e.type).toSet();
|
||||||
|
for (final type in defaults) {
|
||||||
|
if (!persistedTypes.contains(type)) {
|
||||||
|
result.add((type: type, visible: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SearchModuleOrder extends _$SearchModuleOrder {
|
||||||
|
void reorder(int oldIndex, int newIndex) {
|
||||||
|
final list = [...state];
|
||||||
|
final item = list.removeAt(oldIndex);
|
||||||
|
final insertIndex = newIndex > oldIndex ? newIndex - 1 : newIndex;
|
||||||
|
list.insert(insertIndex, item);
|
||||||
|
state = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleVisibility(SearchModuleType type) {
|
||||||
|
state = [
|
||||||
|
for (final e in state)
|
||||||
|
if (e.type == type) (type: e.type, visible: !e.visible) else e,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ModuleOrderEntry> build(SearchModuleGroup group) {
|
||||||
|
persist(
|
||||||
|
ref.watch(riverpodDatabaseStorageProvider),
|
||||||
|
key: group.key,
|
||||||
|
encode: (state) => jsonEncode(
|
||||||
|
state
|
||||||
|
.map((e) => {'type': e.type.name, 'visible': e.visible})
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
decode: (encoded) {
|
||||||
|
final decoded = (jsonDecode(encoded) as List<dynamic>)
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map((e) {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
type: SearchModuleType.values.byName(e['type']! as String),
|
||||||
|
visible: e['visible']! as bool,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereType<ModuleOrderEntry>()
|
||||||
|
.toList();
|
||||||
|
// Merge with defaults to pick up newly added or remove deleted modules
|
||||||
|
return _mergeWithDefaults(decoded, group.defaultModules);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return stateOrNull ??
|
||||||
|
group.defaultModules
|
||||||
|
.map((type) => (type: type, visible: true))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'search_module_order.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(SearchModuleOrder)
|
||||||
|
final searchModuleOrderProvider = SearchModuleOrderFamily._();
|
||||||
|
|
||||||
|
final class SearchModuleOrderProvider
|
||||||
|
extends $NotifierProvider<SearchModuleOrder, List<ModuleOrderEntry>> {
|
||||||
|
SearchModuleOrderProvider._({
|
||||||
|
required SearchModuleOrderFamily super.from,
|
||||||
|
required SearchModuleGroup super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'searchModuleOrderProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$searchModuleOrderHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'searchModuleOrderProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SearchModuleOrder create() => SearchModuleOrder();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(List<ModuleOrderEntry> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<List<ModuleOrderEntry>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is SearchModuleOrderProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$searchModuleOrderHash() => r'7e7822de86688cf87dff1c9991bc2551dc44e002';
|
||||||
|
|
||||||
|
final class SearchModuleOrderFamily extends $Family
|
||||||
|
with
|
||||||
|
$ClassFamilyOverride<
|
||||||
|
SearchModuleOrder,
|
||||||
|
List<ModuleOrderEntry>,
|
||||||
|
List<ModuleOrderEntry>,
|
||||||
|
List<ModuleOrderEntry>,
|
||||||
|
SearchModuleGroup
|
||||||
|
> {
|
||||||
|
SearchModuleOrderFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'searchModuleOrderProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
SearchModuleOrderProvider call(SearchModuleGroup group) =>
|
||||||
|
SearchModuleOrderProvider._(argument: group, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'searchModuleOrderProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$SearchModuleOrder extends $Notifier<List<ModuleOrderEntry>> {
|
||||||
|
late final _$args = ref.$arg as SearchModuleGroup;
|
||||||
|
SearchModuleGroup get group => _$args;
|
||||||
|
|
||||||
|
List<ModuleOrderEntry> build(SearchModuleGroup group);
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<List<ModuleOrderEntry>, List<ModuleOrderEntry>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<List<ModuleOrderEntry>, List<ModuleOrderEntry>>,
|
||||||
|
List<ModuleOrderEntry>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, () => build(_$args));
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-1
@@ -31,7 +31,62 @@ enum SearchModuleType {
|
|||||||
recentHistory,
|
recentHistory,
|
||||||
recentArticles,
|
recentArticles,
|
||||||
recentTabs,
|
recentTabs,
|
||||||
containers,
|
containers;
|
||||||
|
|
||||||
|
String get label => switch (this) {
|
||||||
|
tabs => 'Tabs',
|
||||||
|
articles => 'Articles',
|
||||||
|
bookmarks => 'Bookmarks',
|
||||||
|
history => 'History',
|
||||||
|
historyHighlights => 'History Highlights',
|
||||||
|
topSites => 'Top Sites',
|
||||||
|
recentHistory => 'Recent History',
|
||||||
|
recentArticles => 'Recent Articles',
|
||||||
|
recentTabs => 'Recent Tabs',
|
||||||
|
containers => 'Containers',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SearchModuleGroup {
|
||||||
|
emptyState(
|
||||||
|
key: 'EmptyStateModuleOrder',
|
||||||
|
defaultModules: [
|
||||||
|
SearchModuleType.topSites,
|
||||||
|
SearchModuleType.recentArticles,
|
||||||
|
SearchModuleType.recentTabs,
|
||||||
|
SearchModuleType.recentHistory,
|
||||||
|
SearchModuleType.historyHighlights,
|
||||||
|
SearchModuleType.containers,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
search(
|
||||||
|
key: 'SearchModuleOrder',
|
||||||
|
defaultModules: [
|
||||||
|
SearchModuleType.tabs,
|
||||||
|
SearchModuleType.bookmarks,
|
||||||
|
SearchModuleType.articles,
|
||||||
|
SearchModuleType.history,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchModuleGroup({required this.key, required this.defaultModules});
|
||||||
|
final String key;
|
||||||
|
final List<SearchModuleType> defaultModules;
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SearchModuleTypeGroup on SearchModuleType {
|
||||||
|
SearchModuleGroup get group => switch (this) {
|
||||||
|
SearchModuleType.topSites ||
|
||||||
|
SearchModuleType.recentArticles ||
|
||||||
|
SearchModuleType.recentTabs ||
|
||||||
|
SearchModuleType.recentHistory ||
|
||||||
|
SearchModuleType.historyHighlights ||
|
||||||
|
SearchModuleType.containers => SearchModuleGroup.emptyState,
|
||||||
|
SearchModuleType.tabs ||
|
||||||
|
SearchModuleType.bookmarks ||
|
||||||
|
SearchModuleType.articles ||
|
||||||
|
SearchModuleType.history => SearchModuleGroup.search,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchModuleDisplayState { preview, expanded, collapsed }
|
enum SearchModuleDisplayState { preview, expanded, collapsed }
|
||||||
@@ -67,3 +122,13 @@ class SearchModuleDisplayStateController
|
|||||||
return SearchModuleDisplayState.preview;
|
return SearchModuleDisplayState.preview;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Riverpod()
|
||||||
|
class SearchReorderMode extends _$SearchReorderMode {
|
||||||
|
// ignore: use_setters_to_change_properties
|
||||||
|
void activate(SearchModuleGroup group) => state = group;
|
||||||
|
void deactivate() => state = null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
SearchModuleGroup? build() => null;
|
||||||
|
}
|
||||||
|
|||||||
+52
@@ -119,3 +119,55 @@ abstract class _$SearchModuleDisplayStateController
|
|||||||
element.handleCreate(ref, () => build(_$args));
|
element.handleCreate(ref, () => build(_$args));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ProviderFor(SearchReorderMode)
|
||||||
|
final searchReorderModeProvider = SearchReorderModeProvider._();
|
||||||
|
|
||||||
|
final class SearchReorderModeProvider
|
||||||
|
extends $NotifierProvider<SearchReorderMode, SearchModuleGroup?> {
|
||||||
|
SearchReorderModeProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'searchReorderModeProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$searchReorderModeHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SearchReorderMode create() => SearchReorderMode();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(SearchModuleGroup? value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<SearchModuleGroup?>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$searchReorderModeHash() => r'eda188e53e5b5f1a331ce94c3cb8808c79e358d3';
|
||||||
|
|
||||||
|
abstract class _$SearchReorderMode extends $Notifier<SearchModuleGroup?> {
|
||||||
|
SearchModuleGroup? build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<SearchModuleGroup?, SearchModuleGroup?>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<SearchModuleGroup?, SearchModuleGroup?>,
|
||||||
|
SearchModuleGroup?,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,14 +33,18 @@ 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/domain/providers/search_module_order.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/animated_tab_type_switcher.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/empty_state/containers_section.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_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_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/empty_state/recent_tabs_section.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.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_module_reorder_view.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/bookmark_search.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';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/full_search_suggestions.dart';
|
||||||
@@ -305,6 +309,125 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final reorderGroup = ref.watch(searchReorderModeProvider);
|
||||||
|
|
||||||
|
final emptyStateOrder = ref.watch(
|
||||||
|
searchModuleOrderProvider(SearchModuleGroup.emptyState),
|
||||||
|
);
|
||||||
|
final searchOrder = ref.watch(
|
||||||
|
searchModuleOrderProvider(SearchModuleGroup.search),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> openUriInTab(Uri 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final emptyStateWidgets = <SearchModuleType, Widget>{
|
||||||
|
SearchModuleType.topSites: TopSitesSection(
|
||||||
|
onUriSelected: openUriInTab,
|
||||||
|
),
|
||||||
|
SearchModuleType.recentArticles: RecentFeedArticlesSection(
|
||||||
|
onArticleSelected: (article) {
|
||||||
|
unawaited(
|
||||||
|
FeedArticleRoute(articleId: article.id).push(context),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SearchModuleType.recentTabs: RecentTabsSection(
|
||||||
|
onTabSelected: (tabId) async {
|
||||||
|
await ref
|
||||||
|
.read(tabRepositoryProvider.notifier)
|
||||||
|
.selectTab(tabId);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ref
|
||||||
|
.read(bottomSheetControllerProvider.notifier)
|
||||||
|
.requestDismiss();
|
||||||
|
const BrowserRoute().go(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SearchModuleType.recentHistory: RecentHistorySection(
|
||||||
|
onUriSelected: openUriInTab,
|
||||||
|
),
|
||||||
|
SearchModuleType.historyHighlights: HistoryHighlightsSection(
|
||||||
|
onUriSelected: openUriInTab,
|
||||||
|
),
|
||||||
|
SearchModuleType.containers: 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 (dialogResult == true) {
|
||||||
|
await ref
|
||||||
|
.read(startProxyControllerProvider.notifier)
|
||||||
|
.startProxy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted && result != SetContainerResult.failed) {
|
||||||
|
const TabViewRoute().go(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
final searchWidgets = <SearchModuleType, Widget>{
|
||||||
|
SearchModuleType.tabs: TabSearch(
|
||||||
|
searchTextListenable: sampledSearchText,
|
||||||
|
),
|
||||||
|
SearchModuleType.bookmarks: BookmarkSearch(
|
||||||
|
searchTextListenable: sampledSearchText,
|
||||||
|
onUriSelected: openUriInTab,
|
||||||
|
),
|
||||||
|
SearchModuleType.articles: FeedSearch(
|
||||||
|
searchTextNotifier: sampledSearchText,
|
||||||
|
),
|
||||||
|
SearchModuleType.history: HistorySuggestions(
|
||||||
|
searchTextListenable: sampledSearchText,
|
||||||
|
onUriSelected: openUriInTab,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Form(
|
child: Form(
|
||||||
@@ -502,212 +625,30 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: ClipboardFillLink(controller: searchTextController),
|
child: ClipboardFillLink(controller: searchTextController),
|
||||||
),
|
),
|
||||||
if (showNoInputSections) ...[
|
if (reorderGroup != null)
|
||||||
TopSitesSection(
|
SearchModuleReorderView(group: reorderGroup)
|
||||||
onUriSelected: (uri) async {
|
else if (showNoInputSections) ...[
|
||||||
if (isEditMode) {
|
for (final entry in emptyStateOrder)
|
||||||
await ref
|
if (emptyStateWidgets.containsKey(entry.type))
|
||||||
.read(tabSessionProvider(tabId: tabId).notifier)
|
emptyStateWidgets[entry.type]!,
|
||||||
.loadUrl(url: uri);
|
if (!emptyStateOrder.any((e) => e.visible))
|
||||||
} else {
|
const _CustomizeSectionsButton(
|
||||||
await ref
|
group: SearchModuleGroup.emptyState,
|
||||||
.read(tabRepositoryProvider.notifier)
|
),
|
||||||
.addTab(
|
] else ...[
|
||||||
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) {
|
|
||||||
unawaited(
|
|
||||||
FeedArticleRoute(articleId: article.id).push(context),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
RecentTabsSection(
|
|
||||||
onTabSelected: (tabId) async {
|
|
||||||
await ref
|
|
||||||
.read(tabRepositoryProvider.notifier)
|
|
||||||
.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 (dialogResult == true) {
|
|
||||||
await ref
|
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.startProxy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.mounted &&
|
|
||||||
result != SetContainerResult.failed) {
|
|
||||||
const TabViewRoute().go(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (!showNoInputSections) ...[
|
|
||||||
FullSearchTermSuggestions(
|
FullSearchTermSuggestions(
|
||||||
searchTextController: searchTextController,
|
searchTextController: searchTextController,
|
||||||
activeBang: activeBang,
|
activeBang: activeBang,
|
||||||
submitSearch: submitSearch,
|
submitSearch: submitSearch,
|
||||||
domain: isEditMode ? existingTabState.url.host : null,
|
domain: isEditMode ? existingTabState.url.host : null,
|
||||||
),
|
),
|
||||||
TabSearch(searchTextListenable: sampledSearchText),
|
for (final entry in searchOrder)
|
||||||
BookmarkSearch(
|
if (searchWidgets.containsKey(entry.type))
|
||||||
searchTextListenable: sampledSearchText,
|
searchWidgets[entry.type]!,
|
||||||
onUriSelected: (uri) async {
|
if (!searchOrder.any((e) => e.visible))
|
||||||
if (isEditMode) {
|
const _CustomizeSectionsButton(
|
||||||
await ref
|
group: SearchModuleGroup.search,
|
||||||
.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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -716,3 +657,26 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _CustomizeSectionsButton extends ConsumerWidget {
|
||||||
|
final SearchModuleGroup group;
|
||||||
|
|
||||||
|
const _CustomizeSectionsButton({required this.group});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 24),
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(searchReorderModeProvider.notifier).activate(group),
|
||||||
|
icon: const Icon(Icons.tune, size: 18),
|
||||||
|
label: const Text('Customize sections'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* 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/search_module_order.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart';
|
||||||
|
|
||||||
|
class SearchModuleReorderView extends ConsumerWidget {
|
||||||
|
final SearchModuleGroup group;
|
||||||
|
|
||||||
|
const SearchModuleReorderView({super.key, required this.group});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final entries = ref.watch(searchModuleOrderProvider(group));
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return SliverMainAxisGroup(
|
||||||
|
slivers: [
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 8, 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Customize Sections',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(searchReorderModeProvider.notifier)
|
||||||
|
.deactivate(),
|
||||||
|
child: const Text('Done'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverReorderableList(
|
||||||
|
itemCount: entries.length,
|
||||||
|
onReorder: (oldIndex, newIndex) {
|
||||||
|
ref
|
||||||
|
.read(searchModuleOrderProvider(group).notifier)
|
||||||
|
.reorder(oldIndex, newIndex);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
return Material(
|
||||||
|
key: ValueKey(entry.type),
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: ListTile(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
entry.visible
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
color: entry.visible
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
onPressed: () => ref
|
||||||
|
.read(searchModuleOrderProvider(group).notifier)
|
||||||
|
.toggleVisibility(entry.type),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
entry.type.label.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: entry.visible
|
||||||
|
? null
|
||||||
|
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: ReorderableDragStartListener(
|
||||||
|
index: index,
|
||||||
|
child: Icon(
|
||||||
|
Icons.drag_handle,
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
@@ -37,6 +37,9 @@ class SearchModuleHeader extends StatelessWidget {
|
|||||||
/// The trailing button is hidden when totalCount <= this value.
|
/// The trailing button is hidden when totalCount <= this value.
|
||||||
final int previewLimit;
|
final int previewLimit;
|
||||||
|
|
||||||
|
/// Called when the header is long-pressed (e.g. to enter reorder mode).
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
const SearchModuleHeader({
|
const SearchModuleHeader({
|
||||||
super.key,
|
super.key,
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -46,6 +49,7 @@ class SearchModuleHeader extends StatelessWidget {
|
|||||||
required this.onToggleExpansion,
|
required this.onToggleExpansion,
|
||||||
this.headerTrailing,
|
this.headerTrailing,
|
||||||
this.previewLimit = 3,
|
this.previewLimit = 3,
|
||||||
|
this.onLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -61,6 +65,7 @@ class SearchModuleHeader extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onToggleCollapse,
|
onTap: onToggleCollapse,
|
||||||
|
onLongPress: onLongPress,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
|||||||
+13
@@ -20,6 +20,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:sliver_tools/sliver_tools.dart';
|
import 'package:sliver_tools/sliver_tools.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_module_order.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.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';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,15 @@ class SearchModuleSection extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final moduleOrder = ref.watch(
|
||||||
|
searchModuleOrderProvider(moduleType.group),
|
||||||
|
);
|
||||||
|
final isVisible = moduleOrder
|
||||||
|
.any((e) => e.type == moduleType && e.visible);
|
||||||
|
if (!isVisible) {
|
||||||
|
return MultiSliver(children: const []);
|
||||||
|
}
|
||||||
|
|
||||||
final displayState = ref.watch(
|
final displayState = ref.watch(
|
||||||
searchModuleDisplayStateControllerProvider(moduleType),
|
searchModuleDisplayStateControllerProvider(moduleType),
|
||||||
);
|
);
|
||||||
@@ -102,6 +112,9 @@ class SearchModuleSection extends ConsumerWidget {
|
|||||||
).notifier,
|
).notifier,
|
||||||
)
|
)
|
||||||
.toggleExpansion(),
|
.toggleExpansion(),
|
||||||
|
onLongPress: () => ref
|
||||||
|
.read(searchReorderModeProvider.notifier)
|
||||||
|
.activate(moduleType.group),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f';
|
String _$topSiteRepositoryHash() => r'e32ddb20e61fcd3830c4eb88704824a57e7a536a';
|
||||||
|
|
||||||
abstract class _$TopSiteRepository extends $Notifier<void> {
|
abstract class _$TopSiteRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
Reference in New Issue
Block a user