diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index d6859ef987..387cc67d47 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -231,6 +231,36 @@ F70821D829E59E6D001CA2D7 /* TagListView in Frameworks */ = {isa = PBXBuildFile; productRef = F70821D729E59E6D001CA2D7 /* TagListView */; }; F70898672EDDB39B00EF85BD /* NCNetworking+TransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */; }; F70898692EDDB51700EF85BD /* NCSelectOpen+SelectDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898682EDDB51200EF85BD /* NCSelectOpen+SelectDelegate.swift */; }; + F708B436304EC5F9009D013A /* NCAlbums.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F708B434304EC5F9009D013A /* NCAlbums.storyboard */; }; + F708B437304EC5F9009D013A /* AlbumsNavigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B42A304EC5F9009D013A /* AlbumsNavigator.swift */; }; + F708B438304EC5F9009D013A /* AlbumDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B41D304EC5F9009D013A /* AlbumDetailsViewModel.swift */; }; + F708B439304EC5F9009D013A /* AlbumsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B427304EC5F9009D013A /* AlbumsListViewModel.swift */; }; + F708B43A304EC5F9009D013A /* AlbumDetailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B41C304EC5F9009D013A /* AlbumDetailsScreen.swift */; }; + F708B43B304EC5F9009D013A /* AlbumsListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B426304EC5F9009D013A /* AlbumsListScreen.swift */; }; + F708B43C304EC5F9009D013A /* PhotoGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B420304EC5F9009D013A /* PhotoGridItemView.swift */; }; + F708B43D304EC5F9009D013A /* PhotosGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B421304EC5F9009D013A /* PhotosGridView.swift */; }; + F708B43E304EC5F9009D013A /* NCLoadingAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B41A304EC5F9009D013A /* NCLoadingAlert.swift */; }; + F708B43F304EC5F9009D013A /* LoadableState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B410304EC5F9009D013A /* LoadableState.swift */; }; + F708B440304EC5F9009D013A /* AlbumMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B412304EC5F9009D013A /* AlbumMapper.swift */; }; + F708B441304EC5F9009D013A /* AlbumPhotoMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B413304EC5F9009D013A /* AlbumPhotoMapper.swift */; }; + F708B442304EC5F9009D013A /* Album.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B415304EC5F9009D013A /* Album.swift */; }; + F708B443304EC5F9009D013A /* PhotoSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B42E304EC5F9009D013A /* PhotoSelectionSheet.swift */; }; + F708B444304EC5F9009D013A /* NoPhotosEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B41F304EC5F9009D013A /* NoPhotosEmptyView.swift */; }; + F708B445304EC5F9009D013A /* AlbumsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B433304EC5F9009D013A /* AlbumsViewController.swift */; }; + F708B446304EC5F9009D013A /* AlbumPhotoDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B40B304EC5F9009D013A /* AlbumPhotoDTO.swift */; }; + F708B447304EC5F9009D013A /* MediaSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B41E304EC5F9009D013A /* MediaSelectionSheet.swift */; }; + F708B448304EC5F9009D013A /* Albums+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B40D304EC5F9009D013A /* Albums+WebDAV.swift */; }; + F708B449304EC5F9009D013A /* NoAlbumsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B428304EC5F9009D013A /* NoAlbumsEmptyView.swift */; }; + F708B44A304EC5F9009D013A /* AlbumsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B40F304EC5F9009D013A /* AlbumsManager.swift */; }; + F708B44B304EC5F9009D013A /* AlbumGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B424304EC5F9009D013A /* AlbumGridItemView.swift */; }; + F708B44C304EC5F9009D013A /* AlbumsGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B425304EC5F9009D013A /* AlbumsGridView.swift */; }; + F708B44D304EC5F9009D013A /* AlbumPhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B416304EC5F9009D013A /* AlbumPhoto.swift */; }; + F708B44E304EC5F9009D013A /* AlbumDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B40A304EC5F9009D013A /* AlbumDTO.swift */; }; + F708B44F304EC5F9009D013A /* AlbumsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B430304EC5F9009D013A /* AlbumsRootView.swift */; }; + F708B450304EC5F9009D013A /* AddToAlbumsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B423304EC5F9009D013A /* AddToAlbumsListView.swift */; }; + F708B451304EC5F9009D013A /* NCInputAlbumNameAlertModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B419304EC5F9009D013A /* NCInputAlbumNameAlertModifier.swift */; }; + F708B452304EC5F9009D013A /* AlbumsRoutes.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B42B304EC5F9009D013A /* AlbumsRoutes.swift */; }; + F708B453304EC5F9009D013A /* NCMediaViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F708B42D304EC5F9009D013A /* NCMediaViewRepresentable.swift */; }; F70968A424212C4E00ED60E5 /* NCLivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70968A324212C4E00ED60E5 /* NCLivePhoto.swift */; }; F70B86752642CE3B00ED5349 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = F70B86742642CE3B00ED5349 /* FirebaseCrashlytics */; }; F70BFC7420E0FA7D00C67599 /* NCUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70BFC7320E0FA7C00C67599 /* NCUtility.swift */; }; @@ -1437,6 +1467,36 @@ F707C26421A2DC5200F6181E /* NCStoreReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCStoreReview.swift; sourceTree = ""; }; F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+TransferDelegate.swift"; sourceTree = ""; }; F70898682EDDB51200EF85BD /* NCSelectOpen+SelectDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCSelectOpen+SelectDelegate.swift"; sourceTree = ""; }; + F708B40A304EC5F9009D013A /* AlbumDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDTO.swift; sourceTree = ""; }; + F708B40B304EC5F9009D013A /* AlbumPhotoDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumPhotoDTO.swift; sourceTree = ""; }; + F708B40D304EC5F9009D013A /* Albums+WebDAV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Albums+WebDAV.swift"; sourceTree = ""; }; + F708B40F304EC5F9009D013A /* AlbumsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsManager.swift; sourceTree = ""; }; + F708B410304EC5F9009D013A /* LoadableState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableState.swift; sourceTree = ""; }; + F708B412304EC5F9009D013A /* AlbumMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumMapper.swift; sourceTree = ""; }; + F708B413304EC5F9009D013A /* AlbumPhotoMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumPhotoMapper.swift; sourceTree = ""; }; + F708B415304EC5F9009D013A /* Album.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Album.swift; sourceTree = ""; }; + F708B416304EC5F9009D013A /* AlbumPhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumPhoto.swift; sourceTree = ""; }; + F708B419304EC5F9009D013A /* NCInputAlbumNameAlertModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCInputAlbumNameAlertModifier.swift; sourceTree = ""; }; + F708B41A304EC5F9009D013A /* NCLoadingAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCLoadingAlert.swift; sourceTree = ""; }; + F708B41C304EC5F9009D013A /* AlbumDetailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDetailsScreen.swift; sourceTree = ""; }; + F708B41D304EC5F9009D013A /* AlbumDetailsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumDetailsViewModel.swift; sourceTree = ""; }; + F708B41E304EC5F9009D013A /* MediaSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSelectionSheet.swift; sourceTree = ""; }; + F708B41F304EC5F9009D013A /* NoPhotosEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoPhotosEmptyView.swift; sourceTree = ""; }; + F708B420304EC5F9009D013A /* PhotoGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoGridItemView.swift; sourceTree = ""; }; + F708B421304EC5F9009D013A /* PhotosGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosGridView.swift; sourceTree = ""; }; + F708B423304EC5F9009D013A /* AddToAlbumsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToAlbumsListView.swift; sourceTree = ""; }; + F708B424304EC5F9009D013A /* AlbumGridItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumGridItemView.swift; sourceTree = ""; }; + F708B425304EC5F9009D013A /* AlbumsGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsGridView.swift; sourceTree = ""; }; + F708B426304EC5F9009D013A /* AlbumsListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsListScreen.swift; sourceTree = ""; }; + F708B427304EC5F9009D013A /* AlbumsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsListViewModel.swift; sourceTree = ""; }; + F708B428304EC5F9009D013A /* NoAlbumsEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoAlbumsEmptyView.swift; sourceTree = ""; }; + F708B42A304EC5F9009D013A /* AlbumsNavigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsNavigator.swift; sourceTree = ""; }; + F708B42B304EC5F9009D013A /* AlbumsRoutes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsRoutes.swift; sourceTree = ""; }; + F708B42D304EC5F9009D013A /* NCMediaViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewRepresentable.swift; sourceTree = ""; }; + F708B42E304EC5F9009D013A /* PhotoSelectionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoSelectionSheet.swift; sourceTree = ""; }; + F708B430304EC5F9009D013A /* AlbumsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsRootView.swift; sourceTree = ""; }; + F708B433304EC5F9009D013A /* AlbumsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumsViewController.swift; sourceTree = ""; }; + F708B434304EC5F9009D013A /* NCAlbums.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCAlbums.storyboard; sourceTree = ""; }; F70968A324212C4E00ED60E5 /* NCLivePhoto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCLivePhoto.swift; sourceTree = ""; }; F70A07C8205285FB00DC1231 /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Localizable.strings"; sourceTree = ""; }; F70BFC7320E0FA7C00C67599 /* NCUtility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCUtility.swift; sourceTree = ""; }; @@ -2452,6 +2512,147 @@ path = "File Provider Extension UI"; sourceTree = ""; }; + F708B40C304EC5F9009D013A /* Models */ = { + isa = PBXGroup; + children = ( + F708B40A304EC5F9009D013A /* AlbumDTO.swift */, + F708B40B304EC5F9009D013A /* AlbumPhotoDTO.swift */, + ); + path = Models; + sourceTree = ""; + }; + F708B40E304EC5F9009D013A /* API */ = { + isa = PBXGroup; + children = ( + F708B40C304EC5F9009D013A /* Models */, + F708B40D304EC5F9009D013A /* Albums+WebDAV.swift */, + ); + path = API; + sourceTree = ""; + }; + F708B411304EC5F9009D013A /* Data */ = { + isa = PBXGroup; + children = ( + F708B40F304EC5F9009D013A /* AlbumsManager.swift */, + F708B410304EC5F9009D013A /* LoadableState.swift */, + ); + path = Data; + sourceTree = ""; + }; + F708B414304EC5F9009D013A /* Mappers */ = { + isa = PBXGroup; + children = ( + F708B412304EC5F9009D013A /* AlbumMapper.swift */, + F708B413304EC5F9009D013A /* AlbumPhotoMapper.swift */, + ); + path = Mappers; + sourceTree = ""; + }; + F708B417304EC5F9009D013A /* Models */ = { + isa = PBXGroup; + children = ( + F708B415304EC5F9009D013A /* Album.swift */, + F708B416304EC5F9009D013A /* AlbumPhoto.swift */, + ); + path = Models; + sourceTree = ""; + }; + F708B418304EC5F9009D013A /* Domain */ = { + isa = PBXGroup; + children = ( + F708B414304EC5F9009D013A /* Mappers */, + F708B417304EC5F9009D013A /* Models */, + ); + path = Domain; + sourceTree = ""; + }; + F708B41B304EC5F9009D013A /* Alerts */ = { + isa = PBXGroup; + children = ( + F708B419304EC5F9009D013A /* NCInputAlbumNameAlertModifier.swift */, + F708B41A304EC5F9009D013A /* NCLoadingAlert.swift */, + ); + path = Alerts; + sourceTree = ""; + }; + F708B422304EC5F9009D013A /* Details */ = { + isa = PBXGroup; + children = ( + F708B41C304EC5F9009D013A /* AlbumDetailsScreen.swift */, + F708B41D304EC5F9009D013A /* AlbumDetailsViewModel.swift */, + F708B41E304EC5F9009D013A /* MediaSelectionSheet.swift */, + F708B41F304EC5F9009D013A /* NoPhotosEmptyView.swift */, + F708B420304EC5F9009D013A /* PhotoGridItemView.swift */, + F708B421304EC5F9009D013A /* PhotosGridView.swift */, + ); + path = Details; + sourceTree = ""; + }; + F708B429304EC5F9009D013A /* List */ = { + isa = PBXGroup; + children = ( + F708B423304EC5F9009D013A /* AddToAlbumsListView.swift */, + F708B424304EC5F9009D013A /* AlbumGridItemView.swift */, + F708B425304EC5F9009D013A /* AlbumsGridView.swift */, + F708B426304EC5F9009D013A /* AlbumsListScreen.swift */, + F708B427304EC5F9009D013A /* AlbumsListViewModel.swift */, + F708B428304EC5F9009D013A /* NoAlbumsEmptyView.swift */, + ); + path = List; + sourceTree = ""; + }; + F708B42C304EC5F9009D013A /* Navigation */ = { + isa = PBXGroup; + children = ( + F708B42A304EC5F9009D013A /* AlbumsNavigator.swift */, + F708B42B304EC5F9009D013A /* AlbumsRoutes.swift */, + ); + path = Navigation; + sourceTree = ""; + }; + F708B42F304EC5F9009D013A /* PhotoSelection */ = { + isa = PBXGroup; + children = ( + F708B42D304EC5F9009D013A /* NCMediaViewRepresentable.swift */, + F708B42E304EC5F9009D013A /* PhotoSelectionSheet.swift */, + ); + path = PhotoSelection; + sourceTree = ""; + }; + F708B431304EC5F9009D013A /* Root */ = { + isa = PBXGroup; + children = ( + F708B430304EC5F9009D013A /* AlbumsRootView.swift */, + ); + path = Root; + sourceTree = ""; + }; + F708B432304EC5F9009D013A /* Presentation */ = { + isa = PBXGroup; + children = ( + F708B41B304EC5F9009D013A /* Alerts */, + F708B422304EC5F9009D013A /* Details */, + F708B429304EC5F9009D013A /* List */, + F708B42C304EC5F9009D013A /* Navigation */, + F708B42F304EC5F9009D013A /* PhotoSelection */, + F708B431304EC5F9009D013A /* Root */, + ); + path = Presentation; + sourceTree = ""; + }; + F708B435304EC5F9009D013A /* Albums */ = { + isa = PBXGroup; + children = ( + F708B40E304EC5F9009D013A /* API */, + F708B411304EC5F9009D013A /* Data */, + F708B418304EC5F9009D013A /* Domain */, + F708B432304EC5F9009D013A /* Presentation */, + F708B433304EC5F9009D013A /* AlbumsViewController.swift */, + F708B434304EC5F9009D013A /* NCAlbums.storyboard */, + ); + path = Albums; + sourceTree = ""; + }; F70B866A2642A21300ED5349 /* Color */ = { isa = PBXGroup; children = ( @@ -3646,6 +3847,7 @@ F317C82C2E844C3C00761AEA /* Client Integration */, F70211F31BAC56E9003FC03E /* Main */, F7FDFF5A2E437E55000D7688 /* Account */, + F708B435304EC5F9009D013A /* Albums */, F7A321621E9E37960069AD1B /* Activity */, F76687042B7D067400779E3F /* AudioRecorder */, F3A0478E2BD2668800658E7B /* Assistant */, @@ -4368,6 +4570,7 @@ F751247E2C42919C00E63DB8 /* NCPhotoCell.xib in Resources */, F704B5E32430AA6F00632F5F /* NCCreateFormUploadConflict.storyboard in Resources */, F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */, + F708B436304EC5F9009D013A /* NCAlbums.storyboard in Resources */, F73D11FA253C5F4800DF9BEC /* NCViewerDirectEditing.storyboard in Resources */, F7EDE51B262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib in Resources */, F7FF2CB12842159500EBB7A1 /* NCSectionHeader.xib in Resources */, @@ -5217,6 +5420,35 @@ F7865FF12F39D32F00D09AE4 /* NCCollectionViewCommon+Search.swift in Sources */, F7327E352B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift in Sources */, F76687072B7D067400779E3F /* NCAudioRecorderViewController.swift in Sources */, + F708B437304EC5F9009D013A /* AlbumsNavigator.swift in Sources */, + F708B438304EC5F9009D013A /* AlbumDetailsViewModel.swift in Sources */, + F708B439304EC5F9009D013A /* AlbumsListViewModel.swift in Sources */, + F708B43A304EC5F9009D013A /* AlbumDetailsScreen.swift in Sources */, + F708B43B304EC5F9009D013A /* AlbumsListScreen.swift in Sources */, + F708B43C304EC5F9009D013A /* PhotoGridItemView.swift in Sources */, + F708B43D304EC5F9009D013A /* PhotosGridView.swift in Sources */, + F708B43E304EC5F9009D013A /* NCLoadingAlert.swift in Sources */, + F708B43F304EC5F9009D013A /* LoadableState.swift in Sources */, + F708B440304EC5F9009D013A /* AlbumMapper.swift in Sources */, + F708B441304EC5F9009D013A /* AlbumPhotoMapper.swift in Sources */, + F708B442304EC5F9009D013A /* Album.swift in Sources */, + F708B443304EC5F9009D013A /* PhotoSelectionSheet.swift in Sources */, + F708B444304EC5F9009D013A /* NoPhotosEmptyView.swift in Sources */, + F708B445304EC5F9009D013A /* AlbumsViewController.swift in Sources */, + F708B446304EC5F9009D013A /* AlbumPhotoDTO.swift in Sources */, + F708B447304EC5F9009D013A /* MediaSelectionSheet.swift in Sources */, + F708B448304EC5F9009D013A /* Albums+WebDAV.swift in Sources */, + F708B449304EC5F9009D013A /* NoAlbumsEmptyView.swift in Sources */, + F708B44A304EC5F9009D013A /* AlbumsManager.swift in Sources */, + F708B44B304EC5F9009D013A /* AlbumGridItemView.swift in Sources */, + F708B44C304EC5F9009D013A /* AlbumsGridView.swift in Sources */, + F708B44D304EC5F9009D013A /* AlbumPhoto.swift in Sources */, + F708B44E304EC5F9009D013A /* AlbumDTO.swift in Sources */, + F708B44F304EC5F9009D013A /* AlbumsRootView.swift in Sources */, + F708B450304EC5F9009D013A /* AddToAlbumsListView.swift in Sources */, + F708B451304EC5F9009D013A /* NCInputAlbumNameAlertModifier.swift in Sources */, + F708B452304EC5F9009D013A /* AlbumsRoutes.swift in Sources */, + F708B453304EC5F9009D013A /* NCMediaViewRepresentable.swift in Sources */, F77B0F893008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift in Sources */, F7BDC1D8300F4E8E00C5D9FA /* NCMediaMetadataBackfillProcessor.swift in Sources */, AA8E03DA2D2ED83300E7E89C /* TransientShare.swift in Sources */, diff --git a/iOSClient/Albums/API/Albums+WebDAV.swift b/iOSClient/Albums/API/Albums+WebDAV.swift new file mode 100644 index 0000000000..a2c44cd8b4 --- /dev/null +++ b/iOSClient/Albums/API/Albums+WebDAV.swift @@ -0,0 +1,715 @@ +// +// Albums+WebDAV.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import NextcloudKit +import Alamofire +import SwiftyJSON +import SwiftyXMLParser + +fileprivate extension DateFormatter { + static let httpDate: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter + }() +} + +public extension NextcloudKit { + + // MARK: - Fetch all Albums + func fetchAllAlbums( + for account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result<[AlbumDTO], Error>) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) + + //options.contentType = "application/xml" +// self.utilityFileSystem.getHomeServer(session: session) + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.userId + "/albums/" + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod(rawValue: "PROPFIND") + + let propfindXML = """ + + + + + + + + + + + """ + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = propfindXML.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request( + urlRequest, + //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + ) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + let statusCode = response.response?.statusCode + + // Explicit 404 check + if statusCode == 404 { + return options.queue.async { + completion(.success([])) + } + } + + switch response.result { + + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + + guard let data = response.data else { + return options.queue.async { + completion(.failure(NKError.invalidData)) + } + } + + let albums = self.parseAlbumsXML(data: data) + options.queue.async { + completion(.success(albums)) + } + } + } + } + + private func parseAlbumsXML(data: Data) -> [AlbumDTO] { + + let xml = XML.parse(data) + var albums: [AlbumDTO] = [] + + let elements = xml["d:multistatus", "d:response"] + + for element in elements { + + let href = element["d:href"].element?.text ?? "" + + let prop = element["d:propstat"]["d:prop"] + + let lastPhoto = prop["nc:last-photo"].element?.text + let nbItems = prop["nc:nbItems"].element?.text.flatMap { Int($0) } + let location = prop["nc:location"].element?.text + let dateRange = prop["nc:dateRange"].element?.text + let collaborators = prop["nc:collaborators"].element?.text + + // Optionally skip entries with 404 status + let status = element["d:propstat"]["d:status"].element?.text ?? "" + if status.contains("200") { + let album = AlbumDTO( + href: href, + lastPhotoId: lastPhoto, + itemCount: nbItems, + location: location, + dateRange: dateRange, + collaborators: collaborators + ) + albums.append(album) + } + } + + return albums + } + + // MARK: - Create new Album + func createNewAlbum( + for account: String, + albumName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.userId + "/albums/\(albumName)/" + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod(rawValue: "MKCOL") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request( + urlRequest, + //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + ) + // .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + // Explicit 405 check -> treat as conflict (album already exists) + if let statusCode = response.response?.statusCode, statusCode == 405 { + // Resolve localized message so UI using `localizedDescription` shows the proper text + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + // Build an NSError carrying the localized description + let nsError = NSError( + domain: "NextcloudKit", + code: NCGlobal.shared.errorConflict, + userInfo: [NSLocalizedDescriptionKey: message] + ) + let error = NKError(error: nsError) + return options.queue.async { + completion(.failure(error)) + } + } + + guard let statusCode = response.response?.statusCode, (200...299).contains(statusCode) else { + let code = response.response?.statusCode ?? -1 + let httpError = NKError(errorCode: code, errorDescription: "HTTP error \(code)", responseData: response.data) + return options.queue.async { + completion(.failure(httpError)) + } + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { + completion(.success(true)) + } + } + } + } + + // MARK: - Fetch Album photos + func fetchAlbumPhotos( + for album: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result<[AlbumPhotoDTO], Error>) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.userId + "/albums/" + album + "/" + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod(rawValue: "PROPFIND") + + let propfindXML = """ + + + + + + + + + + + + + + + + + + """ + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.httpBody = propfindXML.data(using: .utf8) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request( + urlRequest, + //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + ) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + let statusCode = response.response?.statusCode + + // Explicit 404 check + if statusCode == 404 { + return options.queue.async { + completion(.success([])) + } + } + + switch response.result { + + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + + guard let data = response.data else { + return options.queue.async { + completion(.failure(NKError.invalidData)) + } + } + + let photos = self.parseAlbumPhotosXML(data: data) + options.queue.async { + completion(.success(photos)) + } + } + } + } + + private func parseAlbumPhotosXML(data: Data) -> [AlbumPhotoDTO] { + + let xml = XML.parse(data) + var photos: [AlbumPhotoDTO] = [] + + let elements = xml["d:multistatus", "d:response"] + + for element in elements { + + let href = element["d:href"].element?.text ?? "" + let fileName = URL(string: href)?.lastPathComponent ?? href + + let propstats: [XML.Element] = element["d:propstat"].all ?? [] + + for propstat in propstats { + + let ps = XML.Accessor(propstat) + + let status = ps["d:status"].element?.text ?? "" + guard status.contains("200") else { continue } + + let prop = ps["d:prop"] + + guard let fileId = prop["oc:fileid"].element?.text else { continue } + + let contentType = prop["d:getcontenttype"].element?.text ?? "" + let contentLength = prop["d:getcontentlength"].element?.text.flatMap { Int($0) } ?? 0 + let lastModified = prop["d:getlastmodified"].element?.text.flatMap { + DateFormatter.httpDate.date(from: $0) + } ?? Date() + + let hasPreview = prop["nc:has-preview"].element?.text == "true" + let isHidden = prop["nc:hidden"].element?.text == "true" + let isFavorite = prop["oc:favorite"].element?.text == "1" + let permissions = prop["oc:permissions"].element?.text ?? "" + + let originalDateTime = prop["nc:metadata-photos-original_date_time"] + .element?.text.flatMap { Double($0) } + .flatMap { Date(timeIntervalSince1970: $0) } + + let sizeNode = prop["nc:metadata-photos-size"] + let width = sizeNode["width"].element?.text.flatMap { Int($0) } + let height = sizeNode["height"].element?.text.flatMap { Int($0) } + + let photo = AlbumPhotoDTO( + fileId: fileId, + fileName: fileName, + contentType: contentType, + contentLength: contentLength, + lastModified: lastModified, + hasPreview: hasPreview, + isHidden: isHidden, + isFavorite: isFavorite, + permissions: permissions, + originalDateTime: originalDateTime, + width: width, + height: height + ) + + photos.append(photo) + } + } + + return photos + } + + // MARK: - Copy Photo to Album + func copyPhotoToAlbum( + account: String, + sourcePath: String, + albumName: String, + fileName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + + let session = NCSession.shared.getSession(account: account) +// + let sourceUrlString: String = { + if sourcePath.lowercased().hasPrefix("http") { + return sourcePath + } else { + return session.urlBase + sourcePath + } + }() + + let destinationPath = "/remote.php/dav/photos/" + session.userId + "/albums/" + albumName + "/" + fileName + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let sourceUrl = sourceUrlString.encodedToUrl, + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) + else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + headers.add( + name: "Destination", + value: destinationPath.urlEncoded ?? destinationPath + ) + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: sourceUrl, method: .init(rawValue: "COPY"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + let statusCode = response.response?.statusCode + + // Explicit 404 check + if statusCode == NCGlobal.shared.errorResourceNotFound || statusCode == NCGlobal.shared.errorForbidden { +// if statusCode == 404 { + return options.queue.async { + completion(.success(())) + } + } + + switch response.result { + case .failure(let error): + let nkErr = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(nkErr)) } + + case .success: + options.queue.async { completion(.success(())) } + } + } + } + + // MARK: - Delete Photo from Album + + /// Asynchronously deletes a Photo FromAlbum from the Nextcloud server. + /// + /// - Parameters: + /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - account: The Nextcloud account identifier. + /// - options: Optional request options including headers, timeout, and queue. + /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. + /// + /// - Returns: A tuple containing: + /// - account: The account used for the request. + /// - responseData: The raw Alamofire response data. + /// - 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 } + ) async -> ( + account: String, + responseData: AFDataResponse?, + error: NKError + ) { + await withCheckedContinuation { continuation in + deletePhotoFromAlbum(albumName: albumName, + fileName: fileName, + serverUrlFileName: serverUrlFileName, + account: account, + options: options, + taskHandler: taskHandler) { account, responseData, error in + continuation.resume(returning: ( + account: account, + responseData: responseData, + error: error + )) + } + } + } + + /// Deletes a Photo From Album from the Nextcloud server at the specified URL. + /// + /// - Parameters: + /// - serverUrlFileName: The full URL string of the file or folder to delete. + /// - account: The Nextcloud account identifier. + /// - options: Optional request options including headers, timeout, and queue. + /// - taskHandler: Callback triggered with the underlying `URLSessionTask`. + /// - completion: Completion handler returning: + /// - account: The account used for the request. + /// - responseData: The raw Alamofire response data. + /// - 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 }, + completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { + let session = NCSession.shared.getSession(account: account) + + // Build URL by appending path components to avoid double-encoding (e.g., %2520) + guard var baseUrl = URL(string: session.urlBase) else { + return options.queue.async { completion(account, nil, .urlError) } + } + baseUrl.appendPathComponent("remote.php") + baseUrl.appendPathComponent("dav") + baseUrl.appendPathComponent("photos") + baseUrl.appendPathComponent(session.userId) + baseUrl.appendPathComponent("albums") + baseUrl.appendPathComponent(albumName) + baseUrl.appendPathComponent(fileName) + let url = baseUrl + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options, contentType: "application/xml", accept: "application/xml") else { + return options.queue.async { completion(account, nil, .urlError) } + } + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .delete, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(account, nil, NKError(error: error)) } + } + + nkSession.sessionData.request(urlRequest).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + // Inline evaluation to avoid calling inaccessible helper + let resultError: NKError + if let statusCode = response.response?.statusCode { + if (200...299).contains(statusCode) { + resultError = .success + } else if statusCode == NCGlobal.shared.errorResourceNotFound { // 404 - already not in album + resultError = .success + } else { + // Non-2xx, non-404: derive from AFError if present, else map to HTTP error + if let afError = response.error { + resultError = NKError(error: afError, afResponse: response, responseData: response.data) + } else { + resultError = NKError(errorCode: statusCode, errorDescription: "HTTP error \(statusCode)", responseData: response.data) + } + } + } else if let afError = response.error { + resultError = NKError(error: afError, afResponse: response, responseData: response.data) + } else { + resultError = NKError.invalidResponseError + } + + options.queue.async { + completion(account, response, resultError) + } + } + } + + // MARK: - Delete Album + func deleteAlbum( + albumName: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, +// completion: @escaping (_ account: String, _ responseData: AFDataResponse?, _ error: NKError) -> Void) { + completion: @escaping (Result) -> Void) { + + let session = NCSession.shared.getSession(account: account) + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.userId + "/albums/" + albumName + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = urlPath.encodedToUrl, + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let method = HTTPMethod.delete + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: method, headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { completion(.success(())) } + } + } + } + + // MARK: - Rename Album + func renameAlbum( + account: String, + from oldName: String, + to newName: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + completion: @escaping (Result) -> Void + ) { + let session = NCSession.shared.getSession(account: account) + + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + let urlPath = session.urlBase + "/remote.php/dav/photos/" + session.userId + "/albums/" + oldName + "/" + let destinationHeader = "/remote.php/dav/photos/" + session.userId + "/albums/" + newName + "/" + + guard let url = urlPath.encodedToUrl, + var headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { completion(.failure(NKError.urlError)) } + } + + // Add the required MOVE header + headers.add( + name: "Destination", + value: destinationHeader.addingPercentEncoding( + withAllowedCharacters: CharacterSet.urlQueryAllowed.subtracting(["+", "?", "&"]) + ) ?? destinationHeader + ) + // Disallow overwriting an existing destination to avoid silent data loss + headers.add(name: "Overwrite", value: "F") + + var urlRequest: URLRequest + do { + try urlRequest = URLRequest(url: url, method: .init(rawValue: "MOVE"), headers: headers) + urlRequest.timeoutInterval = options.timeout + } catch { + return options.queue.async { completion(.failure(NKError(error: error))) } + } + + nkSession.sessionData.request(urlRequest) + .validate(statusCode: 200..<300) + .onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + } + .response(queue: self.nkCommonInstance.backgroundQueue) { response in + if NKLogFileManager.shared.logLevel.rawValue > 0 { + debugPrint(response) + } + + // If server signals that destination already exists, surface a conflict error to the UI + if let statusCode = response.response?.statusCode, statusCode == 412 || statusCode == 409 { + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let nsError = NSError( + domain: "NextcloudKit", + code: NCGlobal.shared.errorConflict, + userInfo: [NSLocalizedDescriptionKey: message] + ) + let error = NKError(error: nsError) + return options.queue.async { completion(.failure(error)) } + } + + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { completion(.failure(error)) } + + case .success: + options.queue.async { completion(.success(())) } + } + } + } +} + diff --git a/iOSClient/Albums/API/Models/AlbumDTO.swift b/iOSClient/Albums/API/Models/AlbumDTO.swift new file mode 100644 index 0000000000..e1ff215c3c --- /dev/null +++ b/iOSClient/Albums/API/Models/AlbumDTO.swift @@ -0,0 +1,16 @@ +// +// AlbumDTO.swift +// Nextcloud +// +// Created by Dhanesh on 28/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +public struct AlbumDTO { + let href: String + let lastPhotoId: String? + let itemCount: Int? + let location: String? + let dateRange: String? + let collaborators: String? +} diff --git a/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift b/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift new file mode 100644 index 0000000000..aaefc427e6 --- /dev/null +++ b/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift @@ -0,0 +1,22 @@ +// +// AlbumPhotoDTO.swift +// Nextcloud +// +// Created by Dhanesh on 01/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +public struct AlbumPhotoDTO { + let fileId: String + let fileName: String + let contentType: String + let contentLength: Int + let lastModified: Date + let hasPreview: Bool + let isHidden: Bool + let isFavorite: Bool + let permissions: String + let originalDateTime: Date? + let width: Int? + let height: Int? +} diff --git a/iOSClient/Albums/AlbumsViewController.swift b/iOSClient/Albums/AlbumsViewController.swift new file mode 100644 index 0000000000..598576fe3c --- /dev/null +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -0,0 +1,84 @@ +// +// AlbumsViewController.swift +// Nextcloud +// +// Created by Dhanesh on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import UIKit +import SwiftUI + +class AlbumsViewController: UIViewController { + + @Environment(\.localAccount) var localAccount: String + + @MainActor + var session: NCSession.Session { + NCSession.shared.getSession(controller: tabBarController) + } + + override func viewDidLoad() { + super.viewDidLoad() + + let albumsRootView = AlbumsRootView() + .environment(\.localAccount, session.account) + + let hostingController = UIHostingController(rootView: albumsRootView) + + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) + + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + hostingController.didMove(toParent: self) + + // Needed, since we use NCViewerMediaPage to show the media, which expects this! + navigationController?.navigationBar.prefersLargeTitles = false + + // Setting up AlbumsManager + AlbumsManager.shared.setAccount(session.account) + AlbumsManager.shared.syncAlbums() + +// // Preload NCMedia early so the selection sheet has data even if Media tab wasn't opened +// NCMediaPreloader.shared.preloadIfNeeded() +// +// if let media = NCMediaPreloader.shared.getPreloaded() { +// media.showOnlyImages = false +// media.showOnlyVideos = false +// Task { @MainActor in +// await media.loadDataSource() +// await media.searchMediaUI(true) +// } +// } + + // UI changes + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = NCBrandColor.shared.customer + UIBarButtonItem.appearance(whenContainedInInstancesOf: [UINavigationBar.self]) + .tintColor = NCBrandColor.shared.customer + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // NCViewerMediaPage messes up with the NavigationBar, so this is needed everytime on view's appearance + navigationController?.setNavigationBarHidden(true, animated: false) + } +} + +struct AccountKey: EnvironmentKey { + static let defaultValue: String = "" +} + +extension EnvironmentValues { + var localAccount: String { + get { self[AccountKey.self] } + set { self[AccountKey.self] = newValue } + } +} diff --git a/iOSClient/Albums/Data/AlbumsManager.swift b/iOSClient/Albums/Data/AlbumsManager.swift new file mode 100644 index 0000000000..d73b83143a --- /dev/null +++ b/iOSClient/Albums/Data/AlbumsManager.swift @@ -0,0 +1,52 @@ +// +// AlbumsManager.swift +// Nextcloud +// +// Created by Dhanesh on 09/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import Foundation +import Combine +import NextcloudKit + +final class AlbumsManager { + + static let shared = AlbumsManager() + + private var account: String = "" + + // Albums publisher - Central + private let albumsSubject = CurrentValueSubject, Never>(.idle) + var albumsPublisher: AnyPublisher, Never> { + albumsSubject.eraseToAnyPublisher() + } + + private init() {} + + // MARK: - Public Methods + func setAccount(_ acc: String) { + self.account = acc + } + + func syncAlbums( + optionalActionOnSuccess: (([Album]) -> Void)? = nil + ) { + + albumsSubject.send(.loading) + + NextcloudKit.shared.fetchAllAlbums(for: account) { [weak self] result in + + switch result { + case .success(let albumDTOs): + let albums = albumDTOs.toAlbums() + self?.albumsSubject.send(.success(albums)) + optionalActionOnSuccess?(albums) + + case .failure(let error): + let nkError = NKError(error: error) + self?.albumsSubject.send(.failure(nkError)) + } + } + } +} diff --git a/iOSClient/Albums/Data/LoadableState.swift b/iOSClient/Albums/Data/LoadableState.swift new file mode 100644 index 0000000000..be9bbf56c0 --- /dev/null +++ b/iOSClient/Albums/Data/LoadableState.swift @@ -0,0 +1,14 @@ +// +// LoadableState.swift +// Nextcloud +// +// Created by Dhanesh on 09/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +enum LoadableState { + case idle + case loading + case success(T) + case failure(Error) +} diff --git a/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift new file mode 100644 index 0000000000..f93e65c958 --- /dev/null +++ b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift @@ -0,0 +1,26 @@ +// +// AlbumMapper.swift +// Nextcloud +// +// Created by Dhanesh on 28/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +extension AlbumDTO { + + func toAlbum() -> Album { + return Album( + href: self.href, + lastPhotoId: self.lastPhotoId, + itemCount: self.itemCount, + location: self.location, + dateRange: self.dateRange, + collaborators: self.collaborators + ) + } +} + +extension Sequence where Element == AlbumDTO { + + func toAlbums() -> [Album] { map { $0.toAlbum() } } +} diff --git a/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift b/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift new file mode 100644 index 0000000000..9aa0dbb699 --- /dev/null +++ b/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift @@ -0,0 +1,32 @@ +// +// AlbumPhotoMapper.swift +// Nextcloud +// +// Created by Dhanesh on 28/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +extension AlbumPhotoDTO { + + func toAlbumPhoto() -> AlbumPhoto { + return AlbumPhoto( + id: self.fileId, + fileName: self.fileName, + contentType: self.contentType, + contentLength: self.contentLength, + lastModified: self.lastModified, + hasPreview: self.hasPreview, + isHidden: self.isHidden, + isFavorite: self.isFavorite, + permissions: self.permissions, + originalDateTime: self.originalDateTime, + width: self.width, + height: self.height + ) + } +} + +extension Sequence where Element == AlbumPhotoDTO { + + func toAlbumPhotos() -> [AlbumPhoto] { map { $0.toAlbumPhoto() } } +} diff --git a/iOSClient/Albums/Domain/Models/Album.swift b/iOSClient/Albums/Domain/Models/Album.swift new file mode 100644 index 0000000000..9abeaed2d4 --- /dev/null +++ b/iOSClient/Albums/Domain/Models/Album.swift @@ -0,0 +1,61 @@ +// +// Album.swift +// Nextcloud +// +// Created by Dhanesh on 26/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +public struct Album: Identifiable, Hashable { + + public let id: String + let href: String + let name: String + let lastPhotoId: String? + let itemCount: Int? + let location: String? + let dateRange: String? + let collaborators: String? + let startDate: Date? + let endDate: Date? + + struct AlbumDateRange: Codable, Hashable { + let start: TimeInterval + let end: TimeInterval + } + + init( + href: String, + lastPhotoId: String?, + itemCount: Int?, + location: String?, + dateRange: String?, + collaborators: String? + ) { + self.id = href + self.href = href + + if let lastComponent = href.split(separator: "/").last { + self.name = lastComponent.removingPercentEncoding ?? String(lastComponent) + } else { + self.name = href + } + + self.lastPhotoId = lastPhotoId + self.itemCount = itemCount + self.location = location + self.dateRange = dateRange + self.collaborators = collaborators + + if let dateRange = dateRange?.data(using: .utf8), + let decoded = try? JSONDecoder().decode(AlbumDateRange.self, from: dateRange) { + self.startDate = Date(timeIntervalSince1970: decoded.start) + self.endDate = Date(timeIntervalSince1970: decoded.end) + } else { + // FALLBACK: Use current date for new/empty albums + let now = Date() + self.startDate = now + self.endDate = now + } + } +} diff --git a/iOSClient/Albums/Domain/Models/AlbumPhoto.swift b/iOSClient/Albums/Domain/Models/AlbumPhoto.swift new file mode 100644 index 0000000000..f1af83a7d4 --- /dev/null +++ b/iOSClient/Albums/Domain/Models/AlbumPhoto.swift @@ -0,0 +1,22 @@ +// +// AlbumPhoto.swift +// Nextcloud +// +// Created by Dhanesh on 28/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +struct AlbumPhoto: Identifiable, Hashable { + public let id: String + let fileName: String + let contentType: String + let contentLength: Int + let lastModified: Date + let hasPreview: Bool + let isHidden: Bool + let isFavorite: Bool + let permissions: String + let originalDateTime: Date? + let width: Int? + let height: Int? +} diff --git a/iOSClient/Albums/NCAlbums.storyboard b/iOSClient/Albums/NCAlbums.storyboard new file mode 100644 index 0000000000..9dc5e12468 --- /dev/null +++ b/iOSClient/Albums/NCAlbums.storyboard @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift new file mode 100644 index 0000000000..efe01d7ea3 --- /dev/null +++ b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift @@ -0,0 +1,135 @@ +// +// NCAlbumCreationAlert.swift +// Nextcloud +// +// Created by Dhanesh on 05/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +extension View { + + func inputAlbumNameAlert( + isPresented: Binding, + albumName: Binding, + error: String?, + isForRenamingAlbum: Bool = false, + onCreate: @escaping () -> Void, + onCancel: @escaping () -> Void + ) -> some View { + self.modifier( + NCInputAlbumNameAlertModifier( + isPresented: isPresented, + albumName: albumName, + isForRenamingAlbum: isForRenamingAlbum, + error: error, + onCreate: onCreate, + onCancel: onCancel + ) + ) + } +} + +private struct NCInputAlbumNameAlertModifier: ViewModifier { + + @Binding var isPresented: Bool + @Binding var albumName: String + + let isForRenamingAlbum: Bool + + var error: String? + let onCreate: () -> Void + let onCancel: () -> Void + + private let title: String + private let description: String + private let textFieldHint: String + private let positiveButtonText: String + private let negativeButtonText: String + + init( + isPresented: Binding, + albumName: Binding, + isForRenamingAlbum: Bool, + error: String? = nil, + onCreate: @escaping () -> Void, + onCancel: @escaping () -> Void + ) { + self._isPresented = isPresented + self._albumName = albumName + self.isForRenamingAlbum = isForRenamingAlbum + self.error = error + self.onCreate = onCreate + self.onCancel = onCancel + + if isForRenamingAlbum { + title = NSLocalizedString("_albums_list_rename_album_popup_title_", comment: "") + description = NSLocalizedString("_albums_list_rename_album_popup_desc_", comment: "") + textFieldHint = NSLocalizedString("_albums_list_rename_album_popup_hint_", comment: "") + positiveButtonText = NSLocalizedString("_albums_list_rename_album_popup_positive_btn_", comment: "") + negativeButtonText = NSLocalizedString("_albums_list_rename_album_popup_negative_btn_", comment: "") + } else { + title = NSLocalizedString("_albums_list_new_album_popup_title_", comment: "") + description = NSLocalizedString("_albums_list_new_album_popup_desc_", comment: "") + textFieldHint = NSLocalizedString("_albums_list_new_album_popup_hint_", comment: "") + positiveButtonText = NSLocalizedString("_albums_list_new_album_popup_positive_btn_", comment: "") + negativeButtonText = NSLocalizedString("_albums_list_new_album_popup_negative_btn_", comment: "") + } + } + + func body(content: Content) -> some View { + content + .alert(title, isPresented: $isPresented) { + TextField(textFieldHint, text: $albumName) + + Button(negativeButtonText, role: .cancel) { + onCancel() + } + + Button(positiveButtonText) { + onCreate() + } + .disabled(error != nil) + } message: { + Text(description) + .foregroundColor(.secondary) + } + } +} + +#if DEBUG +#Preview("Create button enabled") { + ZStack {} + .inputAlbumNameAlert( + isPresented: .constant(true), + albumName: .constant("Album 1"), + error: nil, + onCreate: {}, + onCancel: {} + ) +} + +#Preview("Create button disabled") { + ZStack {} + .inputAlbumNameAlert( + isPresented: .constant(true), + albumName: .constant("Album 1"), + error: "Enter a valid name!", + onCreate: {}, + onCancel: {} + ) +} + +#Preview("Renaming album") { + ZStack {} + .inputAlbumNameAlert( + isPresented: .constant(true), + albumName: .constant("Album 1"), + error: "Enter a valid name!", + isForRenamingAlbum: true, + onCreate: {}, + onCancel: {} + ) +} +#endif diff --git a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift new file mode 100644 index 0000000000..e83230f047 --- /dev/null +++ b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift @@ -0,0 +1,31 @@ +// +// NCLoadingAlert.swift +// Nextcloud +// +// Created by Dhanesh on 05/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct NCLoadingAlert: View { + + var body: some View { + + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + + ProgressView(NSLocalizedString("_albums_loading_popup_desc_", comment: "")) + .padding() + .background(.ultraThinMaterial) + .cornerRadius(10) + } + } +} + +//#if DEBUG +//#Preview { +// NCLoadingAlert() +//} +//#endif diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift new file mode 100644 index 0000000000..a604f2881a --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -0,0 +1,150 @@ +// +// AlbumDetailsScreen.swift +// Nextcloud +// +// Created by Dhanesh on 01/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct AlbumDetailsScreen: View { + + private let album: Album + @StateObject private var viewModel: AlbumDetailsViewModel + @State private var showMedia = false + + init(account: String, album: Album) { + self.album = album + _viewModel = StateObject( + wrappedValue: AlbumDetailsViewModel(account: account, album: album) + ) + } + + var body: some View { + + ZStack { + content() + + if viewModel.isLoadingPopupVisible { + NCLoadingAlert() + } + } + .navigationTitle(viewModel.screenTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if !viewModel.isLoading { + Button(action: handleAddPhotosIntent) { + Image(systemName: "plus") + .imageScale(.large) + } + .buttonStyle(.plain) + .tint(Color(NCBrandColor.shared.iconImageColor)) + + Menu { + Button(NSLocalizedString("_albums_photos_rename_album_btn_", comment: "")) { + viewModel.onRenameAlbumIntent() + } + Button( + NSLocalizedString("_albums_photos_delete_album_btn_", comment: ""), + role: .destructive + ) { + viewModel.onDeleteAlbumIntent() + } + } label: { + Image(systemName: "ellipsis.circle") + .imageScale(.large) + } + .buttonStyle(.plain) + .tint(Color(NCBrandColor.shared.iconImageColor)) + } + } + } + .sheet( + isPresented: $viewModel.isPhotoSelectionSheetVisible + ) { + PhotoSelectionSheet( + onPhotosSelected: viewModel.onPhotosSelected + ) + } + .inputAlbumNameAlert( + isPresented: $viewModel.isRenameAlbumPopupVisible, + albumName: $viewModel.newAlbumName, + error: viewModel.newAlbumNameError, + isForRenamingAlbum: true, + onCreate: { + viewModel.onRenameAlbumPopupConfirm() + }, + onCancel: { + viewModel.onRenameAlbumPopupCancel() + } + ) + .alert( + NSLocalizedString("_albums_delete_album_popup_title_", comment: ""), + isPresented: $viewModel.isDeleteAlbumPopupVisible, + actions: { + Button( + NSLocalizedString("_albums_delete_album_popup_positive_btn_", comment: ""), + role: .destructive, + action: viewModel.onDeleteAlbumPopupConfirm + ) + Button( + NSLocalizedString("_albums_delete_album_popup_negative_btn_", comment: ""), + role: .cancel, + action: viewModel.onDeleteAlbumPopupCancel + ) + }, + message: { + Text(NSLocalizedString("_albums_delete_album_popup_desc_", comment: "")) + } + ) + .onAppear { + // Force end selection mode so the tab bar remains visible on this screen + NotificationCenter.default.post(name: Notification.Name("NCSelectionModeDidEnd"), object: nil) + } + .onDisappear { + NotificationCenter.default.post(name: Notification.Name("NCSelectionModeDidEnd"), object: nil) + } + .onChange(of: viewModel.isPhotoSelectionSheetVisible) { isPresented in + if isPresented == false { + NotificationCenter.default.post(name: Notification.Name("NCSelectionModeDidEnd"), object: nil) + } + } + } + + @ViewBuilder + private func content() -> some View { + if viewModel.isLoading { + ProgressView(NSLocalizedString("_albums_photos_loading_msg_", comment: "")) + } else if let error = viewModel.errorMessage { + Text(error) + .refreshable { + viewModel.onPulledToRefresh() + } + } else if viewModel.photos.isEmpty { + NoPhotosEmptyView( + onAddPhotosIntent: handleAddPhotosIntent + ) + .refreshable { + viewModel.onPulledToRefresh() + } + } else { + PhotosGridView( + localAccount: viewModel.account, + photos: viewModel.photos, + onAddPhotosIntent: handleAddPhotosIntent, + album: album + ) + .refreshable { + viewModel.onPulledToRefresh() + } + } + } + + private func handleAddPhotosIntent() { + viewModel.onAddPhotosIntent() + showMedia = true + } +} + diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift new file mode 100644 index 0000000000..f9a90ae51f --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -0,0 +1,372 @@ +// +// AlbumDetailsViewModel.swift +// Nextcloud +// +// Created by Dhanesh on 01/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import Foundation +import Combine +import NextcloudKit +import UIKit + +// Place this at the top level of a file (outside any class) +protocol AlbumActionHandler: AnyObject { + func deleteMetadataFromAlbum(_ selectedMetadatas: [tableMetadata]) +} + +class AlbumDetailsViewModel: ObservableObject { + + @Published var account: String + private var album: Album + + @Published private(set) var screenTitle: String + + @Published private(set) var photos: [AlbumPhoto : tableMetadata?] = [:] + @Published private(set) var isLoading: Bool = false + @Published private(set) var errorMessage: String? = nil + + @Published var isLoadingPopupVisible: Bool = false + + @Published var isDeleteAlbumPopupVisible: Bool = false + + @Published var isRenameAlbumPopupVisible: Bool = false + @Published var newAlbumName: String = "" + @Published private(set) var newAlbumNameError: String? = nil + + @Published var isPhotoSelectionSheetVisible: Bool = false + + @MainActor + private var windowScene: UIWindowScene? { + SceneManager.shared.getWindowScene(controller: SceneManager.shared.getController(account: account)) + } + + private var cancellables: Set = [] + + init(account: String, album: Album) { + self.account = account + self.album = album + self.screenTitle = album.name + registerPublishers() + loadAlbumPhotos() + + NotificationCenter.default.addObserver(forName: NSNotification.Name("deletePhotosFromAlbum"), object: nil, queue: .main) { [weak self] notification in + if let metadatas = notification.userInfo?["metadatas"] as? [tableMetadata] { + self?.deleteMetadataFromAlbum(metadatas) + } + } + } + + // MARK: - Album name validation + private func registerPublishers() { + $newAlbumName + .removeDuplicates() + .sink { [weak self] name in + guard let self = self else { return } + self.newAlbumNameError = self.validateAlbumName(name).first + } + .store(in: &cancellables) + } + + private func validateAlbumName(_ name: String) -> [String] { + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return [NSLocalizedString("_albums_list_album_name_validation_nonempty_", comment: "")] + } else if trimmed.count < 3 { + return [NSLocalizedString("_albums_list_album_name_validation_min_length_", comment: "")] + } else if trimmed.count > 30 { + return [NSLocalizedString("_albums_list_album_name_validation_max_length_", comment: "")] + } else if trimmed.contains("/") || trimmed.contains("\\") { + return [NSLocalizedString("_albums_list_album_name_validation_specials_", comment: "")] + } + + return [] + } + + // MARK: - Popups + // MARK: Delete Album + func onDeleteAlbumIntent() { + isDeleteAlbumPopupVisible = true + } + + func onDeleteAlbumPopupCancel() { + isDeleteAlbumPopupVisible = false + } + + func onDeleteAlbumPopupConfirm() { + isDeleteAlbumPopupVisible = false + deleteAlbum() + } + + // MARK: Rename Album + func onRenameAlbumIntent() { + isRenameAlbumPopupVisible = true + } + + func onRenameAlbumPopupCancel() { + newAlbumName = "" + isRenameAlbumPopupVisible = false + } + + func onRenameAlbumPopupConfirm() { + isRenameAlbumPopupVisible = false + renameAlbum() + } + + // MARK: - APIs + func onPulledToRefresh() { + loadAlbumPhotos() + } + + private func loadAlbumPhotos( + doOnSuccess: (() -> Void)? = nil + ) { + + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + NextcloudKit.shared.fetchAlbumPhotos(for: album.name, account: account) { [weak self] result in + + self?.isLoading = false + + switch result { + case .success(let photos): + self?.photos = Dictionary(uniqueKeysWithValues: photos.map { photo in + let meta = NCManageDatabase.shared.getMetadataFromFileId(photo.fileId) + return (photo.toAlbumPhoto(), meta) + }) + doOnSuccess?() + + case .failure(let error): + // Task { @MainActor in + // await showErrorBanner(windowScene: self?.windowScene, error: NKError(error: error)) + // } + self?.errorMessage = NSLocalizedString("_albums_photos_error_msg_", comment: "") + } + } + } + + func deleteAlbum() { + + guard !isLoadingPopupVisible else { return } + + isLoadingPopupVisible = true + + NextcloudKit.shared.deleteAlbum( + albumName: album.name, + account: account + ) { [weak self] result in + + self?.isLoadingPopupVisible = false + + switch result { + case .success(): + AlbumsManager.shared.syncAlbums() + AlbumsNavigator.shared.pop() + + case .failure(let error): + Task { @MainActor in + await showErrorBanner(windowScene: self?.windowScene, error: NKError(error: error)) + } + } + } + } + + @MainActor func deletePhotos(with metadatas: [tableMetadata]) async { + for metadata in metadatas { + if let photo = photos.first(where: { $0.value?.ocId == metadata.ocId })?.key { + + guard !isLoadingPopupVisible else { return } + await MainActor.run { + isLoadingPopupVisible = true + } + // Perform the deletion off the main actor but marshal UI updates back to main + let error = await self.deletePhotoFromAlbum(photo, metadata: metadata) + + await MainActor.run { + self.isLoadingPopupVisible = false + } + + guard error == .success else { + await showErrorBanner(windowScene: windowScene, error: NKError(error: error)) + return + } + + // Refresh album contents and sync albums list on main thread + await MainActor.run { + self.loadAlbumPhotos() + AlbumsManager.shared.syncAlbums() + UINavigationController().popupFromNavigationStack(context: "after-pop-deletePhotos") + } + } + } + } + + func deletePhotoFromAlbum(_ photo: AlbumPhoto, metadata: tableMetadata) async -> NKError { + + // Use the album entry's lastPathComponent (includes fileId prefix), do not decode + 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 + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, + path: metadata.serverUrlFileName, + name: "deletePhotoFromAlbum") + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } + } + + if results.error == .success { + // Successfully unlinked from album. Do not delete local file or metadata. + return .success + } else if results.error.errorCode == NCGlobal.shared.errorResourceNotFound { + // Treat missing resource as already unlinked; do not delete local entities. + return .success + } else if results.error.errorCode == NCGlobal.shared.errorForbidden && metadata.isLivePhotoVideo { + // Some servers may forbid removing the video part of a Live Photo; ignore and treat as success. + return .success + } else { + await NCManageDatabase.shared.setMetadataSessionAsync(ocId: metadata.ocId, + status: NCGlobal.shared.metadataStatusNormal) + return results.error + } + } + + func renameAlbum() { + + guard !isLoadingPopupVisible else { return } + + isLoadingPopupVisible = true + + NextcloudKit.shared.renameAlbum(account: account, from: album.name, to: newAlbumName) { [weak self] result in + + switch result { + case .success(): + self?.reloadAlbumAfterRenaming(albumName: self?.newAlbumName ?? "") + + case .failure(let error): + self?.isLoadingPopupVisible = false + let nkError = NKError(error: error) + + if nkError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self?.windowScene, error: nkError) + } + } + } + } + } + + private func reloadAlbumAfterRenaming(albumName: String) { + + AlbumsManager.shared.syncAlbums { [weak self] resultAlbums in + + self?.isLoadingPopupVisible = false + + if let newAlbum = resultAlbums.first(where: { $0.name == albumName }) { + self?.album = newAlbum + self?.loadAlbumPhotos { + self?.screenTitle = self?.album.name ?? "" + self?.newAlbumName = "" + } + } + } + } + + func onAddPhotosIntent() { + isPhotoSelectionSheetVisible = true + } + + func onPhotosSelected(selectedPhotos: [String]) { + + isPhotoSelectionSheetVisible = false + + if selectedPhotos.isEmpty { + return + } + + self.isLoadingPopupVisible = true + + for photo in selectedPhotos { + + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + + NextcloudKit.shared.copyPhotoToAlbum( + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, + albumName: album.name, + fileName: metadata?.fileName ?? photo + ) { [weak self] result in + + DispatchQueue.main.async { + self?.isLoadingPopupVisible = false + } + + switch result { + case .success: + DispatchQueue.main.async { + self?.loadAlbumPhotos() + AlbumsManager.shared.syncAlbums() + } + case .failure(let error): + let nkError = NKError(error: error) + + // 1. Log the high-level error (usually 1) + debugPrint("Top-level errorCode:", nkError.errorCode) + + // 2. Check the nested error for the 409 Conflict + if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + + // This is the "File already exists" case (409) + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_file_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + // Fallback check if the top-level error itself is 409 + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: nkError.errorDescription, errorCode: nkError.errorCode) + } + } else { + // Handle all other errors (Network, 404, 500, etc.) + Task { @MainActor in + await showErrorBanner(windowScene: self?.windowScene, error: nkError) + } + } + } + } + } + } +} + +extension AlbumDetailsViewModel: AlbumActionHandler { + func deleteMetadataFromAlbum(_ selectedMetadatas: [tableMetadata]) { + Task { + await self.deletePhotos(with: selectedMetadatas) + + } + } +} + diff --git a/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift b/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift new file mode 100644 index 0000000000..c5583d619a --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// A reusable SwiftUI sheet that presents the NCMedia selection UI +/// and returns the selected file identifiers. +struct MediaSelectionSheet: View { + // MARK: - Callbacks + let onCancel: () -> Void + let onDone: (_ selectedFiles: [String]) -> Void + + // MARK: - State + @State private var mediaVC: NCMedia? + + var body: some View { + NavigationView { + NCMediaViewRepresentable(ncMedia: $mediaVC) + .navigationTitle(NSLocalizedString("_albums_photo_selection_sheet_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(NSLocalizedString("_albums_photo_selection_sheet_back_btn_", comment: "")) { + onCancel() + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("_albums_photo_selection_sheet_done_btn_", comment: "")) { + onDone(mediaVC?.fileSelect ?? []) + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + .disabled(mediaVC == nil) + .opacity(mediaVC == nil ? 0.5 : 1.0) + } + } + } + } +} + +#if DEBUG +#Preview { + MediaSelectionSheet( + onCancel: {}, + onDone: { _ in } + ) +} +#endif diff --git a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift new file mode 100644 index 0000000000..b40cf4de38 --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift @@ -0,0 +1,65 @@ +// +// NoPhotosEmptyView.swift +// Nextcloud +// +// Created by Dhanesh on 29/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct NoPhotosEmptyView: View { + + let onAddPhotosIntent: () -> Void + + private let contentPadding: CGFloat = 32.0 + + var body: some View { + + ScrollView(.vertical) { + + VStack { + + // Background image + Image("EmptyAlbum") + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, alignment: .leading) + + // Foreground content + VStack(alignment: .leading, spacing: 16) { + + Text(NSLocalizedString("_albums_photos_empty_heading_", comment: "")) + .font(.system(size: 48, weight: .bold)) + + Text(NSLocalizedString("_albums_photos_empty_subheading_", comment: "")) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.secondary) + + Button(action: onAddPhotosIntent) { + Label(NSLocalizedString("_albums_photos_empty_add_photos_btn_", comment: ""), systemImage: "plus") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + + Spacer(minLength: 40) + } + .padding(.horizontal, contentPadding) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, -40) + } + } + } +} + +//#if DEBUG +//#Preview { +// NavigationView { +// NoPhotosEmptyView( +// onAddPhotosIntent: {} +// ) +// .navigationTitle("Album") +// .navigationBarTitleDisplayMode(.inline) +// } +//} +//#endif diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift new file mode 100644 index 0000000000..6cf024d1dd --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -0,0 +1,119 @@ +// +// PhotoGridItemView.swift +// Nextcloud +// +// Created by Dhanesh on 04/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import NextcloudKit + +struct PhotoGridItemView: View { + @Environment(\.localAccount) var localAccount: String + + let album: Album + let photo: AlbumPhoto + let isVideo: Bool + let metadata: tableMetadata? + let iconSize: CGFloat + + @State private var thumbnail: UIImage? + @State private var isLoading = false + + var body: some View { + ZStack { + if let thumbnail = thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } else { + Rectangle().fill(Color.gray.opacity(0.15)) + if isLoading { + ProgressView().controlSize(.small) + } else if !photo.hasPreview { + // Show a generic icon if the API says there is no preview + Image(systemName: "doc").foregroundColor(.gray) + } + } + } + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .aspectRatio(1, contentMode: .fill) + .clipped() + .overlay( + Group { + if isVideo { + Image(systemName: "play.fill") + .resizable() + .frame(width: 10, height: 10) + .foregroundColor(.white) + .padding(8) + } + }, + alignment: .bottomLeading + ) + .cornerRadius(8) + // Use photo.id to trigger the task + .task(id: photo.id) { + await loadThumbnailFromPhoto() + } + } + + private func loadThumbnailFromPhoto() async { + // 1. Validate: Only load if it has a preview and a valid ID + guard photo.hasPreview, !photo.id.isEmpty else { + return + } + + // 2. Clear previous state for reused cells + await MainActor.run { + self.thumbnail = nil + self.isLoading = true + } + + // 3. Setup parameters from Photo object and Metadata fallback + let fileId = photo.id + let userId = metadata?.userId ?? "" + let urlBase = metadata?.urlBase ?? "" + let etag = metadata?.etag ?? "" + + // 4. Try Disk Cache First + if let cachedImage = NCUtility().getImage( + ocId: fileId, + etag: etag, + ext: NCGlobal.shared.previewExt512, + userId: userId, + urlBase: urlBase + ) { + await MainActor.run { + self.thumbnail = cachedImage + self.isLoading = false + } + return + } + + // 5. Download Preview + let results = await NextcloudKit.shared.downloadPreviewAsync( + fileId: fileId, + etag: etag, + account: localAccount + ) { _ in } + + await MainActor.run { + if results.error == .success, + let data = results.responseData?.data, + let image = UIImage(data: data) { + self.thumbnail = image + + // 6. Save to cache (optional but recommended) + Task.detached(priority: .background) { + await NCUtility().createImageFileFrom( + data: data, ocId: fileId, etag: etag, userId: userId, urlBase: urlBase + ) + } + } + self.isLoading = false + } + } +} + diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift new file mode 100644 index 0000000000..8ee4f924ff --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -0,0 +1,90 @@ +// +// PhotosGridView.swift +// Nextcloud +// +// Created by Dhanesh on 01/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct PhotosGridView: View { + let localAccount: String // Add this + let photos: [AlbumPhoto : tableMetadata?] + let onAddPhotosIntent: () -> Void + let album: Album + + private var columns: [GridItem] { + if UIDevice.current.userInterfaceIdiom == .pad { + return Array(repeating: GridItem(.flexible(), spacing: 1), count: 3) + } else { + return [GridItem(.adaptive(minimum: 100, maximum: 300), spacing: 1)] + } + } + + private let calculatedIconSize: CGFloat = 30 + + var body: some View { + // Sort by filename or date to ensure stability + let sortedPhotos = photos.keys.sorted { lhs, rhs in + lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) == .orderedAscending + } + + ScrollView { + LazyVGrid(columns: columns, spacing: 1) { + ForEach(sortedPhotos, id: \.self) { photo in + let metadata = photos[photo] ?? nil + Button { + openPhotoViewer(photo: photo) + } label: { + PhotoGridItemView( + album: album, + photo: photo, + isVideo: (metadata?.isVideo ?? false), + metadata: metadata, + iconSize: calculatedIconSize + ) + } + } + } + } + } + + @MainActor + private func openPhotoViewer(photo: AlbumPhoto) { + let orderedPhotos = photos.keys.sorted { + $0.fileName.localizedCaseInsensitiveCompare($1.fileName) == .orderedAscending + } + let resolvedPhotos = orderedPhotos.compactMap { albumPhoto -> (photo: AlbumPhoto, metadata: tableMetadata)? in + guard let metadata = (photos[albumPhoto] ?? nil) + ?? NCManageDatabase.shared.getMetadataFromFileId(albumPhoto.id), + metadata.account == localAccount else { + return nil + } + return (albumPhoto, metadata.detachedCopy()) + } + let controller = SceneManager.shared.getController(account: localAccount) + guard let selected = resolvedPhotos.first(where: { $0.photo.id == photo.id }) else { + Task { @MainActor in + await showErrorBanner( + windowScene: SceneManager.shared.getWindowScene(controller: controller), + text: "_albums_photos_error_msg_" + ) + } + return + } + + let model = NCMediaViewerModel( + currentMetadata: selected.metadata, + ocIds: resolvedPhotos.map { $0.metadata.ocId }, + session: NCSession.shared.getSession(account: localAccount), + loader: NCMediaViewerLoader() + ) + NCMediaViewerPresenter.shared.show( + model: model, + viewerTransitionSource: nil, + from: controller?.view, + contextMenuController: controller + ) + } +} diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift new file mode 100644 index 0000000000..964da2b361 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -0,0 +1,246 @@ +// +// AddToAlbumsListView.swift +// Nextcloud +// +// Created by Mangesh Murhe on 25/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import NextcloudKit + +struct AddToAlbumsListView: View { + + @StateObject private var viewModel: AlbumsListViewModel + @State private var selectedAlbum: Album? = nil + var localAccount: String + var onFinish: (Album) -> Void + var onDismiss: () -> Void + var onCreateAlbum: () -> Void + + init(viewModel: AlbumsListViewModel, localAccount: String, onFinish: @escaping (Album) -> Void, onDismiss: @escaping () -> Void, onCreateAlbum: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: viewModel) + self.localAccount = localAccount + self.onFinish = onFinish + self.onDismiss = onDismiss + self.onCreateAlbum = onCreateAlbum + } + + var body: some View { + NavigationStack { + ZStack { + List { + Section { + Button(action: { + onCreateAlbum() + }) { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .foregroundColor(Color(NCBrandColor.shared.customer)) + Text(NSLocalizedString("_albums_list_new_album_popup_title_", comment: "Create new album")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.secondarySystemGroupedBackground).opacity(0.08)) + ) + } + .buttonStyle(PlainButtonStyle()) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.hidden) + } + + Section(header: Text(NSLocalizedString("_albums_list_own_albums_heading_", comment: "")) + .listRowInsets(EdgeInsets()) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading)) { + ForEach(viewModel.albums) { album in + AlbumRow(album: album, localAccount: localAccount) + .padding(.vertical, 8) + .onTapGesture { + selectedAlbum = album + } + .listRowBackground( + selectedAlbum?.id == album.id + ? Color.accentColor.opacity(0.2) // light blue highlight (default iOS tint) + : Color.clear + ) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) // Match header padding + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .navigationTitle(NSLocalizedString("_add_to_album", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: onDismiss) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text(NSLocalizedString("_albums_photo_selection_sheet_back_btn_", comment: "")) + }.foregroundColor(Color(NCBrandColor.shared.customer)) + } + .foregroundColor(.pink) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("_albums_photo_selection_sheet_done_btn_", comment: "")) { + if let selected = selectedAlbum { + onFinish(selected) + } + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + .opacity(selectedAlbum == nil ? 0.4 : 1.0) + .disabled(selectedAlbum == nil) + } + } + content() + } + .onAppear { + AlbumsManager.shared.setAccount(localAccount) + AlbumsManager.shared.syncAlbums() + } + } + } + + @ViewBuilder + private func content() -> some View { + if viewModel.isLoading { + ProgressView(NSLocalizedString("_albums_list_loading_msg_", comment: "")) + } else if let error = viewModel.errorMessage { + ScrollView(.vertical) { + VStack { + Spacer() + Text(error) + Spacer() + } + } + .refreshable { + viewModel.onPulledToRefresh() + } + } else if viewModel.albums.isEmpty { + NoAlbumsEmptyView(onNewAlbumCreationIntent: onCreateAlbum) + .refreshable { + viewModel.onPulledToRefresh() + } + } + } +} + +struct AlbumRow: View { + let album: Album + private enum ImageState { case loading, empty, thumbnail(UIImage) } + @State private var imageState: ImageState = .loading + var localAccount: String + + var body: some View { + HStack { + thumbnailView() + .frame(width: 80, height: 60) + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + + if let subtitle = makeSubtitle(for: album), !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 8) + .task(id: album.lastPhotoId) { + await loadThumbnail() + } + } + + private func makeSubtitle(for album: Album) -> String? { + guard let count = album.itemCount else { return nil } + var parts: [String] = ["\(count) \(NSLocalizedString("_albums_list_entities_", comment: ""))"] + let formatter = DateFormatter() + if count > 0, let end = album.endDate { + formatter.dateStyle = .medium + parts.append(formatter.string(from: end)) + } else if count == 0, let created = album.startDate { + formatter.dateFormat = "MMMM yyyy" // "MMMM" for full month name, "yyyy" for year + parts.append(formatter.string(from: created)) + } + return parts.joined(separator: " - ") + } + + /// Renders the thumbnail image based on the current state + @ViewBuilder + private func thumbnailView() -> some View { + switch imageState { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray.opacity(0.1)) + case .empty: + Image("EmptyAlbum") + .resizable() + .scaledToFill() + .clipped() + .foregroundColor(.gray) + .background(Color.gray.opacity(0.1)) + case .thumbnail(let uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipped() + } + } + + private func loadThumbnail() async { + if album.lastPhotoId == "-1" || (album.itemCount ?? 0) == 0 { + imageState = .empty + return + } + guard let photoId = album.lastPhotoId else { + imageState = .empty + return + } + + Task { + + let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: localAccount, + path: photoId, + name: "DownloadPreview") + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } + } + if resultsPreview.error == .success, let data = resultsPreview.responseData?.data { + let session = NCSession.shared.getSession(account: localAccount) + if let image = NCUtility().createImageFileFrom( + data: data, + ocId: photoId, + etag: "", + ext: NCGlobal.shared.previewExt512, + userId: session.userId, + urlBase: session.urlBase + ) { + Task { @MainActor in + await MainActor.run { imageState = .thumbnail(image) } + } + } else { + await MainActor.run { imageState = .empty } + } + } + } + } +} diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift new file mode 100644 index 0000000000..6d661d16a8 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -0,0 +1,124 @@ +// +// AlbumGridItemView.swift +// Nextcloud +// +// Created by Dhanesh on 05/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import NextcloudKit + +struct AlbumGridItemView: View { + + let album: Album + let iconSize: CGFloat // Receive the calculated size + + @Environment(\.localAccount) var localAccount: String + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + private let fixedThumbnailHeight: CGFloat = 160 + + private var dynamicHeight: CGFloat { + if UIDevice.current.userInterfaceIdiom == .pad { + return 260 + } else { + // iPhone logic + return fixedThumbnailHeight + } + } + private enum ImageState { case loading, empty, thumbnail(UIImage) } + @State private var imageState: ImageState = .loading + + var body: some View { + GeometryReader { geo in + ZStack { + switch imageState { + case .loading: + Rectangle() + .fill(Color.gray.opacity(0.15)) + .overlay( + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .gray)) + ) + case .empty: + Image("EmptyAlbum") + .resizable() + .aspectRatio(contentMode: .fill) + .frame( + width: geo.size.width, + height: dynamicHeight, + alignment: .top + ) + .clipped() + case .thumbnail(let img): + Image(uiImage: img) + .resizable() + .scaledToFill() // Ensures the image fills the area (cropping excess) + .frame(width: geo.size.width, height: dynamicHeight) // Matches the grid item size + .clipped() // Prevents the image from bleeding outside the 8pt corner radius + + } + } + .frame(width: geo.size.width, height: dynamicHeight) + .clipped() + .overlay(frame) + .cornerRadius(8) + } + .frame(height: dynamicHeight) + .task(id: album.lastPhotoId) { + await loadThumbnail() + } + } + + private func loadThumbnail() async { + if album.lastPhotoId == "-1" || (album.itemCount ?? 0) == 0 { + imageState = .empty + return + } + guard let photoId = album.lastPhotoId else { + imageState = .empty + return + } + + Task { + + let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: localAccount, + path: photoId, + name: "DownloadPreview") + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } + } + if resultsPreview.error == .success, let data = resultsPreview.responseData?.data { + let session = NCSession.shared.getSession(account: localAccount) + if let image = NCUtility().createImageFileFrom( + data: data, + ocId: photoId, + etag: "", + ext: NCGlobal.shared.previewExt512, + userId: session.userId, + urlBase: session.urlBase + ) { + Task { @MainActor in + await MainActor.run { imageState = .thumbnail(image) } + } + } else { + await MainActor.run { imageState = .empty } + } + } + } + } + + private var frame: some View { + RoundedRectangle( + cornerRadius: 8 + ) + .stroke( + Color.gray.opacity(1), + lineWidth: 1 / UIScreen.main.scale + ) + } +} + diff --git a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift new file mode 100644 index 0000000000..59f686d1c2 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift @@ -0,0 +1,130 @@ +// +// AlbumsGridView.swift +// Nextcloud +// +// Created by Dhanesh on 28/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import Foundation +import UIKit + +struct AlbumsGridView: View { + + @Environment(\.localAccount) var localAccount: String + + let albums: [Album] + + let onAlbumClicked: (Album) -> Void + +// private let columns = [ +// GridItem(.flexible(), spacing: 16), +// GridItem(.flexible(), spacing: 16) +// ] +// Use this inside AlbumsGridView to detect iPad + private var columns: [GridItem] { + let isIPad = UIDevice.current.userInterfaceIdiom == .pad + let count = isIPad ? 3 : 2 // 4 columns for iPad, 2 for iPhone + return Array(repeating: GridItem(.flexible(), spacing: 16), count: count) + } + + // Logic translated from your buildMediaPhotoVideo function + private var iconPointSize: CGFloat { + let count = columns.count + switch count { + case 0...1: return 60 + case 2...3: return 30 + case 4...5: return 25 + default: return 20 + } + } + + var body: some View { + + ScrollView { + + VStack(alignment: .leading, spacing: 16) { + + Text(NSLocalizedString("_albums_list_own_albums_heading_", comment: "")) + .font(.system(size: 21, weight: .bold)) + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(albums, id: \.id) { album in + Button { + onAlbumClicked(album) + } label: { + VStack(alignment: .leading, spacing: 6) { + +// AlbumGridItemView(album: album) + AlbumGridItemView(album: album, iconSize: iconPointSize) + + Text(album.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + + if let subtitle = makeSubtitle(for: album), !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(Color(UIColor.systemGray)) + .lineLimit(1) + } + } + } + } + } + } + .padding() + } + } + + private func makeSubtitle(for album: Album) -> String? { + guard let count = album.itemCount else { return nil } + var parts: [String] = ["\(count) \(NSLocalizedString("_albums_list_entities_", comment: ""))"] + let formatter = DateFormatter() + if count > 0, let end = album.endDate { + formatter.dateStyle = .medium + parts.append(formatter.string(from: end)) + } else if count == 0, let created = album.startDate { + formatter.dateFormat = "MMMM yyyy" // "MMMM" for full month name, "yyyy" for year + parts.append(formatter.string(from: created)) + } + return parts.joined(separator: " - ") + } +} + +//#if DEBUG +//#Preview { +// AlbumsGridView( +// albums: [ +// Album( +// href: "/Geburtstagsalbum", +// lastPhotoId: "birthday", +// itemCount: 16, +// location: "Berlin", +// dateRange: "Feb 2022", +// collaborators: "Anna, John" +// ), +// Album( +// href: "/Urlaub", +// lastPhotoId: "mountain", +// itemCount: 42, +// location: "Alps", +// dateRange: nil, +// collaborators: nil +// ), +// Album( +// href: "/Office Party", +// lastPhotoId: "-1", +// itemCount: 0, +// location: nil, +// dateRange: "Dec 2023", +// collaborators: nil +// ) +// ], +// onAlbumClicked: { _ in} +// ) +//} +//#endif + diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift new file mode 100644 index 0000000000..86a1a2b3a3 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -0,0 +1,161 @@ +// +// AlbumsListScreen.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +extension Notification.Name { + static let albumsPopToRootRequested = Notification.Name("NCAlbumsPopToRootRequested") +} + +struct AlbumsListScreen: View { + + @Environment(\.localAccount) var localAccount: String +// let metadata: tableMetadata? + + enum NavigationDestination: Hashable { + case albumDetails(album: Album) + } + + @StateObject private var viewModel: AlbumsListViewModel + @State private var popToRootTrigger: Int = 0 + + init(viewModel: AlbumsListViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + + ZStack { + content() + + if viewModel.isLoadingPopupVisible { + NCLoadingAlert() + } + } + .navigationTitle(NSLocalizedString("_albums_list_nav_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button(action: { viewModel.onNewAlbumClick() }) { + Text(NSLocalizedString("_albums_list_new_album_btn_", comment: "")) + .font(.body) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .tint(Color(NCBrandColor.shared.iconImageColor)) + } + } + .overlay(setupNavigation.hidden()) + .sheet( + isPresented: $viewModel.isPhotoSelectionSheetVisible, + onDismiss: { + viewModel.onPhotosSelected(selectedPhotos: []) + } + ) { + PhotoSelectionSheet( + onPhotosSelected: viewModel.onPhotosSelected + ) + } + .inputAlbumNameAlert( + isPresented: $viewModel.isNewAlbumCreationPopupVisible, + albumName: $viewModel.newAlbumName, + error: viewModel.newAlbumNameError, + onCreate: { + viewModel.onNewAlbumPopupCreate() + }, + onCancel: { + viewModel.onNewAlbumPopupCancel() + } + ) + .onReceive(NotificationCenter.default.publisher(for: .albumsPopToRootRequested)) { _ in + // Clear any programmatic navigation and force root content + viewModel.navigationDestination = nil + popToRootTrigger += 1 + } + } + + @ViewBuilder + private func content() -> some View { + if viewModel.isLoading { + ProgressView(NSLocalizedString("_albums_list_loading_msg_", comment: "")) + } else if let error = viewModel.errorMessage { + ScrollView(.vertical) { + VStack { + Spacer() + Text(error) + Spacer() + } + } + .refreshable { + viewModel.onPulledToRefresh() + } + } else if viewModel.albums.isEmpty { + NoAlbumsEmptyView(onNewAlbumCreationIntent: viewModel.onNewAlbumClick) + .refreshable { + viewModel.onPulledToRefresh() + } + } else { + AlbumsGridView( + albums: viewModel.albums.sorted { (lhs: Album, rhs: Album) -> Bool in + let l = lhs.name + let r = rhs.name + return l.localizedCaseInsensitiveCompare(r) == .orderedAscending + }, + onAlbumClicked: viewModel.onAlbumClicked + ) + .refreshable { + viewModel.onPulledToRefresh() + } + } + } + + private var setupNavigation: some View { + + let binding = Binding { [weak viewModel] in + viewModel?.navigationDestination != nil + } set: { [weak viewModel] value in + guard !value else { return } + viewModel?.navigationDestination = nil + } + + return NavigationLink(isActive: binding) { + switch viewModel.navigationDestination { + case .some(let value): + navigationDestination(value) + + case .none: + EmptyView() + } + } label: { + EmptyView() + } + } + + @ViewBuilder + private func navigationDestination(_ destination: NavigationDestination) -> some View { + switch destination { + case .albumDetails(let album): + AlbumDetailsScreen(account: localAccount, album: album) + } + } +} + +//#if DEBUG +//#Preview { +// NavigationView { +// AlbumsListScreen(viewModel: .init(account: "123")) +// }.onAppear { +// UIView +// .appearance( +// whenContainedInInstancesOf: [UIAlertController.self] +// ).tintColor = NCBrandColor.shared.customer +// } +//} +//#endif diff --git a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift new file mode 100644 index 0000000000..2f2237b54e --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift @@ -0,0 +1,279 @@ +// +// AlbumsViewModel.swift +// Nextcloud +// +// Created by A200118228 on 08/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import Foundation +import Combine +import NextcloudKit + +class AlbumsListViewModel: ObservableObject { + + private var account: String + + @Published private(set) var albums: [Album] = [] + @Published private(set) var isLoading: Bool = false + @Published private(set) var errorMessage: String? = nil + + private var thumbnailsTask: Task? + @Published private(set) var albumThumbnails: [String: UIImage] = [:] + + @Published var isLoadingPopupVisible: Bool = false + + @Published var isNewAlbumCreationPopupVisible: Bool = false + @Published var newAlbumName: String = "" + @Published private(set) var newAlbumNameError: String? = nil + + @Published var isPhotoSelectionSheetVisible: Bool = false + @Published var newlyCreatedAlbum: Album? = nil + + @Published var navigationDestination: AlbumsListScreen.NavigationDestination? = nil + + @MainActor + private var windowScene: UIWindowScene? { + SceneManager.shared.getWindowScene(controller: SceneManager.shared.getController(account: account)) + } + + private var cancellables: Set = [] + private var isNavigatingToDetails: Bool = false + + init(account: String) { + self.account = account + observeAlbums() + registerPublishers() + } + + // MARK: - Subscriptions + private func observeAlbums() { + AlbumsManager.shared.albumsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + switch state { + case .idle: + self?.isLoading = false + case .loading: + self?.isLoading = true + self?.errorMessage = nil + case .success(let albums): + self?.isLoading = false + self?.albums = albums + case .failure: + self?.isLoading = false + self?.errorMessage = NSLocalizedString("_albums_list_error_msg_", comment: "") + } + } + .store(in: &cancellables) + } + + // MARK: - Album name validation + private func registerPublishers() { + $newAlbumName + .removeDuplicates() + .sink { [weak self] name in + guard let self = self else { return } + self.newAlbumNameError = self.validateAlbumName(name).first + } + .store(in: &cancellables) + } + + private func validateAlbumName(_ name: String) -> [String] { + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return [NSLocalizedString("_albums_list_album_name_validation_nonempty_", comment: "")] + } else if trimmed.count < 3 { + return [NSLocalizedString("_albums_list_album_name_validation_min_length_", comment: "")] + } else if trimmed.count > 30 { + return [NSLocalizedString("_albums_list_album_name_validation_max_length_", comment: "")] + } else if trimmed.contains("/") || trimmed.contains("\\") { + return [NSLocalizedString("_albums_list_album_name_validation_specials_", comment: "")] + } + + return [] + } + + // MARK: - Events + func onAlbumClicked(_ album: Album) { + guard !isNavigatingToDetails else { return } + isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self?.isNavigatingToDetails = false + } + } + + // MARK: - Album name popup + func onNewAlbumClick() { + // Reset any previous error and open the popup with a clean state + newAlbumNameError = nil + isNewAlbumCreationPopupVisible = true + } + + func onNewAlbumPopupCancel() { + // Clear input and error when cancelling + newAlbumName = "" + newAlbumNameError = nil + isNewAlbumCreationPopupVisible = false + } + + func onNewAlbumPopupCreate() { + // Prevent double submission while a request is in-flight + guard !isLoadingPopupVisible else { return } + + // Trim and validate before proceeding (defensive for iOS 17 timing) + let trimmedName = newAlbumName.trimmingCharacters(in: .whitespacesAndNewlines) + let errors = validateAlbumName(trimmedName) + if let firstError = errors.first { + newAlbumNameError = firstError + return + } + + // Capture the valid name, then reset UI state deterministically + let nameToCreate = trimmedName + + // Dismiss the popup and clear the field AFTER we've captured the value + isNewAlbumCreationPopupVisible = false + newAlbumName = "" + newAlbumNameError = nil + + // Kick off creation with a clean state + createNewAlbum(for: nameToCreate) + } + + // MARK: - APIs + func onPulledToRefresh() { + AlbumsManager.shared.syncAlbums() + } + + private func createNewAlbum(for name: String) { + + guard !isLoadingPopupVisible else { return } + + isLoadingPopupVisible = true + + NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { [weak self] result in + + self?.isLoadingPopupVisible = false + + switch result { + case .success(_): + + AlbumsManager.shared.syncAlbums { [weak self] resultAlbums in + if let newAlbum = resultAlbums.first(where: { $0.name == name }) { + self?.newlyCreatedAlbum = newAlbum + self?.isPhotoSelectionSheetVisible = true + } + } + + case .failure(let error): + let nkError = NKError(error: error) + // Prefer friendly info alert for duplicate album names (409) + if let inner = nkError.error as? NKError, inner.errorCode == NCGlobal.shared.errorConflict { + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflict.errorDescription, errorCode: conflict.errorCode) + } + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + // Top-level conflict + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflict.errorDescription, errorCode: conflict.errorCode) + } + } else { + // Other errors + Task { @MainActor in + await showErrorBanner(windowScene: self?.windowScene, error: nkError) + } + } + } + } + } + + func onPhotosSelected(selectedPhotos: [String]) { + isPhotoSelectionSheetVisible = false + + guard let album = newlyCreatedAlbum else { return } + + if selectedPhotos.isEmpty { + guard !isNavigatingToDetails else { return } + isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self?.isNavigatingToDetails = false + } + return + } + + // Batch copy operations and navigate only once after a final sync to avoid iOS 17 navigation race conditions + let group = DispatchGroup() + var hadAnySuccess = false + + for photo in selectedPhotos { + group.enter() + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + + NextcloudKit.shared.copyPhotoToAlbum( + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, + albumName: album.name, + fileName: metadata?.fileName ?? photo + ) { result in + switch result { + case .success: + hadAnySuccess = true + case .failure(let error): + let nkError = NKError(error: error) + + // Check nested conflict first (409), then top-level, otherwise show error + if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_file_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: nkError.errorDescription, errorCode: nkError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + } + group.leave() + } + } + + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + if hadAnySuccess { + AlbumsManager.shared.syncAlbums { _ in + guard !self.isNavigatingToDetails else { return } + self.isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self.isNavigatingToDetails = false + } + } + } else { + guard !self.isNavigatingToDetails else { return } + self.isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self.isNavigatingToDetails = false + } + } + } + } +} + diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift new file mode 100644 index 0000000000..8bcd80fd7d --- /dev/null +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -0,0 +1,51 @@ +// +// NoAlbumsEmptyView.swift +// Nextcloud +// +// Created by Dhanesh on 24/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct NoAlbumsEmptyView: View { + + let onNewAlbumCreationIntent: () -> Void + + private let contentPadding: CGFloat = 32.0 + + var body: some View { + + ScrollView(.vertical) { + + VStack { + + // Background image + Image("noAlbum") + .resizable() + .scaledToFill() + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(alignment: .leading, spacing: 16) { + + Text(NSLocalizedString("_albums_list_empty_heading_", comment: "")) + .font(.system(size: 48, weight: .bold)) + + Text(NSLocalizedString("_albums_list_empty_subheading_", comment: "")) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.secondary) + + Button(action: onNewAlbumCreationIntent) { + Label(NSLocalizedString("_albums_list_empty_new_album_btn_", comment: ""), systemImage: "plus") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + Spacer(minLength: 40) + } + .padding(.horizontal, contentPadding) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, -20) + } + } + } +} diff --git a/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift new file mode 100644 index 0000000000..943ea26809 --- /dev/null +++ b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift @@ -0,0 +1,26 @@ +// +// AlbumsNavigator.swift +// Nextcloud +// +// Created by Dhanesh on 11/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +final class AlbumsNavigator: ObservableObject { + + static let shared = AlbumsNavigator() + + @Published var current: AlbumsRoutes? = nil + + private init() {} + + func push(_ route: AlbumsRoutes) { + current = route + } + + func pop() { + current = nil + } +} diff --git a/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift b/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift new file mode 100644 index 0000000000..28e8c1112a --- /dev/null +++ b/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift @@ -0,0 +1,11 @@ +// +// AlbumsRoutes.swift +// Nextcloud +// +// Created by Dhanesh on 11/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +enum AlbumsRoutes: Hashable { + case albumDetails(album: Album) +} diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift new file mode 100644 index 0000000000..05922485cb --- /dev/null +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -0,0 +1,110 @@ +// +// NCMediaViewRepresentable.swift +// Nextcloud +// +// Created by Dhanesh on 05/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import UIKit + +struct NCMediaViewRepresentable: UIViewControllerRepresentable { + @Environment(\.localAccount) private var account + @Binding var ncMedia: NCMedia? + + func makeCoordinator() -> Coordinator { + let storyboard = UIStoryboard(name: "NCMedia", bundle: nil) + let media = storyboard.instantiateViewController(identifier: "NCMedia.storyboard") { coder in + Coordinator(coder: coder) + } + media.albumAccount = account + return media + } + + func makeUIViewController(context: Context) -> UINavigationController { + let media = context.coordinator + media.onReady = { [weak media] in + ncMedia = media + } + let navigation = UINavigationController(rootViewController: media) + navigation.setNavigationBarHidden(true, animated: false) + return navigation + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} + + static func dismantleUIViewController(_ uiViewController: UINavigationController, coordinator: Coordinator) { + coordinator.prepareTask?.cancel() + coordinator.onReady = nil + } + + // Keeps the album-specific session and selection lifecycle outside NCMedia. + final class Coordinator: NCMedia { + var albumAccount = "" + var onReady: (() -> Void)? + var prepareTask: Task? + private var selectionReady = false + + override var session: NCSession.Session { + NCSession.shared.getSession(account: albumAccount) + } + + override var controller: NCMainTabBarController? { + SceneManager.shared.getController(account: albumAccount) + } + + override var sceneIdentifier: String { + controller?.sceneIdentifier ?? "" + } + + override var windowScene: UIWindowScene? { + viewIfLoaded?.window?.windowScene + } + + 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 + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if selectionReady { + collectionView.isUserInteractionEnabled = true + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard !selectionReady, prepareTask == nil else { return } + 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?() + } + } + + override func viewDidDisappear(_ animated: Bool) { + prepareTask?.cancel() + super.viewDidDisappear(animated) + } + } +} diff --git a/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift new file mode 100644 index 0000000000..3ff615e9f6 --- /dev/null +++ b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift @@ -0,0 +1,43 @@ +// +// PhotoSelectionSheet.swift +// Nextcloud +// +// Created by Dhanesh on 04/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct PhotoSelectionSheet: View { + + let onPhotosSelected: ([String]) -> Void + + @State private var mediaVC: NCMedia? + + var body: some View { + NavigationView { + VStack { + NCMediaViewRepresentable(ncMedia: $mediaVC) + .frame(maxHeight: .infinity) + } + .navigationTitle(NSLocalizedString("_albums_photo_selection_sheet_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(NSLocalizedString("_albums_photo_selection_sheet_back_btn_", comment: "")) { + onPhotosSelected([]) + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("_albums_photo_selection_sheet_done_btn_", comment: "")) { + onPhotosSelected(mediaVC?.fileSelect ?? []) + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + .disabled(mediaVC == nil) + } + + } + } + } +} diff --git a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift new file mode 100644 index 0000000000..04692d68df --- /dev/null +++ b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift @@ -0,0 +1,39 @@ +// +// AlbumsRootView.swift +// Nextcloud +// +// Created by Dhanesh on 24/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct AlbumsRootView: View { + + @Environment(\.localAccount) var localAccount: String + + @StateObject private var navigator = AlbumsNavigator.shared + + var body: some View { + + NavigationStack(path: Binding( + get: { + // If navigator.current is not nil, treat it as a 1-item stack path + navigator.current.map { [$0] } ?? [] + }, + set: { path in + // If the stack path is emptied (e.g., back button), clear navigator + if path.isEmpty { navigator.pop() } + } + )) { + AlbumsListScreen(viewModel: .init(account: localAccount)) + // Explicitly use 'AlbumsRoutes.self' here to solve the inference error + .navigationDestination(for: AlbumsRoutes.self) { route in + switch route { + case .albumDetails(let album): + AlbumDetailsScreen(account: localAccount, album: album) + } + } + } + } +} diff --git a/iOSClient/Extensions/UIAlertController+Extension.swift b/iOSClient/Extensions/UIAlertController+Extension.swift index b1a9a544d2..e0ff834792 100644 --- a/iOSClient/Extensions/UIAlertController+Extension.swift +++ b/iOSClient/Extensions/UIAlertController+Extension.swift @@ -204,7 +204,7 @@ extension UIAlertController { }, completion: completion) } - static func alertDeleteFileOrFolder(titleString: String, message: String?, canDeleteServer: Bool, metadatas: [tableMetadata], completion: @escaping (_ cancelled: Bool) -> Void) -> UIAlertController { + static func alertDeleteFileOrFolder(titleString: String, message: String?, canDeleteServer: Bool, metadatas: [tableMetadata], controller: UITabBarController?, completion: @escaping (_ cancelled: Bool) -> Void) -> UIAlertController { let alertController = UIAlertController( title: titleString, message: message, @@ -212,20 +212,30 @@ extension UIAlertController { if canDeleteServer { alertController.addAction(UIAlertAction(title: NSLocalizedString("_yes_", comment: ""), style: .destructive) { (_: UIAlertAction) in Task { - await NCNetworking.shared.setStatusWaitDelete(metadatas: metadatas) + if controller?.selectedIndex == NCGlobal.shared.selectedTabIndexAlbum { + NotificationCenter.default.post(name: NSNotification.Name("deletePhotosFromAlbum"), object: nil, userInfo: ["metadatas": metadatas]) + + } else { + await NCNetworking.shared.setStatusWaitDelete(metadatas: metadatas) + + } +// await NCNetworking.shared.setStatusWaitDelete(metadatas: metadatas) } completion(false) }) } - alertController.addAction(UIAlertAction(title: NSLocalizedString("_remove_local_file_", comment: ""), style: .default) { (_: UIAlertAction) in - Task { - for metadata in metadatas { - await NCNetworking.shared.deleteCache(metadata) + if controller?.selectedIndex != NCGlobal.shared.selectedTabIndexAlbum { + + alertController.addAction(UIAlertAction(title: NSLocalizedString("_remove_local_file_", comment: ""), style: .default) { (_: UIAlertAction) in + Task { + for metadata in metadatas { + await NCNetworking.shared.deleteCache(metadata) + } } - } - completion(false) - }) + completion(false) + }) + } alertController.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel) { (_: UIAlertAction) in completion(true) diff --git a/iOSClient/Extensions/UIApplication+Extension.swift b/iOSClient/Extensions/UIApplication+Extension.swift index 8c1f44c929..4d0ca541a1 100644 --- a/iOSClient/Extensions/UIApplication+Extension.swift +++ b/iOSClient/Extensions/UIApplication+Extension.swift @@ -59,4 +59,11 @@ extension UIApplication { .compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive } } + + var firstWindow: UIWindow? { + let windowScenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } + let firstActiveScene = windowScenes.first + let keyWindow = firstActiveScene?.keyWindow + return keyWindow + } } diff --git a/iOSClient/Extensions/UINavigationController+Extension.swift b/iOSClient/Extensions/UINavigationController+Extension.swift index 46b9a063fb..eccc4a339e 100644 --- a/iOSClient/Extensions/UINavigationController+Extension.swift +++ b/iOSClient/Extensions/UINavigationController+Extension.swift @@ -37,4 +37,34 @@ extension UINavigationController { navigationBar.tintColor = textColor navigationBar.prefersLargeTitles = false } + + func popupFromNavigationStack(context: String) { + guard let nav = currentNavigationController() else { + return + } + nav.popViewController(animated: true) + } + + func currentNavigationController() -> UINavigationController? { + // Try to find the key window's root and traverse to the visible navigation controller + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + + guard let root = keyWindow?.rootViewController else { return nil } + return findNavigationController(from: root) + } + + func findNavigationController(from vc: UIViewController) -> UINavigationController? { + if let nav = vc as? UINavigationController { return nav } + if let tab = vc as? UITabBarController { + if let selected = tab.selectedViewController, let nav = findNavigationController(from: selected) { return nav } + } + if let presented = vc.presentedViewController, let nav = findNavigationController(from: presented) { return nav } + for child in vc.children { + if let nav = findNavigationController(from: child) { return nav } + } + return vc.navigationController + } } diff --git a/iOSClient/Extensions/UITabBarController+Extension.swift b/iOSClient/Extensions/UITabBarController+Extension.swift index e2901e9ff8..9612e0007a 100644 --- a/iOSClient/Extensions/UITabBarController+Extension.swift +++ b/iOSClient/Extensions/UITabBarController+Extension.swift @@ -30,4 +30,21 @@ extension UITabBarController { override func topMostViewController() -> UIViewController { return self.selectedViewController!.topMostViewController() } + + func getSelectedTabIndex() -> Int? { + // 1. Access the window through the scene delegate + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController else { + return nil + } + + // 2. Identify the UITabBarController + if let tabBarController = rootVC as? UITabBarController { + return tabBarController.selectedIndex + } else if let presentedTabBar = rootVC.presentedViewController as? UITabBarController { + return presentedTabBar.selectedIndex + } + + return nil + } } diff --git a/iOSClient/Extensions/UIViewController+Extension.swift b/iOSClient/Extensions/UIViewController+Extension.swift index 6c417b9f43..8bfca73703 100644 --- a/iOSClient/Extensions/UIViewController+Extension.swift +++ b/iOSClient/Extensions/UIViewController+Extension.swift @@ -6,6 +6,7 @@ import Foundation import UIKit import MessageUI import LucidBanner +import SwiftUI extension UIViewController { // https://stackoverflow.com/questions/6131205/how-to-find-topmost-view-controller-on-ios @@ -44,6 +45,33 @@ extension UIViewController { return false } } + + private struct Holder { + static var loader: UIHostingController? + } + + func showLoader() { + guard Holder.loader == nil else { return } + + let loader = UIHostingController(rootView: NCLoadingAlert()) + loader.view.frame = view.bounds + loader.view.backgroundColor = .clear + loader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + addChild(loader) + view.addSubview(loader.view) + loader.didMove(toParent: self) + + Holder.loader = loader + } + + func hideLoader() { + guard let loader = Holder.loader else { return } + loader.willMove(toParent: nil) + loader.view.removeFromSuperview() + loader.removeFromParent() + Holder.loader = nil + } } extension UIViewController: @retroactive MFMailComposeViewControllerDelegate { diff --git a/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json b/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json new file mode 100644 index 0000000000..467f0e7dd9 --- /dev/null +++ b/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Empty album detailed view.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg b/iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg new file mode 100644 index 0000000000..29b6beea2d --- /dev/null +++ b/iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg @@ -0,0 +1,284 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json b/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json new file mode 100644 index 0000000000..4745035c8d --- /dev/null +++ b/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Empty album viw.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg b/iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg new file mode 100644 index 0000000000..89d90f8f34 --- /dev/null +++ b/iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json new file mode 100644 index 0000000000..78ded2ecd9 --- /dev/null +++ b/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "empty-files.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "empty-files-dark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg b/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg new file mode 100644 index 0000000000..b9bd196eab --- /dev/null +++ b/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg b/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg new file mode 100644 index 0000000000..af914b14fd --- /dev/null +++ b/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg @@ -0,0 +1,265 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Images.xcassets/album.imageset/Contents.json b/iOSClient/Images.xcassets/album.imageset/Contents.json new file mode 100644 index 0000000000..4c555c06e0 --- /dev/null +++ b/iOSClient/Images.xcassets/album.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "album.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/album.imageset/album.svg b/iOSClient/Images.xcassets/album.imageset/album.svg new file mode 100644 index 0000000000..9263df2024 --- /dev/null +++ b/iOSClient/Images.xcassets/album.imageset/album.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json new file mode 100644 index 0000000000..7bdd75cdd9 --- /dev/null +++ b/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "No albums view.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg b/iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg new file mode 100644 index 0000000000..1f9decaf66 --- /dev/null +++ b/iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg @@ -0,0 +1,328 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index 482a3f16aa..60382f6b91 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -286,7 +286,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController } @MainActor - private func collectionViewCommonTrailingItemGroups() async { + func collectionViewCommonTrailingItemGroups() async { guard let topViewController else { return } diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index fc1f534456..922b042e7b 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -4,30 +4,36 @@ import UIKit import NextcloudKit +import SwiftUI class NCMediaNavigationController: NCMainNavigationController { + + static let photosAddedToAlbumNotification = Notification.Name("NCMediaPhotosAddedToAlbumNotification") + static let showAlbumDetailsNotification = Notification.Name("NCMediaShowAlbumDetailsNotification") + + static var windowScene: UIWindowScene? { + UIApplication.shared.firstWindow?.windowScene + } + + // MARK: - Right + + override func viewDidLoad() { + super.viewDidLoad() + NotificationCenter.default.addObserver(self, selector: #selector(handlePhotosAddedToAlbumNotification(_:)), name: Self.photosAddedToAlbumNotification, object: nil) + } + override func setNavigationRightItems() async { guard let media = topViewController as? NCMedia else { return } if media.isEditMode { - let cancel = UIBarButtonItem( - title: NSLocalizedString("_cancel_", comment: ""), - style: .plain - ) { - media.setEditMode(false) - } - - let group = UIBarButtonItemGroup( - barButtonItems: [cancel], - representativeItem: nil - ) - media.navigationItem.trailingItemGroups = [group] media.tabBarSelect.show() + await collectionViewCommonTrailingItemGroups() } else { media.tabBarSelect.hide() await mediaTrailingItemGroups() + await collectionViewCommonTrailingItemGroups() } } @@ -75,17 +81,29 @@ class NCMediaNavigationController: NCMainNavigationController { let select = UIAction(title: NSLocalizedString("_select_", comment: ""), image: utility.loadImage(named: "checkmark.circle")) { _ in media.setEditMode(true) + Task { + await self.updateMenuOption() + } } - let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ - UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in - media.showOnlyImages = true - media.showOnlyVideos = false + let cancel = UIAction(title: NSLocalizedString("_cancel_", comment: ""), + image: utility.loadImage(named: "xmark", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor)) { _ in + media.setEditMode(false) Task { await media.loadDataSource() await media.networkRemoveAll() } - }, + } + + let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ + UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in + media.showOnlyImages = true + media.showOnlyVideos = false + Task { + await media.loadDataSource() + await media.networkRemoveAll() + } + }, UIAction(title: NSLocalizedString("_media_viewvideo_show_", comment: ""), image: utility.loadImage(named: "video")) { _ in media.showOnlyImages = false media.showOnlyVideos = true @@ -94,7 +112,7 @@ class NCMediaNavigationController: NCMainNavigationController { await media.networkRemoveAll() } }, - UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in + UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in media.showOnlyImages = false media.showOnlyVideos = false Task { @@ -114,7 +132,6 @@ class NCMediaNavigationController: NCMainNavigationController { self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutRatio) media.layoutType = self.global.mediaLayoutRatio } - await self.updateMenuOption() media.collectionViewReloadData() } } @@ -177,7 +194,269 @@ class NCMediaNavigationController: NCMainNavigationController { })) self.present(alert, animated: true) } + + let selectAll = UIMenu(title: "", options: .displayInline, children: [ + UIAction( + title: NSLocalizedString("_select_all_", comment: ""), + image: utility.loadImage(named: "checkmark.circle.fill"), + handler: { _ in + // Ensure edit mode is enabled so selection is visible + if !media.isEditMode { + media.setEditMode(true) + } + + // Toggle select all / clear selection + if !media.fileSelect.isEmpty, media.dataSource.compactMetadatas.count == media.fileSelect.count { + media.fileSelect = [] + } else { + media.fileSelect = media.dataSource.compactMetadatas.compactMap { $0.ocId } + } - return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + // Update selection count and refresh UI without reloading data source + media.tabBarSelect.selectCount = media.fileSelect.count + media.collectionViewReloadData() + } + ) + ]) + + let actionsInEditMode: [UIAction] = [ + + UIAction( + title: NSLocalizedString("_add_to_album", comment: ""), + image: utility.loadImage(named: "plus", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), + handler: { _ in + guard let controller = self.controller else { return } + NCMediaNavigationController.presentExistingAlbums(presentingController: controller, selectedPhotos: media.fileSelect, account: controller.account) + } + ), + + UIAction( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + image: utility.loadImage(named: "album", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), + handler: { _ in + guard let controller = self.controller else { return } + NCMediaNavigationController.presentInputAlbumNameAlert(on: controller) { albumName in + NCMediaNavigationController.createNewAlbum(for: albumName, selectedPhotos: media.fileSelect, controller: controller, account: controller.account) + } onCancel: { + + } + } + ) + ] + + let editModeMenu = UIMenu( + title: "", + options: .displayInline, + children: actionsInEditMode + ) + return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu]) + } + + // MARK: - Album related handling + @objc private func handlePhotosAddedToAlbumNotification(_ notification: Notification) { + guard let media = topViewController as? NCMedia else { return } + media.setEditMode(false) + Task { + await media.loadDataSource() + await media.networkRemoveAll() +// await self.updateMenuOption() + } + } + + static func presentInputAlbumNameAlert( + on viewController: UIViewController, + onCreate: @escaping (String) -> Void, + onCancel: @escaping () -> Void + ) { + let alert = UIAlertController( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + message: NSLocalizedString("_albums_list_new_album_popup_desc_", comment: ""), + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = NSLocalizedString("_albums_list_new_album_popup_hint_", comment: "") + } + + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_negative_btn_", comment: ""), style: .default) { _ in + onCancel() + }) + + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_positive_btn_", comment: ""), style: .default) { _ in + let text = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if text.isEmpty { + let emptyAlert = UIAlertController( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + message: NSLocalizedString("_albums_list_new_album_popup_hint_", comment: ""), + preferredStyle: .alert + ) + emptyAlert.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default)) + viewController.present(emptyAlert, animated: true) + } else { + onCreate(text) + } + }) + + alert.view.tintColor = NCBrandColor.shared.customer + viewController.present(alert, animated: true) + } + + static private func createNewAlbum(for name: String, selectedPhotos: [String], controller: UIViewController, account: String) { + + // Use the provided account to avoid mismatches between UI and networking + // (Do not rely on AppDelegate.account here) + + controller.showLoader() + NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { result in + controller.hideLoader() + switch result { + case .success(_): + AlbumsManager.shared.syncAlbums { resultAlbums in + if let newAlbum = resultAlbums.first(where: { $0.name == name }) { + if selectedPhotos.isEmpty { + showAlbumAndNotify(newAlbum) + } else { + addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, account: account) + } + } else { + // Album not yet visible in the sync result; still notify UI to refresh + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) + } + } + + case .failure(let error): + let nkError = NKError(error: error) + if nkError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + } + } + } + + static func presentExistingAlbums(presentingController: UIViewController,selectedPhotos: [String], account: String) { + let viewModel = AlbumsListViewModel(account: account) + let albumListView = AddToAlbumsListView(viewModel: viewModel, localAccount: account, onFinish: { selectedAlbum in + presentingController.dismiss(animated: true) + addPhotosToAlbum(album: selectedAlbum, selectedPhotos: selectedPhotos, account: account) + }, onDismiss: { + presentingController.dismiss(animated: true) + }, onCreateAlbum: { + presentingController.dismiss(animated: true) + presentInputAlbumNameAlert(on: presentingController) { albumName in + createNewAlbum(for: albumName, selectedPhotos: selectedPhotos, controller: presentingController, account: account) + } onCancel: { + + } + }) + + let hostingController = UIHostingController(rootView: albumListView) + if let sheet = hostingController.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + } + presentingController.present(hostingController, animated: true, completion: nil) + } + + private static func showAlbumAndNotify(_ album: Album) { + DispatchQueue.main.async { + // Ensure Albums tab is selected and get its navigation controller + let nav = ensureAlbumsContextSelectedAndGetNavController() + // Pop the Albums navigation stack to root (e.g., dashboard) to ensure a clean state + nav?.popToRootViewController(animated: false) + // Notify listeners to show the album details + NotificationCenter.default.post(name: NCMediaNavigationController.showAlbumDetailsNotification, object: album) + // Let Media UI exit edit mode and refresh + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) + } + } + + static func addPhotosToAlbum(album: Album, selectedPhotos: [String], account: String) { + + if selectedPhotos.isEmpty { + showAlbumAndNotify(album) + return + } + + var completed = 0 + let total = selectedPhotos.count + func finishIfDone() { + completed += 1 + if completed >= total { + AlbumsManager.shared.syncAlbums() + showAlbumAndNotify(album) + } + } + + for photo in selectedPhotos { + + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + + NextcloudKit.shared.copyPhotoToAlbum( + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, + albumName: album.name, + fileName: metadata?.fileName ?? photo + ) { result in + + switch result { + case .success: + finishIfDone() + case .failure(let error): + let nkError = NKError(error: error) + if nkError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + finishIfDone() + } + } + } + } + + // Handle the album navigation after adding photos to album + static func ensureAlbumsContextSelectedAndGetNavController() -> UINavigationController? { + guard let tabbarController = UIApplication.shared.firstWindow?.rootViewController as? NCMainTabBarController else { return nil } + tabbarController.selectedIndex = 3//NCGlobal.shared.selectedTabIndexAlbum + // Try to fetch the selected view controller as a navigation controller + if let nav = tabbarController.selectedViewController as? UINavigationController { + return nav + } + // Fallback: if the tab bar controller has embedded navigation controllers + return tabbarController.navigationController ?? tabbarController.selectedViewController?.navigationController + } + + deinit { + NotificationCenter.default.removeObserver(self, name: Self.photosAddedToAlbumNotification, object: nil) } } + diff --git a/iOSClient/Menu/NCContextMenuActions.swift b/iOSClient/Menu/NCContextMenuActions.swift index bd2f844984..20d758500f 100644 --- a/iOSClient/Menu/NCContextMenuActions.swift +++ b/iOSClient/Menu/NCContextMenuActions.swift @@ -99,16 +99,28 @@ enum NCContextMenuActions { controller: NCMainTabBarController?, completion: (() -> Void)? = nil ) -> UIAction { - UIAction( - title: NSLocalizedString("_delete_", comment: ""), + var titleDelete = NSLocalizedString("_delete_", comment: "") + var msgDelete = NSLocalizedString("_want_delete_", comment: "") + if controller?.getSelectedTabIndex() == NCGlobal.shared.selectedTabIndexAlbum { + titleDelete = NSLocalizedString("_remove_from_album_", comment: "") + msgDelete = NSLocalizedString("_want_remove_from_album_", comment: "") + } else if ((metadatas.first?.directory) != nil) { + titleDelete = NSLocalizedString("_delete_folder_", comment: "") + } else { + titleDelete = NSLocalizedString("_delete_file_", comment: "") + } + + return UIAction( + title: titleDelete, image: NCUtility().loadImage(named: "trash"), attributes: [.destructive] ) { _ in let alert = UIAlertController.alertDeleteFileOrFolder( - titleString: NSLocalizedString("_delete_", comment: "") + "?", - message: NSLocalizedString("_want_delete_", comment: ""), + titleString: titleDelete + "?", + message: NSLocalizedString(msgDelete, comment: ""), canDeleteServer: true, - metadatas: metadatas + metadatas: metadatas, + controller: controller ) { _ in completion?() } diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index 6bbf644948..40558c2707 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -364,9 +364,18 @@ class NCContextMenuMain: NSObject { } private func makeDeleteFileAction(metadata: tableMetadata) -> UIAction { + var titleDelete = NSLocalizedString("_delete_", comment: "") + + // Only when opened from Albums context, show "Remove from album" (no file deletion) + if controller?.getSelectedTabIndex() == NCGlobal.shared.selectedTabIndexAlbum { + titleDelete = NSLocalizedString("_remove_from_album_", comment: "") + } else { + titleDelete = NSLocalizedString("_delete_file_", comment: "") + } + return UIAction( title: NSLocalizedString( - metadata.directory ? "_delete_folder_" : "_delete_file_", + metadata.directory ? "_delete_folder_" : titleDelete, comment: "" ), image: utility.loadImage(named: "trash"), diff --git a/iOSClient/More/NCMoreModel.swift b/iOSClient/More/NCMoreModel.swift index 1ffb241c28..7ca3cc2d5e 100644 --- a/iOSClient/More/NCMoreModel.swift +++ b/iOSClient/More/NCMoreModel.swift @@ -260,6 +260,17 @@ final class NCMoreModel: ObservableObject { ) } + functionItems.append( + Item( + titleKey: "_albums_", + image: "rectangle.stack", + destination: .storyboard( + name: "NCAlbums", + presentation: .push + ) + ) + ) + functionItems.append( Item( titleKey: "_scanned_images_", diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 13e7b65e06..f05d0c20ef 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -400,6 +400,10 @@ final class NCGlobal: Sendable { // let udMigrationMultiDomains = "migrationMultiDomains" let udLastVersion = "lastVersion" + + // Album + // + let selectedTabIndexAlbum: Int = 3 } /** diff --git a/iOSClient/Supporting Files/de.lproj/Localizable.strings b/iOSClient/Supporting Files/de.lproj/Localizable.strings index fd86879da9..dbd3f96a76 100644 Binary files a/iOSClient/Supporting Files/de.lproj/Localizable.strings and b/iOSClient/Supporting Files/de.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings b/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings index 85ffc4b257..afd9eeee82 100644 Binary files a/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings and b/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings differ diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 139af9a09e..22740c6e65 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -876,3 +876,48 @@ You can stop it at any time, adjust the settings, and enable it again."; "_none_" = "None"; "_sections_" = "Sections"; "_governance_labels_load_error_" = "Governance labels could not be loaded."; + +// MARK: Albums +"_albums_loading_popup_desc_" = "Wait a moment..."; +"_albums_list_nav_title_" = "Albums"; +"_albums_list_new_album_btn_" = "New"; +"_albums_list_loading_msg_" = "Loading..."; +"_albums_list_error_msg_" = "Unable to load albums. Please try again later!"; +"_albums_list_album_name_validation_nonempty_" = "Album name cannot be empty."; +"_albums_list_album_name_validation_min_length_" = "Album name must be at least 3 characters."; +"_albums_list_album_name_validation_max_length_" = "Album name cannot be more than 30 characters."; +"_albums_list_album_name_validation_specials_" = "Album name cannot contain slashes."; +"_albums_list_empty_heading_" = "Create\nAlbums\nfor your\nPhotos"; +"_albums_list_empty_subheading_" = "You can organize all your photos in as many albums as you like. You haven't created an album yet."; +"_albums_list_empty_new_album_btn_" = "Create album"; +"_albums_list_own_albums_heading_" = "My albums"; +"_albums_list_entities_" = "Items"; +"_albums_list_new_album_popup_title_" = "Create new Album"; +"_albums_list_new_album_popup_desc_" = "Please enter an album name between 3 and 30 characters."; +"_albums_list_new_album_popup_hint_" = "Album's name"; +"_albums_list_new_album_popup_positive_btn_" = "Create"; +"_albums_list_new_album_popup_negative_btn_" = "Cancel"; +"_albums_list_rename_album_popup_title_" = "Rename Album"; +"_albums_list_rename_album_popup_desc_" = "Please enter new album name between 3 and 30 characters."; +"_albums_list_rename_album_popup_hint_" = "Album's new name"; +"_albums_list_rename_album_popup_positive_btn_" = "Rename"; +"_albums_list_rename_album_popup_negative_btn_" = "Cancel"; +"_albums_photo_selection_sheet_title_" = "Select items"; +"_albums_photo_selection_sheet_back_btn_" = "Back"; +"_albums_photo_selection_sheet_done_btn_" = "Done"; +"_albums_photos_loading_msg_" = "Loading photos..."; +"_albums_photos_error_msg_" = "Unable to load photos. Please try again later!"; +"_albums_photos_empty_heading_" = "All that's\nmissing are\nyour photos"; +"_albums_photos_empty_subheading_" = "You can add as many photos as you like. A photo can also belong to more than one album."; +"_albums_photos_empty_add_photos_btn_" = "Add photos"; +"_albums_photos_add_photos_btn_" = "Add"; +"_albums_photos_rename_album_btn_" = "Rename Album"; +"_albums_photos_delete_album_btn_" = "Delete Album"; +"_albums_delete_album_popup_title_" = "Delete Album?"; +"_albums_delete_album_popup_desc_" = "Are you sure you want to delete this album? This action cannot be undone."; +"_albums_delete_album_popup_positive_btn_" = "Delete"; +"_albums_delete_album_popup_negative_btn_" = "Cancel"; +"_add_to_album" = "Add to Album"; +"_remove_from_album_" = "Remove from Album"; +"_album_already_exists_" = "Could not complete the operation, album with the same name exists"; +"_want_remove_from_album_" = "Do you really want to remove from Album?";