From 77ca4677ecf1acefcf54edc5e903882f201c4de3 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 7 Sep 2026 12:33:37 +0200 Subject: [PATCH 1/8] Init Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 232 ++++++ iOSClient/Albums/API/Albums+WebDAV.swift | 715 ++++++++++++++++++ iOSClient/Albums/API/Models/AlbumDTO.swift | 16 + .../Albums/API/Models/AlbumPhotoDTO.swift | 22 + iOSClient/Albums/AlbumsViewController.swift | 84 ++ iOSClient/Albums/Data/AlbumsManager.swift | 52 ++ iOSClient/Albums/Data/LoadableState.swift | 14 + .../Albums/Domain/Mappers/AlbumMapper.swift | 26 + .../Domain/Mappers/AlbumPhotoMapper.swift | 32 + iOSClient/Albums/Domain/Models/Album.swift | 61 ++ .../Albums/Domain/Models/AlbumPhoto.swift | 22 + iOSClient/Albums/NCAlbums.storyboard | 34 + .../NCInputAlbumNameAlertModifier.swift | 135 ++++ .../Presentation/Alerts/NCLoadingAlert.swift | 31 + .../Details/AlbumDetailsScreen.swift | 149 ++++ .../Details/AlbumDetailsViewModel.swift | 371 +++++++++ .../Details/MediaSelectionSheet.swift | 45 ++ .../Details/NoPhotosEmptyView.swift | 65 ++ .../Details/PhotoGridItemView.swift | 119 +++ .../Presentation/Details/PhotosGridView.swift | 129 ++++ .../List/AddToAlbumsListView.swift | 259 +++++++ .../Presentation/List/AlbumGridItemView.swift | 120 +++ .../Presentation/List/AlbumsGridView.swift | 130 ++++ .../Presentation/List/AlbumsListScreen.swift | 175 +++++ .../List/AlbumsListViewModel.swift | 279 +++++++ .../Presentation/List/NoAlbumsEmptyView.swift | 61 ++ .../Navigation/AlbumsNavigator.swift | 26 + .../Navigation/AlbumsRoutes.swift | 11 + .../NCMediaViewRepresentable.swift | 104 +++ .../PhotoSelection/PhotoSelectionSheet.swift | 43 ++ .../Presentation/Root/AlbumsRootView.swift | 39 + 31 files changed, 3601 insertions(+) create mode 100644 iOSClient/Albums/API/Albums+WebDAV.swift create mode 100644 iOSClient/Albums/API/Models/AlbumDTO.swift create mode 100644 iOSClient/Albums/API/Models/AlbumPhotoDTO.swift create mode 100644 iOSClient/Albums/AlbumsViewController.swift create mode 100644 iOSClient/Albums/Data/AlbumsManager.swift create mode 100644 iOSClient/Albums/Data/LoadableState.swift create mode 100644 iOSClient/Albums/Domain/Mappers/AlbumMapper.swift create mode 100644 iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift create mode 100644 iOSClient/Albums/Domain/Models/Album.swift create mode 100644 iOSClient/Albums/Domain/Models/AlbumPhoto.swift create mode 100644 iOSClient/Albums/NCAlbums.storyboard create mode 100644 iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift create mode 100644 iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift create mode 100644 iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift create mode 100644 iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift create mode 100644 iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift create mode 100644 iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift create mode 100644 iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift create mode 100644 iOSClient/Albums/Presentation/Details/PhotosGridView.swift create mode 100644 iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift create mode 100644 iOSClient/Albums/Presentation/List/AlbumGridItemView.swift create mode 100644 iOSClient/Albums/Presentation/List/AlbumsGridView.swift create mode 100644 iOSClient/Albums/Presentation/List/AlbumsListScreen.swift create mode 100644 iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift create mode 100644 iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift create mode 100644 iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift create mode 100644 iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift create mode 100644 iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift create mode 100644 iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift create mode 100644 iOSClient/Albums/Presentation/Root/AlbumsRootView.swift 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..6e74a7a3b2 --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -0,0 +1,149 @@ +// +// 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..907b322fb4 --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -0,0 +1,371 @@ +// +// 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() + } + } + } + } + + 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..7ea7c9a8bd --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -0,0 +1,129 @@ +// +// 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, metadata: metadata) + } label: { + PhotoGridItemView( + album: album, + photo: photo, + isVideo: (metadata?.isVideo ?? false), + metadata: metadata, + iconSize: calculatedIconSize + ) + } + } + } + } + } + + private func openPhotoViewer(photo: AlbumPhoto, metadata: tableMetadata?) { + // Build a complete, ordered metadata list matching the grid order + // 1) Establish a single, consistent order identical to the grid + let orderedPhotos = photos.keys.sorted { lhs, rhs in + lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) == .orderedAscending + } + + // 2) Build full metadatas list, creating/saving stubs where needed + var metadatas: [tableMetadata] = [] + metadatas.reserveCapacity(orderedPhotos.count) + + for p in orderedPhotos { + // Prefer provided dictionary value; if nil, try DB; otherwise create a stub and save it + if let existing = photos[p] ?? NCManageDatabase.shared.getMetadataFromOcId(p.id) { + metadatas.append(existing) + } else { + let stub = tableMetadata() + stub.ocId = p.id + stub.fileId = p.id + stub.fileName = p.fileName + stub.account = self.localAccount + stub.path = "\(album.href)\(p.fileName)" + // Save the stub so it is available for the viewer and later lookups + NCManageDatabase.shared.addMetadata(stub) + metadatas.append(stub) + } + } + + // 3) Compute ocIds and currentIndex + let ocIds = metadatas.map { $0.ocId } + // Try to find the current index by ocId; fall back to orderedPhotos position if needed + var resolvedCurrentIndex: Int? = metadatas.firstIndex(where: { $0.ocId == photo.id }) + if resolvedCurrentIndex == nil { + // Fallback: try by fileId match + resolvedCurrentIndex = metadatas.firstIndex(where: { $0.fileId == photo.id }) + } + if resolvedCurrentIndex == nil { + // As a last resort, align with the orderedPhotos position + if let fallbackIdx = orderedPhotos.firstIndex(where: { $0.id == photo.id }) { + resolvedCurrentIndex = fallbackIdx + } + } + guard let currentIndex = resolvedCurrentIndex else { + print("[PhotosGridView] Could not resolve currentIndex for photo id: \(photo.id)") + return + } + + // 4) Navigation and Viewer Setup + guard let navController = (UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive })? + .windows + .first(where: { $0.isKeyWindow })?.rootViewController as? NCMainTabBarController)? + .selectedViewController as? UINavigationController else { + print("[PhotosGridView] Could not find active UINavigationController to present viewer") + return + } + + guard let viewer = UIStoryboard(name: "NCViewerMediaPage", bundle: nil) + .instantiateInitialViewController() as? NCViewerMediaPage else { + print("[PhotosGridView] Failed to instantiate NCViewerMediaPage from storyboard") + return + } + + viewer.hidesBottomBarWhenPushed = true + + // 5) Populate viewer with the complete, ordered arrays + viewer.ocIds = ocIds + viewer.metadatas = metadatas + viewer.currentIndex = currentIndex + viewer.albumName = album.name + viewer.albumServerUrl = album.href + viewer.albumPhoto = photo + + navController.pushViewController(viewer, animated: true) + } +} diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift new file mode 100644 index 0000000000..81f62afc17 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -0,0 +1,259 @@ +// +// 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 { + NCUtility().createImageFileFrom(data: data, ocId: photoId, etag: "") + if let image = NCUtility().getImage(ocId: photoId, etag: "", ext: NCGlobal().previewExt512) { + Task { @MainActor in + await MainActor.run { imageState = .thumbnail(image) } + } + } else { + await MainActor.run { imageState = .empty } + } + } + } + } +} + +//#if DEBUG +//#Preview { +// NavigationView { +// AddToAlbumsListView(viewModel: .init(account: "123"), localAccount: "", onFinish: { selectedAlbum in +// print("Album:\(selectedAlbum)") +// }, onDismiss: { +// +// }) { +// +// } +// }.onAppear { +// UIView +// .appearance( +// whenContainedInInstancesOf: [UIAlertController.self] +// ).tintColor = NCBrandColor.shared.customer +// } +//} +//#endif + diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift new file mode 100644 index 0000000000..ffb1e887e2 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -0,0 +1,120 @@ +// +// 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() + 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 { + NCUtility().createImageFileFrom(data: data, ocId: photoId, etag: "") + if let image = NCUtility().getImage(ocId: photoId, etag: "", ext: NCGlobal().previewExt512) { + 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..d4ef047071 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -0,0 +1,175 @@ +// +// 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 + } + .onReceive(NotificationCenter.default.publisher(for: NCMediaNavigationController.showAlbumDetailsNotification)) { output in + guard let album = output.object as? Album else { return } + // Reset to root first + viewModel.navigationDestination = nil + popToRootTrigger += 1 + // Navigate to the requested album + DispatchQueue.main.async { + viewModel.navigationDestination = .albumDetails(album: album) + } + } + } + + @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..0700fc1ef3 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -0,0 +1,61 @@ +// +// 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) + } + } + } +} + +//#if DEBUG +//#Preview { +// NavigationView { +// NoAlbumsEmptyView(onNewAlbumCreationIntent: {}) +// .navigationTitle("Album") +// .navigationBarTitleDisplayMode(.inline) +// } +//} +//#endif 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..6b147e2c3e --- /dev/null +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -0,0 +1,104 @@ +// +// 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 } + await self.loadDataSource() + guard !Task.isCancelled else { return } + // The normal search API requires an attached view outside edit mode. + await self.searchMediaTask?.value + await self.searchMediaUI(true) + guard !Task.isCancelled else { return } + 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) + } + } + } + } +} From 2389b6087681cef0391d92eebc637fec52766647 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 7 Sep 2026 12:38:48 +0200 Subject: [PATCH 2/8] fix: update album media viewing and thumbnail handling Use the media viewer presenter with valid account metadata instead of creating stubs. Create thumbnails using the account session, remove obsolete album navigation handling, and recheck cancellation after media search. Signed-off-by: Marino Faggiana --- .../Presentation/Details/PhotosGridView.swift | 99 ++++++------------- .../List/AddToAlbumsListView.swift | 11 ++- .../Presentation/List/AlbumGridItemView.swift | 11 ++- .../Presentation/List/AlbumsListScreen.swift | 10 -- .../NCMediaViewRepresentable.swift | 1 + 5 files changed, 49 insertions(+), 83 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index 7ea7c9a8bd..e4a890c698 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -35,7 +35,7 @@ struct PhotosGridView: View { ForEach(sortedPhotos, id: \.self) { photo in let metadata = photos[photo] ?? nil Button { - openPhotoViewer(photo: photo, metadata: metadata) + openPhotoViewer(photo: photo) } label: { PhotoGridItemView( album: album, @@ -50,80 +50,41 @@ struct PhotosGridView: View { } } - private func openPhotoViewer(photo: AlbumPhoto, metadata: tableMetadata?) { - // Build a complete, ordered metadata list matching the grid order - // 1) Establish a single, consistent order identical to the grid - let orderedPhotos = photos.keys.sorted { lhs, rhs in - lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) == .orderedAscending + @MainActor + private func openPhotoViewer(photo: AlbumPhoto) { + let orderedPhotos = photos.keys.sorted { + $0.fileName.localizedCaseInsensitiveCompare($1.fileName) == .orderedAscending } - - // 2) Build full metadatas list, creating/saving stubs where needed - var metadatas: [tableMetadata] = [] - metadatas.reserveCapacity(orderedPhotos.count) - - for p in orderedPhotos { - // Prefer provided dictionary value; if nil, try DB; otherwise create a stub and save it - if let existing = photos[p] ?? NCManageDatabase.shared.getMetadataFromOcId(p.id) { - metadatas.append(existing) - } else { - let stub = tableMetadata() - stub.ocId = p.id - stub.fileId = p.id - stub.fileName = p.fileName - stub.account = self.localAccount - stub.path = "\(album.href)\(p.fileName)" - // Save the stub so it is available for the viewer and later lookups - NCManageDatabase.shared.addMetadata(stub) - metadatas.append(stub) + 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()) } - - // 3) Compute ocIds and currentIndex - let ocIds = metadatas.map { $0.ocId } - // Try to find the current index by ocId; fall back to orderedPhotos position if needed - var resolvedCurrentIndex: Int? = metadatas.firstIndex(where: { $0.ocId == photo.id }) - if resolvedCurrentIndex == nil { - // Fallback: try by fileId match - resolvedCurrentIndex = metadatas.firstIndex(where: { $0.fileId == photo.id }) - } - if resolvedCurrentIndex == nil { - // As a last resort, align with the orderedPhotos position - if let fallbackIdx = orderedPhotos.firstIndex(where: { $0.id == photo.id }) { - resolvedCurrentIndex = fallbackIdx + 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_" + ) } - } - guard let currentIndex = resolvedCurrentIndex else { - print("[PhotosGridView] Could not resolve currentIndex for photo id: \(photo.id)") return } - // 4) Navigation and Viewer Setup - guard let navController = (UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first(where: { $0.activationState == .foregroundActive })? - .windows - .first(where: { $0.isKeyWindow })?.rootViewController as? NCMainTabBarController)? - .selectedViewController as? UINavigationController else { - print("[PhotosGridView] Could not find active UINavigationController to present viewer") - return - } - - guard let viewer = UIStoryboard(name: "NCViewerMediaPage", bundle: nil) - .instantiateInitialViewController() as? NCViewerMediaPage else { - print("[PhotosGridView] Failed to instantiate NCViewerMediaPage from storyboard") - return - } - - viewer.hidesBottomBarWhenPushed = true - - // 5) Populate viewer with the complete, ordered arrays - viewer.ocIds = ocIds - viewer.metadatas = metadatas - viewer.currentIndex = currentIndex - viewer.albumName = album.name - viewer.albumServerUrl = album.href - viewer.albumPhoto = photo - - navController.pushViewController(viewer, animated: true) + 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: nil + ) } } diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 81f62afc17..4428239319 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -225,8 +225,15 @@ struct AlbumRow: View { } } if resultsPreview.error == .success, let data = resultsPreview.responseData?.data { - NCUtility().createImageFileFrom(data: data, ocId: photoId, etag: "") - if let image = NCUtility().getImage(ocId: photoId, etag: "", ext: NCGlobal().previewExt512) { + 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) } } diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index ffb1e887e2..772f8cca3b 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -96,8 +96,15 @@ struct AlbumGridItemView: View { } } if resultsPreview.error == .success, let data = resultsPreview.responseData?.data { - NCUtility().createImageFileFrom(data: data, ocId: photoId, etag: "") - if let image = NCUtility().getImage(ocId: photoId, etag: "", ext: NCGlobal().previewExt512) { + 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) } } diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift index d4ef047071..511b661362 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -79,16 +79,6 @@ struct AlbumsListScreen: View { viewModel.navigationDestination = nil popToRootTrigger += 1 } - .onReceive(NotificationCenter.default.publisher(for: NCMediaNavigationController.showAlbumDetailsNotification)) { output in - guard let album = output.object as? Album else { return } - // Reset to root first - viewModel.navigationDestination = nil - popToRootTrigger += 1 - // Navigate to the requested album - DispatchQueue.main.async { - viewModel.navigationDestination = .albumDetails(album: album) - } - } } @ViewBuilder diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 6b147e2c3e..8fd07ebcf4 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -87,6 +87,7 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { 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 } self.isEditMode = true From 729bdbffd43cee3456792b513b8fe1aee226f237 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 7 Sep 2026 12:41:38 +0200 Subject: [PATCH 3/8] feat: add Albums entry to the More menu Signed-off-by: Marino Faggiana --- iOSClient/More/NCMoreModel.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) 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_", From 35c0dfbfb3818cc7d52d7281570c6b39fecd3359 Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Wed, 9 Sep 2026 12:18:28 +0530 Subject: [PATCH 4/8] feat: Add albums images and localisations. Add "add to album" and "create new album" menu from media tab to add photos to album. Remove the unused code. Signed-off-by: harshada-15-tsys --- .../Details/AlbumDetailsScreen.swift | 1 + .../List/AddToAlbumsListView.swift | 20 -- .../Presentation/List/AlbumGridItemView.swift | 5 +- .../Presentation/List/AlbumsListScreen.swift | 2 - .../Presentation/List/NoAlbumsEmptyView.swift | 10 - .../Extensions/UIApplication+Extension.swift | 7 + .../UIViewController+Extension.swift | 28 ++ .../Contents.json | 12 + .../Empty album detailed view.svg | 284 +++++++++++++++ .../Empty album viw.imageset/Contents.json | 12 + .../Empty album viw.svg | 264 ++++++++++++++ .../EmptyAlbum.imageset/Contents.json | 22 ++ .../EmptyAlbum.imageset/empty-files-dark.svg | 260 ++++++++++++++ .../EmptyAlbum.imageset/empty-files.svg | 265 ++++++++++++++ .../album.imageset/Contents.json | 12 + .../Images.xcassets/album.imageset/album.svg | 8 + .../noAlbum.imageset/Contents.json | 12 + .../noAlbum.imageset/No albums view.svg | 328 ++++++++++++++++++ .../Main/NCMainNavigationController.swift | 2 +- .../Media/NCMediaNavigationController.swift | 319 +++++++++++++++-- .../de.lproj/Localizable.strings | Bin 121594 -> 129324 bytes .../en-GB.lproj/Localizable.strings | Bin 109464 -> 116584 bytes .../en.lproj/Localizable.strings | 45 +++ 23 files changed, 1861 insertions(+), 57 deletions(-) create mode 100644 iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg create mode 100644 iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg create mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg create mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg create mode 100644 iOSClient/Images.xcassets/album.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/album.imageset/album.svg create mode 100644 iOSClient/Images.xcassets/noAlbum.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index 6e74a7a3b2..a604f2881a 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -147,3 +147,4 @@ struct AlbumDetailsScreen: View { showMedia = true } } + diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 4428239319..964da2b361 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -244,23 +244,3 @@ struct AlbumRow: View { } } } - -//#if DEBUG -//#Preview { -// NavigationView { -// AddToAlbumsListView(viewModel: .init(account: "123"), localAccount: "", onFinish: { selectedAlbum in -// print("Album:\(selectedAlbum)") -// }, onDismiss: { -// -// }) { -// -// } -// }.onAppear { -// UIView -// .appearance( -// whenContainedInInstancesOf: [UIAlertController.self] -// ).tintColor = NCBrandColor.shared.customer -// } -//} -//#endif - diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 772f8cca3b..6d661d16a8 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -51,10 +51,6 @@ struct AlbumGridItemView: View { alignment: .top ) .clipped() -// case .thumbnail(let img): -// Image(uiImage: img) -// .resizable() -// .scaledToFill() case .thumbnail(let img): Image(uiImage: img) .resizable() @@ -125,3 +121,4 @@ struct AlbumGridItemView: View { ) } } + diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift index 511b661362..ed9002c15b 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -161,5 +161,3 @@ struct AlbumsListScreen: View { //#endif - - diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift index 0700fc1ef3..8bcd80fd7d 100644 --- a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -49,13 +49,3 @@ struct NoAlbumsEmptyView: View { } } } - -//#if DEBUG -//#Preview { -// NavigationView { -// NoAlbumsEmptyView(onNewAlbumCreationIntent: {}) -// .navigationTitle("Album") -// .navigationBarTitleDisplayMode(.inline) -// } -//} -//#endif 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/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..0d2e3cea11 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,30 @@ 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() +// 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 + 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 +113,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 +133,7 @@ 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() +// await self.updateMenuOption() media.collectionViewReloadData() } } @@ -177,7 +196,267 @@ 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", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor),//, colors: [NCBrandColor.shared.iconImageColor]), + handler: { _ in + if !media.fileSelect.isEmpty, media.dataSource.compactMetadatas.count == media.fileSelect.count { + media.fileSelect = [] + } else { + media.fileSelect = media.dataSource.compactMetadatas.compactMap({ $0.ocId }) + } + media.tabBarSelect.selectCount = media.fileSelect.count + Task { + await media.loadDataSource() + await media.networkRemoveAll() +// await self.updateMenuOption() + } + } + ) + ]) + + 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: { + + } + } + ) + ] - return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + let editModeMenu = UIMenu( + title: "", + options: .displayInline, + children: actionsInEditMode + ) + return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu])//, playFile, playURL]) + +// return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + } + + // 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/Supporting Files/de.lproj/Localizable.strings b/iOSClient/Supporting Files/de.lproj/Localizable.strings index fd86879da90d0de0af21f0ded6677e2650b7dadb..dbd3f96a768251b465c2bd5ceae31078f4ccf6e3 100644 GIT binary patch delta 4269 zcmcgwU1(cn82&!&()6tD+Qjr{N!!zQr3+h^X5ARG7j10atn_F8Y%dCC!p_ z(=oeEyz+;&eJ?(AFgxbO#4#(VD2y=`#0wc42!dA=cB2yW+>|8C(gm z2#c{vd>Y65IPQ;&Uj^R-{fm`i=_s!hE5K{|!njFgBe*5X=sLxA$WI&VH35&{u_=U& z;UUMx*dqve=2U~Yc9GjW`G#cohS>>&p?-8B1z-Q&wK!D`H+EV)#Sb+21LI(3FslzrFJ=9fOAwHRQ8qP8%=o=yJ!&+l+Y;={$0GaGZPYgm>a5eq}gk@iu}Q`>z%NC z!X?yUZh_HTO6QB{qYr(XkmSivB)SWeUGUEFGmvGe~G*yo#W!DmyOkG)?IW#psEb{M9inABE-86`6nkVxj0={88bl13G?jvhdO;@2@1Cu3` z++ybt(MfbIh2P`Ih2C%KAz*8h*`c@4+zc*;O3x>ez5=L+6}V<;Fy$qIztoA()njgZ zMoHwT8$MN99|{h&;Ql|f;4_nI2|nj2Vp*+&`1h5kN7M3LuQEzIwV*yQLVoB8+2OZ} z=7SICRY9e&7V5n`~|~JGFM2Grh_w zk6P%h0UTJr7~<6fRM==Jp^h0VGOT79AY{YjN3)}bI=8@|%+7mz%;&v)G&$B8n*EZc z0b(AD`v{&jC4(5FevY46*)R3pNNch&i;o9+V_x?%KgQVYyA_+>?KdWo>U8%OeV4E) ze%N6NJlyK|JS$Xc%rebmvPz#Y^d^znfDHjR9=7DT(LGtlTr9#Lv(>7|!z zt+$-SeH0g*sxdAk{S@|zpXT`{xbm)&S0Bv%qo9T`P{tkFdqO*k#|pnYEIdh4H_aak z^jbJwQ1gRT^uNx_#P}P$Mg-=#vUe>#nwJyJux;6Sgevx$Sfp7>ZDQZp1Xg1$+;`>) z!~EBVsiKDrs?xd}&F?ELDZCNxSO*$*kRIr+x5Bu!PPo41%5EASeF&<MUsez|hUNPGznN_7a7(Qp!#0x}HmHptJNOEE>M3h>f+Kap4SV&bBS;LrRi^_D=eE~F{E~_< z%bOiUBY7Lmr4#sYOGSne;kn8?L`?-(=^ditkW4O|J>aX+RuOxeZx!>;@iJHnSvKZ` zM9f^}+eDz3w|TPz>Is?9ROU&{QAE-vA~Or|KLl{^+-4LE*||EzR2B+*urS>89}ipD An*aa+ delta 11 TcmZ4UiT&4B_J%Etzit5lCNKtN diff --git a/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings b/iOSClient/Supporting Files/en-GB.lproj/Localizable.strings index 85ffc4b25708dc64a6560c3169aad547c7b72bfb..afd9eeee821311f008fa495ec9c5adfde0cc788e 100644 GIT binary patch delta 3583 zcmb7HUrd`-6u&njl&_rG3T<(RO5pthL%BL2rG=Q%(2Fd z7-JUWpNi_1}-J=!4$g=Q+qv- z`?_-@RwrJ|a*sHj<*u-OQ;NM+;z4NY@FTC`hr%A`P zDU6O#!ZKhRbn$@rC&PpOd9;$2JkWaz$y&z1B5^MyeU63#wm5r(4@*+20tHQ=t5O-# ziCCXQH?F|7pS?)!u##1moRcUd&#KfxM>^>{c}?D@@Du5yvq7ed{3`c~TpM>C*@Y=~ z17j+8!F|XD5gOuwc1n^JX&;?M%V}wM;OLf%EE>pOicKR0NVB+qfwyDaWkxTx@smv? zk%8t?_+MqEjoX3_Y)wShc>o5!Yz5Lk*K#cK2D%r+(9AnR-)Ch2I$Oi{RSbpJ;)uP3 z6fTMWEDr|lxg|Bm%?UKU$}Wr8I`>N(+fA)-u;L*7v+a|K6ta#l;Q_h@mOaB_KG*1Z;u1>n$4%ZY z58BFytSoT%?S9yGo_O*CI+zl@Ssv=Luq(M!ELyViPzia^;%ZSlbxU4(caI9C>YqMv z9O56iVCTcSbE*Omi-)E(JC~M5}8$dxl@aPAvRzUK74oN5j6wQ-d6Ui63 z13rDIU~M5&l`y%pXN#>3p`zWe`;!K7O>2yq7gZ`*8<5g7KHh5TdEV61%o8hH?KQPl zMF%Yzju2i^*OR2H*QP5HL-)?o_gt;Ol~oS^5`YK2b;zd!zG_sG)C^r&Z-UM}Y?aB= zwQLql4AH<)sC!Jy1W_GQ73&n-KktX!U_A^p`XJ}_h~)EJA&eXm0Wm3mrx1Oin8_hH zc*Ox*i2G)zim@iR8N47Aqj@nxu>Fn(TQ1cygO+CoZZW3^IYLbcEt^(o&M~KzjdUw?Ui4HMP0Fwe-N~xI$v^doYRnmI7KZF@G-Vk0 zQd1z6EXrN#dxa(?_HfRV!ajA_*{bai3%%L?zKx>u852Ypcy@zF#q#y2f zSn=9w$HYDktB13mP#$-(A5W@8?DK zJuRx{l$P8c82H2myW=irvE;I-EV`ACsN%ZZqQm%K6F9z+q88Ut{Prq*yy+JwhIpGZ zjch7G)SyO?-DO@#!Yj9_Aco?B$wKj}dc1Ox%HIx_HYNC8bGRvWY#j*l@5cc1ccW81 zvJDZicecG@7ViiC5`s_%Hm8xNQ4tq3-)SzT*AKrROvpiV_g$Yzj`9wO=iAI50dOrp v1mDd1{G>PKtZKw|Wc&#QTMJkk9S$e$B1RY48hkbphA-}D9-B65jAQ=;&D>_{ delta 11 ScmaFS$3EjaTf-K{8LI&y!3Apo 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?"; From 2a113f8aea2f928371c9ac1db4d2080c1ab6190e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 9 Sep 2026 10:33:09 +0200 Subject: [PATCH 5/8] align version code Signed-off-by: Marino Faggiana --- .../List/AddToAlbumsListView.swift | 20 --------- .../Presentation/List/AlbumGridItemView.swift | 4 -- .../Presentation/List/AlbumsListScreen.swift | 4 -- .../Presentation/List/NoAlbumsEmptyView.swift | 10 ----- .../en.lproj/Localizable.strings | 45 +++++++++++++++++++ 5 files changed, 45 insertions(+), 38 deletions(-) diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 4428239319..964da2b361 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -244,23 +244,3 @@ struct AlbumRow: View { } } } - -//#if DEBUG -//#Preview { -// NavigationView { -// AddToAlbumsListView(viewModel: .init(account: "123"), localAccount: "", onFinish: { selectedAlbum in -// print("Album:\(selectedAlbum)") -// }, onDismiss: { -// -// }) { -// -// } -// }.onAppear { -// UIView -// .appearance( -// whenContainedInInstancesOf: [UIAlertController.self] -// ).tintColor = NCBrandColor.shared.customer -// } -//} -//#endif - diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 772f8cca3b..c466bc64fc 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -51,10 +51,6 @@ struct AlbumGridItemView: View { alignment: .top ) .clipped() -// case .thumbnail(let img): -// Image(uiImage: img) -// .resizable() -// .scaledToFill() case .thumbnail(let img): Image(uiImage: img) .resizable() diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift index 511b661362..86a1a2b3a3 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -159,7 +159,3 @@ struct AlbumsListScreen: View { // } //} //#endif - - - - diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift index 0700fc1ef3..8bcd80fd7d 100644 --- a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -49,13 +49,3 @@ struct NoAlbumsEmptyView: View { } } } - -//#if DEBUG -//#Preview { -// NavigationView { -// NoAlbumsEmptyView(onNewAlbumCreationIntent: {}) -// .navigationTitle("Album") -// .navigationBarTitleDisplayMode(.inline) -// } -//} -//#endif 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?"; From 580274d8abb5874109f602223aa674ad19216005 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 9 Sep 2026 10:37:21 +0200 Subject: [PATCH 6/8] fix: reload media in album photo selection sheet Force data source loading before and after media search, then refresh the collection view before enabling selection. Signed-off-by: Marino Faggiana --- .../PhotoSelection/NCMediaViewRepresentable.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 8fd07ebcf4..05922485cb 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -83,13 +83,18 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { prepareTask = Task { @MainActor [weak self] in guard let self else { return } defer { self.prepareTask = nil } - await self.loadDataSource() + // 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 From 2843f9817e56d0d64256a4559f2c66f79bc159aa Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Wed, 9 Sep 2026 15:25:05 +0530 Subject: [PATCH 7/8] fix: reload media in album photo selection sheet Force data source loading before and after media search, then refresh the collection view before enabling selection. fix: Handling select all functionality in media menu Signed-off-by: harshada-15-tsys --- .../NCMediaViewRepresentable.swift | 7 +++++- .../Media/NCMediaNavigationController.swift | 24 +++++++++---------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 8fd07ebcf4..05922485cb 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -83,13 +83,18 @@ struct NCMediaViewRepresentable: UIViewControllerRepresentable { prepareTask = Task { @MainActor [weak self] in guard let self else { return } defer { self.prepareTask = nil } - await self.loadDataSource() + // 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 diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index 0d2e3cea11..922b042e7b 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -92,7 +92,6 @@ class NCMediaNavigationController: NCMainNavigationController { Task { await media.loadDataSource() await media.networkRemoveAll() -// await self.updateMenuOption() } } @@ -133,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() } } @@ -200,19 +198,23 @@ class NCMediaNavigationController: NCMainNavigationController { let selectAll = UIMenu(title: "", options: .displayInline, children: [ UIAction( title: NSLocalizedString("_select_all_", comment: ""), - image: utility.loadImage(named: "checkmark.circle.fill", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor),//, colors: [NCBrandColor.shared.iconImageColor]), + 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 }) + media.fileSelect = media.dataSource.compactMetadatas.compactMap { $0.ocId } } + + // Update selection count and refresh UI without reloading data source media.tabBarSelect.selectCount = media.fileSelect.count - Task { - await media.loadDataSource() - await media.networkRemoveAll() -// await self.updateMenuOption() - } + media.collectionViewReloadData() } ) ]) @@ -247,9 +249,7 @@ class NCMediaNavigationController: NCMainNavigationController { options: .displayInline, children: actionsInEditMode ) - return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu])//, playFile, playURL]) - -// return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu]) } // MARK: - Album related handling From 59710be3005d1e504d3caf01233ac7d6d222aaa2 Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Fri, 11 Sep 2026 18:41:49 +0530 Subject: [PATCH 8/8] fix - Added the handling for removing photo from album This will only remove photo from the album not delete. Signed-off-by: harshada-15-tsys --- .../Details/AlbumDetailsViewModel.swift | 1 + .../Presentation/Details/PhotosGridView.swift | 2 +- .../UIAlertController+Extension.swift | 28 +++++++++++------ .../UINavigationController+Extension.swift | 30 +++++++++++++++++++ .../UITabBarController+Extension.swift | 17 +++++++++++ iOSClient/Menu/NCContextMenuActions.swift | 22 ++++++++++---- iOSClient/Menu/NCContextMenuMain.swift | 11 ++++++- iOSClient/NCGlobal.swift | 4 +++ 8 files changed, 99 insertions(+), 16 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift index 907b322fb4..f9a90ae51f 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -201,6 +201,7 @@ class AlbumDetailsViewModel: ObservableObject { await MainActor.run { self.loadAlbumPhotos() AlbumsManager.shared.syncAlbums() + UINavigationController().popupFromNavigationStack(context: "after-pop-deletePhotos") } } } diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index e4a890c698..8ee4f924ff 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -84,7 +84,7 @@ struct PhotosGridView: View { model: model, viewerTransitionSource: nil, from: controller?.view, - contextMenuController: nil + contextMenuController: controller ) } } 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/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/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/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 } /**