diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart new file mode 100644 index 00000000..cd191574 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import '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 _mergeWithDefaults( + List? persisted, + List 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 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) + .cast>() + .map((e) { + try { + return ( + type: SearchModuleType.values.byName(e['type']! as String), + visible: e['visible']! as bool, + ); + } catch (_) { + return null; + } + }) + .whereType() + .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(); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart new file mode 100644 index 00000000..39fd77f0 --- /dev/null +++ b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart @@ -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> { + 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 value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(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, + List, + List, + 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> { + late final _$args = ref.$arg as SearchModuleGroup; + SearchModuleGroup get group => _$args; + + List build(SearchModuleGroup group); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart index 3b8d7f93..a5f980c2 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.dart @@ -31,7 +31,62 @@ enum SearchModuleType { recentHistory, recentArticles, 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 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 } @@ -67,3 +122,13 @@ class SearchModuleDisplayStateController 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; +} diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart index 37a5f4ff..e51d0e33 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_modules_view.g.dart @@ -119,3 +119,55 @@ abstract class _$SearchModuleDisplayStateController element.handleCreate(ref, () => build(_$args)); } } + +@ProviderFor(SearchReorderMode) +final searchReorderModeProvider = SearchReorderModeProvider._(); + +final class SearchReorderModeProvider + extends $NotifierProvider { + 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(value), + ); + } +} + +String _$searchReorderModeHash() => r'eda188e53e5b5f1a331ce94c3cb8808c79e358d3'; + +abstract class _$SearchReorderMode extends $Notifier { + SearchModuleGroup? build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + SearchModuleGroup?, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/screens/search.dart b/app/lib/features/geckoview/features/search/presentation/screens/search.dart index 29e8770f..91a774ea 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -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/repositories/tab.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/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/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_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/feed_search.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 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.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( + 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.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( body: SafeArea( child: Form( @@ -502,212 +625,30 @@ class SearchScreen extends HookConsumerWidget { SliverToBoxAdapter( child: ClipboardFillLink(controller: searchTextController), ), - if (showNoInputSections) ...[ - TopSitesSection( - 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) { - 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( - 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) ...[ + if (reorderGroup != null) + SearchModuleReorderView(group: reorderGroup) + else if (showNoInputSections) ...[ + for (final entry in emptyStateOrder) + if (emptyStateWidgets.containsKey(entry.type)) + emptyStateWidgets[entry.type]!, + if (!emptyStateOrder.any((e) => e.visible)) + const _CustomizeSectionsButton( + group: SearchModuleGroup.emptyState, + ), + ] else ...[ 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(); - - 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); - } - }, - ), + for (final entry in searchOrder) + if (searchWidgets.containsKey(entry.type)) + searchWidgets[entry.type]!, + if (!searchOrder.any((e) => e.visible)) + const _CustomizeSectionsButton( + group: SearchModuleGroup.search, + ), ], ], ), @@ -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'), + ), + ), + ), + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart new file mode 100644 index 00000000..6528608d --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart @@ -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 . + */ +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, + ), + ), + ), + ); + }, + ), + ], + ); + } +} diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart index a03a9b28..1fbe73cc 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_header.dart @@ -37,6 +37,9 @@ class SearchModuleHeader extends StatelessWidget { /// The trailing button is hidden when totalCount <= this value. final int previewLimit; + /// Called when the header is long-pressed (e.g. to enter reorder mode). + final VoidCallback? onLongPress; + const SearchModuleHeader({ super.key, required this.title, @@ -46,6 +49,7 @@ class SearchModuleHeader extends StatelessWidget { required this.onToggleExpansion, this.headerTrailing, this.previewLimit = 3, + this.onLongPress, }); @override @@ -61,6 +65,7 @@ class SearchModuleHeader extends StatelessWidget { Expanded( child: InkWell( onTap: onToggleCollapse, + onLongPress: onLongPress, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart index 290b5f49..6a683166 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart @@ -20,6 +20,7 @@ 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_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/search_modules/search_module_header.dart'; @@ -63,6 +64,15 @@ class SearchModuleSection extends ConsumerWidget { @override 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( searchModuleDisplayStateControllerProvider(moduleType), ); @@ -102,6 +112,9 @@ class SearchModuleSection extends ConsumerWidget { ).notifier, ) .toggleExpansion(), + onLongPress: () => ref + .read(searchReorderModeProvider.notifier) + .activate(moduleType.group), ), ), ), diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart index 3f0154d3..ba9eee7d 100644 --- a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart +++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.g.dart @@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider } } -String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f'; +String _$topSiteRepositoryHash() => r'e32ddb20e61fcd3830c4eb88704824a57e7a536a'; abstract class _$TopSiteRepository extends $Notifier { void build();