improved bookmark management

This commit is contained in:
Fabian Freund
2026-02-28 07:38:22 +01:00
parent f99484b0fa
commit 9be77d00e5
13 changed files with 1613 additions and 296 deletions
@@ -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,
];
}
@@ -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);
}
@@ -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);
}
}
@@ -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);
}
}
}
}
@@ -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'),
),
],
),
],
),
),
);
}
}
@@ -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(
@@ -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)
File diff suppressed because it is too large Load Diff
@@ -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);
}
}