From e0c59f68efec1ad4e14e0d3aad08c9fc5de02c51 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 9 Mar 2026 11:32:56 +0100 Subject: [PATCH] improved ui/ux --- .../widgets/browser_menu_sheet.dart | 61 ++ .../domain/providers/search_modules_view.dart | 1 + .../dialogs/edit_top_site_dialog.dart | 127 ++++ .../search/presentation/screens/search.dart | 8 +- .../empty_state/top_sites_section.dart | 582 ++++++++++++++++++ .../search_modules/search_module_header.dart | 5 + .../search_modules/search_module_section.dart | 14 +- .../repositories/top_site_repository.dart | 47 +- .../repositories/top_site_repository.g.dart | 2 +- 9 files changed, 835 insertions(+), 12 deletions(-) create mode 100644 app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart create mode 100644 app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart diff --git a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart index bf73ea1f..4ec3606f 100644 --- a/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart +++ b/app/lib/features/geckoview/features/browser/presentation/widgets/browser_menu_sheet.dart @@ -63,6 +63,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/entities/contai import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart'; import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart'; import 'package:weblibre/features/sync/domain/repositories/sync.dart'; import 'package:weblibre/features/tor/domain/services/tor_proxy.dart'; @@ -383,6 +384,10 @@ class _PageActionsCard extends HookConsumerWidget { ), _buildDivider(), + // Pin/Unpin Top Site + _PinTopSiteTile(selectedTabId: selectedTabId), + _buildDivider(), + // Find in page ListTile( leading: const Icon(Icons.search), @@ -575,6 +580,62 @@ class _AddToHomeScreenTile extends ConsumerWidget { } } +class _PinTopSiteTile extends HookConsumerWidget { + final String selectedTabId; + + const _PinTopSiteTile({required this.selectedTabId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tabState = ref.watch(tabStateProvider(selectedTabId)); + final url = tabState?.url; + + final isPinned = useCachedFuture( + () => url != null + ? ref + .read(topSiteRepositoryProvider.notifier) + .isPersistedTopSiteUrl(url) + : Future.value(false), + [url], + ); + + final pinned = isPinned.data ?? false; + + return ListTile( + leading: Icon(pinned ? MdiIcons.pinOff : MdiIcons.pin), + title: Text(pinned ? 'Unpin from Top Sites' : 'Pin to Top Sites'), + onTap: () async { + if (tabState == null || url == null) return; + Navigator.pop(context); + try { + if (pinned) { + await ref + .read(topSiteRepositoryProvider.notifier) + .unpinSiteByUrl(url); + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Unpinned from Top Sites'); + } + } else { + await ref + .read(topSiteRepositoryProvider.notifier) + .addPinnedSite( + title: tabState.titleOrAuthority, + url: url, + ); + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Pinned to Top Sites'); + } + } + } catch (e) { + if (context.mounted) { + ui_helper.showErrorMessage(context, 'Failed to update Top Sites'); + } + } + }, + ); + } +} + class _TranslatePageTile extends ConsumerWidget { final String selectedTabId; 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 26642506..3b8d7f93 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 @@ -27,6 +27,7 @@ enum SearchModuleType { bookmarks, history, historyHighlights, + topSites, recentHistory, recentArticles, recentTabs, diff --git a/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart b/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart new file mode 100644 index 00000000..385cd9b4 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart @@ -0,0 +1,127 @@ +/* + * 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'; + +Future<({String title, Uri url})?> showEditTopSiteDialog( + BuildContext context, { + required String initialTitle, + required Uri initialUrl, +}) { + return showDialog<({String title, Uri url})>( + context: context, + builder: (context) => _EditTopSiteDialog( + initialTitle: initialTitle, + initialUrl: initialUrl, + ), + ); +} + +class _EditTopSiteDialog extends StatefulWidget { + final String initialTitle; + final Uri initialUrl; + + const _EditTopSiteDialog({ + required this.initialTitle, + required this.initialUrl, + }); + + @override + State<_EditTopSiteDialog> createState() => _EditTopSiteDialogState(); +} + +class _EditTopSiteDialogState extends State<_EditTopSiteDialog> { + late final TextEditingController _titleController; + late final TextEditingController _urlController; + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.initialTitle); + _urlController = TextEditingController(text: widget.initialUrl.toString()); + } + + @override + void dispose() { + _titleController.dispose(); + _urlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Edit Top Site'), + content: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + autofocus: true, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Title cannot be empty'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _urlController, + decoration: const InputDecoration(labelText: 'URL'), + keyboardType: TextInputType.url, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'URL cannot be empty'; + } + final uri = Uri.tryParse(value.trim()); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) { + return 'Enter a valid URL'; + } + return null; + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + if (_formKey.currentState?.validate() == true) { + final url = Uri.parse(_urlController.text.trim()); + Navigator.pop( + context, + (title: _titleController.text.trim(), url: url), + ); + } + }, + child: const Text('Save'), + ), + ], + ); + } +} 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 247c0d84..29e8770f 100644 --- a/app/lib/features/geckoview/features/search/presentation/screens/search.dart +++ b/app/lib/features/geckoview/features/search/presentation/screens/search.dart @@ -36,10 +36,10 @@ import 'package:weblibre/features/geckoview/features/browser/domain/providers.da 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_modules/bookmark_search.dart'; import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart'; @@ -503,7 +503,7 @@ class SearchScreen extends HookConsumerWidget { child: ClipboardFillLink(controller: searchTextController), ), if (showNoInputSections) ...[ - HistoryHighlightsSection( + TopSitesSection( onUriSelected: (uri) async { if (isEditMode) { await ref @@ -539,7 +539,9 @@ class SearchScreen extends HookConsumerWidget { ), RecentFeedArticlesSection( onArticleSelected: (article) { - FeedArticleRoute(articleId: article.id).go(context); + unawaited( + FeedArticleRoute(articleId: article.id).push(context), + ); }, ), RecentTabsSection( diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart new file mode 100644 index 00000000..67902682 --- /dev/null +++ b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/top_sites_section.dart @@ -0,0 +1,582 @@ +/* + * Copyright (c) 2024-2026 Fabian Freund. + * + * This file is part of WebLibre + * (see https://weblibre.eu). + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_reorderable_grid_view/widgets/custom_draggable.dart'; +import 'package:flutter_reorderable_grid_view/widgets/reorderable_builder.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/features/geckoview/features/search/domain/providers/search_modules_view.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/dialogs/edit_top_site_dialog.dart'; +import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_module_section.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_item.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/entities/top_site_source.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/providers.dart'; +import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart'; +import 'package:weblibre/presentation/widgets/url_icon.dart'; +import 'package:weblibre/utils/ui_helper.dart' as ui_helper; + +const _gridMainAxisSpacing = 8.0; +const _gridCrossAxisSpacing = 8.0; +const _topSitesPreviewLimit = 8; +const _topSitesMaxLimit = 25; + +class _TopSitesGridLayout { + final int crossAxisCount; + final double childAspectRatio; + + const _TopSitesGridLayout({ + required this.crossAxisCount, + required this.childAspectRatio, + }); +} + +_TopSitesGridLayout _resolveGridLayout(double width) { + const minTileWidth = 74.0; + const effectiveTileWidth = minTileWidth + _gridCrossAxisSpacing; + final rawCount = (width / effectiveTileWidth).floor(); + final crossAxisCount = rawCount.clamp(4, 7); + + // Keep cells compact on phones and allow slightly wider tiles on large screens. + final childAspectRatio = switch (crossAxisCount) { + 4 => 0.92, + 5 => 0.96, + _ => 1.0, + }; + + return _TopSitesGridLayout( + crossAxisCount: crossAxisCount, + childAspectRatio: childAspectRatio, + ); +} + +class TopSitesSection extends HookConsumerWidget { + final void Function(Uri uri) onUriSelected; + + const TopSitesSection({super.key, required this.onUriSelected}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final topSitesAsync = ref.watch( + topSiteListProvider(limit: _topSitesMaxLimit), + ); + final topSites = topSitesAsync.value ?? []; + + if (topSitesAsync.hasValue && topSites.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + final reorderMode = useState(false); + final reorderBusy = useState(false); + + final persistedItems = topSites.where((s) => s.isPersisted).toList(); + final historyItems = topSites.where((s) => !s.isPersisted).toList(); + + return SearchModuleSection( + title: 'Top Sites', + moduleType: SearchModuleType.topSites, + totalCount: topSites.length, + previewLimit: _topSitesPreviewLimit, + headerTrailing: persistedItems.length >= 2 + ? IconButton.filledTonal( + icon: const Icon(Icons.swap_vert), + visualDensity: VisualDensity.compact, + isSelected: reorderMode.value, + iconSize: 18, + padding: EdgeInsets.zero, + tooltip: reorderMode.value + ? 'Disable reordering mode' + : 'Enable reordering mode', + onPressed: () { + final wasEnabled = reorderMode.value; + reorderMode.value = !wasEnabled; + if (!wasEnabled && context.mounted) { + ui_helper.showInfoMessage( + context, + 'Drag and drop top sites to reorder', + ); + } + }, + ) + : null, + contentSliverBuilder: + ({required bool isCollapsed, required int visibleCount}) => [ + if (!isCollapsed) + if (reorderMode.value) + _ReorderableTopSitesGrid( + persistedItems: persistedItems, + historyItems: historyItems, + reorderBusy: reorderBusy, + onUriSelected: onUriSelected, + ) + else + _TopSitesGrid( + items: topSites, + visibleCount: visibleCount, + onUriSelected: onUriSelected, + ), + ], + ); + } +} + +class _TopSitesGrid extends ConsumerWidget { + final List items; + final int visibleCount; + final void Function(Uri uri) onUriSelected; + + const _TopSitesGrid({ + required this.items, + required this.visibleCount, + required this.onUriSelected, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final displayItems = items.take(visibleCount).toList(); + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + sliver: SliverLayoutBuilder( + builder: (context, constraints) { + final layout = _resolveGridLayout(constraints.crossAxisExtent); + return SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: layout.crossAxisCount, + mainAxisSpacing: _gridMainAxisSpacing, + crossAxisSpacing: _gridCrossAxisSpacing, + childAspectRatio: layout.childAspectRatio, + ), + itemCount: displayItems.length, + itemBuilder: (context, index) { + final item = displayItems[index]; + return _TopSiteGridTile( + item: item, + onTap: () => onUriSelected(item.url), + onPin: () => _pinItem(context, ref, item), + onEdit: () => _editItem(context, ref, item), + onRemove: () => _removeItem(context, ref, item), + ); + }, + ); + }, + ), + ); + } +} + +class _ReorderableTopSitesGrid extends HookConsumerWidget { + final List persistedItems; + final List historyItems; + final ValueNotifier reorderBusy; + final void Function(Uri uri) onUriSelected; + + const _ReorderableTopSitesGrid({ + required this.persistedItems, + required this.historyItems, + required this.reorderBusy, + required this.onUriSelected, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localItems = useState(persistedItems); + + useEffect(() { + localItems.value = persistedItems; + return null; + }, [persistedItems]); + + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0), + child: LayoutBuilder( + builder: (context, constraints) { + final layout = _resolveGridLayout(constraints.maxWidth); + return ReorderableBuilder.builder( + itemCount: localItems.value.length, + onReorderPositions: (positions) async { + if (reorderBusy.value || positions.isEmpty) return; + + final oldIndex = positions.first.oldIndex; + final newIndex = positions.first.newIndex; + if (oldIndex < 0 || oldIndex >= localItems.value.length) return; + if (newIndex < 0 || newIndex > localItems.value.length) return; + + final items = localItems.value.toList(); + final movedItem = items.removeAt(oldIndex); + if (movedItem.id == null) return; + + final targetIndex = newIndex.clamp(0, items.length); + + // The grid package reports target indices in the final order. + items.insert(targetIndex, movedItem); + localItems.value = items; + + reorderBusy.value = true; + try { + final repo = ref.read(topSiteRepositoryProvider.notifier); + + final String key; + if (targetIndex <= 0) { + key = await repo.getLeadingOrderKey(); + } else if (targetIndex >= items.length - 1) { + key = await repo.getTrailingOrderKey(); + } else if (targetIndex < oldIndex) { + key = (await repo.getOrderKeyAfterSite( + items[targetIndex - 1].id!, + ))!; + } else { + key = await repo.getOrderKeyBeforeSite( + items[targetIndex + 1].id!, + ); + } + + await repo.assignOrderKey(movedItem.id!, key); + } catch (e) { + localItems.value = persistedItems; + if (context.mounted) { + ui_helper.showErrorMessage( + context, + 'Failed to reorder top site', + ); + } + } finally { + reorderBusy.value = false; + } + }, + childBuilder: (reorderableItemBuilder) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: layout.crossAxisCount, + mainAxisSpacing: _gridMainAxisSpacing, + crossAxisSpacing: _gridCrossAxisSpacing, + childAspectRatio: layout.childAspectRatio, + ), + itemCount: localItems.value.length, + itemBuilder: (context, index) { + final item = localItems.value[index]; + final draggable = CustomDraggable( + key: ValueKey(item.id ?? 'top-site-$index'), + data: item.id ?? 'top-site-$index', + child: _TopSiteGridTile( + item: item, + onTap: () => onUriSelected(item.url), + showDragHandle: true, + ), + ); + return reorderableItemBuilder(draggable, index); + }, + ), + if (historyItems.isNotEmpty) ...[ + const SizedBox(height: 8.0), + GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: layout.crossAxisCount, + mainAxisSpacing: _gridMainAxisSpacing, + crossAxisSpacing: _gridCrossAxisSpacing, + childAspectRatio: layout.childAspectRatio, + ), + itemCount: historyItems.length, + itemBuilder: (context, index) { + final item = historyItems[index]; + return Opacity( + opacity: 0.5, + child: _TopSiteGridTile( + item: item, + onTap: () => onUriSelected(item.url), + ), + ); + }, + ), + ], + ], + ); + }, + ); + }, + ), + ), + ); + } +} + +class _TopSiteGridTile extends StatelessWidget { + final TopSiteItem item; + final VoidCallback onTap; + final VoidCallback? onPin; + final VoidCallback? onEdit; + final VoidCallback? onRemove; + final bool showDragHandle; + + const _TopSiteGridTile({ + required this.item, + required this.onTap, + this.onPin, + this.onEdit, + this.onRemove, + this.showDragHandle = false, + }); + + static const _iconSize = 40.0; + static const _borderRadius = BorderRadius.all(Radius.circular(12.0)); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: _borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: _borderRadius, + onTap: onTap, + onLongPress: _hasMenu ? () => _showContextMenu(context) : null, + child: Stack( + fit: StackFit.expand, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 10.0, + ), + child: LayoutBuilder( + builder: (context, constraints) { + final titleStyle = textTheme.bodySmall?.copyWith( + color: colorScheme.onSurface, + ); + final lineHeight = + (titleStyle?.fontSize ?? 12.0) * + (titleStyle?.height ?? 1.2); + const textLines = 2; + const gap = 6.0; + const minIconSize = 18.0; + const textHeightPadding = 2.0; + final minTextHeight = lineHeight + textHeightPadding; + final maxTextHeight = + lineHeight * textLines + textHeightPadding; + final iconSize = (constraints.maxHeight - minTextHeight - gap) + .clamp(minIconSize, _iconSize); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + child: SizedBox.square( + dimension: iconSize, + child: RepaintBoundary( + child: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + child: UrlIcon([item.url], iconSize: iconSize), + ), + ), + ), + ), + const SizedBox(height: gap), + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxTextHeight), + child: Text( + item.title, + maxLines: textLines, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: titleStyle, + ), + ), + ), + ], + ); + }, + ), + ), + if (item.source == TopSiteSource.pinned) + Positioned( + top: 4, + right: 4, + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + ), + child: Padding( + padding: const EdgeInsets.all(3.0), + child: Icon( + Icons.push_pin, + size: 12, + color: colorScheme.onPrimaryContainer, + ), + ), + ), + ), + if (showDragHandle) + Positioned( + top: 2, + right: 2, + child: Icon( + Icons.drag_indicator, + size: 16, + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ); + } + + bool get _hasMenu => + (item.isPersisted && (onEdit != null || onRemove != null)) || + (!item.isPersisted && onPin != null); + + Future _showContextMenu(BuildContext context) async { + final RenderBox renderBox = context.findRenderObject()! as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + final value = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + size.height, + position.dx + size.width, + position.dy + size.height, + ), + items: [ + if (!item.isPersisted && onPin != null) + const PopupMenuItem(value: 'pin', child: Text('Pin')), + if (item.isPersisted && onEdit != null) + const PopupMenuItem(value: 'edit', child: Text('Edit')), + if (item.isPersisted && onRemove != null) + const PopupMenuItem(value: 'remove', child: Text('Remove')), + ], + ); + + switch (value) { + case 'pin': + onPin?.call(); + case 'edit': + onEdit?.call(); + case 'remove': + onRemove?.call(); + default: + break; + } + } +} + +Future _pinItem( + BuildContext context, + WidgetRef ref, + TopSiteItem item, +) async { + try { + await ref + .read(topSiteRepositoryProvider.notifier) + .addPinnedSite(title: item.title, url: item.url); + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Pinned "${item.title}"'); + } + } catch (e) { + if (context.mounted) { + ui_helper.showErrorMessage(context, 'Failed to pin site'); + } + } +} + +Future _editItem( + BuildContext context, + WidgetRef ref, + TopSiteItem item, +) async { + if (item.id == null) return; + + final result = await showEditTopSiteDialog( + context, + initialTitle: item.title, + initialUrl: item.url, + ); + + if (result == null || !context.mounted) return; + + try { + await ref + .read(topSiteRepositoryProvider.notifier) + .updatePersistedSite( + id: item.id!, + title: result.title, + url: result.url, + ); + if (context.mounted) { + ui_helper.showInfoMessage(context, 'Top site updated'); + } + } catch (e) { + if (context.mounted) { + ui_helper.showErrorMessage(context, 'Failed to update top site'); + } + } +} + +Future _removeItem( + BuildContext context, + WidgetRef ref, + TopSiteItem item, +) async { + if (item.id == null) return; + + try { + await ref + .read(topSiteRepositoryProvider.notifier) + .removePersistedSite(item.id!); + if (context.mounted) { + ui_helper.showInfoMessage( + context, + 'Removed "${item.title}"', + action: SnackBarAction( + label: 'Undo', + onPressed: () async { + try { + await ref + .read(topSiteRepositoryProvider.notifier) + .addPinnedSite(title: item.title, url: item.url); + } catch (_) {} + }, + ), + ); + } + } catch (e) { + if (context.mounted) { + ui_helper.showErrorMessage(context, 'Failed to remove top site'); + } + } +} 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 6f2313ed..a03a9b28 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 @@ -30,6 +30,9 @@ class SearchModuleHeader extends StatelessWidget { final VoidCallback onToggleCollapse; final VoidCallback onToggleExpansion; + /// Optional widget placed between the title area and the trailing button. + final Widget? headerTrailing; + /// The maximum number of items shown in preview mode. /// The trailing button is hidden when totalCount <= this value. final int previewLimit; @@ -41,6 +44,7 @@ class SearchModuleHeader extends StatelessWidget { required this.displayState, required this.onToggleCollapse, required this.onToggleExpansion, + this.headerTrailing, this.previewLimit = 3, }); @@ -83,6 +87,7 @@ class SearchModuleHeader extends StatelessWidget { ), ), ), + if (headerTrailing != null) headerTrailing!, if (showTrailing) TextButton( onPressed: onToggleExpansion, 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 085304b1..290b5f49 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 @@ -39,6 +39,12 @@ class SearchModuleSection extends ConsumerWidget { /// [isCollapsed] indicates whether the section is fully collapsed (no items). /// [visibleCount] is the number of items to display (0 when collapsed, /// limited in preview, or all when expanded). + /// Optional widget placed in the header between title and trailing button. + final Widget? headerTrailing; + + /// The maximum number of items shown in preview mode for this section. + final int previewLimit; + final List Function({ required bool isCollapsed, required int visibleCount, @@ -51,6 +57,8 @@ class SearchModuleSection extends ConsumerWidget { required this.moduleType, required this.totalCount, required this.contentSliverBuilder, + this.headerTrailing, + this.previewLimit = previewItemsPerModule, }); @override @@ -62,10 +70,10 @@ class SearchModuleSection extends ConsumerWidget { final isCollapsed = displayState == SearchModuleDisplayState.collapsed; final showAllItems = displayState == SearchModuleDisplayState.expanded || - totalCount <= previewItemsPerModule; + totalCount <= previewLimit; final visibleCount = isCollapsed ? 0 - : (showAllItems ? totalCount : previewItemsPerModule); + : (showAllItems ? totalCount : previewLimit); return MultiSliver( pushPinnedChildren: true, @@ -78,6 +86,8 @@ class SearchModuleSection extends ConsumerWidget { title: title, totalCount: totalCount, displayState: displayState, + headerTrailing: isCollapsed ? null : headerTrailing, + previewLimit: previewLimit, onToggleCollapse: () => ref .read( searchModuleDisplayStateControllerProvider( diff --git a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart index 28acbf33..759558cd 100644 --- a/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart +++ b/app/lib/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart @@ -69,12 +69,15 @@ class TopSiteRepository extends _$TopSiteRepository { Future> getTopSites({int limit = 8}) async { final persisted = await _getPersistedItems(); + final targetCount = limit < 0 ? 0 : limit; - if (persisted.length >= limit) { - return persisted.take(limit).toList(); + // Always keep all persisted items. The limit is only used as a history + // padding target. + if (persisted.length >= targetCount) { + return persisted; } - final remaining = limit - persisted.length; + final remaining = targetCount - persisted.length; final historyItems = await _getHistoryItems( limit: remaining, excludeUrls: persisted.map((s) => s.url.toString()).toSet(), @@ -89,12 +92,15 @@ class TopSiteRepository extends _$TopSiteRepository { persistedRows, ) async { final persistedItems = persistedRows.map(_mapPersistedRow).toList(); + final targetCount = limit < 0 ? 0 : limit; - if (persistedItems.length >= limit) { - return persistedItems.take(limit).toList(); + // Always keep all persisted items. The limit is only used as a history + // padding target. + if (persistedItems.length >= targetCount) { + return persistedItems; } - final remaining = limit - persistedItems.length; + final remaining = targetCount - persistedItems.length; final historyItems = await _getHistoryItems( limit: remaining, excludeUrls: persistedItems.map((s) => s.url.toString()).toSet(), @@ -162,6 +168,35 @@ class TopSiteRepository extends _$TopSiteRepository { return ref.read(topSiteDatabaseProvider).topSiteDao.deletePersistedSite(id); } + Future getPersistedTopSiteByUrl(Uri url) async { + final row = await ref + .read(topSiteDatabaseProvider) + .topSiteDao + .getPersistedTopSiteByUrl(url); + return row != null ? _mapPersistedRow(row) : null; + } + + Future isPersistedTopSiteUrl(Uri url) async { + final row = await ref + .read(topSiteDatabaseProvider) + .topSiteDao + .getPersistedTopSiteByUrl(url); + return row != null; + } + + Future unpinSiteByUrl(Uri url) async { + final row = await ref + .read(topSiteDatabaseProvider) + .topSiteDao + .getPersistedTopSiteByUrl(url); + if (row == null) return false; + await ref + .read(topSiteDatabaseProvider) + .topSiteDao + .deletePersistedSite(row.id); + return true; + } + Future assignOrderKey(String id, String orderKey) { return ref .read(topSiteDatabaseProvider) 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 c4e009ca..3f0154d3 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'b70c50a60411cbafb5367cd303d822c2eeb11add'; +String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f'; abstract class _$TopSiteRepository extends $Notifier { void build();