improved ui/ux
This commit is contained in:
+61
@@ -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,
|
||||
|
||||
+127
@@ -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(
|
||||
|
||||
+582
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -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,
|
||||
|
||||
+12
-2
@@ -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(
|
||||
|
||||
+41
-6
@@ -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)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ final class TopSiteRepositoryProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$topSiteRepositoryHash() => r'b70c50a60411cbafb5367cd303d822c2eeb11add';
|
||||
String _$topSiteRepositoryHash() => r'b0cd825d479609ff0be3c9cf0f585ab9b4d6d86f';
|
||||
|
||||
abstract class _$TopSiteRepository extends $Notifier<void> {
|
||||
void build();
|
||||
|
||||
Reference in New Issue
Block a user