improved ui/ux

This commit is contained in:
Fabian Freund
2026-03-09 11:32:56 +01:00
parent 20ed5dc67c
commit e0c59f68ef
9 changed files with 835 additions and 12 deletions
@@ -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;
@@ -27,6 +27,7 @@ enum SearchModuleType {
bookmarks,
history,
historyHighlights,
topSites,
recentHistory,
recentArticles,
recentTabs,
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<FormState>();
@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'),
),
],
);
}
}
@@ -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(
@@ -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 <http://www.gnu.org/licenses/>.
*/
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<TopSiteItem> 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<TopSiteItem> persistedItems;
final List<TopSiteItem> historyItems;
final ValueNotifier<bool> 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<void> _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<String>(
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<void> _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<void> _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<void> _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');
}
}
}
@@ -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,
@@ -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<Widget> 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(
@@ -69,12 +69,15 @@ class TopSiteRepository extends _$TopSiteRepository {
Future<List<TopSiteItem>> 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<TopSiteItem?> getPersistedTopSiteByUrl(Uri url) async {
final row = await ref
.read(topSiteDatabaseProvider)
.topSiteDao
.getPersistedTopSiteByUrl(url);
return row != null ? _mapPersistedRow(row) : null;
}
Future<bool> isPersistedTopSiteUrl(Uri url) async {
final row = await ref
.read(topSiteDatabaseProvider)
.topSiteDao
.getPersistedTopSiteByUrl(url);
return row != null;
}
Future<bool> 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<void> assignOrderKey(String id, String orderKey) {
return ref
.read(topSiteDatabaseProvider)
@@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider
}
}
String _$topSiteRepositoryHash() => r'b70c50a60411cbafb5367cd303d822c2eeb11add';
String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f';
abstract class _$TopSiteRepository extends $Notifier<void> {
void build();