From 77ca4677ecf1acefcf54edc5e903882f201c4de3 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 7 Sep 2026 12:33:37 +0200 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 2a113f8aea2f928371c9ac1db4d2080c1ab6190e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 9 Sep 2026 10:33:09 +0200 Subject: [PATCH 04/13] 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 05/13] 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 0b9f63e82ef76c6f0b36e95b315b48219e1c1150 Mon Sep 17 00:00:00 2001 From: harshada-15-tsys Date: Wed, 9 Sep 2026 19:06:00 +0530 Subject: [PATCH 06/13] iOS Albums feature (#4290) * 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 * 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 --------- Signed-off-by: harshada-15-tsys --- .../Details/AlbumDetailsScreen.swift | 1 + .../Presentation/List/AlbumGridItemView.swift | 1 + .../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 19 files changed, 1816 insertions(+), 21 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/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index c466bc64fc..6d661d16a8 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -121,3 +121,4 @@ struct AlbumGridItemView: View { ) } } + 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..922b042e7b 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -4,30 +4,36 @@ import UIKit import NextcloudKit +import SwiftUI class NCMediaNavigationController: NCMainNavigationController { + + static let photosAddedToAlbumNotification = Notification.Name("NCMediaPhotosAddedToAlbumNotification") + static let showAlbumDetailsNotification = Notification.Name("NCMediaShowAlbumDetailsNotification") + + static var windowScene: UIWindowScene? { + UIApplication.shared.firstWindow?.windowScene + } + + // MARK: - Right + + override func viewDidLoad() { + super.viewDidLoad() + NotificationCenter.default.addObserver(self, selector: #selector(handlePhotosAddedToAlbumNotification(_:)), name: Self.photosAddedToAlbumNotification, object: nil) + } + override func setNavigationRightItems() async { guard let media = topViewController as? NCMedia else { return } if media.isEditMode { - let cancel = UIBarButtonItem( - title: NSLocalizedString("_cancel_", comment: ""), - style: .plain - ) { - media.setEditMode(false) - } - - let group = UIBarButtonItemGroup( - barButtonItems: [cancel], - representativeItem: nil - ) - media.navigationItem.trailingItemGroups = [group] media.tabBarSelect.show() + await collectionViewCommonTrailingItemGroups() } else { media.tabBarSelect.hide() await mediaTrailingItemGroups() + await collectionViewCommonTrailingItemGroups() } } @@ -75,17 +81,29 @@ class NCMediaNavigationController: NCMainNavigationController { let select = UIAction(title: NSLocalizedString("_select_", comment: ""), image: utility.loadImage(named: "checkmark.circle")) { _ in media.setEditMode(true) + Task { + await self.updateMenuOption() + } } - let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ - UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in - media.showOnlyImages = true - media.showOnlyVideos = false + let cancel = UIAction(title: NSLocalizedString("_cancel_", comment: ""), + image: utility.loadImage(named: "xmark", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor)) { _ in + media.setEditMode(false) Task { await media.loadDataSource() await media.networkRemoveAll() } - }, + } + + let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ + UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in + media.showOnlyImages = true + media.showOnlyVideos = false + Task { + await media.loadDataSource() + await media.networkRemoveAll() + } + }, UIAction(title: NSLocalizedString("_media_viewvideo_show_", comment: ""), image: utility.loadImage(named: "video")) { _ in media.showOnlyImages = false media.showOnlyVideos = true @@ -94,7 +112,7 @@ class NCMediaNavigationController: NCMainNavigationController { await media.networkRemoveAll() } }, - UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in + UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in media.showOnlyImages = false media.showOnlyVideos = false Task { @@ -114,7 +132,6 @@ class NCMediaNavigationController: NCMainNavigationController { self.database.setLayoutForView(account: self.session.account, key: self.global.layoutViewMedia, serverUrl: "", layout: self.global.mediaLayoutRatio) media.layoutType = self.global.mediaLayoutRatio } - await self.updateMenuOption() media.collectionViewReloadData() } } @@ -177,7 +194,269 @@ class NCMediaNavigationController: NCMainNavigationController { })) self.present(alert, animated: true) } + + let selectAll = UIMenu(title: "", options: .displayInline, children: [ + UIAction( + title: NSLocalizedString("_select_all_", comment: ""), + image: utility.loadImage(named: "checkmark.circle.fill"), + handler: { _ in + // Ensure edit mode is enabled so selection is visible + if !media.isEditMode { + media.setEditMode(true) + } + + // Toggle select all / clear selection + if !media.fileSelect.isEmpty, media.dataSource.compactMetadatas.count == media.fileSelect.count { + media.fileSelect = [] + } else { + media.fileSelect = media.dataSource.compactMetadatas.compactMap { $0.ocId } + } - return UIMenu(title: "", children: [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL]) + // Update selection count and refresh UI without reloading data source + media.tabBarSelect.selectCount = media.fileSelect.count + media.collectionViewReloadData() + } + ) + ]) + + let actionsInEditMode: [UIAction] = [ + + UIAction( + title: NSLocalizedString("_add_to_album", comment: ""), + image: utility.loadImage(named: "plus", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), + handler: { _ in + guard let controller = self.controller else { return } + NCMediaNavigationController.presentExistingAlbums(presentingController: controller, selectedPhotos: media.fileSelect, account: controller.account) + } + ), + + UIAction( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + image: utility.loadImage(named: "album", colors: [NCBrandColor.shared.iconImageColor], size: 24).withTintColor(NCBrandColor.shared.iconImageColor), + handler: { _ in + guard let controller = self.controller else { return } + NCMediaNavigationController.presentInputAlbumNameAlert(on: controller) { albumName in + NCMediaNavigationController.createNewAlbum(for: albumName, selectedPhotos: media.fileSelect, controller: controller, account: controller.account) + } onCancel: { + + } + } + ) + ] + + let editModeMenu = UIMenu( + title: "", + options: .displayInline, + children: actionsInEditMode + ) + return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu]) + } + + // MARK: - Album related handling + @objc private func handlePhotosAddedToAlbumNotification(_ notification: Notification) { + guard let media = topViewController as? NCMedia else { return } + media.setEditMode(false) + Task { + await media.loadDataSource() + await media.networkRemoveAll() +// await self.updateMenuOption() + } + } + + static func presentInputAlbumNameAlert( + on viewController: UIViewController, + onCreate: @escaping (String) -> Void, + onCancel: @escaping () -> Void + ) { + let alert = UIAlertController( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + message: NSLocalizedString("_albums_list_new_album_popup_desc_", comment: ""), + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = NSLocalizedString("_albums_list_new_album_popup_hint_", comment: "") + } + + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_negative_btn_", comment: ""), style: .default) { _ in + onCancel() + }) + + alert.addAction(UIAlertAction(title: NSLocalizedString("_albums_list_new_album_popup_positive_btn_", comment: ""), style: .default) { _ in + let text = alert.textFields?.first?.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if text.isEmpty { + let emptyAlert = UIAlertController( + title: NSLocalizedString("_albums_list_new_album_popup_title_", comment: ""), + message: NSLocalizedString("_albums_list_new_album_popup_hint_", comment: ""), + preferredStyle: .alert + ) + emptyAlert.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default)) + viewController.present(emptyAlert, animated: true) + } else { + onCreate(text) + } + }) + + alert.view.tintColor = NCBrandColor.shared.customer + viewController.present(alert, animated: true) + } + + static private func createNewAlbum(for name: String, selectedPhotos: [String], controller: UIViewController, account: String) { + + // Use the provided account to avoid mismatches between UI and networking + // (Do not rely on AppDelegate.account here) + + controller.showLoader() + NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { result in + controller.hideLoader() + switch result { + case .success(_): + AlbumsManager.shared.syncAlbums { resultAlbums in + if let newAlbum = resultAlbums.first(where: { $0.name == name }) { + if selectedPhotos.isEmpty { + showAlbumAndNotify(newAlbum) + } else { + addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, account: account) + } + } else { + // Album not yet visible in the sync result; still notify UI to refresh + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) + } + } + + case .failure(let error): + let nkError = NKError(error: error) + if nkError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + } + } + } + + static func presentExistingAlbums(presentingController: UIViewController,selectedPhotos: [String], account: String) { + let viewModel = AlbumsListViewModel(account: account) + let albumListView = AddToAlbumsListView(viewModel: viewModel, localAccount: account, onFinish: { selectedAlbum in + presentingController.dismiss(animated: true) + addPhotosToAlbum(album: selectedAlbum, selectedPhotos: selectedPhotos, account: account) + }, onDismiss: { + presentingController.dismiss(animated: true) + }, onCreateAlbum: { + presentingController.dismiss(animated: true) + presentInputAlbumNameAlert(on: presentingController) { albumName in + createNewAlbum(for: albumName, selectedPhotos: selectedPhotos, controller: presentingController, account: account) + } onCancel: { + + } + }) + + let hostingController = UIHostingController(rootView: albumListView) + if let sheet = hostingController.sheetPresentationController { + sheet.detents = [.large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + } + presentingController.present(hostingController, animated: true, completion: nil) + } + + private static func showAlbumAndNotify(_ album: Album) { + DispatchQueue.main.async { + // Ensure Albums tab is selected and get its navigation controller + let nav = ensureAlbumsContextSelectedAndGetNavController() + // Pop the Albums navigation stack to root (e.g., dashboard) to ensure a clean state + nav?.popToRootViewController(animated: false) + // Notify listeners to show the album details + NotificationCenter.default.post(name: NCMediaNavigationController.showAlbumDetailsNotification, object: album) + // Let Media UI exit edit mode and refresh + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) + } + } + + static func addPhotosToAlbum(album: Album, selectedPhotos: [String], account: String) { + + if selectedPhotos.isEmpty { + showAlbumAndNotify(album) + return + } + + var completed = 0 + let total = selectedPhotos.count + func finishIfDone() { + completed += 1 + if completed >= total { + AlbumsManager.shared.syncAlbums() + showAlbumAndNotify(album) + } + } + + for photo in selectedPhotos { + + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + + NextcloudKit.shared.copyPhotoToAlbum( + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, + albumName: album.name, + fileName: metadata?.fileName ?? photo + ) { result in + + switch result { + case .success: + finishIfDone() + case .failure(let error): + let nkError = NKError(error: error) + if nkError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_album_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + finishIfDone() + } + } + } + } + + // Handle the album navigation after adding photos to album + static func ensureAlbumsContextSelectedAndGetNavController() -> UINavigationController? { + guard let tabbarController = UIApplication.shared.firstWindow?.rootViewController as? NCMainTabBarController else { return nil } + tabbarController.selectedIndex = 3//NCGlobal.shared.selectedTabIndexAlbum + // Try to fetch the selected view controller as a navigation controller + if let nav = tabbarController.selectedViewController as? UINavigationController { + return nav + } + // Fallback: if the tab bar controller has embedded navigation controllers + return tabbarController.navigationController ?? tabbarController.selectedViewController?.navigationController + } + + deinit { + NotificationCenter.default.removeObserver(self, name: Self.photosAddedToAlbumNotification, object: nil) } } + diff --git a/iOSClient/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 From 5ce915618e28ec8f3571783b77849d02f5db2dac Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 9 Sep 2026 15:57:01 +0200 Subject: [PATCH 07/13] fix: scope album navigation and feedback to the originating window Open album details through the More tab after creating albums or adding photos. Scope loaders, banners, and refresh notifications to the originating controller, and wait for sheet dismissal before presenting the next view. Signed-off-by: Marino Faggiana --- iOSClient/Albums/AlbumsViewController.swift | 3 +- .../Presentation/Alerts/NCLoadingAlert.swift | 24 ++++ .../Presentation/Root/AlbumsRootView.swift | 11 ++ .../Extensions/UIApplication+Extension.swift | 7 - .../UIViewController+Extension.swift | 28 ---- .../Media/NCMediaNavigationController.swift | 134 ++++++++---------- .../en.lproj/Localizable.strings | 6 +- 7 files changed, 101 insertions(+), 112 deletions(-) diff --git a/iOSClient/Albums/AlbumsViewController.swift b/iOSClient/Albums/AlbumsViewController.swift index 598576fe3c..dabbe74eb1 100644 --- a/iOSClient/Albums/AlbumsViewController.swift +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -10,6 +10,7 @@ import UIKit import SwiftUI class AlbumsViewController: UIViewController { + var initialAlbum: Album? @Environment(\.localAccount) var localAccount: String @@ -21,7 +22,7 @@ class AlbumsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - let albumsRootView = AlbumsRootView() + let albumsRootView = AlbumsRootView(initialAlbum: initialAlbum) .environment(\.localAccount, session.account) let hostingController = UIHostingController(rootView: albumsRootView) diff --git a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift index e83230f047..f15f4e9d7b 100644 --- a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift +++ b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift @@ -7,6 +7,7 @@ // import SwiftUI +import UIKit struct NCLoadingAlert: View { @@ -29,3 +30,26 @@ struct NCLoadingAlert: View { // NCLoadingAlert() //} //#endif + +// UIKit presentation stays scoped to Albums; each operation owns its loader. +extension NCLoadingAlert { + @MainActor + static func show(on controller: UIViewController) -> UIHostingController { + let loader = UIHostingController(rootView: NCLoadingAlert()) + loader.view.frame = controller.view.bounds + loader.view.backgroundColor = .clear + loader.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + + controller.addChild(loader) + controller.view.addSubview(loader.view) + loader.didMove(toParent: controller) + return loader + } + + @MainActor + static func hide(_ loader: UIHostingController) { + loader.willMove(toParent: nil) + loader.view.removeFromSuperview() + loader.removeFromParent() + } +} diff --git a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift index 04692d68df..9de26131a5 100644 --- a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift +++ b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift @@ -9,6 +9,8 @@ import SwiftUI struct AlbumsRootView: View { + var initialAlbum: Album? = nil + @State private var didApplyInitialRoute = false @Environment(\.localAccount) var localAccount: String @@ -35,5 +37,14 @@ struct AlbumsRootView: View { } } } + .onAppear { + guard !didApplyInitialRoute else { return } + didApplyInitialRoute = true + if let initialAlbum { + navigator.push(.albumDetails(album: initialAlbum)) + } else { + navigator.pop() + } + } } } diff --git a/iOSClient/Extensions/UIApplication+Extension.swift b/iOSClient/Extensions/UIApplication+Extension.swift index 4d0ca541a1..8c1f44c929 100644 --- a/iOSClient/Extensions/UIApplication+Extension.swift +++ b/iOSClient/Extensions/UIApplication+Extension.swift @@ -59,11 +59,4 @@ 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 8bfca73703..6c417b9f43 100644 --- a/iOSClient/Extensions/UIViewController+Extension.swift +++ b/iOSClient/Extensions/UIViewController+Extension.swift @@ -6,7 +6,6 @@ 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 @@ -45,33 +44,6 @@ 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/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index 922b042e7b..8d7b4493f4 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -7,21 +7,15 @@ 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 @@ -94,7 +88,7 @@ class NCMediaNavigationController: NCMainNavigationController { await media.networkRemoveAll() } } - + let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in media.showOnlyImages = true @@ -194,7 +188,7 @@ class NCMediaNavigationController: NCMainNavigationController { })) self.present(alert, animated: true) } - + let selectAll = UIMenu(title: "", options: .displayInline, children: [ UIAction( title: NSLocalizedString("_select_all_", comment: ""), @@ -218,9 +212,9 @@ class NCMediaNavigationController: NCMainNavigationController { } ) ]) - + 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), @@ -229,7 +223,7 @@ class NCMediaNavigationController: NCMainNavigationController { 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), @@ -238,7 +232,7 @@ class NCMediaNavigationController: NCMainNavigationController { NCMediaNavigationController.presentInputAlbumNameAlert(on: controller) { albumName in NCMediaNavigationController.createNewAlbum(for: albumName, selectedPhotos: media.fileSelect, controller: controller, account: controller.account) } onCancel: { - + } } ) @@ -251,10 +245,12 @@ class NCMediaNavigationController: NCMainNavigationController { ) return UIMenu(title: "", children: !media.isEditMode ? [select, viewFilterMenu, viewLayoutMenu, viewFolderMedia, playFile, playURL] : [cancel, selectAll, editModeMenu]) } - + // MARK: - Album related handling @objc private func handlePhotosAddedToAlbumNotification(_ notification: Notification) { - guard let media = topViewController as? NCMedia else { return } + guard let sourceController = notification.object as? NCMainTabBarController, + sourceController === controller, + let media = topViewController as? NCMedia else { return } media.setEditMode(false) Task { await media.loadDataSource() @@ -262,7 +258,7 @@ class NCMediaNavigationController: NCMainNavigationController { // await self.updateMenuOption() } } - + static func presentInputAlbumNameAlert( on viewController: UIViewController, onCreate: @escaping (String) -> Void, @@ -273,15 +269,15 @@ class NCMediaNavigationController: NCMainNavigationController { 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 { @@ -296,74 +292,74 @@ class NCMediaNavigationController: NCMainNavigationController { 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) { - + + static private func createNewAlbum(for name: String, selectedPhotos: [String], controller: NCMainTabBarController, account: String) { + // Use the provided account to avoid mismatches between UI and networking // (Do not rely on AppDelegate.account here) - controller.showLoader() + let loader = NCLoadingAlert.show(on: controller) NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { result in - controller.hideLoader() + NCLoadingAlert.hide(loader) switch result { case .success(_): AlbumsManager.shared.syncAlbums { resultAlbums in if let newAlbum = resultAlbums.first(where: { $0.name == name }) { if selectedPhotos.isEmpty { - showAlbumAndNotify(newAlbum) + showAlbumAndNotify(newAlbum, controller: controller, account: account) } else { - addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, account: account) + addPhotosToAlbum(album: newAlbum, selectedPhotos: selectedPhotos, account: account, controller: controller) } } else { // Album not yet visible in the sync result; still notify UI to refresh - NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: nil) + NotificationCenter.default.post(name: NCMediaNavigationController.photosAddedToAlbumNotification, object: controller) } } - + 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) + await showInfoBanner(windowScene: controller.viewIfLoaded?.window?.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) + await showInfoBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) } } else { Task { @MainActor in - await showErrorBanner(windowScene: self.windowScene, error: nkError) + await showErrorBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, error: nkError) } } } } } - static func presentExistingAlbums(presentingController: UIViewController,selectedPhotos: [String], account: String) { + static func presentExistingAlbums(presentingController: NCMainTabBarController,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) + presentingController.dismiss(animated: true) { + addPhotosToAlbum(album: selectedAlbum, selectedPhotos: selectedPhotos, account: account, controller: presentingController) + } }, 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: { - + 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()] @@ -372,34 +368,39 @@ class NCMediaNavigationController: NCMainNavigationController { } presentingController.present(hostingController, animated: true, completion: nil) } - - private static func showAlbumAndNotify(_ album: Album) { + + private static func showAlbumAndNotify(_ album: Album, controller: NCMainTabBarController, account: String) { 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) + guard controller.account == account, + controller.viewIfLoaded?.window != nil, + let navigationController = controller.viewControllers?.compactMap({ $0 as? NCMoreNavigationController }).first, + let albumsController = UIStoryboard(name: "NCAlbums", bundle: nil) + .instantiateInitialViewController() as? AlbumsViewController else { + return + } + + albumsController.initialAlbum = album + controller.selectedViewController = navigationController + guard let moreController = navigationController.viewControllers.first else { return } + navigationController.setViewControllers([moreController, albumsController], animated: true) + NotificationCenter.default.post(name: photosAddedToAlbumNotification, object: controller) } } - - static func addPhotosToAlbum(album: Album, selectedPhotos: [String], account: String) { - + + static func addPhotosToAlbum(album: Album, selectedPhotos: [String], account: String, controller: NCMainTabBarController) { + if selectedPhotos.isEmpty { - showAlbumAndNotify(album) + showAlbumAndNotify(album, controller: controller, account: account) return } - + var completed = 0 let total = selectedPhotos.count func finishIfDone() { completed += 1 if completed >= total { AlbumsManager.shared.syncAlbums() - showAlbumAndNotify(album) + showAlbumAndNotify(album, controller: controller, account: account) } } @@ -423,18 +424,18 @@ class NCMediaNavigationController: NCMainNavigationController { 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) + await showInfoBanner(windowScene: controller.viewIfLoaded?.window?.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) + await showInfoBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) } } else { Task { @MainActor in - await showErrorBanner(windowScene: self.windowScene, error: nkError) + await showErrorBanner(windowScene: controller.viewIfLoaded?.window?.windowScene, error: nkError) } } finishIfDone() @@ -443,20 +444,7 @@ class NCMediaNavigationController: NCMainNavigationController { } } - // 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/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 22740c6e65..4df25b9d7b 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -878,10 +878,10 @@ You can stop it at any time, adjust the settings, and enable it again."; "_governance_labels_load_error_" = "Governance labels could not be loaded."; // MARK: Albums -"_albums_loading_popup_desc_" = "Wait a moment..."; +"_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_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."; @@ -905,7 +905,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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_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."; From b7593f5cf74652b6b3477af24a504457861040ae Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 9 Sep 2026 16:13:06 +0200 Subject: [PATCH 08/13] Album image Signed-off-by: Marino Faggiana --- .../Images.xcassets/album.imageset/Contents.json | 12 ------------ iOSClient/Images.xcassets/album.imageset/album.svg | 8 -------- iOSClient/Media/NCMediaNavigationController.swift | 2 +- iOSClient/More/NCMoreModel.swift | 2 +- 4 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 iOSClient/Images.xcassets/album.imageset/Contents.json delete mode 100644 iOSClient/Images.xcassets/album.imageset/album.svg diff --git a/iOSClient/Images.xcassets/album.imageset/Contents.json b/iOSClient/Images.xcassets/album.imageset/Contents.json deleted file mode 100644 index 4c555c06e0..0000000000 --- a/iOSClient/Images.xcassets/album.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 9263df2024..0000000000 --- a/iOSClient/Images.xcassets/album.imageset/album.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index 8d7b4493f4..7484890ce0 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -226,7 +226,7 @@ class NCMediaNavigationController: NCMainNavigationController { 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), + image: utility.loadImage(named: "photo.badge.plus"), handler: { _ in guard let controller = self.controller else { return } NCMediaNavigationController.presentInputAlbumNameAlert(on: controller) { albumName in diff --git a/iOSClient/More/NCMoreModel.swift b/iOSClient/More/NCMoreModel.swift index 7ca3cc2d5e..f91ca91c96 100644 --- a/iOSClient/More/NCMoreModel.swift +++ b/iOSClient/More/NCMoreModel.swift @@ -263,7 +263,7 @@ final class NCMoreModel: ObservableObject { functionItems.append( Item( titleKey: "_albums_", - image: "rectangle.stack", + image: "photo.stack", destination: .storyboard( name: "NCAlbums", presentation: .push From 3cd692b19168c48a2db308805712d602f1f2d228 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 10 Sep 2026 08:26:14 +0200 Subject: [PATCH 09/13] lint Signed-off-by: Marino Faggiana --- iOSClient/Albums/API/Albums+WebDAV.swift | 235 +++++++++--------- iOSClient/Albums/AlbumsViewController.swift | 30 +-- iOSClient/Albums/Data/AlbumsManager.swift | 20 +- .../Albums/Domain/Mappers/AlbumMapper.swift | 4 +- .../Domain/Mappers/AlbumPhotoMapper.swift | 4 +- iOSClient/Albums/Domain/Models/Album.swift | 10 +- .../NCInputAlbumNameAlertModifier.swift | 20 +- .../Presentation/Alerts/NCLoadingAlert.swift | 14 +- .../Details/AlbumDetailsScreen.swift | 15 +- .../Details/AlbumDetailsViewModel.swift | 151 ++++++----- .../Details/NoPhotosEmptyView.swift | 30 +-- .../Details/PhotoGridItemView.swift | 15 +- .../Presentation/Details/PhotosGridView.swift | 12 +- .../List/AddToAlbumsListView.swift | 27 +- .../Presentation/List/AlbumGridItemView.swift | 15 +- .../Presentation/List/AlbumsGridView.swift | 31 ++- .../Presentation/List/AlbumsListScreen.swift | 32 +-- .../List/AlbumsListViewModel.swift | 84 +++---- .../Presentation/List/NoAlbumsEmptyView.swift | 20 +- .../Navigation/AlbumsNavigator.swift | 10 +- .../PhotoSelection/PhotoSelectionSheet.swift | 4 +- .../Presentation/Root/AlbumsRootView.swift | 6 +- .../Media/NCMediaNavigationController.swift | 16 +- 23 files changed, 396 insertions(+), 409 deletions(-) diff --git a/iOSClient/Albums/API/Albums+WebDAV.swift b/iOSClient/Albums/API/Albums+WebDAV.swift index a2c44cd8b4..4d8b63f365 100644 --- a/iOSClient/Albums/API/Albums+WebDAV.swift +++ b/iOSClient/Albums/API/Albums+WebDAV.swift @@ -21,7 +21,7 @@ fileprivate extension DateFormatter { } public extension NextcloudKit { - + // MARK: - Fetch all Albums func fetchAllAlbums( for account: String, @@ -29,21 +29,21 @@ public extension NextcloudKit { taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, completion: @escaping (Result<[AlbumDTO], Error>) -> Void ) { - + let session = NCSession.shared.getSession(account: account) - - //options.contentType = "application/xml" + + // 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 = """ @@ -56,7 +56,7 @@ public extension NextcloudKit { """ - + var urlRequest: URLRequest do { try urlRequest = URLRequest(url: url, method: method, headers: headers) @@ -65,10 +65,10 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request( urlRequest, - //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + // interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) ) .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -76,34 +76,34 @@ public extension NextcloudKit { 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)) @@ -111,26 +111,26 @@ public extension NextcloudKit { } } } - + 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") { @@ -145,10 +145,10 @@ public extension NextcloudKit { albums.append(album) } } - + return albums } - + // MARK: - Create new Album func createNewAlbum( for account: String, @@ -157,9 +157,9 @@ public extension NextcloudKit { 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), @@ -167,9 +167,9 @@ public extension NextcloudKit { 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) @@ -177,10 +177,10 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request( urlRequest, - //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + // interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) ) // .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -188,11 +188,11 @@ public extension NextcloudKit { 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 @@ -208,7 +208,7 @@ public extension NextcloudKit { 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) @@ -216,12 +216,12 @@ public extension NextcloudKit { 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)) @@ -229,7 +229,7 @@ public extension NextcloudKit { } } } - + // MARK: - Fetch Album photos func fetchAlbumPhotos( for album: String, @@ -238,19 +238,19 @@ public extension NextcloudKit { 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 = """ @@ -271,7 +271,7 @@ public extension NextcloudKit { """ - + var urlRequest: URLRequest do { try urlRequest = URLRequest(url: url, method: method, headers: headers) @@ -280,10 +280,10 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request( urlRequest, - //interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) + // interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance) ) .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -291,34 +291,34 @@ public extension NextcloudKit { 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)) @@ -326,51 +326,51 @@ public extension NextcloudKit { } } } - + 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, @@ -385,14 +385,14 @@ public extension NextcloudKit { width: width, height: height ) - + photos.append(photo) } } - + return photos } - + // MARK: - Copy Photo to Album func copyPhotoToAlbum( account: String, @@ -403,9 +403,9 @@ public extension NextcloudKit { 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 @@ -413,21 +413,21 @@ public extension NextcloudKit { 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) @@ -435,7 +435,7 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request(urlRequest) .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -446,9 +446,9 @@ public extension NextcloudKit { 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 { @@ -456,20 +456,20 @@ public extension NextcloudKit { 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: @@ -486,8 +486,8 @@ public extension NextcloudKit { fileName: String, serverUrlFileName: String, account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> ( account: String, responseData: AFDataResponse?, @@ -508,7 +508,7 @@ public extension NextcloudKit { } } } - + /// Deletes a Photo From Album from the Nextcloud server at the specified URL. /// /// - Parameters: @@ -521,12 +521,12 @@ public extension NextcloudKit { /// - 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) { + 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) @@ -584,7 +584,7 @@ public extension NextcloudKit { } } } - + // MARK: - Delete Album func deleteAlbum( albumName: String, @@ -593,19 +593,19 @@ public extension NextcloudKit { 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) @@ -613,7 +613,7 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request(urlRequest) .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -621,22 +621,22 @@ public extension NextcloudKit { 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, @@ -647,19 +647,19 @@ public extension NextcloudKit { 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", @@ -669,7 +669,7 @@ public extension NextcloudKit { ) // 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) @@ -677,7 +677,7 @@ public extension NextcloudKit { } catch { return options.queue.async { completion(.failure(NKError(error: error))) } } - + nkSession.sessionData.request(urlRequest) .validate(statusCode: 200..<300) .onURLSessionTaskCreation { task in @@ -688,7 +688,7 @@ public extension NextcloudKit { 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") @@ -700,16 +700,15 @@ public extension NextcloudKit { 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/AlbumsViewController.swift b/iOSClient/Albums/AlbumsViewController.swift index dabbe74eb1..3ce53bad35 100644 --- a/iOSClient/Albums/AlbumsViewController.swift +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -11,45 +11,45 @@ import SwiftUI class AlbumsViewController: UIViewController { var initialAlbum: Album? - + @Environment(\.localAccount) var localAccount: String - + @MainActor var session: NCSession.Session { NCSession.shared.getSession(controller: tabBarController) } - + override func viewDidLoad() { super.viewDidLoad() - + let albumsRootView = AlbumsRootView(initialAlbum: initialAlbum) .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 @@ -58,16 +58,16 @@ class AlbumsViewController: UIViewController { // 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) } diff --git a/iOSClient/Albums/Data/AlbumsManager.swift b/iOSClient/Albums/Data/AlbumsManager.swift index d73b83143a..e41b6f639d 100644 --- a/iOSClient/Albums/Data/AlbumsManager.swift +++ b/iOSClient/Albums/Data/AlbumsManager.swift @@ -11,38 +11,38 @@ 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/Domain/Mappers/AlbumMapper.swift b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift index f93e65c958..44ea165a24 100644 --- a/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift +++ b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift @@ -7,7 +7,7 @@ // extension AlbumDTO { - + func toAlbum() -> Album { return Album( href: self.href, @@ -21,6 +21,6 @@ extension AlbumDTO { } 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 index 9aa0dbb699..fafcfdcdae 100644 --- a/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift +++ b/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift @@ -7,7 +7,7 @@ // extension AlbumPhotoDTO { - + func toAlbumPhoto() -> AlbumPhoto { return AlbumPhoto( id: self.fileId, @@ -27,6 +27,6 @@ extension AlbumPhotoDTO { } 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 index 9abeaed2d4..afc2fcd860 100644 --- a/iOSClient/Albums/Domain/Models/Album.swift +++ b/iOSClient/Albums/Domain/Models/Album.swift @@ -7,7 +7,7 @@ // public struct Album: Identifiable, Hashable { - + public let id: String let href: String let name: String @@ -23,7 +23,7 @@ public struct Album: Identifiable, Hashable { let start: TimeInterval let end: TimeInterval } - + init( href: String, lastPhotoId: String?, @@ -34,19 +34,19 @@ public struct Album: Identifiable, Hashable { ) { 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) diff --git a/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift index efe01d7ea3..fc47466b17 100644 --- a/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift +++ b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift @@ -9,7 +9,7 @@ import SwiftUI extension View { - + func inputAlbumNameAlert( isPresented: Binding, albumName: Binding, @@ -32,22 +32,22 @@ extension View { } 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, @@ -62,7 +62,7 @@ private struct NCInputAlbumNameAlertModifier: ViewModifier { 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: "") @@ -77,16 +77,16 @@ private struct NCInputAlbumNameAlertModifier: ViewModifier { 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() } diff --git a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift index f15f4e9d7b..1be5015ed7 100644 --- a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift +++ b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift @@ -10,13 +10,13 @@ import SwiftUI import UIKit struct NCLoadingAlert: View { - + var body: some View { - + ZStack { Color.black.opacity(0.4) .ignoresSafeArea() - + ProgressView(NSLocalizedString("_albums_loading_popup_desc_", comment: "")) .padding() .background(.ultraThinMaterial) @@ -25,11 +25,11 @@ struct NCLoadingAlert: View { } } -//#if DEBUG -//#Preview { +// #if DEBUG +// #Preview { // NCLoadingAlert() -//} -//#endif +// } +// #endif // UIKit presentation stays scoped to Albums; each operation owns its loader. extension NCLoadingAlert { diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index a604f2881a..3aa253a5db 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -9,23 +9,23 @@ 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() } @@ -112,7 +112,7 @@ struct AlbumDetailsScreen: View { } } } - + @ViewBuilder private func content() -> some View { if viewModel.isLoading { @@ -141,10 +141,9 @@ struct AlbumDetailsScreen: View { } } } - + private func handleAddPhotosIntent() { viewModel.onAddPhotosIntent() showMedia = true } } - diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift index 907b322fb4..bada67ae6a 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -17,47 +17,47 @@ protocol AlbumActionHandler: AnyObject { } 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 photos: [AlbumPhoto: tableMetadata?] = [:] @Published private(set) var isLoading: Bool = false - @Published private(set) var errorMessage: String? = nil - + @Published private(set) var errorMessage: String? + @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 private(set) var newAlbumNameError: String? + @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 @@ -68,11 +68,11 @@ class AlbumDetailsViewModel: ObservableObject { } .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 { @@ -82,58 +82,58 @@ class AlbumDetailsViewModel: ObservableObject { } 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 @@ -141,8 +141,8 @@ class AlbumDetailsViewModel: ObservableObject { return (photo.toAlbumPhoto(), meta) }) doOnSuccess?() - - case .failure(let error): + + case .failure: // Task { @MainActor in // await showErrorBanner(windowScene: self?.windowScene, error: NKError(error: error)) // } @@ -150,25 +150,25 @@ class AlbumDetailsViewModel: ObservableObject { } } } - + 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(): + case .success: AlbumsManager.shared.syncAlbums() AlbumsNavigator.shared.pop() - + case .failure(let error): Task { @MainActor in await showErrorBanner(windowScene: self?.windowScene, error: NKError(error: error)) @@ -176,27 +176,27 @@ class AlbumDetailsViewModel: ObservableObject { } } } - + @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() @@ -207,11 +207,11 @@ class AlbumDetailsViewModel: ObservableObject { } 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, @@ -220,7 +220,7 @@ class AlbumDetailsViewModel: ObservableObject { 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 @@ -236,19 +236,19 @@ class AlbumDetailsViewModel: ObservableObject { 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(): + case .success: self?.reloadAlbumAfterRenaming(albumName: self?.newAlbumName ?? "") - + case .failure(let error): self?.isLoadingPopupVisible = false let nkError = NKError(error: error) @@ -274,13 +274,13 @@ class AlbumDetailsViewModel: ObservableObject { } } } - + 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 { @@ -290,36 +290,36 @@ class AlbumDetailsViewModel: ObservableObject { } } } - + 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 { @@ -328,21 +328,21 @@ class AlbumDetailsViewModel: ObservableObject { } 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 @@ -364,8 +364,7 @@ extension AlbumDetailsViewModel: AlbumActionHandler { func deleteMetadataFromAlbum(_ selectedMetadatas: [tableMetadata]) { Task { await self.deletePhotos(with: selectedMetadatas) - + } } } - diff --git a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift index b40cf4de38..b4fc92fe93 100644 --- a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift +++ b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift @@ -9,39 +9,39 @@ 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) @@ -52,8 +52,8 @@ struct NoPhotosEmptyView: View { } } -//#if DEBUG -//#Preview { +// #if DEBUG +// #Preview { // NavigationView { // NoPhotosEmptyView( // onAddPhotosIntent: {} @@ -61,5 +61,5 @@ struct NoPhotosEmptyView: View { // .navigationTitle("Album") // .navigationBarTitleDisplayMode(.inline) // } -//} -//#endif +// } +// #endif diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift index 6cf024d1dd..a8523d55e9 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -11,16 +11,16 @@ import NextcloudKit struct PhotoGridItemView: View { @Environment(\.localAccount) var localAccount: String - + let album: Album - let photo: AlbumPhoto + 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 { @@ -58,7 +58,7 @@ struct PhotoGridItemView: View { 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 { @@ -104,10 +104,10 @@ struct PhotoGridItemView: View { 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( + NCUtility().createImageFileFrom( data: data, ocId: fileId, etag: etag, userId: userId, urlBase: urlBase ) } @@ -116,4 +116,3 @@ struct PhotoGridItemView: View { } } } - diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index e4a890c698..1dfb7919a0 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -9,8 +9,8 @@ import SwiftUI struct PhotosGridView: View { - let localAccount: String // Add this - let photos: [AlbumPhoto : tableMetadata?] + let localAccount: String + let photos: [AlbumPhoto: tableMetadata?] let onAddPhotosIntent: () -> Void let album: Album @@ -21,15 +21,15 @@ struct PhotosGridView: View { 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 @@ -49,7 +49,7 @@ struct PhotosGridView: View { } } } - + @MainActor private func openPhotoViewer(photo: AlbumPhoto) { let orderedPhotos = photos.keys.sorted { diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 964da2b361..219a9c45ad 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -10,14 +10,13 @@ import SwiftUI import NextcloudKit struct AddToAlbumsListView: View { - @StateObject private var viewModel: AlbumsListViewModel - @State private var selectedAlbum: Album? = nil + @State private var selectedAlbum: Album? 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 @@ -25,7 +24,7 @@ struct AddToAlbumsListView: View { self.onDismiss = onDismiss self.onCreateAlbum = onCreateAlbum } - + var body: some View { NavigationStack { ZStack { @@ -53,7 +52,7 @@ struct AddToAlbumsListView: View { .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)) @@ -90,7 +89,7 @@ struct AddToAlbumsListView: View { } .foregroundColor(.pink) } - + ToolbarItem(placement: .navigationBarTrailing) { Button(NSLocalizedString("_albums_photo_selection_sheet_done_btn_", comment: "")) { if let selected = selectedAlbum { @@ -110,7 +109,7 @@ struct AddToAlbumsListView: View { } } } - + @ViewBuilder private func content() -> some View { if viewModel.isLoading { @@ -140,19 +139,19 @@ struct AlbumRow: View { 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)) @@ -166,7 +165,7 @@ struct AlbumRow: View { 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: ""))"] @@ -180,7 +179,7 @@ struct AlbumRow: View { } return parts.joined(separator: " - ") } - + /// Renders the thumbnail image based on the current state @ViewBuilder private func thumbnailView() -> some View { @@ -203,7 +202,7 @@ struct AlbumRow: View { .clipped() } } - + private func loadThumbnail() async { if album.lastPhotoId == "-1" || (album.itemCount ?? 0) == 0 { imageState = .empty @@ -213,7 +212,7 @@ struct AlbumRow: View { imageState = .empty return } - + Task { let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 6d661d16a8..578dbf6220 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -10,15 +10,15 @@ 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 @@ -29,7 +29,7 @@ struct AlbumGridItemView: View { } private enum ImageState { case loading, empty, thumbnail(UIImage) } @State private var imageState: ImageState = .loading - + var body: some View { GeometryReader { geo in ZStack { @@ -70,7 +70,7 @@ struct AlbumGridItemView: View { await loadThumbnail() } } - + private func loadThumbnail() async { if album.lastPhotoId == "-1" || (album.itemCount ?? 0) == 0 { imageState = .empty @@ -80,7 +80,7 @@ struct AlbumGridItemView: View { imageState = .empty return } - + Task { let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in @@ -110,7 +110,7 @@ struct AlbumGridItemView: View { } } } - + private var frame: some View { RoundedRectangle( cornerRadius: 8 @@ -121,4 +121,3 @@ struct AlbumGridItemView: View { ) } } - diff --git a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift index 59f686d1c2..c5c01afc2a 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift @@ -11,13 +11,13 @@ 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) @@ -41,21 +41,21 @@ struct AlbumsGridView: View { } 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) @@ -63,7 +63,7 @@ struct AlbumsGridView: View { .font(.system(size: 15, weight: .medium)) .foregroundColor(.primary) .lineLimit(1) - + if let subtitle = makeSubtitle(for: album), !subtitle.isEmpty { Text(subtitle) .font(.system(size: 13)) @@ -78,7 +78,7 @@ struct AlbumsGridView: View { .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: ""))"] @@ -94,8 +94,8 @@ struct AlbumsGridView: View { } } -//#if DEBUG -//#Preview { +// #if DEBUG +// #Preview { // AlbumsGridView( // albums: [ // Album( @@ -125,6 +125,5 @@ struct AlbumsGridView: View { // ], // onAlbumClicked: { _ in} // ) -//} -//#endif - +// } +// #endif diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift index 86a1a2b3a3..2321f26206 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -13,26 +13,26 @@ extension Notification.Name { } 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() } @@ -80,7 +80,7 @@ struct AlbumsListScreen: View { popToRootTrigger += 1 } } - + @ViewBuilder private func content() -> some View { if viewModel.isLoading { @@ -115,21 +115,21 @@ struct AlbumsListScreen: View { } } } - + 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() } @@ -137,7 +137,7 @@ struct AlbumsListScreen: View { EmptyView() } } - + @ViewBuilder private func navigationDestination(_ destination: NavigationDestination) -> some View { switch destination { @@ -147,8 +147,8 @@ struct AlbumsListScreen: View { } } -//#if DEBUG -//#Preview { +// #if DEBUG +// #Preview { // NavigationView { // AlbumsListScreen(viewModel: .init(account: "123")) // }.onAppear { @@ -157,5 +157,5 @@ struct AlbumsListScreen: View { // whenContainedInInstancesOf: [UIAlertController.self] // ).tintColor = NCBrandColor.shared.customer // } -//} -//#endif +// } +// #endif diff --git a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift index 2f2237b54e..8ecdad011b 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift @@ -11,41 +11,41 @@ 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 - + @Published private(set) var errorMessage: String? + 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 private(set) var newAlbumNameError: String? + @Published var isPhotoSelectionSheetVisible: Bool = false - @Published var newlyCreatedAlbum: Album? = nil - - @Published var navigationDestination: AlbumsListScreen.NavigationDestination? = nil - + @Published var newlyCreatedAlbum: Album? + + @Published var navigationDestination: AlbumsListScreen.NavigationDestination? + @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 @@ -67,7 +67,7 @@ class AlbumsListViewModel: ObservableObject { } .store(in: &cancellables) } - + // MARK: - Album name validation private func registerPublishers() { $newAlbumName @@ -78,11 +78,11 @@ class AlbumsListViewModel: ObservableObject { } .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 { @@ -92,10 +92,10 @@ class AlbumsListViewModel: ObservableObject { } 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 } @@ -105,21 +105,21 @@ class AlbumsListViewModel: ObservableObject { 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 } @@ -143,32 +143,31 @@ class AlbumsListViewModel: ObservableObject { // 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(_): - + 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) @@ -194,12 +193,12 @@ class AlbumsListViewModel: ObservableObject { } } } - + func onPhotosSelected(selectedPhotos: [String]) { isPhotoSelectionSheetVisible = false - + guard let album = newlyCreatedAlbum else { return } - + if selectedPhotos.isEmpty { guard !isNavigatingToDetails else { return } isNavigatingToDetails = true @@ -209,15 +208,15 @@ class AlbumsListViewModel: ObservableObject { } 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, @@ -229,7 +228,7 @@ class AlbumsListViewModel: ObservableObject { 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 { @@ -251,7 +250,7 @@ class AlbumsListViewModel: ObservableObject { group.leave() } } - + group.notify(queue: .main) { [weak self] in guard let self = self else { return } if hadAnySuccess { @@ -276,4 +275,3 @@ class AlbumsListViewModel: ObservableObject { } } } - diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift index 8bcd80fd7d..a0f5205750 100644 --- a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -9,32 +9,32 @@ 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)) diff --git a/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift index 943ea26809..55859d79e7 100644 --- a/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift +++ b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift @@ -9,17 +9,15 @@ import SwiftUI final class AlbumsNavigator: ObservableObject { - static let shared = AlbumsNavigator() - - @Published var current: AlbumsRoutes? = nil - + @Published var current: AlbumsRoutes? + private init() {} - + func push(_ route: AlbumsRoutes) { current = route } - + func pop() { current = nil } diff --git a/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift index 3ff615e9f6..83fd3b9c65 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift @@ -9,9 +9,9 @@ import SwiftUI struct PhotoSelectionSheet: View { - + let onPhotosSelected: ([String]) -> Void - + @State private var mediaVC: NCMedia? var body: some View { diff --git a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift index 9de26131a5..954a2286fe 100644 --- a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift +++ b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift @@ -9,13 +9,11 @@ import SwiftUI struct AlbumsRootView: View { - var initialAlbum: Album? = nil + var initialAlbum: Album? @State private var didApplyInitialRoute = false - @Environment(\.localAccount) var localAccount: String - @StateObject private var navigator = AlbumsNavigator.shared - + var body: some View { NavigationStack(path: Binding( diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index 7484890ce0..f8fd8023f0 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -301,12 +301,12 @@ class NCMediaNavigationController: NCMainNavigationController { // Use the provided account to avoid mismatches between UI and networking // (Do not rely on AppDelegate.account here) - + let loader = NCLoadingAlert.show(on: controller) NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { result in NCLoadingAlert.hide(loader) switch result { - case .success(_): + case .success: AlbumsManager.shared.syncAlbums { resultAlbums in if let newAlbum = resultAlbums.first(where: { $0.name == name }) { if selectedPhotos.isEmpty { @@ -343,8 +343,8 @@ class NCMediaNavigationController: NCMainNavigationController { } } } - - static func presentExistingAlbums(presentingController: NCMainTabBarController,selectedPhotos: [String], account: String) { + + static func presentExistingAlbums(presentingController: NCMainTabBarController, selectedPhotos: [String], account: String) { let viewModel = AlbumsListViewModel(account: account) let albumListView = AddToAlbumsListView(viewModel: viewModel, localAccount: account, onFinish: { selectedAlbum in presentingController.dismiss(animated: true) { @@ -403,18 +403,18 @@ class NCMediaNavigationController: NCMainNavigationController { showAlbumAndNotify(album, controller: controller, account: account) } } - + 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() From a178bb0d912d3a7257d3f1d5fac53cc1a29543af Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 10 Sep 2026 08:48:20 +0200 Subject: [PATCH 10/13] license - lint Signed-off-by: Marino Faggiana --- iOSClient/Albums/API/Albums+WebDAV.swift | 11 +- iOSClient/Albums/API/Models/AlbumDTO.swift | 10 +- .../Albums/API/Models/AlbumPhotoDTO.swift | 10 +- iOSClient/Albums/AlbumsViewController.swift | 11 +- iOSClient/Albums/Data/AlbumsManager.swift | 13 +- iOSClient/Albums/Data/LoadableState.swift | 11 +- .../Albums/Domain/Mappers/AlbumMapper.swift | 13 +- .../Domain/Mappers/AlbumPhotoMapper.swift | 13 +- iOSClient/Albums/Domain/Models/Album.swift | 12 +- .../Albums/Domain/Models/AlbumPhoto.swift | 11 +- .../NCInputAlbumNameAlertModifier.swift | 13 +- .../Presentation/Alerts/NCLoadingAlert.swift | 19 +- .../Details/AlbumDetailsScreen.swift | 12 +- .../Details/AlbumDetailsViewModel.swift | 11 +- .../Details/MediaSelectionSheet.swift | 5 + .../Details/NoPhotosEmptyView.swift | 50 ++- .../Details/PhotoGridItemView.swift | 11 +- .../Presentation/Details/PhotosGridView.swift | 11 +- .../List/AddToAlbumsListView.swift | 20 +- .../Presentation/List/AlbumGridItemView.swift | 20 +- .../Presentation/List/AlbumsGridView.swift | 11 +- .../Presentation/List/AlbumsListScreen.swift | 11 +- .../List/AlbumsListViewModel.swift | 11 +- .../Presentation/List/NoAlbumsEmptyView.swift | 21 +- .../Navigation/AlbumsNavigator.swift | 11 +- .../Navigation/AlbumsRoutes.swift | 11 +- .../NCMediaViewRepresentable.swift | 11 +- .../PhotoSelection/PhotoSelectionSheet.swift | 13 +- .../Presentation/Root/AlbumsRootView.swift | 12 +- .../noAlbum.imageset/Contents.json | 12 - .../noAlbum.imageset/No albums view.svg | 328 ------------------ 31 files changed, 145 insertions(+), 594 deletions(-) delete mode 100644 iOSClient/Images.xcassets/noAlbum.imageset/Contents.json delete mode 100644 iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg diff --git a/iOSClient/Albums/API/Albums+WebDAV.swift b/iOSClient/Albums/API/Albums+WebDAV.swift index 4d8b63f365..4ad6da448e 100644 --- a/iOSClient/Albums/API/Albums+WebDAV.swift +++ b/iOSClient/Albums/API/Albums+WebDAV.swift @@ -1,10 +1,7 @@ -// -// Albums+WebDAV.swift -// Nextcloud -// -// Created by A200118228 on 07/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import NextcloudKit import Alamofire diff --git a/iOSClient/Albums/API/Models/AlbumDTO.swift b/iOSClient/Albums/API/Models/AlbumDTO.swift index e1ff215c3c..c9010d830e 100644 --- a/iOSClient/Albums/API/Models/AlbumDTO.swift +++ b/iOSClient/Albums/API/Models/AlbumDTO.swift @@ -1,10 +1,6 @@ -// -// AlbumDTO.swift -// Nextcloud -// -// Created by Dhanesh on 28/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-License-Identifier: GPL-3.0-or-later public struct AlbumDTO { let href: String diff --git a/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift b/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift index aaefc427e6..c07355f60f 100644 --- a/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift +++ b/iOSClient/Albums/API/Models/AlbumPhotoDTO.swift @@ -1,10 +1,6 @@ -// -// AlbumPhotoDTO.swift -// Nextcloud -// -// Created by Dhanesh on 01/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-License-Identifier: GPL-3.0-or-later public struct AlbumPhotoDTO { let fileId: String diff --git a/iOSClient/Albums/AlbumsViewController.swift b/iOSClient/Albums/AlbumsViewController.swift index 3ce53bad35..45ddc8ad49 100644 --- a/iOSClient/Albums/AlbumsViewController.swift +++ b/iOSClient/Albums/AlbumsViewController.swift @@ -1,10 +1,7 @@ -// -// AlbumsViewController.swift -// Nextcloud -// -// Created by Dhanesh on 07/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import UIKit import SwiftUI diff --git a/iOSClient/Albums/Data/AlbumsManager.swift b/iOSClient/Albums/Data/AlbumsManager.swift index e41b6f639d..d082705523 100644 --- a/iOSClient/Albums/Data/AlbumsManager.swift +++ b/iOSClient/Albums/Data/AlbumsManager.swift @@ -1,19 +1,14 @@ -// -// AlbumsManager.swift -// Nextcloud -// -// Created by Dhanesh on 09/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Foundation import Combine import NextcloudKit final class AlbumsManager { - static let shared = AlbumsManager() - private var account: String = "" // Albums publisher - Central diff --git a/iOSClient/Albums/Data/LoadableState.swift b/iOSClient/Albums/Data/LoadableState.swift index be9bbf56c0..6693d5eaa8 100644 --- a/iOSClient/Albums/Data/LoadableState.swift +++ b/iOSClient/Albums/Data/LoadableState.swift @@ -1,10 +1,7 @@ -// -// LoadableState.swift -// Nextcloud -// -// Created by Dhanesh on 09/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later enum LoadableState { case idle diff --git a/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift index 44ea165a24..6e2a824069 100644 --- a/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift +++ b/iOSClient/Albums/Domain/Mappers/AlbumMapper.swift @@ -1,13 +1,9 @@ -// -// AlbumMapper.swift -// Nextcloud -// -// Created by Dhanesh on 28/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later extension AlbumDTO { - func toAlbum() -> Album { return Album( href: self.href, @@ -21,6 +17,5 @@ extension AlbumDTO { } 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 index fafcfdcdae..03c44b28ef 100644 --- a/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift +++ b/iOSClient/Albums/Domain/Mappers/AlbumPhotoMapper.swift @@ -1,13 +1,9 @@ -// -// AlbumPhotoMapper.swift -// Nextcloud -// -// Created by Dhanesh on 28/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later extension AlbumPhotoDTO { - func toAlbumPhoto() -> AlbumPhoto { return AlbumPhoto( id: self.fileId, @@ -27,6 +23,5 @@ extension AlbumPhotoDTO { } 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 index afc2fcd860..3c8ca37eb8 100644 --- a/iOSClient/Albums/Domain/Models/Album.swift +++ b/iOSClient/Albums/Domain/Models/Album.swift @@ -1,13 +1,9 @@ -// -// Album.swift -// Nextcloud -// -// Created by Dhanesh on 26/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later public struct Album: Identifiable, Hashable { - public let id: String let href: String let name: String diff --git a/iOSClient/Albums/Domain/Models/AlbumPhoto.swift b/iOSClient/Albums/Domain/Models/AlbumPhoto.swift index f1af83a7d4..ca6f6321a5 100644 --- a/iOSClient/Albums/Domain/Models/AlbumPhoto.swift +++ b/iOSClient/Albums/Domain/Models/AlbumPhoto.swift @@ -1,10 +1,7 @@ -// -// AlbumPhoto.swift -// Nextcloud -// -// Created by Dhanesh on 28/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later struct AlbumPhoto: Identifiable, Hashable { public let id: String diff --git a/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift index fc47466b17..fad9cb7ec7 100644 --- a/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift +++ b/iOSClient/Albums/Presentation/Alerts/NCInputAlbumNameAlertModifier.swift @@ -1,15 +1,11 @@ -// -// NCAlbumCreationAlert.swift -// Nextcloud -// -// Created by Dhanesh on 05/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI extension View { - func inputAlbumNameAlert( isPresented: Binding, albumName: Binding, @@ -32,7 +28,6 @@ extension View { } private struct NCInputAlbumNameAlertModifier: ViewModifier { - @Binding var isPresented: Bool @Binding var albumName: String diff --git a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift index 1be5015ed7..aa6c063dc6 100644 --- a/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift +++ b/iOSClient/Albums/Presentation/Alerts/NCLoadingAlert.swift @@ -1,18 +1,13 @@ -// -// NCLoadingAlert.swift -// Nextcloud -// -// Created by Dhanesh on 05/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import UIKit struct NCLoadingAlert: View { - var body: some View { - ZStack { Color.black.opacity(0.4) .ignoresSafeArea() @@ -25,12 +20,6 @@ struct NCLoadingAlert: View { } } -// #if DEBUG -// #Preview { -// NCLoadingAlert() -// } -// #endif - // UIKit presentation stays scoped to Albums; each operation owns its loader. extension NCLoadingAlert { @MainActor diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index 3aa253a5db..79794e79af 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -1,10 +1,7 @@ -// -// AlbumDetailsScreen.swift -// Nextcloud -// -// Created by Dhanesh on 01/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -22,7 +19,6 @@ struct AlbumDetailsScreen: View { } var body: some View { - ZStack { content() diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift index bada67ae6a..430f82de3f 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsViewModel.swift @@ -1,10 +1,7 @@ -// -// AlbumDetailsViewModel.swift -// Nextcloud -// -// Created by Dhanesh on 01/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Foundation import Combine diff --git a/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift b/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift index c5583d619a..6b838d5fd9 100644 --- a/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift +++ b/iOSClient/Albums/Presentation/Details/MediaSelectionSheet.swift @@ -1,3 +1,8 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + import SwiftUI /// A reusable SwiftUI sheet that presents the NCMedia selection UI diff --git a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift index b4fc92fe93..cf906d84e5 100644 --- a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift +++ b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift @@ -1,34 +1,27 @@ -// -// NoPhotosEmptyView.swift -// Nextcloud -// -// Created by Dhanesh on 29/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later 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) + Image(systemName: "photo.stack") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: 180) + .padding(.bottom, 20) // Foreground content VStack(alignment: .leading, spacing: 16) { - Text(NSLocalizedString("_albums_photos_empty_heading_", comment: "")) .font(.system(size: 48, weight: .bold)) @@ -41,7 +34,6 @@ struct NoPhotosEmptyView: View { .font(.system(size: 15, weight: .medium)) .foregroundColor(Color(NCBrandColor.shared.customer)) } - Spacer(minLength: 40) } .padding(.horizontal, contentPadding) @@ -52,14 +44,14 @@ struct NoPhotosEmptyView: View { } } -// #if DEBUG -// #Preview { -// NavigationView { -// NoPhotosEmptyView( -// onAddPhotosIntent: {} -// ) -// .navigationTitle("Album") -// .navigationBarTitleDisplayMode(.inline) -// } -// } -// #endif +#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 index a8523d55e9..11b090c24b 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -1,10 +1,7 @@ -// -// PhotoGridItemView.swift -// Nextcloud -// -// Created by Dhanesh on 04/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index 1dfb7919a0..49ac41b2cd 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -1,10 +1,7 @@ -// -// PhotosGridView.swift -// Nextcloud -// -// Created by Dhanesh on 01/08/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 219a9c45ad..53027221c4 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -1,10 +1,7 @@ -// -// AddToAlbumsListView.swift -// Nextcloud -// -// Created by Mangesh Murhe on 25/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit @@ -189,12 +186,11 @@ struct AlbumRow: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.gray.opacity(0.1)) case .empty: - Image("EmptyAlbum") + Image(systemName: "photo.stack") .resizable() - .scaledToFill() - .clipped() - .foregroundColor(.gray) - .background(Color.gray.opacity(0.1)) + .scaledToFit() + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: 180) case .thumbnail(let uiImage): Image(uiImage: uiImage) .resizable() diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 578dbf6220..f323e41a76 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -1,10 +1,7 @@ -// -// AlbumGridItemView.swift -// Nextcloud -// -// Created by Dhanesh on 05/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import NextcloudKit @@ -42,15 +39,16 @@ struct AlbumGridItemView: View { .progressViewStyle(CircularProgressViewStyle(tint: .gray)) ) case .empty: - Image("EmptyAlbum") + Image(systemName: "photo.stack") .resizable() - .aspectRatio(contentMode: .fill) + .scaledToFit() + .foregroundStyle(.secondary) .frame( width: geo.size.width, height: dynamicHeight, - alignment: .top + alignment: .center ) - .clipped() + .padding() case .thumbnail(let img): Image(uiImage: img) .resizable() diff --git a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift index c5c01afc2a..d1ce559eef 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift @@ -1,10 +1,7 @@ -// -// AlbumsGridView.swift -// Nextcloud -// -// Created by Dhanesh on 28/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import Foundation diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift index 2321f26206..e2e594fd79 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -1,10 +1,7 @@ -// -// AlbumsListScreen.swift -// Nextcloud -// -// Created by A200118228 on 07/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI diff --git a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift index 8ecdad011b..de45d27e76 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift @@ -1,10 +1,7 @@ -// -// AlbumsViewModel.swift -// Nextcloud -// -// Created by A200118228 on 08/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Foundation import Combine diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift index a0f5205750..95cc528a2c 100644 --- a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift +++ b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift @@ -1,10 +1,7 @@ -// -// NoAlbumsEmptyView.swift -// Nextcloud -// -// Created by Dhanesh on 24/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -19,12 +16,10 @@ struct NoAlbumsEmptyView: View { ScrollView(.vertical) { VStack { - - // Background image - Image("noAlbum") - .resizable() - .scaledToFill() - .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 96, weight: .light)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 220) VStack(alignment: .leading, spacing: 16) { diff --git a/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift index 55859d79e7..36b2dbcd1e 100644 --- a/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift +++ b/iOSClient/Albums/Presentation/Navigation/AlbumsNavigator.swift @@ -1,10 +1,7 @@ -// -// AlbumsNavigator.swift -// Nextcloud -// -// Created by Dhanesh on 11/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI diff --git a/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift b/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift index 28e8c1112a..56f7485126 100644 --- a/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift +++ b/iOSClient/Albums/Presentation/Navigation/AlbumsRoutes.swift @@ -1,10 +1,7 @@ -// -// AlbumsRoutes.swift -// Nextcloud -// -// Created by Dhanesh on 11/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later enum AlbumsRoutes: Hashable { case albumDetails(album: Album) diff --git a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift index 05922485cb..09fa958e75 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -1,10 +1,7 @@ -// -// NCMediaViewRepresentable.swift -// Nextcloud -// -// Created by Dhanesh on 05/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI import UIKit diff --git a/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift index 83fd3b9c65..03bfbc129f 100644 --- a/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift +++ b/iOSClient/Albums/Presentation/PhotoSelection/PhotoSelectionSheet.swift @@ -1,17 +1,12 @@ -// -// PhotoSelectionSheet.swift -// Nextcloud -// -// Created by Dhanesh on 04/09/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI struct PhotoSelectionSheet: View { - let onPhotosSelected: ([String]) -> Void - @State private var mediaVC: NCMedia? var body: some View { diff --git a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift index 954a2286fe..ae916aba33 100644 --- a/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift +++ b/iOSClient/Albums/Presentation/Root/AlbumsRootView.swift @@ -1,10 +1,7 @@ -// -// AlbumsRootView.swift -// Nextcloud -// -// Created by Dhanesh on 24/07/25. -// Copyright © 2025 Marino Faggiana. All rights reserved. -// +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Dhanesh +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI @@ -15,7 +12,6 @@ struct AlbumsRootView: View { @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 diff --git a/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json deleted file mode 100644 index 7bdd75cdd9..0000000000 --- a/iOSClient/Images.xcassets/noAlbum.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 1f9decaf66..0000000000 --- a/iOSClient/Images.xcassets/noAlbum.imageset/No albums view.svg +++ /dev/null @@ -1,328 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 59e2b19197ffdee091d734fe2bc744e55084c0ad Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 10 Sep 2026 08:57:34 +0200 Subject: [PATCH 11/13] V 35.0.0 - Build 4 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 8 +- .../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 ---------------- 8 files changed, 4 insertions(+), 1123 deletions(-) delete mode 100644 iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json delete mode 100644 iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg delete mode 100644 iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json delete mode 100644 iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg delete mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json delete mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg delete mode 100644 iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 387cc67d47..1d83431944 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6615,7 +6615,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6642,7 +6642,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 34.2.0; + MARKETING_VERSION = 35.0.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; @@ -6683,7 +6683,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 0; + CURRENT_PROJECT_VERSION = 4; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6708,7 +6708,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 34.2.0; + MARKETING_VERSION = 35.0.0; ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "-v"; OTHER_LDFLAGS = ""; diff --git a/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json b/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json deleted file mode 100644 index 467f0e7dd9..0000000000 --- a/iOSClient/Images.xcassets/Empty album detailed view.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 29b6beea2d..0000000000 --- a/iOSClient/Images.xcassets/Empty album detailed view.imageset/Empty album detailed view.svg +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json b/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json deleted file mode 100644 index 4745035c8d..0000000000 --- a/iOSClient/Images.xcassets/Empty album viw.imageset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "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 deleted file mode 100644 index 89d90f8f34..0000000000 --- a/iOSClient/Images.xcassets/Empty album viw.imageset/Empty album viw.svg +++ /dev/null @@ -1,264 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json b/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json deleted file mode 100644 index 78ded2ecd9..0000000000 --- a/iOSClient/Images.xcassets/EmptyAlbum.imageset/Contents.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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 deleted file mode 100644 index b9bd196eab..0000000000 --- a/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files-dark.svg +++ /dev/null @@ -1,260 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg b/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg deleted file mode 100644 index af914b14fd..0000000000 --- a/iOSClient/Images.xcassets/EmptyAlbum.imageset/empty-files.svg +++ /dev/null @@ -1,265 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From e1e1e337052531e0dd856de44b7b6ee68b0bf295 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 10 Sep 2026 11:29:59 +0200 Subject: [PATCH 12/13] fix: swap Activity and Album navigation Signed-off-by: Marino Faggiana --- iOSClient/Main/Main.storyboard | 8 ++++---- iOSClient/Main/NCMainTabBarController.swift | 4 ++-- iOSClient/More/NCMoreModel.swift | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/iOSClient/Main/Main.storyboard b/iOSClient/Main/Main.storyboard index 9b624f89ad..fbc3b9c855 100644 --- a/iOSClient/Main/Main.storyboard +++ b/iOSClient/Main/Main.storyboard @@ -117,11 +117,11 @@ - + - + @@ -134,10 +134,10 @@ - + - + diff --git a/iOSClient/Main/NCMainTabBarController.swift b/iOSClient/Main/NCMainTabBarController.swift index f97a65e81d..3f219d4b45 100644 --- a/iOSClient/Main/NCMainTabBarController.swift +++ b/iOSClient/Main/NCMainTabBarController.swift @@ -151,8 +151,8 @@ class NCMainTabBarController: UITabBarController { configureTabBarItem( at: 3, - title: "_activity_", - imageName: "bolt.fill", + title: "_albums_", + imageName: "photo.stack", tag: 103 ) } diff --git a/iOSClient/More/NCMoreModel.swift b/iOSClient/More/NCMoreModel.swift index f91ca91c96..cddb0415d6 100644 --- a/iOSClient/More/NCMoreModel.swift +++ b/iOSClient/More/NCMoreModel.swift @@ -262,10 +262,10 @@ final class NCMoreModel: ObservableObject { functionItems.append( Item( - titleKey: "_albums_", - image: "photo.stack", + titleKey: "_activity_", + image: "bolt.fill", destination: .storyboard( - name: "NCAlbums", + name: "NCActivity", presentation: .push ) ) From 2e700aec563f29c5290fc2a7ff53f98a9b894248 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 10 Sep 2026 12:23:37 +0200 Subject: [PATCH 13/13] wip Signed-off-by: Marino Faggiana --- .../Details/NoPhotosEmptyView.swift | 72 +++++++++++-------- iOSClient/More/NCMoreModel.swift | 22 +++--- .../en.lproj/Localizable.strings | 4 +- 3 files changed, 57 insertions(+), 41 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift index cf906d84e5..e14cc491e2 100644 --- a/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift +++ b/iOSClient/Albums/Presentation/Details/NoPhotosEmptyView.swift @@ -7,38 +7,54 @@ import SwiftUI struct NoPhotosEmptyView: View { let onAddPhotosIntent: () -> Void - private let contentPadding: CGFloat = 32.0 var body: some View { - ScrollView(.vertical) { - VStack { - // Background image - Image(systemName: "photo.stack") - .resizable() - .scaledToFit() - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: 180) - .padding(.bottom, 20) - - // 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)) + GeometryReader { geometry in + ScrollView(.vertical) { + VStack(spacing: 0) { + Spacer(minLength: 0) + + Image(systemName: "photo.stack") + .resizable() + .scaledToFit() + // NCBrandColor.shared.getElement(account: session.account)] + .foregroundStyle(Color(NCBrandColor.shared.customer)) + .frame(maxWidth: .infinity, maxHeight: 75) + .padding(.bottom, 30) + + VStack(alignment: .center, spacing: 16) { + Text(NSLocalizedString("_albums_photos_empty_heading_", comment: "")) + .cappedFont(.headline, maxDynamicType: .accessibility2) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + Text(NSLocalizedString("_albums_photos_empty_subheading_", comment: "")) + .cappedFont(.subheadline, maxDynamicType: .accessibility1) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + + Button(action: onAddPhotosIntent) { + Label( + NSLocalizedString( + "_albums_photos_empty_add_photos_btn_", + comment: "" + ), + systemImage: "plus" + ) + .cappedFont(.subheadline, maxDynamicType: .accessibility1) + .foregroundStyle(Color(NCBrandColor.shared.customer)) + .multilineTextAlignment(.center) + } } - Spacer(minLength: 40) + .padding(.horizontal, 32) + + Spacer(minLength: 0) } - .padding(.horizontal, contentPadding) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, -40) + .frame( + maxWidth: .infinity, + minHeight: geometry.size.height + ) } } } diff --git a/iOSClient/More/NCMoreModel.swift b/iOSClient/More/NCMoreModel.swift index cddb0415d6..807d2dbbac 100644 --- a/iOSClient/More/NCMoreModel.swift +++ b/iOSClient/More/NCMoreModel.swift @@ -181,6 +181,17 @@ final class NCMoreModel: ObservableObject { quotaExternalSiteTitle = "" quotaExternalSiteUrl = nil + functionItems.append( + Item( + titleKey: "_activity_", + image: "bolt.fill", + destination: .storyboard( + name: "NCActivity", + presentation: .push + ) + ) + ) + functionItems.append( Item( titleKey: "_recent_", @@ -260,17 +271,6 @@ final class NCMoreModel: ObservableObject { ) } - functionItems.append( - Item( - titleKey: "_activity_", - image: "bolt.fill", - destination: .storyboard( - name: "NCActivity", - presentation: .push - ) - ) - ) - functionItems.append( Item( titleKey: "_scanned_images_", diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 4df25b9d7b..d78edadc77 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -887,7 +887,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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_heading_" = "Create Albums for your Photos"; "_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"; @@ -907,7 +907,7 @@ You can stop it at any time, adjust the settings, and enable it again."; "_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_heading_" = "All that's missing are your 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";