diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 67a643d71e..e8f8596050 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6618,7 +6618,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6686,7 +6686,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 6; + CURRENT_PROJECT_VERSION = 7; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6955,8 +6955,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/nextcloud/NextcloudKit"; requirement = { - branch = "unified-sharing"; - kind = branch; + kind = exactVersion; + version = 7.6.0; }; }; F788ECC5263AAAF900ADC67F /* XCRemoteSwiftPackageReference "MarkdownKit" */ = { diff --git a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6fbc92050e..62fa58b117 100644 --- a/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -195,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nextcloud/NextcloudKit", "state" : { - "branch" : "unified-sharing", - "revision" : "1897d66c1687d85ec7c5acbf9fe7fd396b511257" + "revision" : "15145557690e8655227583b1461e5c45df9a6c1e", + "version" : "7.6.0" } }, { diff --git a/iOSClient/Albums/API/Albums+WebDAV.swift b/iOSClient/Albums/API/Albums+WebDAV.swift index 4ad6da448e..13665db87e 100644 --- a/iOSClient/Albums/API/Albums+WebDAV.swift +++ b/iOSClient/Albums/API/Albums+WebDAV.swift @@ -467,10 +467,11 @@ public extension NextcloudKit { // MARK: - Delete Photo from Album - /// Asynchronously deletes a Photo FromAlbum from the Nextcloud server. + /// Asynchronously removes a photo from an album without deleting the original file. /// /// - Parameters: - /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - albumName: The name of the album containing the photo. + /// - fileName: The album entry name returned by WebDAV, including its file ID prefix. /// - account: The Nextcloud account identifier. /// - options: Optional request options including headers, timeout, and queue. /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. @@ -481,7 +482,6 @@ public extension NextcloudKit { /// - error: The `NKError` result indicating success or failure. func deletePhotoFromAlbumAsync(albumName: String, fileName: String, - serverUrlFileName: String, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } @@ -493,7 +493,6 @@ public extension NextcloudKit { await withCheckedContinuation { continuation in deletePhotoFromAlbum(albumName: albumName, fileName: fileName, - serverUrlFileName: serverUrlFileName, account: account, options: options, taskHandler: taskHandler) { account, responseData, error in @@ -506,10 +505,11 @@ public extension NextcloudKit { } } - /// Deletes a Photo From Album from the Nextcloud server at the specified URL. + /// Removes a photo from an album without deleting the original file. /// /// - Parameters: - /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - albumName: The name of the album containing the photo. + /// - fileName: The album entry name returned by WebDAV, including its file ID prefix. /// - account: The Nextcloud account identifier. /// - options: Optional request options including headers, timeout, and queue. /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. @@ -519,7 +519,6 @@ public extension NextcloudKit { /// - error: The `NKError` result indicating success or failure. func deletePhotoFromAlbum(albumName: String, fileName: String, - serverUrlFileName: String, account: String, options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index 79794e79af..ac5010d1e0 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -130,7 +130,12 @@ struct AlbumDetailsScreen: View { localAccount: viewModel.account, photos: viewModel.photos, onAddPhotosIntent: handleAddPhotosIntent, - album: album + album: album, + onRemovePhoto: { photo in + Task { @MainActor in + await viewModel.removePhoto(photo) + } + } ) .refreshable { viewModel.onPulledToRefresh() diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift index 430f82de3f..3d4c9af5db 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -174,6 +174,29 @@ class AlbumDetailsViewModel: ObservableObject { } } + @MainActor + func removePhoto(_ photo: AlbumPhoto) async { + guard !isLoadingPopupVisible, photos.keys.contains(photo) else { return } + + isLoadingPopupVisible = true + defer { isLoadingPopupVisible = false } + + // Remove the album entry, including when the original file has no local metadata. + let result = await NextcloudKit.shared.deletePhotoFromAlbumAsync( + albumName: album.name, + fileName: photo.fileName, + account: account + ) + + guard result.error == .success else { + await showErrorBanner(windowScene: windowScene, error: result.error) + return + } + + photos.removeValue(forKey: photo) + AlbumsManager.shared.syncAlbums() + } + @MainActor func deletePhotos(with metadatas: [tableMetadata]) async { for metadata in metadatas { if let photo = photos.first(where: { $0.value?.ocId == metadata.ocId })?.key { @@ -209,7 +232,7 @@ class AlbumDetailsViewModel: ObservableObject { let fileName: String = photo.fileName print("DEBUG: Attempting to remove: \(fileName)") - let results = await NextcloudKit.shared.deletePhotoFromAlbumAsync(albumName: album.name, fileName: fileName, serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) { task in + let results = await NextcloudKit.shared.deletePhotoFromAlbumAsync(albumName: album.name, fileName: fileName, account: metadata.account) { task in Task { let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, path: metadata.serverUrlFileName, diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index 49ac41b2cd..f881f45c2a 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -10,6 +10,9 @@ struct PhotosGridView: View { let photos: [AlbumPhoto: tableMetadata?] let onAddPhotosIntent: () -> Void let album: Album + let onRemovePhoto: (AlbumPhoto) -> Void + + @State private var photoToRemove: AlbumPhoto? private var columns: [GridItem] { if UIDevice.current.userInterfaceIdiom == .pad { @@ -42,9 +45,31 @@ struct PhotosGridView: View { iconSize: calculatedIconSize ) } + .contextMenu { + Button(role: .destructive) { + photoToRemove = photo + } label: { + Label(NSLocalizedString("_remove_from_album_", comment: ""), systemImage: "minus.circle") + } + } } } } + .alert( + NSLocalizedString("_remove_from_album_", comment: ""), + isPresented: Binding( + get: { photoToRemove != nil }, + set: { if !$0 { photoToRemove = nil } } + ), + presenting: photoToRemove + ) { photo in + Button(NSLocalizedString("_remove_from_album_", comment: ""), role: .destructive) { + onRemovePhoto(photo) + } + Button(NSLocalizedString("_cancel_", comment: ""), role: .cancel) {} + } message: { _ in + Text(NSLocalizedString("_want_remove_from_album_", comment: "")) + } } @MainActor diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 09fa958e75..7bda66909c 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -59,12 +59,16 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { viewIfLoaded?.window?.windowScene } + override var allowsSearchWhileSelecting: Bool { true } + + override var isMediaPresentationActive: Bool { isViewActived } + override func viewDidLoad() { super.viewDidLoad() // Keep Media's selection bookkeeping without its share/move/delete toolbar. tabBarSelect = NCMediaSelectTabBar(viewController: self) collectionView.dragInteractionEnabled = false - collectionView.isUserInteractionEnabled = false + isEditMode = true } override func viewWillAppear(_ animated: Bool) { @@ -80,22 +84,15 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { prepareTask = Task { @MainActor [weak self] in guard let self else { return } defer { self.prepareTask = nil } + // This controller is presented in a sheet, not as the selected Media tab. await self.loadDataSource(forced: true) guard !Task.isCancelled else { return } - // The normal search API requires an attached view outside edit mode. - await self.searchMediaTask?.value - guard !Task.isCancelled else { return } - await self.searchMediaUI(true) - guard !Task.isCancelled else { return } - // Search updates the database; its normal reload also requires the Media tab. - await self.loadDataSource(forced: true) - guard !Task.isCancelled else { return } - self.collectionViewReloadData() - self.isEditMode = true self.selectionReady = true self.collectionView.isUserInteractionEnabled = true self.onReady?() + + self.searchNewMedia() } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index ea1f63c9a3..9f7cdd0310 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -122,6 +122,13 @@ class NCMedia: UIViewController { return self.isViewLoaded && self.view.window != nil } + // Album selection presents Media in a sheet and keeps searching while selecting. + var allowsSearchWhileSelecting: Bool { false } + + var isMediaPresentationActive: Bool { + isViewActived && tabBarController?.selectedViewController === navigationController + } + var isPinchGestureActive: Bool { return pinchGesture.state == .began || pinchGesture.state == .changed } diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 213655d874..f533d666f5 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -68,7 +68,7 @@ extension NCMedia { self.isViewActived && self.session.account == account && self.view.window != nil && - self.tabBarController?.selectedViewController === self.navigationController + self.isMediaPresentationActive ) } @@ -91,7 +91,7 @@ extension NCMedia { self.isViewActived && self.session.account == account && self.view.window != nil && - self.tabBarController?.selectedViewController === self.navigationController + self.isMediaPresentationActive ) else { return } @@ -200,7 +200,7 @@ extension NCMedia { !self.isPinchGestureActive, !self.showOnlyImages, !self.showOnlyVideos, - !self.isEditMode else { + (!self.isEditMode || self.allowsSearchWhileSelecting) else { return false }