make folder parent assignable

This commit is contained in:
Fabian Freund
2026-01-04 08:01:14 +01:00
parent a1838e5b01
commit c6bd60a0a7
5 changed files with 204 additions and 110 deletions
@@ -62,8 +62,15 @@ class BookmarksRepository extends _$BookmarksRepository {
ref.invalidateSelf();
}
Future<void> editFolder({required String guid, required String title}) async {
await _service.updateNode(guid, BookmarkInfo(title: title));
Future<void> editFolder({
required String guid,
String? title,
String? parentGuid,
}) async {
await _service.updateNode(
guid,
BookmarkInfo(title: title, parentGuid: parentGuid),
);
ref.invalidateSelf();
}
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
}
String _$bookmarksRepositoryHash() =>
r'932441e926afcb1d60dc34b8a8756b7ad8fe0a27';
r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
FutureOr<BookmarkItem?> build();
@@ -17,19 +17,16 @@
* 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:animated_tree_view/animated_tree_view.dart';
import 'package:flutter/material.dart';
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:go_router/go_router.dart';
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/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.dart';
import 'package:weblibre/presentation/widgets/failure_widget.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
import 'package:weblibre/utils/form_validators.dart';
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
@@ -45,11 +42,6 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final formKey = useMemoized(() => GlobalKey<FormState>());
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
final folderList = ref.watch(
bookmarksProvider<BookmarkFolder>(BookmarkRoot.root.id),
);
final nameTextController = useTextEditingController(
text: initialInfo?.title ?? exisitingEntry?.title,
@@ -153,102 +145,9 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
},
),
const SizedBox(height: 16),
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
folderList.when(
skipLoadingOnReload: true,
data: (list) {
TreeNode<BookmarkFolder> addChildren(
TreeNode<BookmarkFolder>? parent,
BookmarkFolder item,
) {
final node = TreeNode(
key: item.guid,
data: item,
parent: parent,
);
final targetNode = (parent?..add(node)) ?? node;
if (item.children != null) {
for (final child in item.children!) {
addChildren(node, child as BookmarkFolder);
}
}
return targetNode;
}
final root = (list != null)
? addChildren(null, list)
: TreeNode<BookmarkFolder>.root();
return TreeView.simple(
key: treeKey,
tree: root,
shrinkWrap: true,
onTreeReady: (controller) {
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 isSelected = item.data?.guid == parentGuid.value;
return Padding(
padding: const EdgeInsets.only(right: 42.0),
child: switch (item.data) {
final BookmarkFolder folder => ListTile(
key: ValueKey(folder.guid),
contentPadding: EdgeInsets.zero,
selected: isSelected,
leading: (item.isExpanded)
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) const Icon(Icons.check),
IconButton(
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: folder.guid,
).push(context);
},
icon: const Icon(MdiIcons.folderPlus),
),
],
),
title: Text(folder.title),
onTap: () {
parentGuid.value = folder.guid;
},
),
null => const SizedBox.shrink(),
},
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bookmark Folders',
exception: error,
onRetry: () {
// ignore: unused_result
ref.refresh(
bookmarksProvider<BookmarkFolder>(
BookmarkRoot.root.id,
),
);
},
),
),
loading: () => const SizedBox.shrink(),
FolderTreePicker(
selectedFolderGuid: parentGuid,
entryGuid: BookmarkRoot.root.id,
),
const SizedBox(height: 16),
if (exisitingEntry != null)
@@ -25,6 +25,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/entities/bookmark_item.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_folder_dialog.dart';
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart';
import 'package:weblibre/utils/form_validators.dart';
class BookmarkFolderEditScreen extends HookConsumerWidget {
@@ -38,6 +39,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
final formKey = useMemoized(() => GlobalKey<FormState>());
final nameTextController = useTextEditingController(text: folder?.title);
final currentParentGuid = useState(
parentGuid ?? folder?.parentGuid ?? BookmarkRoot.mobile.id,
);
// Check if this is a bookmark root folder (these cannot be moved)
final isBookmarkRoot =
folder != null && bookmarkRootIds.contains(folder!.guid);
return Scaffold(
appBar: AppBar(
title: (folder != null)
@@ -52,7 +61,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
.read(bookmarksRepositoryProvider.notifier)
.editFolder(
guid: folder!.guid,
title: nameTextController.text,
title: (nameTextController.text != folder!.title)
? nameTextController.text
: null,
parentGuid:
(!isBookmarkRoot &&
currentParentGuid.value != folder!.parentGuid)
? currentParentGuid.value
: null,
);
if (context.mounted) {
@@ -62,7 +78,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
await ref
.read(bookmarksRepositoryProvider.notifier)
.addFolder(
parentGuid: parentGuid ?? BookmarkRoot.mobile.id,
parentGuid: currentParentGuid.value,
title: nameTextController.text,
);
@@ -91,6 +107,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
validator: validateRequired,
),
const SizedBox(height: 16),
if (!isBookmarkRoot) ...[
FolderTreePicker(
selectedFolderGuid: currentParentGuid,
excludeFolderGuid: folder?.guid,
entryGuid: BookmarkRoot.root.id,
),
const SizedBox(height: 16),
],
if (folder != null)
SizedBox(
width: double.infinity,
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2024-2025 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:animated_tree_view/animated_tree_view.dart';
import 'package:flutter/material.dart';
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: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/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).
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;
final String entryGuid;
const FolderTreePicker({
required this.selectedFolderGuid,
required this.entryGuid,
this.excludeFolderGuid,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
final folderList = ref.watch(bookmarksProvider<BookmarkFolder>(entryGuid));
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
folderList.when(
skipLoadingOnReload: true,
data: (list) {
TreeNode<BookmarkFolder> addChildren(
TreeNode<BookmarkFolder>? parent,
BookmarkFolder item,
) {
final node = TreeNode(key: item.guid, data: item, parent: parent);
final targetNode = (parent?..add(node)) ?? node;
if (item.children != null) {
for (final child in item.children!) {
// Skip the excluded folder and its descendants
if (child is BookmarkFolder &&
child.guid != excludeFolderGuid) {
addChildren(node, child);
}
}
}
return targetNode;
}
final root = (list != null)
? addChildren(null, list)
: TreeNode<BookmarkFolder>.root();
return TreeView.simple(
key: treeKey,
tree: root,
shrinkWrap: true,
showRootNode: entryGuid != BookmarkRoot.root.id,
onTreeReady: (controller) {
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 isSelected = item.data?.guid == selectedFolderGuid.value;
// BookmarkRoot.root cannot be selected as a parent
final isRootFolder = item.data?.guid == BookmarkRoot.root.id;
return Padding(
padding: const EdgeInsets.only(right: 42.0),
child: switch (item.data) {
final BookmarkFolder folder => ListTile(
key: ValueKey(folder.guid),
contentPadding: EdgeInsets.zero,
selected: isSelected,
enabled: !isRootFolder,
leading: (item.isExpanded)
? const Icon(MdiIcons.folderOpen)
: const Icon(MdiIcons.folder),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isSelected) const Icon(Icons.check),
IconButton(
onPressed: () async {
await BookmarkFolderAddRoute(
parentGuid: folder.guid,
).push(context);
},
icon: const Icon(MdiIcons.folderPlus),
),
],
),
title: Text(folder.title),
onTap: !isRootFolder
? () {
selectedFolderGuid.value = folder.guid;
}
: null,
),
null => const SizedBox.shrink(),
},
);
},
);
},
error: (error, stackTrace) => Center(
child: FailureWidget(
title: 'Failed to load Bookmark Folders',
exception: error,
onRetry: () {
ref.invalidate(bookmarksProvider<BookmarkFolder>(entryGuid));
},
),
),
loading: () => const SizedBox.shrink(),
),
],
);
}
}