make folder parent assignable
This commit is contained in:
@@ -62,8 +62,15 @@ class BookmarksRepository extends _$BookmarksRepository {
|
|||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> editFolder({required String guid, required String title}) async {
|
Future<void> editFolder({
|
||||||
await _service.updateNode(guid, BookmarkInfo(title: title));
|
required String guid,
|
||||||
|
String? title,
|
||||||
|
String? parentGuid,
|
||||||
|
}) async {
|
||||||
|
await _service.updateNode(
|
||||||
|
guid,
|
||||||
|
BookmarkInfo(title: title, parentGuid: parentGuid),
|
||||||
|
);
|
||||||
ref.invalidateSelf();
|
ref.invalidateSelf();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ final class BookmarksRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$bookmarksRepositoryHash() =>
|
String _$bookmarksRepositoryHash() =>
|
||||||
r'932441e926afcb1d60dc34b8a8756b7ad8fe0a27';
|
r'c53414612bf1d1da824e1150ca4eca7bb7c3bec7';
|
||||||
|
|
||||||
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
abstract class _$BookmarksRepository extends $AsyncNotifier<BookmarkItem?> {
|
||||||
FutureOr<BookmarkItem?> build();
|
FutureOr<BookmarkItem?> build();
|
||||||
|
|||||||
+4
-105
@@ -17,19 +17,16 @@
|
|||||||
* You should have received a copy of the GNU Affero General Public License
|
* 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/>.
|
* 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/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/domain/repositories/bookmarks.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/presentation/dialogs/delete_bookmark_dialog.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/form_validators.dart';
|
||||||
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
import 'package:weblibre/utils/uri_parser.dart' as uri_parser;
|
||||||
|
|
||||||
@@ -45,11 +42,6 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||||
final treeKey = useMemoized(() => GlobalKey<TreeViewState>());
|
|
||||||
|
|
||||||
final folderList = ref.watch(
|
|
||||||
bookmarksProvider<BookmarkFolder>(BookmarkRoot.root.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
final nameTextController = useTextEditingController(
|
final nameTextController = useTextEditingController(
|
||||||
text: initialInfo?.title ?? exisitingEntry?.title,
|
text: initialInfo?.title ?? exisitingEntry?.title,
|
||||||
@@ -153,102 +145,9 @@ class BookmarkEntryEditScreen extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text('Folder', style: Theme.of(context).textTheme.labelMedium),
|
FolderTreePicker(
|
||||||
folderList.when(
|
selectedFolderGuid: parentGuid,
|
||||||
skipLoadingOnReload: true,
|
entryGuid: BookmarkRoot.root.id,
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (exisitingEntry != null)
|
if (exisitingEntry != null)
|
||||||
|
|||||||
+26
-2
@@ -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/entities/bookmark_item.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/bookmarks/domain/repositories/bookmarks.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/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';
|
import 'package:weblibre/utils/form_validators.dart';
|
||||||
|
|
||||||
class BookmarkFolderEditScreen extends HookConsumerWidget {
|
class BookmarkFolderEditScreen extends HookConsumerWidget {
|
||||||
@@ -38,6 +39,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
final formKey = useMemoized(() => GlobalKey<FormState>());
|
final formKey = useMemoized(() => GlobalKey<FormState>());
|
||||||
final nameTextController = useTextEditingController(text: folder?.title);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: (folder != null)
|
title: (folder != null)
|
||||||
@@ -52,7 +61,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
.read(bookmarksRepositoryProvider.notifier)
|
.read(bookmarksRepositoryProvider.notifier)
|
||||||
.editFolder(
|
.editFolder(
|
||||||
guid: folder!.guid,
|
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) {
|
if (context.mounted) {
|
||||||
@@ -62,7 +78,7 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
await ref
|
await ref
|
||||||
.read(bookmarksRepositoryProvider.notifier)
|
.read(bookmarksRepositoryProvider.notifier)
|
||||||
.addFolder(
|
.addFolder(
|
||||||
parentGuid: parentGuid ?? BookmarkRoot.mobile.id,
|
parentGuid: currentParentGuid.value,
|
||||||
title: nameTextController.text,
|
title: nameTextController.text,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -91,6 +107,14 @@ class BookmarkFolderEditScreen extends HookConsumerWidget {
|
|||||||
validator: validateRequired,
|
validator: validateRequired,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (!isBookmarkRoot) ...[
|
||||||
|
FolderTreePicker(
|
||||||
|
selectedFolderGuid: currentParentGuid,
|
||||||
|
excludeFolderGuid: folder?.guid,
|
||||||
|
entryGuid: BookmarkRoot.root.id,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
if (folder != null)
|
if (folder != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
|||||||
+164
@@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user