diff --git a/app/lib/features/bangs/domain/providers/search.g.dart b/app/lib/features/bangs/domain/providers/search.g.dart index 7c611838..6cfb078a 100644 --- a/app/lib/features/bangs/domain/providers/search.g.dart +++ b/app/lib/features/bangs/domain/providers/search.g.dart @@ -33,7 +33,7 @@ final class BangSearchProvider BangSearch create() => BangSearch(); } -String _$bangSearchHash() => r'7993bba3765d24ca9a7a17eca86ffdbc7c1b5e65'; +String _$bangSearchHash() => r'feed24edfe703b0697f4a855be9c7359c456b0f2'; abstract class _$BangSearch extends $StreamNotifier> { Stream> build(); diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart index cd191574..e0311da4 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.dart @@ -19,6 +19,8 @@ */ import 'dart:convert'; +import 'package:fast_equatable/fast_equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; import 'package:riverpod/experimental/persist.dart'; import 'package:riverpod_annotation/experimental/persist.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -27,14 +29,30 @@ import 'package:weblibre/features/user/data/providers.dart'; part 'search_module_order.g.dart'; -typedef ModuleOrderEntry = ({SearchModuleType type, bool visible}); +@JsonSerializable() +class ModuleOrderEntry with FastEquatable { + final SearchModuleType type; + final bool visible; + + ModuleOrderEntry({required this.type, required this.visible}); + + factory ModuleOrderEntry.fromJson(Map json) => + _$ModuleOrderEntryFromJson(json); + + Map toJson() => _$ModuleOrderEntryToJson(this); + + @override + List get hashParameters => [type, visible]; +} List _mergeWithDefaults( List? persisted, List defaults, ) { if (persisted == null) { - return defaults.map((type) => (type: type, visible: true)).toList(); + return defaults + .map((type) => ModuleOrderEntry(type: type, visible: true)) + .toList(); } final defaultSet = defaults.toSet(); @@ -44,7 +62,7 @@ List _mergeWithDefaults( final persistedTypes = result.map((e) => e.type).toSet(); for (final type in defaults) { if (!persistedTypes.contains(type)) { - result.add((type: type, visible: true)); + result.add(ModuleOrderEntry(type: type, visible: true)); } } return result; @@ -63,7 +81,10 @@ class SearchModuleOrder extends _$SearchModuleOrder { void toggleVisibility(SearchModuleType type) { state = [ for (final e in state) - if (e.type == type) (type: e.type, visible: !e.visible) else e, + if (e.type == type) + ModuleOrderEntry(type: e.type, visible: !e.visible) + else + e, ]; } @@ -72,20 +93,13 @@ class SearchModuleOrder extends _$SearchModuleOrder { persist( ref.watch(riverpodDatabaseStorageProvider), key: group.key, - encode: (state) => jsonEncode( - state - .map((e) => {'type': e.type.name, 'visible': e.visible}) - .toList(), - ), + encode: (state) => jsonEncode(state.map((e) => e.toJson()).toList()), decode: (encoded) { final decoded = (jsonDecode(encoded) as List) .cast>() .map((e) { try { - return ( - type: SearchModuleType.values.byName(e['type']! as String), - visible: e['visible']! as bool, - ); + return ModuleOrderEntry.fromJson(e); } catch (_) { return null; } @@ -99,7 +113,7 @@ class SearchModuleOrder extends _$SearchModuleOrder { return stateOrNull ?? group.defaultModules - .map((type) => (type: type, visible: true)) + .map((type) => ModuleOrderEntry(type: type, visible: true)) .toList(); } } diff --git a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart index 1cc176e7..d312ff73 100644 --- a/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart +++ b/app/lib/features/geckoview/features/search/domain/providers/search_module_order.g.dart @@ -2,6 +2,35 @@ part of 'search_module_order.dart'; +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ModuleOrderEntry _$ModuleOrderEntryFromJson(Map json) => + ModuleOrderEntry( + type: $enumDecode(_$SearchModuleTypeEnumMap, json['type']), + visible: json['visible'] as bool, + ); + +Map _$ModuleOrderEntryToJson(ModuleOrderEntry instance) => + { + 'type': _$SearchModuleTypeEnumMap[instance.type]!, + 'visible': instance.visible, + }; + +const _$SearchModuleTypeEnumMap = { + SearchModuleType.tabs: 'tabs', + SearchModuleType.articles: 'articles', + SearchModuleType.bookmarks: 'bookmarks', + SearchModuleType.history: 'history', + SearchModuleType.historyHighlights: 'historyHighlights', + SearchModuleType.topSites: 'topSites', + SearchModuleType.recentHistory: 'recentHistory', + SearchModuleType.recentArticles: 'recentArticles', + SearchModuleType.recentTabs: 'recentTabs', + SearchModuleType.containers: 'containers', +}; + // ************************************************************************** // RiverpodGenerator // ************************************************************************** @@ -58,7 +87,7 @@ final class SearchModuleOrderProvider } } -String _$searchModuleOrderHash() => r'eeea86534497671a12c1383cbf251a8df797c1fc'; +String _$searchModuleOrderHash() => r'245c933174b1808b2cb27c4c375fac4edd12d727'; final class SearchModuleOrderFamily extends $Family with diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart index e4ae7f67..c496bfa2 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/history_highlights_section.dart @@ -31,10 +31,13 @@ class HistoryHighlightsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final highlightsAsync = ref.watch(searchEmptyHistoryHighlightsProvider()); - final highlights = highlightsAsync.value ?? []; + final highlights = ref.watch( + searchEmptyHistoryHighlightsProvider().select( + (value) => value.value ?? [], + ), + ); - if (highlightsAsync.hasValue && highlights.isEmpty) { + if (highlights.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart index 241a0eb2..ae4a6c26 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_feed_articles_section.dart @@ -37,10 +37,13 @@ class RecentFeedArticlesSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final articlesAsync = ref.watch(searchEmptyRecentFeedArticlesProvider()); - final articles = articlesAsync.value ?? []; + final articles = ref.watch( + searchEmptyRecentFeedArticlesProvider().select( + (value) => value.value ?? [], + ), + ); - if (articlesAsync.hasValue && articles.isEmpty) { + if (articles.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } diff --git a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart index 24a4ed93..7eadb2ee 100644 --- a/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart +++ b/app/lib/features/geckoview/features/search/presentation/widgets/empty_state/recent_history_section.dart @@ -31,10 +31,11 @@ class RecentHistorySection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final historyAsync = ref.watch(searchEmptyRecentHistoryProvider()); - final visits = historyAsync.value ?? []; + final visits = ref.watch( + searchEmptyRecentHistoryProvider().select((value) => value.value ?? []), + ); - if (historyAsync.hasValue && visits.isEmpty) { + if (visits.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } 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 index c38075d3..5ac0c380 100644 --- 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 @@ -73,12 +73,13 @@ class TopSitesSection extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final topSitesAsync = ref.watch( - topSiteListProvider(limit: _topSitesMaxLimit), + final topSites = ref.watch( + topSiteListProvider( + limit: _topSitesMaxLimit, + ).select((value) => value.value ?? []), ); - final topSites = topSitesAsync.value ?? []; - if (topSitesAsync.hasValue && topSites.isEmpty) { + if (topSites.isEmpty) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -335,7 +336,7 @@ class _ReorderableTopSitesGrid extends HookConsumerWidget { } } -class _TopSiteGridTile extends StatelessWidget { +class _TopSiteGridTile extends StatefulWidget { final TopSiteItem item; final VoidCallback onTap; final VoidCallback? onPin; @@ -355,153 +356,149 @@ class _TopSiteGridTile extends StatelessWidget { static const _iconSize = 40.0; static const _borderRadius = BorderRadius.all(Radius.circular(12.0)); + @override + State<_TopSiteGridTile> createState() => _TopSiteGridTileState(); +} + +class _TopSiteGridTileState extends State<_TopSiteGridTile> { + final _menuController = MenuController(); + + bool get _hasMenu => + (widget.item.isPersisted && + (widget.onEdit != null || widget.onRemove != null)) || + (!widget.item.isPersisted && widget.onPin != null); + @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 = 16.0; - final minTextHeight = lineHeight + textHeightPadding; - final maxTextHeight = - lineHeight * textLines + textHeightPadding; - final iconSize = (constraints.maxHeight - minTextHeight - gap) - .clamp(minIconSize, _iconSize); + return MenuAnchor( + controller: _menuController, + menuChildren: [ + if (!widget.item.isPersisted && widget.onPin != null) + MenuItemButton(onPressed: widget.onPin, child: const Text('Pin')), + if (widget.item.isPersisted && widget.onEdit != null) + MenuItemButton(onPressed: widget.onEdit, child: const Text('Edit')), + if (widget.item.isPersisted && widget.onRemove != null) + MenuItemButton( + onPressed: widget.onRemove, + child: const Text('Remove'), + ), + ], + child: Material( + color: colorScheme.surfaceContainerHigh, + borderRadius: _TopSiteGridTile._borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + borderRadius: _TopSiteGridTile._borderRadius, + onTap: widget.onTap, + onLongPress: _hasMenu ? () => _menuController.open() : 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 = 16.0; + final minTextHeight = lineHeight + textHeightPadding; + final maxTextHeight = + lineHeight * textLines + textHeightPadding; + final iconSize = + (constraints.maxHeight - minTextHeight - gap).clamp( + minIconSize, + _TopSiteGridTile._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), + 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([ + widget.item.url, + ], iconSize: iconSize), ), - 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, + const SizedBox(height: gap), + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: maxTextHeight, + ), + child: Text( + widget.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 (widget.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), + if (widget.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( 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 b95dc679..ce34d1a4 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'8aa231fadabe1e115d92cd37e032f27ba8da55e6'; +String _$topSiteRepositoryHash() => r'6e954c84ed5916ac0b5a9e251d43cbe1e9ed15c3'; abstract class _$TopSiteRepository extends $Notifier { void build();