From f2cb542025d7fb4975689407ea22f99b2f9b142c Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 1 Dec 2025 10:10:28 +0100 Subject: [PATCH] implement text filter; expand all nodes action; --- .../bookmarks/domain/providers/bookmarks.dart | 97 ++++++++++++ .../domain/providers/bookmarks.g.dart | 145 ++++++++++++++++++ .../presentation/screens/bookmark_list.dart | 88 ++++++++++- 3 files changed, 326 insertions(+), 4 deletions(-) diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart index 10a1bd79..b00b00e4 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart @@ -1,3 +1,7 @@ +import 'dart:async'; + +import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; +import 'package:nullability/nullability.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart'; @@ -42,6 +46,57 @@ T _cloneAndFilterChildrenType(T node) { return node.clone() as T; } +BookmarkItem? _cloneAndFilterOnGuids(BookmarkItem node, Set guids) { + if (node is BookmarkFolder) { + if (node.children != null) { + final filtered = node.children + ?.where((e) => e is BookmarkFolder || guids.contains(e.guid)) + .map((e) => _cloneAndFilterOnGuids(e, guids)) + .nonNulls + .toList(); + + if (filtered.isNotEmpty) { + return node.copyWith.children(filtered); + } else { + return null; + } + } + } + + if (guids.contains(node.guid)) { + return node.clone(); + } + + return null; +} + +@Riverpod() +class BookmarksSearch extends _$BookmarksSearch { + final _service = GeckoBookmarksService(); + late StreamController> _streamController; + + Future search(String query, {int limit = 10}) async { + if (query.isNotEmpty) { + await _service.searchBookmarks(query, limit: limit).then((value) { + if (!_streamController.isClosed) { + _streamController.add(value.map((e) => e.guid).toSet()); + } + }); + } + } + + @override + Stream> build() { + _streamController = StreamController(); + + ref.onDispose(() async { + await _streamController.close(); + }); + + return _streamController.stream; + } +} + @Riverpod() AsyncValue bookmarks(Ref ref, String entryGuid) { final bookmarksAsync = ref.watch(bookmarksRepositoryProvider); @@ -66,3 +121,45 @@ AsyncValue bookmarks(Ref ref, String entryGuid) { return null; }); } + +@Riverpod() +class SeamlessBookmarks extends _$SeamlessBookmarks { + bool _hasSearch = false; + + void search(String input) { + if (input.isNotEmpty) { + if (!_hasSearch) { + _hasSearch = true; + ref.invalidateSelf(); + } + + //Don't block + unawaited(ref.read(bookmarksSearchProvider.notifier).search(input)); + } else if (_hasSearch) { + _hasSearch = false; + ref.invalidateSelf(); + } + } + + @override + AsyncValue build(String entryGuid) { + final bookmarks = ref.watch(bookmarksProvider(entryGuid)); + + if (_hasSearch) { + final filterGuids = ref.watch(bookmarksSearchProvider); + return bookmarks.map( + data: (node) => + node.value.mapNotNull( + (node) => filterGuids.whenData( + (results) => _cloneAndFilterOnGuids(node, results), + ), + ) ?? + const AsyncValue.data(null), + error: (e) => e, + loading: (s) => s, + ); + } else { + return bookmarks; + } + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.g.dart index 010cd3d2..db87bd4e 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.g.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/providers/bookmarks.g.dart @@ -9,6 +9,51 @@ part of 'bookmarks.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning +@ProviderFor(BookmarksSearch) +const bookmarksSearchProvider = BookmarksSearchProvider._(); + +final class BookmarksSearchProvider + extends $StreamNotifierProvider> { + const BookmarksSearchProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'bookmarksSearchProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$bookmarksSearchHash(); + + @$internal + @override + BookmarksSearch create() => BookmarksSearch(); +} + +String _$bookmarksSearchHash() => r'ac2ce51c4fd66c2c0e25bdf2475cf2fba57c9e0c'; + +abstract class _$BookmarksSearch extends $StreamNotifier> { + Stream> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref>, Set>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, Set>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + @ProviderFor(bookmarks) const bookmarksProvider = BookmarksFamily._(); @@ -108,3 +153,103 @@ final class BookmarksFamily extends $Family { }, ); } + +@ProviderFor(SeamlessBookmarks) +const seamlessBookmarksProvider = SeamlessBookmarksFamily._(); + +final class SeamlessBookmarksProvider + extends $NotifierProvider> { + const SeamlessBookmarksProvider._({ + required SeamlessBookmarksFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'seamlessBookmarksProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$seamlessBookmarksHash(); + + @override + String toString() { + return r'seamlessBookmarksProvider' + '' + '($argument)'; + } + + @$internal + @override + SeamlessBookmarks create() => SeamlessBookmarks(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(AsyncValue value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + + @override + bool operator ==(Object other) { + return other is SeamlessBookmarksProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$seamlessBookmarksHash() => r'ddbc51ba22141a2c81bd9cf25adc610e5cdd72d9'; + +final class SeamlessBookmarksFamily extends $Family + with + $ClassFamilyOverride< + SeamlessBookmarks, + AsyncValue, + AsyncValue, + AsyncValue, + String + > { + const SeamlessBookmarksFamily._() + : super( + retry: null, + name: r'seamlessBookmarksProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + SeamlessBookmarksProvider call(String entryGuid) => + SeamlessBookmarksProvider._(argument: entryGuid, from: this); + + @override + String toString() => r'seamlessBookmarksProvider'; +} + +abstract class _$SeamlessBookmarks + extends $Notifier> { + late final _$args = ref.$arg as String; + String get entryGuid => _$args; + + AsyncValue build(String entryGuid); + @$mustCallSuper + @override + void runBuild() { + final created = build(_$args); + final ref = + this.ref as $Ref, AsyncValue>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, AsyncValue>, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart index 374957ef..bbd29d09 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_list.dart @@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart'; import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart'; +import 'package:weblibre/presentation/hooks/listenable_callback.dart'; import 'package:weblibre/presentation/hooks/menu_controller.dart'; import 'package:weblibre/presentation/widgets/failure_widget.dart'; import 'package:weblibre/presentation/widgets/uri_breadcrumb.dart'; @@ -21,14 +22,82 @@ class BookmarkListScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final bookmarkList = ref.watch(bookmarksProvider(entryGuid)); + final treeKey = useMemoized(() => GlobalKey()); + final bookmarkList = ref.watch(seamlessBookmarksProvider(entryGuid)); + + final textFilterEnabled = useState(false); + final textFilterController = useTextEditingController(); + + useListenableCallback(textFilterController, () { + if (ref.exists(seamlessBookmarksProvider(entryGuid))) { + ref + .read(seamlessBookmarksProvider(entryGuid).notifier) + .search(textFilterController.text); + } + }); return Scaffold( - appBar: AppBar(title: const Text('Bookmarks')), + appBar: AppBar( + title: textFilterEnabled.value + ? TextField( + controller: textFilterController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.only(top: 12), + border: InputBorder.none, + hintText: 'Filter bookmarks...', + floatingLabelBehavior: FloatingLabelBehavior.always, + suffixIcon: IconButton( + onPressed: () { + if (textFilterController.text.isNotEmpty) { + textFilterController.clear(); + } else { + textFilterEnabled.value = false; + } + }, + icon: const Icon(Icons.clear), + ), + ), + ) + : const Text('Bookmarks'), + actions: [ + if (!textFilterEnabled.value) + IconButton( + onPressed: () { + textFilterEnabled.value = !textFilterEnabled.value; + }, + icon: const Icon(Icons.search), + ), + MenuAnchor( + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(MdiIcons.expandAll), + child: const Text('Expand All Folders'), + onPressed: () { + treeKey.currentState?.controller.expandAllChildren( + treeKey.currentState!.controller.tree, + recursive: true, + ); + }, + ), + ], + builder: (context, controller, child) => IconButton( + onPressed: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + icon: const Icon(MdiIcons.dotsVertical), + ), + ), + ], + ), body: SafeArea( child: Padding( padding: const EdgeInsets.only(left: 12.0), child: bookmarkList.when( + skipLoadingOnReload: true, data: (list) { TreeNode addChildren( TreeNode? parent, @@ -55,9 +124,14 @@ class BookmarkListScreen extends HookConsumerWidget { : TreeNode.root(); return TreeView.simple( + key: treeKey, tree: root, onTreeReady: (controller) { - controller.expandNode(root); + if (textFilterEnabled.value) { + controller.expandAllChildren(root, recursive: true); + } else { + controller.expandNode(root); + } }, expansionIndicatorBuilder: (context, tree) => ChevronIndicator.upDown( @@ -70,6 +144,7 @@ class BookmarkListScreen extends HookConsumerWidget { builder: (context, item) { return switch (item.data) { final BookmarkEntry bookmark => ListTile( + key: ValueKey(bookmark.guid), contentPadding: EdgeInsets.zero, leading: UrlIcon([bookmark.url], iconSize: 34.0), trailing: IconButton( @@ -80,7 +155,11 @@ class BookmarkListScreen extends HookConsumerWidget { ).push(context); }, ), - title: Text(bookmark.title), + title: Text( + bookmark.title, + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), subtitle: UriBreadcrumb(uri: bookmark.url), onTap: () async { final result = await OpenSharedContentRoute( @@ -95,6 +174,7 @@ class BookmarkListScreen extends HookConsumerWidget { }, ), final BookmarkFolder folder => Padding( + key: ValueKey(folder.guid), padding: const EdgeInsets.only(right: 42.0), child: HookBuilder( builder: (context) {