diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart index 61e5cff9..18bb7d71 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.dart @@ -62,8 +62,15 @@ class BookmarksRepository extends _$BookmarksRepository { ref.invalidateSelf(); } - Future editFolder({required String guid, required String title}) async { - await _service.updateNode(guid, BookmarkInfo(title: title)); + Future editFolder({ + required String guid, + String? title, + String? parentGuid, + }) async { + await _service.updateNode( + guid, + BookmarkInfo(title: title, parentGuid: parentGuid), + ); ref.invalidateSelf(); } diff --git a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart index 4014387c..458db048 100644 --- a/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart +++ b/app/lib/features/geckoview/features/bookmarks/domain/repositories/bookmarks.g.dart @@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider } String _$bookmarksRepositoryHash() => - r'932441e926afcb1d60dc34b8a8756b7ad8fe0a27'; + r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7'; abstract class _$BookmarksRepository extends $AsyncNotifier { FutureOr build(); diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart index 9daf2761..0515cf39 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_entry_edit.dart @@ -17,19 +17,16 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -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()); - final treeKey = useMemoized(() => GlobalKey()); - - final folderList = ref.watch( - bookmarksProvider(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 addChildren( - TreeNode? 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.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( - BookmarkRoot.root.id, - ), - ); - }, - ), - ), - loading: () => const SizedBox.shrink(), + FolderTreePicker( + selectedFolderGuid: parentGuid, + entryGuid: BookmarkRoot.root.id, ), const SizedBox(height: 16), if (exisitingEntry != null) diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart index 17ca538f..8e5f987f 100644 --- a/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart +++ b/app/lib/features/geckoview/features/bookmarks/presentation/screens/bookmark_folder_edit.dart @@ -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()); 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, diff --git a/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart new file mode 100644 index 00000000..7ee5c4a0 --- /dev/null +++ b/app/lib/features/geckoview/features/bookmarks/presentation/widgets/folder_tree_picker.dart @@ -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 . + */ +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 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()); + + final folderList = ref.watch(bookmarksProvider(entryGuid)); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Folder', style: Theme.of(context).textTheme.labelMedium), + folderList.when( + skipLoadingOnReload: true, + data: (list) { + TreeNode addChildren( + TreeNode? 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.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(entryGuid)); + }, + ), + ), + loading: () => const SizedBox.shrink(), + ), + ], + ); + } +}