diff --git a/lib/components/home/grid_folders.dart b/lib/components/home/grid_folders.dart index cb9cd89cc..ae0f6001a 100644 --- a/lib/components/home/grid_folders.dart +++ b/lib/components/home/grid_folders.dart @@ -2,6 +2,7 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:saber/components/home/delete_folder_button.dart'; +import 'package:saber/components/home/move_folder_button.dart'; import 'package:saber/components/home/new_folder_dialog.dart'; import 'package:saber/components/home/rename_folder_button.dart'; import 'package:saber/components/theming/adaptive_icon.dart'; @@ -20,6 +21,8 @@ class GridFolders extends StatelessWidget { required this.deleteFolder, required this.doesFolderExist, required this.folders, + this.currentFolderPath, + this.moveFolder, }); final bool isAtRoot; @@ -32,6 +35,12 @@ class GridFolders extends StatelessWidget { final Future Function(String) isFolderEmpty; final Future Function(String) deleteFolder; + /// The current folder path (with trailing slash), used for move operation. + final String? currentFolderPath; + + /// Callback when a folder is moved. If null, move button won't be shown. + final Future Function(String folderName)? moveFolder; + final List folders; @override @@ -61,6 +70,8 @@ class GridFolders extends StatelessWidget { renameFolder: renameFolder, isFolderEmpty: isFolderEmpty, deleteFolder: deleteFolder, + currentFolderPath: currentFolderPath, + moveFolder: moveFolder, onTap: onTap, ); }, @@ -81,6 +92,8 @@ class _GridFolder extends StatefulWidget { required this.isFolderEmpty, required this.deleteFolder, required this.onTap, + this.currentFolderPath, + this.moveFolder, }) : assert( (folderName == null) ^ (cardType == .realFolder), 'Real folders must specify a folder name', @@ -93,6 +106,8 @@ class _GridFolder extends StatefulWidget { final Future Function(String oldName, String newName) renameFolder; final Future Function(String) isFolderEmpty; final Future Function(String) deleteFolder; + final String? currentFolderPath; + final Future Function(String folderName)? moveFolder; final Function(String) onTap; @override @@ -210,6 +225,19 @@ class _GridFolderState extends State<_GridFolder> { expanded.value = false; }, ), + if (widget.moveFolder != null && + widget.currentFolderPath != null) + MoveFolderButton( + folderName: widget.folderName!, + currentFolder: + widget.currentFolderPath!, + onMoved: () async { + await widget.moveFolder!( + widget.folderName!, + ); + expanded.value = false; + }, + ), DeleteFolderButton( folderName: widget.folderName!, deleteFolder: (String folderName) async { diff --git a/lib/components/home/move_folder_button.dart b/lib/components/home/move_folder_button.dart new file mode 100644 index 000000000..2c78eb7b6 --- /dev/null +++ b/lib/components/home/move_folder_button.dart @@ -0,0 +1,234 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:saber/components/home/grid_folders.dart'; +import 'package:saber/components/theming/adaptive_alert_dialog.dart'; +import 'package:saber/data/file_manager/file_manager.dart'; +import 'package:saber/i18n/strings.g.dart'; + +class MoveFolderButton extends StatelessWidget { + const MoveFolderButton({ + super.key, + required this.folderName, + required this.currentFolder, + required this.onMoved, + }); + + final String folderName; + final String currentFolder; + final void Function() onMoved; + + @override + Widget build(BuildContext context) { + return IconButton( + padding: EdgeInsets.zero, + tooltip: t.home.moveFolder.moveFolder, + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return _MoveFolderDialog( + folderName: folderName, + currentFolder: currentFolder, + onMoved: onMoved, + ); + }, + ); + }, + icon: const Icon(Icons.drive_file_move), + ); + } +} + +class _MoveFolderDialog extends StatefulWidget { + const _MoveFolderDialog({ + required this.folderName, + required this.currentFolder, + required this.onMoved, + }); + + final String folderName; + final String currentFolder; + final void Function() onMoved; + + @override + State<_MoveFolderDialog> createState() => _MoveFolderDialogState(); +} + +class _MoveFolderDialogState extends State<_MoveFolderDialog> { + /// The full path of the folder being moved + late final sourceFolderPath = '${widget.currentFolder}${widget.folderName}'; + + late String _destinationFolder; + + /// The current folder browsed to in the dialog (destination parent folder). + String get destinationFolder => _destinationFolder; + set destinationFolder(String folder) { + _destinationFolder = folder; + destinationFolderChildren = null; + findChildrenOfDestinationFolder(); + } + + /// The children of [destinationFolder]. + DirectoryChildren? destinationFolderChildren; + + /// The final folder name at the destination (might be renamed if conflict). + String? newFolderName; + + /// Whether the folder will be renamed due to conflict. + bool get willBeRenamed => + newFolderName != null && newFolderName != widget.folderName; + + /// Whether moving to the destination is invalid (moving into itself). + bool get isInvalidDestination { + final destinationPath = '$destinationFolder${widget.folderName}'; + // Cannot move a folder into itself or its subdirectory + return destinationPath.startsWith('$sourceFolderPath/') || + destinationPath == sourceFolderPath; + } + + Future findChildrenOfDestinationFolder() async { + destinationFolderChildren = await FileManager.getChildrenOfDirectory( + destinationFolder, + ); + + // Check if folder name conflicts with existing directory + if (destinationFolderChildren?.directories.contains(widget.folderName) ?? + false) { + // Find a unique name + int i = 2; + String candidateName = '${widget.folderName} ($i)'; + while (destinationFolderChildren?.directories.contains(candidateName) ?? + false) { + i++; + candidateName = '${widget.folderName} ($i)'; + } + newFolderName = candidateName; + } else { + newFolderName = widget.folderName; + } + + if (!mounted) return; + setState(() {}); + } + + Future createFolder(String folderName) async { + final folderPath = '$destinationFolder$folderName'; + await FileManager.createFolder(folderPath); + findChildrenOfDestinationFolder(); + } + + @override + void initState() { + // Start at the parent of the folder's current location + destinationFolder = widget.currentFolder; + if (!destinationFolder.startsWith('/')) { + destinationFolder = '/$destinationFolder'; + } + super.initState(); + + findChildrenOfDestinationFolder(); + } + + @override + Widget build(BuildContext context) { + return AdaptiveAlertDialog( + title: Text(t.home.moveFolder.moveName(f: widget.folderName)), + content: SizedBox( + width: 300, + height: 300, + child: Column( + children: [ + Text(destinationFolder), + Expanded( + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + GridFolders( + isAtRoot: destinationFolder == '/', + crossAxisCount: 3, + onTap: (String folder) { + setState(() { + if (folder == '..') { + destinationFolder = destinationFolder.substring( + 0, + destinationFolder.lastIndexOf( + '/', + destinationFolder.length - 2, + ) + + 1, + ); + } else { + destinationFolder = '$destinationFolder$folder/'; + } + }); + }, + createFolder: createFolder, + doesFolderExist: (String folderName) { + return destinationFolderChildren?.directories.contains( + folderName, + ) ?? + false; + }, + renameFolder: (String oldName, String newName) async { + final oldPath = '$destinationFolder$oldName'; + await FileManager.renameDirectory(oldPath, newName); + findChildrenOfDestinationFolder(); + }, + isFolderEmpty: (String folderName) async { + final folderPath = '$destinationFolder$folderName'; + final children = await FileManager.getChildrenOfDirectory( + folderPath, + ); + return children?.isEmpty ?? true; + }, + deleteFolder: (String folderName) async { + final folderPath = '$destinationFolder$folderName'; + await FileManager.deleteDirectory(folderPath); + findChildrenOfDestinationFolder(); + }, + folders: [ + for (final directoryPath + in destinationFolderChildren?.directories ?? const []) + directoryPath, + ], + ), + ], + ), + ), + if (isInvalidDestination) + Text( + t.home.moveFolder.cantMoveHere, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ) + else if (willBeRenamed) + Text(t.home.moveFolder.renamedTo(newName: newFolderName!)), + ], + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(); + }, + child: Text(t.common.cancel), + ), + CupertinoDialogAction( + onPressed: isInvalidDestination + ? null + : () async { + final destinationPath = + '$destinationFolder${widget.folderName}'; + await FileManager.moveDirectory( + sourceFolderPath, + destinationPath, + ); + widget.onMoved(); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + child: Text(t.home.moveFolder.move), + ), + ], + ); + } +} diff --git a/lib/data/file_manager/file_manager.dart b/lib/data/file_manager/file_manager.dart index f8ef478fb..1d79223f9 100644 --- a/lib/data/file_manager/file_manager.dart +++ b/lib/data/file_manager/file_manager.dart @@ -520,6 +520,90 @@ class FileManager { await directory.delete(recursive: recursive); } + /// Moves a directory from [fromPath] to [toPath]. + /// + /// Returns the final destination path after the move. + /// If a directory already exists at [toPath], the directory name will be + /// suffixed with a number e.g. "folder (2)". + /// + /// This method prevents moving a directory into its own subdirectory. + static Future moveDirectory(String fromPath, String toPath) async { + fromPath = _sanitisePath(fromPath); + toPath = _sanitisePath(toPath); + + // Ensure paths don't end with slash for consistent handling + if (fromPath.endsWith('/')) { + fromPath = fromPath.substring(0, fromPath.length - 1); + } + if (toPath.endsWith('/')) { + toPath = toPath.substring(0, toPath.length - 1); + } + + // Prevent moving a directory into itself or its subdirectory + if (toPath.startsWith('$fromPath/')) { + throw ArgumentError('Cannot move a folder into its own subdirectory'); + } + + // Make the destination path unique if it already exists + toPath = await _suffixDirectoryPathToMakeItUnique(toPath); + + if (fromPath == toPath) return toPath; + + final fromDir = Directory(documentsDirectory + fromPath); + if (!fromDir.existsSync()) { + log.warning( + 'Tried to move non-existent directory from $fromPath to $toPath', + ); + return toPath; + } + + // Collect all files in the directory for reference updates + final List children = []; + await for (final entity in fromDir.list(recursive: true)) { + if (entity is File) { + children.add( + entity.path.substring(documentsDirectory.length + fromPath.length), + ); + } + } + + // Create the parent directory of the destination if needed + final toParentDir = Directory( + documentsDirectory + toPath.substring(0, toPath.lastIndexOf('/')), + ); + if (!toParentDir.existsSync()) { + await toParentDir.create(recursive: true); + } + + // Move the directory + await fromDir.rename(documentsDirectory + toPath); + + // Update references and broadcast file operations for all children + for (final child in children) { + _renameReferences(fromPath + child, toPath + child); + broadcastFileWrite(FileOperationType.delete, fromPath + child); + broadcastFileWrite(FileOperationType.write, toPath + child); + } + + return toPath; + } + + /// Returns a unique directory path by appending a number to the end. + /// e.g. "/MyFolder" -> "/MyFolder (2)" + static Future _suffixDirectoryPathToMakeItUnique( + String directoryPath, + ) async { + String newPath = directoryPath; + int i = 1; + + while (Directory(documentsDirectory + newPath).existsSync()) { + i++; + newPath = '$directoryPath ($i)'; + } + + return newPath; + } + /// Gets the children of a directory, separated into /// [DirectoryChildren.directories] and [DirectoryChildren.files]. /// diff --git a/lib/i18n/en.i18n.yaml b/lib/i18n/en.i18n.yaml index dedcb0b35..75b0744cf 100644 --- a/lib/i18n/en.i18n.yaml +++ b/lib/i18n/en.i18n.yaml @@ -56,11 +56,17 @@ home: folderNameEmpty: Folder name can't be empty folderNameContainsSlash: Folder name can't contain a slash folderNameExists: A folder with this name already exists - deleteFolder: + deleteFolder: deleteFolder: Delete folder deleteName: Delete $f delete: Delete alsoDeleteContents: Also delete all notes inside this folder + moveFolder: + moveFolder: Move folder + moveName: Move $f + move: Move + renamedTo: Folder will be renamed to $newName + cantMoveHere: Cannot move folder here sentry: consent: title: Help improve Saber? diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 5bdb5c45f..bf081765d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -104,6 +104,7 @@ class TranslationsHomeEn { late final TranslationsHomeRenameFolderEn renameFolder = TranslationsHomeRenameFolderEn.internal(_root); late final TranslationsHomeDeleteFolderEn deleteFolder = TranslationsHomeDeleteFolderEn.internal(_root); + late final TranslationsHomeMoveFolderEn moveFolder = TranslationsHomeMoveFolderEn.internal(_root); } // Path: sentry @@ -525,6 +526,30 @@ class TranslationsHomeDeleteFolderEn { String get alsoDeleteContents => 'Also delete all notes inside this folder'; } +// Path: home.moveFolder +class TranslationsHomeMoveFolderEn { + TranslationsHomeMoveFolderEn.internal(this._root); + + final Translations _root; // ignore: unused_field + + // Translations + + /// en: 'Move folder' + String get moveFolder => 'Move folder'; + + /// en: 'Move $f' + String moveName({required Object f}) => 'Move ${f}'; + + /// en: 'Move' + String get move => 'Move'; + + /// en: 'Folder will be renamed to $newName' + String renamedTo({required Object newName}) => 'Folder will be renamed to ${newName}'; + + /// en: 'Cannot move folder here' + String get cantMoveHere => 'Cannot move folder here'; +} + // Path: sentry.consent class TranslationsSentryConsentEn { TranslationsSentryConsentEn.internal(this._root); diff --git a/lib/pages/home/browse.dart b/lib/pages/home/browse.dart index 03db7cabf..9ed50c43e 100644 --- a/lib/pages/home/browse.dart +++ b/lib/pages/home/browse.dart @@ -174,6 +174,14 @@ class _BrowsePageState extends State { await FileManager.deleteDirectory(folderPath); findChildrenOfPath(); }, + currentFolderPath: '${path ?? ''}/'.replaceFirst( + RegExp(r'^/+'), + '/', + ), + moveFolder: (String folderName) async { + // Refresh the folder list after move + findChildrenOfPath(); + }, folders: [ for (final directoryPath in children?.directories ?? const []) directoryPath,