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..e4a890c698 --- /dev/null +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -0,0 +1,90 @@ +// +// PhotosGridView.swift +// Nextcloud +// +// Created by Dhanesh on 01/08/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +struct PhotosGridView: View { + let localAccount: String // Add this + let photos: [AlbumPhoto : tableMetadata?] + let onAddPhotosIntent: () -> Void + let album: Album + + private var columns: [GridItem] { + if UIDevice.current.userInterfaceIdiom == .pad { + return Array(repeating: GridItem(.flexible(), spacing: 1), count: 3) + } else { + return [GridItem(.adaptive(minimum: 100, maximum: 300), spacing: 1)] + } + } + + private let calculatedIconSize: CGFloat = 30 + + var body: some View { + // Sort by filename or date to ensure stability + let sortedPhotos = photos.keys.sorted { lhs, rhs in + lhs.fileName.localizedCaseInsensitiveCompare(rhs.fileName) == .orderedAscending + } + + ScrollView { + LazyVGrid(columns: columns, spacing: 1) { + ForEach(sortedPhotos, id: \.self) { photo in + let metadata = photos[photo] ?? nil + Button { + openPhotoViewer(photo: photo) + } label: { + PhotoGridItemView( + album: album, + photo: photo, + isVideo: (metadata?.isVideo ?? false), + metadata: metadata, + iconSize: calculatedIconSize + ) + } + } + } + } + } + + @MainActor + private func openPhotoViewer(photo: AlbumPhoto) { + let orderedPhotos = photos.keys.sorted { + $0.fileName.localizedCaseInsensitiveCompare($1.fileName) == .orderedAscending + } + let resolvedPhotos = orderedPhotos.compactMap { albumPhoto -> (photo: AlbumPhoto, metadata: tableMetadata)? in + guard let metadata = (photos[albumPhoto] ?? nil) + ?? NCManageDatabase.shared.getMetadataFromFileId(albumPhoto.id), + metadata.account == localAccount else { + return nil + } + return (albumPhoto, metadata.detachedCopy()) + } + let controller = SceneManager.shared.getController(account: localAccount) + guard let selected = resolvedPhotos.first(where: { $0.photo.id == photo.id }) else { + Task { @MainActor in + await showErrorBanner( + windowScene: SceneManager.shared.getWindowScene(controller: controller), + text: "_albums_photos_error_msg_" + ) + } + return + } + + let model = NCMediaViewerModel( + currentMetadata: selected.metadata, + ocIds: resolvedPhotos.map { $0.metadata.ocId }, + session: NCSession.shared.getSession(account: localAccount), + loader: NCMediaViewerLoader() + ) + NCMediaViewerPresenter.shared.show( + model: model, + viewerTransitionSource: nil, + from: controller?.view, + contextMenuController: nil + ) + } +} diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift new file mode 100644 index 0000000000..4428239319 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -0,0 +1,266 @@ +// +// AddToAlbumsListView.swift +// Nextcloud +// +// Created by Mangesh Murhe on 25/09/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import NextcloudKit + +struct AddToAlbumsListView: View { + + @StateObject private var viewModel: AlbumsListViewModel + @State private var selectedAlbum: Album? = nil + var localAccount: String + var onFinish: (Album) -> Void + var onDismiss: () -> Void + var onCreateAlbum: () -> Void + + init(viewModel: AlbumsListViewModel, localAccount: String, onFinish: @escaping (Album) -> Void, onDismiss: @escaping () -> Void, onCreateAlbum: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: viewModel) + self.localAccount = localAccount + self.onFinish = onFinish + self.onDismiss = onDismiss + self.onCreateAlbum = onCreateAlbum + } + + var body: some View { + NavigationStack { + ZStack { + List { + Section { + Button(action: { + onCreateAlbum() + }) { + HStack(spacing: 8) { + Image(systemName: "plus.circle.fill") + .foregroundColor(Color(NCBrandColor.shared.customer)) + Text(NSLocalizedString("_albums_list_new_album_popup_title_", comment: "Create new album")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color(NCBrandColor.shared.customer)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 10) + .padding(.horizontal, 16) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color(.secondarySystemGroupedBackground).opacity(0.08)) + ) + } + .buttonStyle(PlainButtonStyle()) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowSeparator(.hidden) + } + + Section(header: Text(NSLocalizedString("_albums_list_own_albums_heading_", comment: "")) + .listRowInsets(EdgeInsets()) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, alignment: .leading)) { + ForEach(viewModel.albums) { album in + AlbumRow(album: album, localAccount: localAccount) + .padding(.vertical, 8) + .onTapGesture { + selectedAlbum = album + } + .listRowBackground( + selectedAlbum?.id == album.id + ? Color.accentColor.opacity(0.2) // light blue highlight (default iOS tint) + : Color.clear + ) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) // Match header padding + .listRowSeparator(.hidden) + } + } + } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .navigationTitle(NSLocalizedString("_add_to_album", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: onDismiss) { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text(NSLocalizedString("_albums_photo_selection_sheet_back_btn_", comment: "")) + }.foregroundColor(Color(NCBrandColor.shared.customer)) + } + .foregroundColor(.pink) + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button(NSLocalizedString("_albums_photo_selection_sheet_done_btn_", comment: "")) { + if let selected = selectedAlbum { + onFinish(selected) + } + } + .foregroundColor(Color(NCBrandColor.shared.customer)) + .opacity(selectedAlbum == nil ? 0.4 : 1.0) + .disabled(selectedAlbum == nil) + } + } + content() + } + .onAppear { + AlbumsManager.shared.setAccount(localAccount) + AlbumsManager.shared.syncAlbums() + } + } + } + + @ViewBuilder + private func content() -> some View { + if viewModel.isLoading { + ProgressView(NSLocalizedString("_albums_list_loading_msg_", comment: "")) + } else if let error = viewModel.errorMessage { + ScrollView(.vertical) { + VStack { + Spacer() + Text(error) + Spacer() + } + } + .refreshable { + viewModel.onPulledToRefresh() + } + } else if viewModel.albums.isEmpty { + NoAlbumsEmptyView(onNewAlbumCreationIntent: onCreateAlbum) + .refreshable { + viewModel.onPulledToRefresh() + } + } + } +} + +struct AlbumRow: View { + let album: Album + private enum ImageState { case loading, empty, thumbnail(UIImage) } + @State private var imageState: ImageState = .loading + var localAccount: String + + var body: some View { + HStack { + thumbnailView() + .frame(width: 80, height: 60) + .cornerRadius(6) + + VStack(alignment: .leading, spacing: 2) { + Text(album.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + + if let subtitle = makeSubtitle(for: album), !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + .padding(.horizontal, 8) + .task(id: album.lastPhotoId) { + await loadThumbnail() + } + } + + private func makeSubtitle(for album: Album) -> String? { + guard let count = album.itemCount else { return nil } + var parts: [String] = ["\(count) \(NSLocalizedString("_albums_list_entities_", comment: ""))"] + let formatter = DateFormatter() + if count > 0, let end = album.endDate { + formatter.dateStyle = .medium + parts.append(formatter.string(from: end)) + } else if count == 0, let created = album.startDate { + formatter.dateFormat = "MMMM yyyy" // "MMMM" for full month name, "yyyy" for year + parts.append(formatter.string(from: created)) + } + return parts.joined(separator: " - ") + } + + /// Renders the thumbnail image based on the current state + @ViewBuilder + private func thumbnailView() -> some View { + switch imageState { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray.opacity(0.1)) + case .empty: + Image("EmptyAlbum") + .resizable() + .scaledToFill() + .clipped() + .foregroundColor(.gray) + .background(Color.gray.opacity(0.1)) + case .thumbnail(let uiImage): + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .clipped() + } + } + + private func loadThumbnail() async { + if album.lastPhotoId == "-1" || (album.itemCount ?? 0) == 0 { + imageState = .empty + return + } + guard let photoId = album.lastPhotoId else { + imageState = .empty + return + } + + Task { + + let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: localAccount, + path: photoId, + name: "DownloadPreview") + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + } + } + if resultsPreview.error == .success, let data = resultsPreview.responseData?.data { + let session = NCSession.shared.getSession(account: localAccount) + if let image = NCUtility().createImageFileFrom( + data: data, + ocId: photoId, + etag: "", + ext: NCGlobal.shared.previewExt512, + userId: session.userId, + urlBase: session.urlBase + ) { + Task { @MainActor in + await MainActor.run { imageState = .thumbnail(image) } + } + } else { + await MainActor.run { imageState = .empty } + } + } + } + } +} + +//#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..772f8cca3b --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -0,0 +1,127 @@ +// +// 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 { + let session = NCSession.shared.getSession(account: localAccount) + if let image = NCUtility().createImageFileFrom( + data: data, + ocId: photoId, + etag: "", + ext: NCGlobal.shared.previewExt512, + userId: session.userId, + urlBase: session.urlBase + ) { + Task { @MainActor in + await MainActor.run { imageState = .thumbnail(image) } + } + } else { + await MainActor.run { imageState = .empty } + } + } + } + } + + private var frame: some View { + RoundedRectangle( + cornerRadius: 8 + ) + .stroke( + Color.gray.opacity(1), + lineWidth: 1 / UIScreen.main.scale + ) + } +} diff --git a/iOSClient/Albums/Presentation/List/AlbumsGridView.swift b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift new file mode 100644 index 0000000000..59f686d1c2 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsGridView.swift @@ -0,0 +1,130 @@ +// +// AlbumsGridView.swift +// Nextcloud +// +// Created by Dhanesh on 28/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI +import Foundation +import UIKit + +struct AlbumsGridView: View { + + @Environment(\.localAccount) var localAccount: String + + let albums: [Album] + + let onAlbumClicked: (Album) -> Void + +// private let columns = [ +// GridItem(.flexible(), spacing: 16), +// GridItem(.flexible(), spacing: 16) +// ] +// Use this inside AlbumsGridView to detect iPad + private var columns: [GridItem] { + let isIPad = UIDevice.current.userInterfaceIdiom == .pad + let count = isIPad ? 3 : 2 // 4 columns for iPad, 2 for iPhone + return Array(repeating: GridItem(.flexible(), spacing: 16), count: count) + } + + // Logic translated from your buildMediaPhotoVideo function + private var iconPointSize: CGFloat { + let count = columns.count + switch count { + case 0...1: return 60 + case 2...3: return 30 + case 4...5: return 25 + default: return 20 + } + } + + var body: some View { + + ScrollView { + + VStack(alignment: .leading, spacing: 16) { + + Text(NSLocalizedString("_albums_list_own_albums_heading_", comment: "")) + .font(.system(size: 21, weight: .bold)) + + LazyVGrid(columns: columns, spacing: 20) { + ForEach(albums, id: \.id) { album in + Button { + onAlbumClicked(album) + } label: { + VStack(alignment: .leading, spacing: 6) { + +// AlbumGridItemView(album: album) + AlbumGridItemView(album: album, iconSize: iconPointSize) + + Text(album.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + + if let subtitle = makeSubtitle(for: album), !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 13)) + .foregroundColor(Color(UIColor.systemGray)) + .lineLimit(1) + } + } + } + } + } + } + .padding() + } + } + + private func makeSubtitle(for album: Album) -> String? { + guard let count = album.itemCount else { return nil } + var parts: [String] = ["\(count) \(NSLocalizedString("_albums_list_entities_", comment: ""))"] + let formatter = DateFormatter() + if count > 0, let end = album.endDate { + formatter.dateStyle = .medium + parts.append(formatter.string(from: end)) + } else if count == 0, let created = album.startDate { + formatter.dateFormat = "MMMM yyyy" // "MMMM" for full month name, "yyyy" for year + parts.append(formatter.string(from: created)) + } + return parts.joined(separator: " - ") + } +} + +//#if DEBUG +//#Preview { +// AlbumsGridView( +// albums: [ +// Album( +// href: "/Geburtstagsalbum", +// lastPhotoId: "birthday", +// itemCount: 16, +// location: "Berlin", +// dateRange: "Feb 2022", +// collaborators: "Anna, John" +// ), +// Album( +// href: "/Urlaub", +// lastPhotoId: "mountain", +// itemCount: 42, +// location: "Alps", +// dateRange: nil, +// collaborators: nil +// ), +// Album( +// href: "/Office Party", +// lastPhotoId: "-1", +// itemCount: 0, +// location: nil, +// dateRange: "Dec 2023", +// collaborators: nil +// ) +// ], +// onAlbumClicked: { _ in} +// ) +//} +//#endif + diff --git a/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift new file mode 100644 index 0000000000..511b661362 --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsListScreen.swift @@ -0,0 +1,165 @@ +// +// AlbumsListScreen.swift +// Nextcloud +// +// Created by A200118228 on 07/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import SwiftUI + +extension Notification.Name { + static let albumsPopToRootRequested = Notification.Name("NCAlbumsPopToRootRequested") +} + +struct AlbumsListScreen: View { + + @Environment(\.localAccount) var localAccount: String +// let metadata: tableMetadata? + + enum NavigationDestination: Hashable { + case albumDetails(album: Album) + } + + @StateObject private var viewModel: AlbumsListViewModel + @State private var popToRootTrigger: Int = 0 + + init(viewModel: AlbumsListViewModel) { + self._viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + + ZStack { + content() + + if viewModel.isLoadingPopupVisible { + NCLoadingAlert() + } + } + .navigationTitle(NSLocalizedString("_albums_list_nav_title_", comment: "")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + Button(action: { viewModel.onNewAlbumClick() }) { + Text(NSLocalizedString("_albums_list_new_album_btn_", comment: "")) + .font(.body) + .padding(.vertical, 6) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .tint(Color(NCBrandColor.shared.iconImageColor)) + } + } + .overlay(setupNavigation.hidden()) + .sheet( + isPresented: $viewModel.isPhotoSelectionSheetVisible, + onDismiss: { + viewModel.onPhotosSelected(selectedPhotos: []) + } + ) { + PhotoSelectionSheet( + onPhotosSelected: viewModel.onPhotosSelected + ) + } + .inputAlbumNameAlert( + isPresented: $viewModel.isNewAlbumCreationPopupVisible, + albumName: $viewModel.newAlbumName, + error: viewModel.newAlbumNameError, + onCreate: { + viewModel.onNewAlbumPopupCreate() + }, + onCancel: { + viewModel.onNewAlbumPopupCancel() + } + ) + .onReceive(NotificationCenter.default.publisher(for: .albumsPopToRootRequested)) { _ in + // Clear any programmatic navigation and force root content + viewModel.navigationDestination = nil + popToRootTrigger += 1 + } + } + + @ViewBuilder + private func content() -> some View { + if viewModel.isLoading { + ProgressView(NSLocalizedString("_albums_list_loading_msg_", comment: "")) + } else if let error = viewModel.errorMessage { + ScrollView(.vertical) { + VStack { + Spacer() + Text(error) + Spacer() + } + } + .refreshable { + viewModel.onPulledToRefresh() + } + } else if viewModel.albums.isEmpty { + NoAlbumsEmptyView(onNewAlbumCreationIntent: viewModel.onNewAlbumClick) + .refreshable { + viewModel.onPulledToRefresh() + } + } else { + AlbumsGridView( + albums: viewModel.albums.sorted { (lhs: Album, rhs: Album) -> Bool in + let l = lhs.name + let r = rhs.name + return l.localizedCaseInsensitiveCompare(r) == .orderedAscending + }, + onAlbumClicked: viewModel.onAlbumClicked + ) + .refreshable { + viewModel.onPulledToRefresh() + } + } + } + + private var setupNavigation: some View { + + let binding = Binding { [weak viewModel] in + viewModel?.navigationDestination != nil + } set: { [weak viewModel] value in + guard !value else { return } + viewModel?.navigationDestination = nil + } + + return NavigationLink(isActive: binding) { + switch viewModel.navigationDestination { + case .some(let value): + navigationDestination(value) + + case .none: + EmptyView() + } + } label: { + EmptyView() + } + } + + @ViewBuilder + private func navigationDestination(_ destination: NavigationDestination) -> some View { + switch destination { + case .albumDetails(let album): + AlbumDetailsScreen(account: localAccount, album: album) + } + } +} + +//#if DEBUG +//#Preview { +// NavigationView { +// AlbumsListScreen(viewModel: .init(account: "123")) +// }.onAppear { +// UIView +// .appearance( +// whenContainedInInstancesOf: [UIAlertController.self] +// ).tintColor = NCBrandColor.shared.customer +// } +//} +//#endif + + + + diff --git a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift new file mode 100644 index 0000000000..2f2237b54e --- /dev/null +++ b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift @@ -0,0 +1,279 @@ +// +// AlbumsViewModel.swift +// Nextcloud +// +// Created by A200118228 on 08/07/25. +// Copyright © 2025 Marino Faggiana. All rights reserved. +// + +import Foundation +import Combine +import NextcloudKit + +class AlbumsListViewModel: ObservableObject { + + private var account: String + + @Published private(set) var albums: [Album] = [] + @Published private(set) var isLoading: Bool = false + @Published private(set) var errorMessage: String? = nil + + private var thumbnailsTask: Task? + @Published private(set) var albumThumbnails: [String: UIImage] = [:] + + @Published var isLoadingPopupVisible: Bool = false + + @Published var isNewAlbumCreationPopupVisible: Bool = false + @Published var newAlbumName: String = "" + @Published private(set) var newAlbumNameError: String? = nil + + @Published var isPhotoSelectionSheetVisible: Bool = false + @Published var newlyCreatedAlbum: Album? = nil + + @Published var navigationDestination: AlbumsListScreen.NavigationDestination? = nil + + @MainActor + private var windowScene: UIWindowScene? { + SceneManager.shared.getWindowScene(controller: SceneManager.shared.getController(account: account)) + } + + private var cancellables: Set = [] + private var isNavigatingToDetails: Bool = false + + init(account: String) { + self.account = account + observeAlbums() + registerPublishers() + } + + // MARK: - Subscriptions + private func observeAlbums() { + AlbumsManager.shared.albumsPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + switch state { + case .idle: + self?.isLoading = false + case .loading: + self?.isLoading = true + self?.errorMessage = nil + case .success(let albums): + self?.isLoading = false + self?.albums = albums + case .failure: + self?.isLoading = false + self?.errorMessage = NSLocalizedString("_albums_list_error_msg_", comment: "") + } + } + .store(in: &cancellables) + } + + // MARK: - Album name validation + private func registerPublishers() { + $newAlbumName + .removeDuplicates() + .sink { [weak self] name in + guard let self = self else { return } + self.newAlbumNameError = self.validateAlbumName(name).first + } + .store(in: &cancellables) + } + + private func validateAlbumName(_ name: String) -> [String] { + + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + + if trimmed.isEmpty { + return [NSLocalizedString("_albums_list_album_name_validation_nonempty_", comment: "")] + } else if trimmed.count < 3 { + return [NSLocalizedString("_albums_list_album_name_validation_min_length_", comment: "")] + } else if trimmed.count > 30 { + return [NSLocalizedString("_albums_list_album_name_validation_max_length_", comment: "")] + } else if trimmed.contains("/") || trimmed.contains("\\") { + return [NSLocalizedString("_albums_list_album_name_validation_specials_", comment: "")] + } + + return [] + } + + // MARK: - Events + func onAlbumClicked(_ album: Album) { + guard !isNavigatingToDetails else { return } + isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self?.isNavigatingToDetails = false + } + } + + // MARK: - Album name popup + func onNewAlbumClick() { + // Reset any previous error and open the popup with a clean state + newAlbumNameError = nil + isNewAlbumCreationPopupVisible = true + } + + func onNewAlbumPopupCancel() { + // Clear input and error when cancelling + newAlbumName = "" + newAlbumNameError = nil + isNewAlbumCreationPopupVisible = false + } + + func onNewAlbumPopupCreate() { + // Prevent double submission while a request is in-flight + guard !isLoadingPopupVisible else { return } + + // Trim and validate before proceeding (defensive for iOS 17 timing) + let trimmedName = newAlbumName.trimmingCharacters(in: .whitespacesAndNewlines) + let errors = validateAlbumName(trimmedName) + if let firstError = errors.first { + newAlbumNameError = firstError + return + } + + // Capture the valid name, then reset UI state deterministically + let nameToCreate = trimmedName + + // Dismiss the popup and clear the field AFTER we've captured the value + isNewAlbumCreationPopupVisible = false + newAlbumName = "" + newAlbumNameError = nil + + // Kick off creation with a clean state + createNewAlbum(for: nameToCreate) + } + + // MARK: - APIs + func onPulledToRefresh() { + AlbumsManager.shared.syncAlbums() + } + + private func createNewAlbum(for name: String) { + + guard !isLoadingPopupVisible else { return } + + isLoadingPopupVisible = true + + NextcloudKit.shared.createNewAlbum(for: account, albumName: name) { [weak self] result in + + self?.isLoadingPopupVisible = false + + switch result { + case .success(_): + + AlbumsManager.shared.syncAlbums { [weak self] resultAlbums in + if let newAlbum = resultAlbums.first(where: { $0.name == name }) { + self?.newlyCreatedAlbum = newAlbum + self?.isPhotoSelectionSheetVisible = true + } + } + + case .failure(let error): + let nkError = NKError(error: error) + // Prefer friendly info alert for duplicate album names (409) + if let inner = nkError.error as? NKError, inner.errorCode == NCGlobal.shared.errorConflict { + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflict.errorDescription, errorCode: conflict.errorCode) + } + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + // Top-level conflict + let message = NSLocalizedString("_album_already_exists_", comment: "Album already exists") + let conflict = NKError(errorCode: NCGlobal.shared.errorConflict, errorDescription: message) + Task { @MainActor in + await showInfoBanner(windowScene: self?.windowScene, text: conflict.errorDescription, errorCode: conflict.errorCode) + } + } else { + // Other errors + Task { @MainActor in + await showErrorBanner(windowScene: self?.windowScene, error: nkError) + } + } + } + } + } + + func onPhotosSelected(selectedPhotos: [String]) { + isPhotoSelectionSheetVisible = false + + guard let album = newlyCreatedAlbum else { return } + + if selectedPhotos.isEmpty { + guard !isNavigatingToDetails else { return } + isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self?.isNavigatingToDetails = false + } + return + } + + // Batch copy operations and navigate only once after a final sync to avoid iOS 17 navigation race conditions + let group = DispatchGroup() + var hadAnySuccess = false + + for photo in selectedPhotos { + group.enter() + let metadata: tableMetadata? = NCManageDatabase.shared.getMetadataFromOcId(photo) + + NextcloudKit.shared.copyPhotoToAlbum( + account: account, + sourcePath: metadata?.serverUrlFileName ?? photo, + albumName: album.name, + fileName: metadata?.fileName ?? photo + ) { result in + switch result { + case .success: + hadAnySuccess = true + case .failure(let error): + let nkError = NKError(error: error) + + // Check nested conflict first (409), then top-level, otherwise show error + if let innerError = nkError.error as? NKError, + innerError.errorCode == NCGlobal.shared.errorConflict { + let conflictError = NKError(errorCode: NCGlobal.shared.errorConflict, + errorDescription: "_file_already_exists_") + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: conflictError.errorDescription, errorCode: conflictError.errorCode) + } + } else if nkError.errorCode == NCGlobal.shared.errorConflict { + Task { @MainActor in + await showInfoBanner(windowScene: self.windowScene, text: nkError.errorDescription, errorCode: nkError.errorCode) + } + } else { + Task { @MainActor in + await showErrorBanner(windowScene: self.windowScene, error: nkError) + } + } + } + group.leave() + } + } + + group.notify(queue: .main) { [weak self] in + guard let self = self else { return } + if hadAnySuccess { + AlbumsManager.shared.syncAlbums { _ in + guard !self.isNavigatingToDetails else { return } + self.isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self.isNavigatingToDetails = false + } + } + } else { + guard !self.isNavigatingToDetails else { return } + self.isNavigatingToDetails = true + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + AlbumsNavigator.shared.push(.albumDetails(album: album)) + self.isNavigatingToDetails = false + } + } + } + } +} + diff --git a/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift b/iOSClient/Albums/Presentation/List/NoAlbumsEmptyView.swift new file mode 100644 index 0000000000..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..8fd07ebcf4 --- /dev/null +++ b/iOSClient/Albums/Presentation/PhotoSelection/NCMediaViewRepresentable.swift @@ -0,0 +1,105 @@ +// +// 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 + guard !Task.isCancelled else { return } + 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) + } + } + } + } +} 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_",