implement text filter; expand all nodes action;
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+84
-4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user