improved bookmark management
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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:copy_with_extension/copy_with_extension.dart';
|
||||
import 'package:fast_equatable/fast_equatable.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
|
||||
part 'bookmark_list_ui_state.g.dart';
|
||||
|
||||
@CopyWith()
|
||||
class BookmarkListUiState with FastEquatable {
|
||||
final bool selectionMode;
|
||||
final Set<String> selectedGuids;
|
||||
final BookmarkSortType sortType;
|
||||
final bool foldersOnly;
|
||||
|
||||
BookmarkListUiState({
|
||||
this.selectionMode = false,
|
||||
this.selectedGuids = const {},
|
||||
this.sortType = BookmarkSortType.manual,
|
||||
this.foldersOnly = false,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get hashParameters => [
|
||||
selectionMode,
|
||||
selectedGuids,
|
||||
sortType,
|
||||
foldersOnly,
|
||||
];
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bookmark_list_ui_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// CopyWithGenerator
|
||||
// **************************************************************************
|
||||
|
||||
abstract class _$BookmarkListUiStateCWProxy {
|
||||
BookmarkListUiState selectionMode(bool selectionMode);
|
||||
|
||||
BookmarkListUiState selectedGuids(Set<String> selectedGuids);
|
||||
|
||||
BookmarkListUiState sortType(BookmarkSortType sortType);
|
||||
|
||||
BookmarkListUiState foldersOnly(bool foldersOnly);
|
||||
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
BookmarkListUiState call({
|
||||
bool selectionMode,
|
||||
Set<String> selectedGuids,
|
||||
BookmarkSortType sortType,
|
||||
bool foldersOnly,
|
||||
});
|
||||
}
|
||||
|
||||
/// Callable proxy for `copyWith` functionality.
|
||||
/// Use as `instanceOfBookmarkListUiState.copyWith(...)` or call `instanceOfBookmarkListUiState.copyWith.fieldName(value)` for a single field.
|
||||
class _$BookmarkListUiStateCWProxyImpl implements _$BookmarkListUiStateCWProxy {
|
||||
const _$BookmarkListUiStateCWProxyImpl(this._value);
|
||||
|
||||
final BookmarkListUiState _value;
|
||||
|
||||
@override
|
||||
BookmarkListUiState selectionMode(bool selectionMode) =>
|
||||
call(selectionMode: selectionMode);
|
||||
|
||||
@override
|
||||
BookmarkListUiState selectedGuids(Set<String> selectedGuids) =>
|
||||
call(selectedGuids: selectedGuids);
|
||||
|
||||
@override
|
||||
BookmarkListUiState sortType(BookmarkSortType sortType) =>
|
||||
call(sortType: sortType);
|
||||
|
||||
@override
|
||||
BookmarkListUiState foldersOnly(bool foldersOnly) =>
|
||||
call(foldersOnly: foldersOnly);
|
||||
|
||||
@override
|
||||
/// Creates a new instance with the provided field values.
|
||||
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `BookmarkListUiState(...).copyWith.fieldName(value)`.
|
||||
///
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// BookmarkListUiState(...).copyWith(id: 12, name: "My name")
|
||||
/// ```
|
||||
BookmarkListUiState call({
|
||||
Object? selectionMode = const $CopyWithPlaceholder(),
|
||||
Object? selectedGuids = const $CopyWithPlaceholder(),
|
||||
Object? sortType = const $CopyWithPlaceholder(),
|
||||
Object? foldersOnly = const $CopyWithPlaceholder(),
|
||||
}) {
|
||||
return BookmarkListUiState(
|
||||
selectionMode:
|
||||
selectionMode == const $CopyWithPlaceholder() || selectionMode == null
|
||||
? _value.selectionMode
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: selectionMode as bool,
|
||||
selectedGuids:
|
||||
selectedGuids == const $CopyWithPlaceholder() || selectedGuids == null
|
||||
? _value.selectedGuids
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: selectedGuids as Set<String>,
|
||||
sortType: sortType == const $CopyWithPlaceholder() || sortType == null
|
||||
? _value.sortType
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: sortType as BookmarkSortType,
|
||||
foldersOnly:
|
||||
foldersOnly == const $CopyWithPlaceholder() || foldersOnly == null
|
||||
? _value.foldersOnly
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
: foldersOnly as bool,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
extension $BookmarkListUiStateCopyWith on BookmarkListUiState {
|
||||
/// Returns a callable class used to build a new instance with modified fields.
|
||||
/// Example: `instanceOfBookmarkListUiState.copyWith(...)` or `instanceOfBookmarkListUiState.copyWith.fieldName(...)`.
|
||||
// ignore: library_private_types_in_public_api
|
||||
_$BookmarkListUiStateCWProxy get copyWith =>
|
||||
_$BookmarkListUiStateCWProxyImpl(this);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
|
||||
enum BookmarkSortType {
|
||||
manual('Default'),
|
||||
titleAsc('Title A-Z'),
|
||||
titleDesc('Title Z-A'),
|
||||
urlAsc('URL A-Z'),
|
||||
dateAddedDesc('Newest First');
|
||||
|
||||
final String label;
|
||||
|
||||
const BookmarkSortType(this.label);
|
||||
}
|
||||
|
||||
int compareBookmarkItems(
|
||||
BookmarkItem a,
|
||||
BookmarkItem b,
|
||||
BookmarkSortType sort,
|
||||
) {
|
||||
return switch (sort) {
|
||||
BookmarkSortType.manual => 0,
|
||||
BookmarkSortType.titleAsc => a.title.toLowerCase().compareTo(
|
||||
b.title.toLowerCase(),
|
||||
),
|
||||
BookmarkSortType.titleDesc => b.title.toLowerCase().compareTo(
|
||||
a.title.toLowerCase(),
|
||||
),
|
||||
BookmarkSortType.urlAsc => _compareByUrl(a, b),
|
||||
BookmarkSortType.dateAddedDesc => b.dateAdded.compareTo(a.dateAdded),
|
||||
};
|
||||
}
|
||||
|
||||
int _compareByUrl(BookmarkItem a, BookmarkItem b) {
|
||||
final aUrl = a is BookmarkEntry ? a.url.toString() : a.title.toLowerCase();
|
||||
final bUrl = b is BookmarkEntry ? b.url.toString() : b.title.toLowerCase();
|
||||
return aUrl.compareTo(bUrl);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
|
||||
part 'bookmark_list_ui_state.g.dart';
|
||||
|
||||
@Riverpod()
|
||||
class BookmarkListUiStateNotifier extends _$BookmarkListUiStateNotifier {
|
||||
@override
|
||||
BookmarkListUiState build() => BookmarkListUiState();
|
||||
|
||||
void enterSelectionMode({String? initialGuid}) {
|
||||
state = state.copyWith(
|
||||
selectionMode: true,
|
||||
selectedGuids: initialGuid != null ? {initialGuid} : {},
|
||||
);
|
||||
}
|
||||
|
||||
void exitSelectionMode() {
|
||||
state = state.copyWith(selectionMode: false, selectedGuids: {});
|
||||
}
|
||||
|
||||
void toggleSelection(String guid) {
|
||||
final updated = Set<String>.from(state.selectedGuids);
|
||||
if (updated.contains(guid)) {
|
||||
updated.remove(guid);
|
||||
} else {
|
||||
updated.add(guid);
|
||||
}
|
||||
if (updated.isEmpty) {
|
||||
exitSelectionMode();
|
||||
} else {
|
||||
state = state.copyWith(selectedGuids: updated);
|
||||
}
|
||||
}
|
||||
|
||||
void selectAll(Iterable<String> guids) {
|
||||
state = state.copyWith(
|
||||
selectionMode: true,
|
||||
selectedGuids: Set<String>.from(guids),
|
||||
);
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
exitSelectionMode();
|
||||
}
|
||||
|
||||
void setSortType(BookmarkSortType sortType) {
|
||||
state = state.copyWith(sortType: sortType);
|
||||
}
|
||||
|
||||
void toggleFoldersOnly() {
|
||||
state = state.copyWith(foldersOnly: !state.foldersOnly);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'bookmark_list_ui_state.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(BookmarkListUiStateNotifier)
|
||||
final bookmarkListUiStateProvider = BookmarkListUiStateNotifierProvider._();
|
||||
|
||||
final class BookmarkListUiStateNotifierProvider
|
||||
extends
|
||||
$NotifierProvider<BookmarkListUiStateNotifier, BookmarkListUiState> {
|
||||
BookmarkListUiStateNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'bookmarkListUiStateProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$bookmarkListUiStateNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
BookmarkListUiStateNotifier create() => BookmarkListUiStateNotifier();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(BookmarkListUiState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<BookmarkListUiState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$bookmarkListUiStateNotifierHash() =>
|
||||
r'7c764fc2f1deb178063ca95764775ca237471f9f';
|
||||
|
||||
abstract class _$BookmarkListUiStateNotifier
|
||||
extends $Notifier<BookmarkListUiState> {
|
||||
BookmarkListUiState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref = this.ref as $Ref<BookmarkListUiState, BookmarkListUiState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<BookmarkListUiState, BookmarkListUiState>,
|
||||
BookmarkListUiState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_html_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/utils/bookmark_json_utils.dart';
|
||||
@@ -36,16 +37,18 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
required String parentGuid,
|
||||
required Uri url,
|
||||
required String title,
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.addItem(parentGuid, url, title, null);
|
||||
await _service.addItem(parentGuid, url, title, position);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> addFolder({
|
||||
required String parentGuid,
|
||||
required String title,
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.addFolder(parentGuid, title, null);
|
||||
await _service.addFolder(parentGuid, title, position);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
@@ -54,10 +57,16 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
String? title,
|
||||
Uri? url,
|
||||
String? parentGuid,
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.updateNode(
|
||||
guid,
|
||||
BookmarkInfo(title: title, url: url?.toString(), parentGuid: parentGuid),
|
||||
BookmarkInfo(
|
||||
title: title,
|
||||
url: url?.toString(),
|
||||
parentGuid: parentGuid,
|
||||
position: position,
|
||||
),
|
||||
);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
@@ -66,10 +75,11 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
required String guid,
|
||||
String? title,
|
||||
String? parentGuid,
|
||||
int? position,
|
||||
}) async {
|
||||
await _service.updateNode(
|
||||
guid,
|
||||
BookmarkInfo(title: title, parentGuid: parentGuid),
|
||||
BookmarkInfo(title: title, parentGuid: parentGuid, position: position),
|
||||
);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
@@ -79,6 +89,78 @@ class BookmarksRepository extends _$BookmarksRepository {
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> moveMany({
|
||||
required Iterable<BookmarkItem> items,
|
||||
required String targetParentGuid,
|
||||
}) async {
|
||||
for (final item in items) {
|
||||
if (bookmarkRootIds.contains(item.guid)) {
|
||||
logger.w('Skipping move of root folder: ${item.guid}');
|
||||
continue;
|
||||
}
|
||||
if (item.parentGuid == targetParentGuid) continue;
|
||||
await _service.updateNode(
|
||||
item.guid,
|
||||
BookmarkInfo(parentGuid: targetParentGuid),
|
||||
);
|
||||
}
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> deleteMany(Iterable<String> guids) async {
|
||||
for (final guid in guids) {
|
||||
if (bookmarkRootIds.contains(guid)) {
|
||||
logger.w('Skipping delete of root folder: $guid');
|
||||
continue;
|
||||
}
|
||||
await _service.deleteNode(guid);
|
||||
}
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
Future<void> flattenFolder({required BookmarkFolder folder}) async {
|
||||
if (folder.parentGuid == null || bookmarkRootIds.contains(folder.guid)) {
|
||||
logger.w('Cannot flatten root or parentless folder: ${folder.guid}');
|
||||
return;
|
||||
}
|
||||
// Fetch the full folder tree from storage to avoid operating on a
|
||||
// filtered subset (e.g. when search is active), which would silently
|
||||
// delete children that were not moved.
|
||||
final fullNode = await _service.getTree(folder.guid);
|
||||
final children = fullNode?.children;
|
||||
if (children != null) {
|
||||
for (final child in children) {
|
||||
await _service.updateNode(
|
||||
child.guid,
|
||||
BookmarkInfo(parentGuid: folder.parentGuid),
|
||||
);
|
||||
}
|
||||
}
|
||||
await _service.deleteNode(folder.guid);
|
||||
ref.invalidateSelf();
|
||||
}
|
||||
|
||||
/// Returns the GUIDs of all descendant folders of [guid] by fetching the
|
||||
/// full subtree from storage. This is safe to call even when the UI tree is
|
||||
/// filtered (e.g. during search), unlike the pure-utility
|
||||
/// [collectDescendantFolderGuids] which only walks the in-memory tree.
|
||||
Future<Set<String>> getDescendantFolderGuids(String guid) async {
|
||||
final node = await _service.getTree(guid, recursive: true);
|
||||
if (node == null) return const {};
|
||||
final result = <String>{};
|
||||
void collect(BookmarkNode n) {
|
||||
for (final child in n.children ?? const <BookmarkNode>[]) {
|
||||
if (child.type == BookmarkNodeType.folder) {
|
||||
result.add(child.guid);
|
||||
collect(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(node);
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> eraseEverything(BookmarkRoot root) async {
|
||||
await _service.eraseEverything(root);
|
||||
ref.invalidateSelf();
|
||||
|
||||
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
|
||||
}
|
||||
|
||||
String _$bookmarksRepositoryHash() =>
|
||||
r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
|
||||
r'2169d5b354c4a22192096451c96ab1490cf55ab4';
|
||||
|
||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||
FutureOr<BookmarkItem?> build();
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
|
||||
/// Recursively sorts a bookmark tree by the given sort type.
|
||||
/// Root-level built-in folders are kept in their canonical order.
|
||||
BookmarkItem sortBookmarkTree(
|
||||
BookmarkItem item,
|
||||
BookmarkSortType sortType, {
|
||||
bool isRoot = false,
|
||||
}) {
|
||||
if (sortType == BookmarkSortType.manual) return item;
|
||||
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
final sortedChildren = item.children!.map((child) {
|
||||
return sortBookmarkTree(child, sortType);
|
||||
}).toList();
|
||||
|
||||
// At root level, keep built-in root folders pinned in original order
|
||||
if (isRoot) {
|
||||
final rootFolders = <BookmarkItem>[];
|
||||
final nonRootItems = <BookmarkItem>[];
|
||||
for (final child in sortedChildren) {
|
||||
if (bookmarkRootIds.contains(child.guid)) {
|
||||
rootFolders.add(child);
|
||||
} else {
|
||||
nonRootItems.add(child);
|
||||
}
|
||||
}
|
||||
nonRootItems.sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
return BookmarkFolder(
|
||||
guid: item.guid,
|
||||
parentGuid: item.parentGuid,
|
||||
title: item.title,
|
||||
position: item.position,
|
||||
dateAdded: item.dateAdded,
|
||||
children: [...rootFolders, ...nonRootItems],
|
||||
);
|
||||
}
|
||||
|
||||
sortedChildren.sort((a, b) => compareBookmarkItems(a, b, sortType));
|
||||
return BookmarkFolder(
|
||||
guid: item.guid,
|
||||
parentGuid: item.parentGuid,
|
||||
title: item.title,
|
||||
position: item.position,
|
||||
dateAdded: item.dateAdded,
|
||||
children: sortedChildren,
|
||||
);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/// Collects all descendant folder GUIDs from a folder (not including the folder itself).
|
||||
Set<String> collectDescendantFolderGuids(BookmarkFolder folder) {
|
||||
final result = <String>{};
|
||||
if (folder.children != null) {
|
||||
for (final child in folder.children!) {
|
||||
if (child is BookmarkFolder) {
|
||||
result.add(child.guid);
|
||||
result.addAll(collectDescendantFolderGuids(child));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Resolves BookmarkItems from a tree by their GUIDs.
|
||||
List<BookmarkItem> resolveSelectedItems(BookmarkItem root, Set<String> guids) {
|
||||
final result = <BookmarkItem>[];
|
||||
_collectByGuids(root, guids, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
void _collectByGuids(
|
||||
BookmarkItem item,
|
||||
Set<String> guids,
|
||||
List<BookmarkItem> result,
|
||||
) {
|
||||
if (guids.contains(item.guid)) {
|
||||
result.add(item);
|
||||
}
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
_collectByGuids(child, guids, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder can be flattened (non-root, has a parent, has children).
|
||||
bool canFlattenFolder(BookmarkFolder folder) {
|
||||
return folder.parentGuid != null &&
|
||||
!bookmarkRootIds.contains(folder.guid) &&
|
||||
folder.children != null &&
|
||||
folder.children!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Normalizes a selection set: removes items that are descendants of selected folders.
|
||||
/// This prevents double-applying moves when both a folder and its children are selected.
|
||||
Set<String> normalizeSelection(BookmarkItem root, Set<String> selectedGuids) {
|
||||
final items = resolveSelectedItems(root, selectedGuids);
|
||||
final folderGuidsToRemove = <String>{};
|
||||
|
||||
for (final item in items) {
|
||||
if (item is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(item, folderGuidsToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
return selectedGuids.difference(folderGuidsToRemove);
|
||||
}
|
||||
|
||||
void _collectAllDescendantGuids(BookmarkFolder folder, Set<String> result) {
|
||||
if (folder.children != null) {
|
||||
for (final child in folder.children!) {
|
||||
result.add(child.guid);
|
||||
if (child is BookmarkFolder) {
|
||||
_collectAllDescendantGuids(child, result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
|
||||
|
||||
/// Shows a bottom sheet for selecting a bookmark folder destination (for move operations).
|
||||
///
|
||||
/// Returns the selected folder GUID, or null if cancelled.
|
||||
Future<String?> showSelectBookmarkFolderDialog(
|
||||
BuildContext context, {
|
||||
Set<String> excludeFolderGuids = const {},
|
||||
String? initialFolderGuid,
|
||||
}) {
|
||||
return showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => _SelectBookmarkFolderSheet(
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
initialFolderGuid: initialFolderGuid,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SelectBookmarkFolderSheet extends HookConsumerWidget {
|
||||
final Set<String> excludeFolderGuids;
|
||||
final String? initialFolderGuid;
|
||||
|
||||
const _SelectBookmarkFolderSheet({
|
||||
required this.excludeFolderGuids,
|
||||
this.initialFolderGuid,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedGuid = useState(initialFolderGuid ?? BookmarkRoot.mobile.id);
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Move to Folder',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: FolderTreePicker(
|
||||
selectedFolderGuid: selectedGuid,
|
||||
excludeFolderGuids: excludeFolderGuids,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => context.pop(selectedGuid.value),
|
||||
child: const Text('Move'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -57,6 +57,8 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
||||
BookmarkRoot.mobile.id,
|
||||
);
|
||||
|
||||
final addToTop = useState(false);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: (exisitingEntry != null)
|
||||
@@ -104,6 +106,7 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
||||
parentGuid: parentGuid.value,
|
||||
title: nameTextController.text,
|
||||
url: newUrl,
|
||||
position: addToTop.value ? 0 : null,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -157,6 +160,15 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
||||
selectedFolderGuid: parentGuid,
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
if (exisitingEntry == null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Add to top'),
|
||||
value: addToTop.value,
|
||||
onChanged: (value) => addToTop.value = value,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (exisitingEntry != null)
|
||||
SizedBox(
|
||||
|
||||
+15
-1
@@ -47,6 +47,8 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
||||
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
|
||||
);
|
||||
|
||||
final addToTop = useState(false);
|
||||
|
||||
// Check if this is a bookmark root folder (these cannot be moved)
|
||||
final isBookmarkRoot =
|
||||
folder != null && bookmarkRootIds.contains(folder!.guid);
|
||||
@@ -84,6 +86,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
||||
.addFolder(
|
||||
parentGuid: currentParentGuid.value,
|
||||
title: nameTextController.text,
|
||||
position: addToTop.value ? 0 : null,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
@@ -114,9 +117,20 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
||||
if (!isBookmarkRoot) ...[
|
||||
FolderTreePicker(
|
||||
selectedFolderGuid: currentParentGuid,
|
||||
excludeFolderGuid: folder?.guid,
|
||||
excludeFolderGuids: folder != null
|
||||
? {folder!.guid}
|
||||
: const {},
|
||||
entryGuid: BookmarkRoot.root.id,
|
||||
),
|
||||
if (folder == null) ...[
|
||||
const SizedBox(height: 8),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: const Text('Add to top'),
|
||||
value: addToTop.value,
|
||||
onChanged: (value) => addToTop.value = value,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
if (folder != null)
|
||||
|
||||
+723
-100
@@ -28,13 +28,24 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:nullability/nullability.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:weblibre/core/logger.dart';
|
||||
import 'package:weblibre/core/routing/routes.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/providers/tab_state.dart';
|
||||
import 'package:weblibre/features/geckoview/domain/repositories/tab.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_list_ui_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_sort_type.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmark_list_ui_state.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/providers/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/utils/bookmark_tree_utils.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/import_bookmarks_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/select_bookmark_folder_dialog.dart';
|
||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||
import 'package:weblibre/features/user/domain/repositories/general_settings.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';
|
||||
@@ -48,8 +59,16 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
|
||||
final treeController =
|
||||
useState<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>(
|
||||
null,
|
||||
);
|
||||
// Tracks which folder GUIDs are expanded, so expansion state survives
|
||||
// tree rebuilds caused by sort type changes.
|
||||
final expandedGuids = useRef(<String>{});
|
||||
final hideEmptyRoots = useState(true);
|
||||
final uiState = ref.watch(bookmarkListUiStateProvider);
|
||||
final uiStateNotifier = ref.read(bookmarkListUiStateProvider.notifier);
|
||||
final bookmarkList = ref.watch(
|
||||
seamlessBookmarksProvider(
|
||||
entryGuid,
|
||||
@@ -78,8 +97,193 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
return PopScope(
|
||||
canPop: !uiState.selectionMode,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop && uiState.selectionMode) {
|
||||
uiStateNotifier.exitSelectionMode();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: uiState.selectionMode
|
||||
? _buildSelectionAppBar(context, ref, uiState, uiStateNotifier)
|
||||
: _buildNormalAppBar(
|
||||
context,
|
||||
ref,
|
||||
treeController,
|
||||
expandedGuids,
|
||||
hideEmptyRoots,
|
||||
textFilterEnabled,
|
||||
textFilterController,
|
||||
uiStateNotifier,
|
||||
uiState,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
child: bookmarkList.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (list) {
|
||||
final sortedList = list != null
|
||||
? sortBookmarkTree(
|
||||
list,
|
||||
uiState.sortType,
|
||||
isRoot: entryGuid == BookmarkRoot.root.id,
|
||||
)
|
||||
: null;
|
||||
|
||||
TreeNode<BookmarkItem> addChildren(
|
||||
TreeNode<BookmarkItem>? parent,
|
||||
BookmarkItem item,
|
||||
) {
|
||||
if (uiState.foldersOnly && item is BookmarkEntry) {
|
||||
return parent ?? TreeNode<BookmarkItem>.root();
|
||||
}
|
||||
|
||||
final node = TreeNode(
|
||||
key: item.guid,
|
||||
data: item,
|
||||
parent: parent,
|
||||
);
|
||||
final targetNode = (parent?..add(node)) ?? node;
|
||||
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
addChildren(node, child);
|
||||
}
|
||||
}
|
||||
|
||||
return targetNode;
|
||||
}
|
||||
|
||||
final root = (sortedList != null)
|
||||
? addChildren(null, sortedList)
|
||||
: TreeNode<BookmarkItem>.root();
|
||||
|
||||
return TreeView.simple(
|
||||
key: ValueKey((uiState.sortType, uiState.foldersOnly)),
|
||||
tree: root,
|
||||
showRootNode: entryGuid != BookmarkRoot.root.id,
|
||||
onTreeReady: (controller) {
|
||||
treeController.value = controller;
|
||||
if (expandedGuids.value.isNotEmpty) {
|
||||
_restoreExpansion(controller, root, expandedGuids.value);
|
||||
} else {
|
||||
controller.expandAllChildren(root, recursive: true);
|
||||
}
|
||||
},
|
||||
expansionIndicatorBuilder: (context, tree) =>
|
||||
ChevronIndicator.upDown(
|
||||
tree: tree,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
),
|
||||
builder: (context, item) {
|
||||
final BookmarkItem? data = item.data;
|
||||
final isSelected = uiState.selectedGuids.contains(
|
||||
data?.guid,
|
||||
);
|
||||
final bool isLeaf = item.isLeaf;
|
||||
final bool isExpanded = item.isExpanded;
|
||||
|
||||
return switch (data) {
|
||||
final BookmarkEntry bookmark => _buildEntryTile(
|
||||
context,
|
||||
ref,
|
||||
bookmark,
|
||||
uiState,
|
||||
uiStateNotifier,
|
||||
isSelected,
|
||||
sortedList,
|
||||
),
|
||||
final BookmarkFolder folder => _buildFolderTile(
|
||||
context,
|
||||
ref,
|
||||
folder,
|
||||
isLeaf: isLeaf,
|
||||
isExpanded: isExpanded,
|
||||
uiState: uiState,
|
||||
uiStateNotifier: uiStateNotifier,
|
||||
isSelected: isSelected,
|
||||
rootItem: sortedList,
|
||||
),
|
||||
null => const Center(child: Text('Empty')),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Bookmarks',
|
||||
exception: error,
|
||||
onRetry: () {
|
||||
// ignore: unused_result
|
||||
ref.refresh(bookmarksProvider<BookmarkItem>(entryGuid));
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- App Bars --
|
||||
|
||||
PreferredSizeWidget _buildSelectionAppBar(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
) {
|
||||
final count = uiState.selectedGuids.length;
|
||||
return AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => uiStateNotifier.exitSelectionMode(),
|
||||
),
|
||||
title: Text('$count selected'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.tabPlus),
|
||||
tooltip: 'Open in background',
|
||||
onPressed: count > 0
|
||||
? () => _bulkOpenInBackground(context, ref, uiState)
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.folderMove),
|
||||
tooltip: 'Move selected',
|
||||
onPressed: count > 0 ? () => _bulkMove(context, ref, uiState) : null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(MdiIcons.delete),
|
||||
tooltip: 'Delete selected',
|
||||
onPressed: count > 0
|
||||
? () => _bulkDelete(context, ref, uiState, uiStateNotifier)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
AppBar _buildNormalAppBar(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ValueNotifier<TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>?>
|
||||
treeController,
|
||||
ObjectRef<Set<String>> expandedGuids,
|
||||
ValueNotifier<bool> hideEmptyRoots,
|
||||
ValueNotifier<bool> textFilterEnabled,
|
||||
TextEditingController textFilterController,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
BookmarkListUiState uiState,
|
||||
) {
|
||||
return AppBar(
|
||||
title: textFilterEnabled.value
|
||||
? TextField(
|
||||
controller: textFilterController,
|
||||
@@ -110,33 +314,73 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
icon: const Icon(Icons.search),
|
||||
),
|
||||
MenuAnchor(
|
||||
menuChildren: [
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.eye),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.expandAll),
|
||||
child: const Text('Expand All Folders'),
|
||||
child: const Text('Expand All'),
|
||||
onPressed: () {
|
||||
treeKey.currentState?.controller.mapNotNull((controller) {
|
||||
final controller = treeController.value;
|
||||
if (controller != null) {
|
||||
controller.expandAllChildren(
|
||||
controller.tree,
|
||||
recursive: true,
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
if (entryGuid == BookmarkRoot.root.id)
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
hideEmptyRoots.value ? MdiIcons.eyeOff : MdiIcons.eye,
|
||||
hideEmptyRoots.value
|
||||
? MdiIcons.folderOff
|
||||
: MdiIcons.folder,
|
||||
),
|
||||
child: Text(
|
||||
hideEmptyRoots.value
|
||||
? 'Show Empty Root Folders'
|
||||
: 'Hide Empty Root Folders',
|
||||
? 'Show Empty Folders'
|
||||
: 'Hide Empty Folders',
|
||||
),
|
||||
onPressed: () {
|
||||
hideEmptyRoots.value = !hideEmptyRoots.value;
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: Icon(
|
||||
uiState.foldersOnly
|
||||
? MdiIcons.bookmarkMultiple
|
||||
: MdiIcons.folderOutline,
|
||||
),
|
||||
child: Text(
|
||||
uiState.foldersOnly ? 'Show Bookmarks' : 'Folders Only',
|
||||
),
|
||||
onPressed: () {
|
||||
_snapshotExpansion(treeController.value, expandedGuids);
|
||||
uiStateNotifier.toggleFoldersOnly();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Text('Visibility'),
|
||||
),
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.sort),
|
||||
menuChildren: [
|
||||
for (final sortType in BookmarkSortType.values)
|
||||
MenuItemButton(
|
||||
leadingIcon: sortType == uiState.sortType
|
||||
? const Icon(Icons.check)
|
||||
: const SizedBox(width: 24),
|
||||
child: Text(sortType.label),
|
||||
onPressed: () {
|
||||
_snapshotExpansion(treeController.value, expandedGuids);
|
||||
uiStateNotifier.setSortType(sortType);
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Text('Sort'),
|
||||
),
|
||||
SubmenuButton(
|
||||
leadingIcon: const Icon(MdiIcons.import),
|
||||
menuChildren: [
|
||||
@@ -182,69 +426,28 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 12.0),
|
||||
child: bookmarkList.when(
|
||||
skipLoadingOnReload: true,
|
||||
data: (list) {
|
||||
TreeNode<BookmarkItem> addChildren(
|
||||
TreeNode<BookmarkItem>? parent,
|
||||
BookmarkItem item,
|
||||
) {
|
||||
final node = TreeNode(
|
||||
key: item.guid,
|
||||
data: item,
|
||||
parent: parent,
|
||||
);
|
||||
final targetNode = (parent?..add(node)) ?? node;
|
||||
|
||||
if (item is BookmarkFolder && item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
addChildren(node, child);
|
||||
}
|
||||
}
|
||||
|
||||
return targetNode;
|
||||
}
|
||||
// -- Entry Tile --
|
||||
|
||||
final root = (list != null)
|
||||
? addChildren(null, list)
|
||||
: TreeNode<BookmarkItem>.root();
|
||||
|
||||
return TreeView.simple(
|
||||
key: treeKey,
|
||||
tree: root,
|
||||
showRootNode: entryGuid != BookmarkRoot.root.id,
|
||||
onTreeReady: (controller) {
|
||||
if (textFilterEnabled.value) {
|
||||
controller.expandAllChildren(root, recursive: true);
|
||||
} else {
|
||||
controller.expandNode(root);
|
||||
}
|
||||
},
|
||||
expansionIndicatorBuilder: (context, tree) =>
|
||||
ChevronIndicator.upDown(
|
||||
tree: tree,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 12.0,
|
||||
),
|
||||
),
|
||||
builder: (context, item) {
|
||||
return switch (item.data) {
|
||||
final BookmarkEntry bookmark => ListTile(
|
||||
Widget _buildEntryTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkEntry bookmark,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
bool isSelected,
|
||||
BookmarkItem? rootItem,
|
||||
) {
|
||||
if (uiState.selectionMode) {
|
||||
return ListTile(
|
||||
key: ValueKey(bookmark.guid),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: UrlIcon([bookmark.url], iconSize: 34.0),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () async {
|
||||
await BookmarkEntryEditRoute(
|
||||
bookmarkEntry: jsonEncode(bookmark.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
trailing: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) => uiStateNotifier.toggleSelection(bookmark.guid),
|
||||
),
|
||||
title: Text(
|
||||
bookmark.title,
|
||||
@@ -252,6 +455,17 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: UriBreadcrumb(uri: bookmark.url),
|
||||
onTap: () => uiStateNotifier.toggleSelection(bookmark.guid),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey(bookmark.guid),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: UrlIcon([bookmark.url], iconSize: 34.0),
|
||||
trailing: _buildEntryMenu(context, ref, bookmark, rootItem),
|
||||
title: Text(bookmark.title, maxLines: 3, overflow: TextOverflow.ellipsis),
|
||||
subtitle: UriBreadcrumb(uri: bookmark.url),
|
||||
onTap: () async {
|
||||
final result = await OpenSharedContentRoute(
|
||||
sharedUrl: bookmark.url.toString(),
|
||||
@@ -263,10 +477,172 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
uiStateNotifier.enterSelectionMode(initialGuid: bookmark.guid);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEntryMenu(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkEntry bookmark,
|
||||
BookmarkItem? rootItem,
|
||||
) {
|
||||
return HookBuilder(
|
||||
builder: (context) {
|
||||
final controller = useMenuController();
|
||||
|
||||
return MenuAnchor(
|
||||
controller: controller,
|
||||
builder: (context, controller, child) => InkWell(
|
||||
onTap: () {
|
||||
if (controller.isOpen) {
|
||||
controller.close();
|
||||
} else {
|
||||
controller.open();
|
||||
}
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 15.0),
|
||||
child: Icon(MdiIcons.dotsVertical),
|
||||
),
|
||||
final BookmarkFolder folder => Padding(
|
||||
),
|
||||
menuChildren: [
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.openInNew),
|
||||
child: const Text('Open'),
|
||||
onPressed: () async {
|
||||
final result = await OpenSharedContentRoute(
|
||||
sharedUrl: bookmark.url.toString(),
|
||||
).push<bool>(context);
|
||||
if (result == true && context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.tabPlus),
|
||||
child: const Text('Open in New Tab'),
|
||||
onPressed: () async {
|
||||
await _openInNewTab(
|
||||
context,
|
||||
ref,
|
||||
bookmark.url,
|
||||
selectTab: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.tab),
|
||||
child: const Text('Open in Background'),
|
||||
onPressed: () async {
|
||||
await _openInNewTab(
|
||||
context,
|
||||
ref,
|
||||
bookmark.url,
|
||||
selectTab: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.share),
|
||||
child: const Text('Share'),
|
||||
onPressed: () async {
|
||||
await SharePlus.instance.share(
|
||||
ShareParams(text: bookmark.url.toString()),
|
||||
);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.folderMove),
|
||||
child: const Text('Move'),
|
||||
onPressed: () async {
|
||||
final targetGuid = await showSelectBookmarkFolderDialog(
|
||||
context,
|
||||
initialFolderGuid: bookmark.parentGuid,
|
||||
);
|
||||
if (targetGuid != null) {
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.editBookmark(
|
||||
guid: bookmark.guid,
|
||||
parentGuid: targetGuid,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.edit),
|
||||
child: const Text('Edit'),
|
||||
onPressed: () async {
|
||||
await BookmarkEntryEditRoute(
|
||||
bookmarkEntry: jsonEncode(bookmark.toJson()),
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkRemove),
|
||||
child: const Text('Delete'),
|
||||
onPressed: () async {
|
||||
final result = await showDeleteBookmarkDialog(context);
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.delete(bookmark.guid);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- Folder Tile --
|
||||
|
||||
Widget _buildFolderTile(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkFolder folder, {
|
||||
required bool isLeaf,
|
||||
required bool isExpanded,
|
||||
required BookmarkListUiState uiState,
|
||||
required BookmarkListUiStateNotifier uiStateNotifier,
|
||||
required bool isSelected,
|
||||
required BookmarkItem? rootItem,
|
||||
}) {
|
||||
final isRoot = bookmarkRootIds.contains(folder.guid);
|
||||
|
||||
if (uiState.selectionMode) {
|
||||
return Padding(
|
||||
key: ValueKey(folder.guid),
|
||||
padding: (item.isLeaf)
|
||||
padding: isLeaf
|
||||
? const EdgeInsets.only(right: 4.0)
|
||||
: const EdgeInsets.only(right: 42.0),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: isExpanded
|
||||
? const Icon(MdiIcons.folderOpen)
|
||||
: const Icon(MdiIcons.folder),
|
||||
title: Text(folder.title),
|
||||
trailing: isRoot
|
||||
? null
|
||||
: Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (_) =>
|
||||
uiStateNotifier.toggleSelection(folder.guid),
|
||||
),
|
||||
onTap: isRoot
|
||||
? null
|
||||
: () => uiStateNotifier.toggleSelection(folder.guid),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
key: ValueKey(folder.guid),
|
||||
padding: isLeaf
|
||||
? const EdgeInsets.only(right: 4.0)
|
||||
: const EdgeInsets.only(right: 42.0),
|
||||
child: HookBuilder(
|
||||
@@ -275,7 +651,7 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
|
||||
return ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: (item.isExpanded)
|
||||
leading: isExpanded
|
||||
? const Icon(MdiIcons.folderOpen)
|
||||
: const Icon(MdiIcons.folder),
|
||||
title: Text(folder.title),
|
||||
@@ -298,11 +674,43 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
menuChildren: [
|
||||
if (!bookmarkRootIds.contains(folder.guid))
|
||||
if (!isRoot) ...[
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(
|
||||
MdiIcons.folderEdit,
|
||||
leadingIcon: const Icon(MdiIcons.folderMove),
|
||||
child: const Text('Move'),
|
||||
onPressed: () async {
|
||||
final repo = ref.read(
|
||||
bookmarksRepositoryProvider.notifier,
|
||||
);
|
||||
final descendantGuids = await repo
|
||||
.getDescendantFolderGuids(folder.guid);
|
||||
final excludeGuids = {folder.guid, ...descendantGuids};
|
||||
if (!context.mounted) return;
|
||||
final targetGuid = await showSelectBookmarkFolderDialog(
|
||||
context,
|
||||
excludeFolderGuids: excludeGuids,
|
||||
initialFolderGuid: folder.parentGuid,
|
||||
);
|
||||
if (targetGuid != null) {
|
||||
await repo.editFolder(
|
||||
guid: folder.guid,
|
||||
parentGuid: targetGuid,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (canFlattenFolder(folder))
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.folderRemove),
|
||||
child: const Text('Flatten'),
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.flattenFolder(folder: folder);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.folderEdit),
|
||||
child: const Text('Edit'),
|
||||
onPressed: () async {
|
||||
await BookmarkFolderEditRoute(
|
||||
@@ -310,6 +718,19 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
).push(context);
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(Icons.delete),
|
||||
child: const Text('Delete'),
|
||||
onPressed: () async {
|
||||
final result = await showDeleteFolderDialog(context);
|
||||
if (result == true) {
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.delete(folder.guid);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(MdiIcons.folderPlus),
|
||||
child: const Text('Add Subfolder'),
|
||||
@@ -320,16 +741,12 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
MenuItemButton(
|
||||
leadingIcon: const Icon(
|
||||
MdiIcons.bookmarkPlus,
|
||||
),
|
||||
leadingIcon: const Icon(MdiIcons.bookmarkPlus),
|
||||
child: const Text('Add Bookmark'),
|
||||
onPressed: () async {
|
||||
await BookmarkEntryAddRoute(
|
||||
bookmarkInfo: jsonEncode(
|
||||
BookmarkInfo(
|
||||
parentGuid: folder.guid,
|
||||
).encode(),
|
||||
BookmarkInfo(parentGuid: folder.guid).encode(),
|
||||
),
|
||||
).push(context);
|
||||
},
|
||||
@@ -338,37 +755,243 @@ class BookmarkListScreen extends HookConsumerWidget {
|
||||
),
|
||||
onTap: () async {
|
||||
if (folder.guid != entryGuid) {
|
||||
await BookmarkListRoute(
|
||||
entryGuid: folder.guid,
|
||||
).push(context);
|
||||
await BookmarkListRoute(entryGuid: folder.guid).push(context);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
null => const Center(child: Text('Empty')),
|
||||
};
|
||||
onLongPress: isRoot
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(bookmarkListUiStateProvider.notifier)
|
||||
.enterSelectionMode(initialGuid: folder.guid);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) => Center(
|
||||
child: FailureWidget(
|
||||
title: 'Failed to load Bookmarks',
|
||||
exception: error,
|
||||
onRetry: () {
|
||||
// ignore: unused_result
|
||||
ref.refresh(bookmarksProvider<BookmarkItem>(entryGuid));
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// -- Bulk Actions --
|
||||
|
||||
Future<void> _bulkOpenInBackground(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
final entries = items.whereType<BookmarkEntry>().toList();
|
||||
|
||||
if (entries.isEmpty) {
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'No bookmark entries selected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final currentTab = ref.read(selectedTabStateProvider);
|
||||
final tabMode =
|
||||
currentTab?.tabMode ??
|
||||
TabMode.fromTabType(
|
||||
ref
|
||||
.read(generalSettingsWithDefaultsProvider)
|
||||
.effectiveDefaultCreateTabType,
|
||||
);
|
||||
|
||||
for (final entry in entries) {
|
||||
await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(url: entry.url, selectTab: false, tabMode: tabMode);
|
||||
}
|
||||
|
||||
ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Opened ${entries.length} tabs in background');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkMove(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
|
||||
// Build exclusion set from selected folders and their full descendant
|
||||
// trees fetched from storage, so hidden folders (e.g. filtered by search)
|
||||
// are still properly excluded as move targets.
|
||||
final repo = ref.read(bookmarksRepositoryProvider.notifier);
|
||||
final excludeGuids = <String>{};
|
||||
for (final item in items) {
|
||||
if (item is BookmarkFolder) {
|
||||
excludeGuids.add(item.guid);
|
||||
excludeGuids.addAll(await repo.getDescendantFolderGuids(item.guid));
|
||||
}
|
||||
}
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
final targetGuid = await showSelectBookmarkFolderDialog(
|
||||
context,
|
||||
excludeFolderGuids: excludeGuids,
|
||||
);
|
||||
|
||||
if (targetGuid == null) return;
|
||||
|
||||
// Normalize selection to avoid double-moves
|
||||
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
|
||||
final normalizedItems = resolveSelectedItems(root, normalizedGuids);
|
||||
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.moveMany(items: normalizedItems, targetParentGuid: targetGuid);
|
||||
|
||||
ref.read(bookmarkListUiStateProvider.notifier).exitSelectionMode();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Moved ${normalizedItems.length} items');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _bulkDelete(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
BookmarkListUiState uiState,
|
||||
BookmarkListUiStateNotifier uiStateNotifier,
|
||||
) async {
|
||||
final bookmarkData = ref.read(
|
||||
seamlessBookmarksProvider(entryGuid, hideEmptyRoots: true),
|
||||
);
|
||||
final root = bookmarkData.value;
|
||||
if (root == null) return;
|
||||
|
||||
final items = resolveSelectedItems(root, uiState.selectedGuids);
|
||||
final hasFolders = items.any((item) => item is BookmarkFolder);
|
||||
|
||||
if (!context.mounted) return;
|
||||
final result = await (hasFolders
|
||||
? showDeleteFolderDialog(context)
|
||||
: showDeleteBookmarkDialog(context));
|
||||
if (result != true) return;
|
||||
|
||||
// Normalize to avoid deleting children whose parent folder is also being deleted
|
||||
final normalizedGuids = normalizeSelection(root, uiState.selectedGuids);
|
||||
|
||||
await ref
|
||||
.read(bookmarksRepositoryProvider.notifier)
|
||||
.deleteMany(normalizedGuids);
|
||||
|
||||
uiStateNotifier.exitSelectionMode();
|
||||
|
||||
if (context.mounted) {
|
||||
showInfoMessage(context, 'Deleted ${normalizedGuids.length} items');
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tree Expansion State Helpers --
|
||||
|
||||
/// Collects the GUIDs of all currently expanded nodes from the tree.
|
||||
void _snapshotExpansion(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>>? controller,
|
||||
ObjectRef<Set<String>> expandedGuids,
|
||||
) {
|
||||
if (controller == null) return;
|
||||
final guids = <String>{};
|
||||
_collectExpandedGuids(controller.tree, guids);
|
||||
expandedGuids.value = guids;
|
||||
}
|
||||
|
||||
void _collectExpandedGuids(TreeNode<BookmarkItem> node, Set<String> guids) {
|
||||
if (node.isExpanded && node.key != INode.ROOT_KEY) {
|
||||
guids.add(node.key);
|
||||
}
|
||||
for (final child in node.childrenAsList) {
|
||||
_collectExpandedGuids(child as TreeNode<BookmarkItem>, guids);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores expansion state by expanding nodes whose GUIDs are in the set.
|
||||
void _restoreExpansion(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||
TreeNode<BookmarkItem> root,
|
||||
Set<String> guids,
|
||||
) {
|
||||
// Always expand root
|
||||
controller.expandNode(root);
|
||||
_expandMatchingNodes(controller, root, guids);
|
||||
}
|
||||
|
||||
void _expandMatchingNodes(
|
||||
TreeViewController<BookmarkItem, TreeNode<BookmarkItem>> controller,
|
||||
TreeNode<BookmarkItem> node,
|
||||
Set<String> guids,
|
||||
) {
|
||||
for (final child in node.childrenAsList) {
|
||||
final typedChild = child as TreeNode<BookmarkItem>;
|
||||
if (guids.contains(typedChild.key)) {
|
||||
controller.expandNode(typedChild);
|
||||
}
|
||||
_expandMatchingNodes(controller, typedChild, guids);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Tab Opening Helper --
|
||||
|
||||
Future<void> _openInNewTab(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Uri url, {
|
||||
required bool selectTab,
|
||||
}) async {
|
||||
final currentTab = ref.read(selectedTabStateProvider);
|
||||
final tabMode =
|
||||
currentTab?.tabMode ??
|
||||
TabMode.fromTabType(
|
||||
ref
|
||||
.read(generalSettingsWithDefaultsProvider)
|
||||
.effectiveDefaultCreateTabType,
|
||||
);
|
||||
|
||||
final tabId = await ref
|
||||
.read(tabRepositoryProvider.notifier)
|
||||
.addTab(
|
||||
url: url,
|
||||
parentId: currentTab?.id,
|
||||
selectTab: selectTab,
|
||||
tabMode: tabMode,
|
||||
);
|
||||
|
||||
if (selectTab) {
|
||||
if (context.mounted) {
|
||||
const BrowserRoute().go(context);
|
||||
}
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
final repo = ref.read(tabRepositoryProvider.notifier);
|
||||
showTabSwitchMessage(
|
||||
context,
|
||||
onSwitch: () async {
|
||||
await repo.selectTab(tabId);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- Import/Export (unchanged) --
|
||||
|
||||
Future<void> _handleImport(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
+8
-8
@@ -31,22 +31,22 @@ import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||
/// A widget that displays a tree view of bookmark folders and allows the user
|
||||
/// to select a parent folder.
|
||||
///
|
||||
/// When editing a folder, pass [excludeFolderGuid] to prevent selecting the
|
||||
/// folder itself or its descendants as the parent (which would create a circular reference).
|
||||
/// When editing a folder, pass [excludeFolderGuids] to prevent selecting the
|
||||
/// folders or their descendants as the parent (which would create a circular reference).
|
||||
class FolderTreePicker extends HookConsumerWidget {
|
||||
/// The currently selected folder GUID
|
||||
final ValueNotifier<String> selectedFolderGuid;
|
||||
|
||||
/// Optional folder GUID to exclude from the tree (along with its descendants).
|
||||
/// Used when editing a folder to prevent circular parent relationships.
|
||||
final String? excludeFolderGuid;
|
||||
/// Optional folder GUIDs to exclude from the tree (along with their descendants).
|
||||
/// Used when editing/moving folders to prevent circular parent relationships.
|
||||
final Set<String> excludeFolderGuids;
|
||||
|
||||
final String entryGuid;
|
||||
|
||||
const FolderTreePicker({
|
||||
required this.selectedFolderGuid,
|
||||
required this.entryGuid,
|
||||
this.excludeFolderGuid,
|
||||
this.excludeFolderGuids = const {},
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -72,9 +72,9 @@ class FolderTreePicker extends HookConsumerWidget {
|
||||
|
||||
if (item.children != null) {
|
||||
for (final child in item.children!) {
|
||||
// Skip the excluded folder and its descendants
|
||||
// Skip excluded folders and their descendants
|
||||
if (child is BookmarkFolder &&
|
||||
child.guid != excludeFolderGuid) {
|
||||
!excludeFolderGuids.contains(child.guid)) {
|
||||
addChildren(node, child);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user