implement text filter; expand all nodes action;

This commit is contained in:
Fabian Freund
2025-12-01 10:10:28 +01:00
parent 1c35b4efea
commit f2cb542025
3 changed files with 326 additions and 4 deletions
@@ -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 extends BookmarkItem>(T node) {
return node.clone() as T;
}
BookmarkItem? _cloneAndFilterOnGuids(BookmarkItem node, Set<String> 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<Set<String>> _streamController;
Future<void> 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<Set<String>> build() {
_streamController = StreamController();
ref.onDispose(() async {
await _streamController.close();
});
return _streamController.stream;
}
}
@Riverpod()
AsyncValue<T?> bookmarks<T extends BookmarkItem>(Ref ref, String entryGuid) {
final bookmarksAsync = ref.watch(bookmarksRepositoryProvider);
@@ -66,3 +121,45 @@ AsyncValue<T?> bookmarks<T extends BookmarkItem>(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<BookmarkItem?> build(String entryGuid) {
final bookmarks = ref.watch(bookmarksProvider<BookmarkItem>(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;
}
}
}
@@ -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<BookmarksSearch, Set<String>> {
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<Set<String>> {
Stream<Set<String>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<Set<String>>, Set<String>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<Set<String>>, Set<String>>,
AsyncValue<Set<String>>,
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<SeamlessBookmarks, AsyncValue<BookmarkItem?>> {
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<BookmarkItem?> value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<AsyncValue<BookmarkItem?>>(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<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
AsyncValue<BookmarkItem?>,
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<AsyncValue<BookmarkItem?>> {
late final _$args = ref.$arg as String;
String get entryGuid => _$args;
AsyncValue<BookmarkItem?> build(String entryGuid);
@$mustCallSuper
@override
void runBuild() {
final created = build(_$args);
final ref =
this.ref as $Ref<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<BookmarkItem?>, AsyncValue<BookmarkItem?>>,
AsyncValue<BookmarkItem?>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}
@@ -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<BookmarkItem>(entryGuid));
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
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<BookmarkItem> addChildren(
TreeNode<BookmarkItem>? parent,
@@ -55,9 +124,14 @@ class BookmarkListScreen extends HookConsumerWidget {
: TreeNode<BookmarkItem>.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) {