From 8be8eea299c8ebc70d9bbd680412dcbaa91746b4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 08:27:30 +0200 Subject: [PATCH 1/7] fix: improve album photo previews and titles Load cached and locally generated thumbnails before falling back to server previews, and display the resolved album screen title in the photo grid. Signed-off-by: Marino Faggiana --- .../Details/AlbumDetailsScreen.swift | 1 + .../Details/PhotoGridItemView.swift | 55 +++++++++++++++---- .../Presentation/Details/PhotosGridView.swift | 5 +- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index db10eae4d4..db1f1f4203 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -144,6 +144,7 @@ struct AlbumDetailsScreen: View { photos: viewModel.photos, onAddPhotosIntent: handleAddPhotosIntent, album: album, + albumTitle: viewModel.screenTitle, onRemovePhoto: { photo in Task { @MainActor in await viewModel.removePhoto(photo) diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift index 8c4ef13895..313b8f36ef 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -81,25 +81,28 @@ struct PhotoGridItemView: View { // reused cover view could keep showing a removed photo with no replacement. await MainActor.run { self.thumbnail = nil - self.isLoading = metadata.hasPreview && !photo.id.isEmpty + self.isLoading = !photo.id.isEmpty } - // 1. Validate: Only load if it has a preview and a valid ID - guard metadata.hasPreview, !photo.id.isEmpty else { + // 1. Validate the photo ID. + guard !photo.id.isEmpty else { return } - // 2. Setup parameters from Photo object and Metadata fallback + // 2. Setup parameters from Photo object and Metadata fallback. let fileId = photo.id + let ocId = metadata.ocId let userId = metadata.userId let urlBase = metadata.urlBase let etag = metadata.etag + let previewExt = NCGlobal.shared.previewExt512 + let utility = NCUtility() - // 3. Try Disk Cache First - if let cachedImage = NCUtility().getImage( - ocId: fileId, + // 3. Look in the preview cache first. + if let cachedImage = utility.getImage( + ocId: ocId, etag: etag, - ext: NCGlobal.shared.previewExt512, + ext: previewExt, userId: userId, urlBase: urlBase ) { @@ -110,7 +113,32 @@ struct PhotoGridItemView: View { return } - // 4. Download Preview + // 4. If the original file is local, generate its preview locally. + if NCUtilityFileSystem().fileProviderStorageExists(metadata) { + utility.createImageFileFrom(metadata: metadata) + if let localImage = utility.getImage( + ocId: ocId, + etag: etag, + ext: previewExt, + userId: userId, + urlBase: urlBase + ) { + await MainActor.run { + self.thumbnail = localImage + self.isLoading = false + } + return + } + } + + // 5. Download the server preview only as the final fallback. + guard metadata.hasPreview else { + await MainActor.run { + self.isLoading = false + } + return + } + let results = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: etag, account: localAccount) { _ in } await MainActor.run { @@ -119,10 +147,15 @@ struct PhotoGridItemView: View { let image = UIImage(data: data) { self.thumbnail = image - // 5. Save to cache (optional but recommended) + // 6. Save the downloaded preview to the cache. Task.detached(priority: .background) { NCUtility().createImageFileFrom( - data: data, ocId: fileId, etag: etag, userId: userId, urlBase: urlBase + data: data, + ocId: ocId, + etag: etag, + ext: previewExt, + userId: userId, + urlBase: urlBase ) } } diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index cc5b4ed2c5..89bbe56e9d 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -12,6 +12,7 @@ struct PhotosGridView: View { let photos: [AlbumPhoto] let onAddPhotosIntent: () -> Void let album: Album + let albumTitle: String let onRemovePhoto: (AlbumPhoto) -> Void @State private var photoToRemove: AlbumPhoto? @@ -22,12 +23,14 @@ struct PhotosGridView: View { photos: [AlbumPhoto], onAddPhotosIntent: @escaping () -> Void, album: Album, + albumTitle: String, onRemovePhoto: @escaping (AlbumPhoto) -> Void ) { self.controller = controller self.photos = photos self.onAddPhotosIntent = onAddPhotosIntent self.album = album + self.albumTitle = albumTitle self.onRemovePhoto = onRemovePhoto } @@ -84,7 +87,7 @@ struct PhotosGridView: View { ) VStack(alignment: .leading, spacing: 4) { - Text(album.name) + Text(albumTitle) .font(.title2.bold()) Text( From 0b146c27aeb46ef2e740f4ddfc4038d9cf81383c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 11:18:57 +0200 Subject: [PATCH 2/7] fix: improve album cover thumbnail loading Signed-off-by: Marino Faggiana --- .../Presentation/List/AlbumGridItemView.swift | 138 +++++++++++------- 1 file changed, 84 insertions(+), 54 deletions(-) diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 825c7c6b08..e9f9cbba66 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -49,29 +49,24 @@ struct AlbumGridItemView: View { .cornerRadius(8) } .aspectRatio(1, contentMode: .fit) - .task(id: coverCacheId) { + .task(id: coverRequestId) { await loadThumbnail() } } - // Keep the successful cover across view recreation and app launches. Changes to - // the album's cover or item count select a fresh cache entry. - private var coverCacheId: String { + private var coverRequestId: String { let components = [localAccount, album.id, album.lastPhotoId ?? "", album.itemCount.map { String($0) } ?? "unknown"] let key = components.map { "\($0.utf8.count):\($0)" }.joined() - return "album-cover-" + key.md5() + return key.md5() } - private var cachedThumbnail: UIImage? { - guard album.itemCount != 0 else { return nil } - let session = NCSession.shared.getSession(account: localAccount) - return NCUtility().getImage( - ocId: coverCacheId, - etag: "", - ext: NCGlobal.shared.previewExt512, - userId: session.userId, - urlBase: session.urlBase - ) + private var preferredPhotoId: String? { + guard let photoId = album.lastPhotoId, + !photoId.isEmpty, + photoId != "-1" else { + return nil + } + return photoId } @MainActor @@ -82,59 +77,102 @@ struct AlbumGridItemView: View { return } - if let image = cachedThumbnail { - imageState = .thumbnail(image) - return + imageState = .loading + defer { + if case .loading = imageState { + imageState = .empty + } } - imageState = .loading - if let photoId = album.lastPhotoId, - !photoId.isEmpty, - photoId != "-1", - let image = await downloadThumbnail(fileId: photoId) { + let preferredPhoto = preferredPhoto() + if let preferredPhoto { + if let image = await loadImage(for: preferredPhoto) { + guard !Task.isCancelled else { return } + imageState = .thumbnail(image) + return + } + } + + for photo in await alternativePhotos(excluding: preferredPhoto?.id) { guard !Task.isCancelled else { return } - imageState = .thumbnail(image) - return + if let image = await loadImage(for: photo) { + imageState = .thumbnail(image) + return + } } + guard !Task.isCancelled else { return } + imageState = .empty + } - let preferredPhotoId = album.lastPhotoId - let photos: [AlbumPhoto] - if let cached = NCManageDatabase.shared.getAlbumPhotos(album: album) { + private func preferredPhoto() -> AlbumPhoto? { + guard let preferredPhotoId, + let metadata = NCManageDatabase.shared.getMetadataFromFileId(preferredPhotoId, account: localAccount) else { + return nil + } + return AlbumPhoto(metadata: metadata) + } + + @MainActor + private func alternativePhotos(excluding photoId: String?) async -> [AlbumPhoto] { + let cached = NCManageDatabase.shared.getAlbumPhotos(album: album) + var photos: [AlbumPhoto] + if let cached { photos = cached.map { AlbumPhoto(metadata: $0) } } else { photos = (try? await AlbumsManager.shared.refreshAlbumPhotos(album)) ?? [] } - let candidateIds = photos.filter { - $0.metadata.hasPreview && $0.id != preferredPhotoId + + if cached != nil, + let preferredPhotoId, + !photos.contains(where: { $0.id == preferredPhotoId }), + let refreshed = try? await AlbumsManager.shared.refreshAlbumPhotos(album) { + photos = refreshed + } + + return Array(photos.filter { + $0.metadata.isImageOrVideo && $0.id != photoId }.sorted { + let lhsIsPreferred = $0.id == preferredPhotoId + let rhsIsPreferred = $1.id == preferredPhotoId + if lhsIsPreferred != rhsIsPreferred { return lhsIsPreferred } if $0.metadata.date != $1.metadata.date { return $0.metadata.date.compare($1.metadata.date as Date) == .orderedDescending } return $0.id < $1.id - }.prefix(5).map(\.id) - guard !Task.isCancelled else { return } + }.prefix(1)) + } - for photoId in candidateIds { - if let image = await downloadThumbnail(fileId: photoId) { - guard !Task.isCancelled else { return } - imageState = .thumbnail(image) - return + @MainActor + private func loadImage(for photo: AlbumPhoto) async -> UIImage? { + guard !Task.isCancelled else { return nil } + + let metadata = photo.metadata + let utility = NCUtility() + let previewExt = NCGlobal.shared.previewExt512 + + if let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: previewExt, userId: metadata.userId, urlBase: metadata.urlBase) { + return image + } + + if NCUtilityFileSystem().fileProviderStorageExists(metadata) { + utility.createImageFileFrom(metadata: metadata) + if let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: previewExt, userId: metadata.userId, urlBase: metadata.urlBase) { + return image } - guard !Task.isCancelled else { return } } - imageState = .empty + + guard metadata.hasPreview else { return nil } + return await downloadThumbnail(metadata: metadata) } @MainActor - private func downloadThumbnail(fileId photoId: String) async -> UIImage? { + private func downloadThumbnail(metadata: tableMetadata) async -> UIImage? { guard !Task.isCancelled else { return nil } - let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: photoId, etag: "", account: localAccount) { task in + let fileId = metadata.fileId + let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: metadata.etag, account: localAccount) { task in Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( - account: localAccount, - path: photoId, - name: "DownloadPreview") + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: localAccount, path: fileId, name: "DownloadPreview") await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) } } @@ -145,15 +183,7 @@ struct AlbumGridItemView: View { return nil } - let session = NCSession.shared.getSession(account: localAccount) - return NCUtility().createImageFileFrom( - data: data, - ocId: coverCacheId, - etag: "", - ext: NCGlobal.shared.previewExt512, - userId: session.userId, - urlBase: session.urlBase - ) + return NCUtility().createImageFileFrom(data: data, metadata: metadata, ext: NCGlobal.shared.previewExt512) } private var frame: some View { From 5042ae98147848f0433689de6bd79047af88c1dc Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 11:21:54 +0200 Subject: [PATCH 3/7] fix: isolate photo thumbnail loading to main actor Reset loading state consistently and ignore cancelled preview downloads. Signed-off-by: Marino Faggiana --- .../Details/PhotoGridItemView.swift | 68 +++++-------------- 1 file changed, 18 insertions(+), 50 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift index 313b8f36ef..7af98f724c 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -76,13 +76,13 @@ struct PhotoGridItemView: View { } } + @MainActor private func loadThumbnailFromPhoto() async { // Clear the previous image before validating the new photo. Otherwise a // reused cover view could keep showing a removed photo with no replacement. - await MainActor.run { - self.thumbnail = nil - self.isLoading = !photo.id.isEmpty - } + thumbnail = nil + isLoading = !photo.id.isEmpty + defer { isLoading = false } // 1. Validate the photo ID. guard !photo.id.isEmpty else { @@ -99,67 +99,35 @@ struct PhotoGridItemView: View { let utility = NCUtility() // 3. Look in the preview cache first. - if let cachedImage = utility.getImage( - ocId: ocId, - etag: etag, - ext: previewExt, - userId: userId, - urlBase: urlBase - ) { - await MainActor.run { - self.thumbnail = cachedImage - self.isLoading = false - } + if let cachedImage = utility.getImage(ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) { + thumbnail = cachedImage return } // 4. If the original file is local, generate its preview locally. if NCUtilityFileSystem().fileProviderStorageExists(metadata) { utility.createImageFileFrom(metadata: metadata) - if let localImage = utility.getImage( - ocId: ocId, - etag: etag, - ext: previewExt, - userId: userId, - urlBase: urlBase - ) { - await MainActor.run { - self.thumbnail = localImage - self.isLoading = false - } + if let localImage = utility.getImage(ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) { + thumbnail = localImage return } } // 5. Download the server preview only as the final fallback. - guard metadata.hasPreview else { - await MainActor.run { - self.isLoading = false - } - return - } + guard metadata.hasPreview else { return } let results = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: etag, account: localAccount) { _ in } + guard !Task.isCancelled else { return } - await MainActor.run { - if results.error == .success, - let data = results.responseData?.data, - let image = UIImage(data: data) { - self.thumbnail = image - - // 6. Save the downloaded preview to the cache. - Task.detached(priority: .background) { - NCUtility().createImageFileFrom( - data: data, - ocId: ocId, - etag: etag, - ext: previewExt, - userId: userId, - urlBase: urlBase - ) - } + if results.error == .success, + let data = results.responseData?.data, + let image = UIImage(data: data) { + thumbnail = image + + // 6. Save the downloaded preview to the cache. + Task.detached(priority: .background) { + NCUtility().createImageFileFrom(data: data, ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) } - self.isLoading = false } } } From 64a608e1d6a9d5220ef98c0b7a9bac63c4baf3e5 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 11:31:54 +0200 Subject: [PATCH 4/7] refactor: reuse album grid item for list thumbnails Signed-off-by: Marino Faggiana --- .../List/AddToAlbumsListView.swift | 74 +------------------ 1 file changed, 3 insertions(+), 71 deletions(-) diff --git a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift index 3d38bb718f..4c29e7813f 100644 --- a/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift +++ b/iOSClient/Albums/Presentation/List/AddToAlbumsListView.swift @@ -136,14 +136,13 @@ struct AddToAlbumsListView: View { 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) + AlbumGridItemView(album: album) + .environment(\.localAccount, localAccount) + .frame(width: 60, height: 60) .cornerRadius(6) VStack(alignment: .leading, spacing: 2) { @@ -161,9 +160,6 @@ struct AlbumRow: View { } } .padding(.horizontal, 8) - .task(id: album.lastPhotoId) { - await loadThumbnail() - } } private func makeSubtitle(for album: Album) -> String? { @@ -184,68 +180,4 @@ struct AlbumRow: View { } 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(systemName: "photo.stack.fill") - .resizable() - .scaledToFit() - .foregroundStyle( - Color(NCBrandColor.shared.getElement(account: localAccount)) - ) - .frame(maxWidth: .infinity, maxHeight: 180) - 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 } - } - } - } - } } From 493d35d277bc3ab70d886f04bfb4140de0ce405a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 11:48:11 +0200 Subject: [PATCH 5/7] cleaning Signed-off-by: Marino Faggiana --- .../Details/AlbumDetailsScreen.swift | 12 +-- .../Details/PhotoGridItemView.swift | 3 - .../Presentation/Details/PhotosGridView.swift | 28 ++----- .../Presentation/List/AlbumGridItemView.swift | 74 +++++-------------- 4 files changed, 27 insertions(+), 90 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift index db1f1f4203..20e339b2e5 100644 --- a/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift +++ b/iOSClient/Albums/Presentation/Details/AlbumDetailsScreen.swift @@ -7,17 +7,10 @@ import SwiftUI struct AlbumDetailsScreen: View { private unowned let controller: NCMainTabBarController - private let album: Album @StateObject private var viewModel: AlbumDetailsViewModel - @State private var showMedia = false - init( - controller: NCMainTabBarController, - album: Album, - navigator: AlbumsNavigator = AlbumsNavigator() - ) { + init(controller: NCMainTabBarController, album: Album, navigator: AlbumsNavigator = AlbumsNavigator()) { self.controller = controller - self.album = album _viewModel = StateObject( wrappedValue: AlbumDetailsViewModel(controller: controller, album: album, navigator: navigator) ) @@ -142,8 +135,6 @@ struct AlbumDetailsScreen: View { PhotosGridView( controller: controller, photos: viewModel.photos, - onAddPhotosIntent: handleAddPhotosIntent, - album: album, albumTitle: viewModel.screenTitle, onRemovePhoto: { photo in Task { @MainActor in @@ -159,6 +150,5 @@ struct AlbumDetailsScreen: View { private func handleAddPhotosIntent() { viewModel.onAddPhotosIntent() - showMedia = true } } diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift index 7af98f724c..cdd059f6d3 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -9,7 +9,6 @@ import NextcloudKit struct PhotoGridItemView: View { @Environment(\.localAccount) var localAccount: String - let album: Album let photo: AlbumPhoto let aspectRatio: CGFloat let showsMediaTypeIcon: Bool @@ -28,12 +27,10 @@ struct PhotoGridItemView: View { @State private var isLoading = false init( - album: Album, photo: AlbumPhoto, aspectRatio: CGFloat = 1, showsMediaTypeIcon: Bool = true ) { - self.album = album self.photo = photo self.aspectRatio = aspectRatio self.showsMediaTypeIcon = showsMediaTypeIcon diff --git a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift index 89bbe56e9d..522937da20 100644 --- a/iOSClient/Albums/Presentation/Details/PhotosGridView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotosGridView.swift @@ -10,8 +10,6 @@ struct PhotosGridView: View { private unowned let controller: NCMainTabBarController let photos: [AlbumPhoto] - let onAddPhotosIntent: () -> Void - let album: Album let albumTitle: String let onRemovePhoto: (AlbumPhoto) -> Void @@ -21,15 +19,11 @@ struct PhotosGridView: View { init( controller: NCMainTabBarController, photos: [AlbumPhoto], - onAddPhotosIntent: @escaping () -> Void, - album: Album, albumTitle: String, onRemovePhoto: @escaping (AlbumPhoto) -> Void ) { self.controller = controller self.photos = photos - self.onAddPhotosIntent = onAddPhotosIntent - self.album = album self.albumTitle = albumTitle self.onRemovePhoto = onRemovePhoto } @@ -46,21 +40,12 @@ struct PhotosGridView: View { } private var coverPhoto: AlbumPhoto? { - let serverCover = photos.first { $0.id == album.lastPhotoId } - if serverCover?.metadata.hasPreview == true { - return serverCover - } - - let mostRecentWithPreview = photos - .filter(\.metadata.hasPreview) - .reduce(nil as AlbumPhoto?) { newest, photo in - guard let newest else { return photo } - return photo.metadata.date.compare(newest.metadata.date as Date) == .orderedDescending - ? photo - : newest + photos.filter(\.metadata.hasPreview).max { + if $0.metadata.date != $1.metadata.date { + return $0.metadata.date.compare($1.metadata.date as Date) == .orderedAscending } - - return mostRecentWithPreview ?? serverCover + return $0.id < $1.id + } } var body: some View { @@ -73,7 +58,6 @@ struct PhotosGridView: View { } label: { ZStack(alignment: .bottomLeading) { PhotoGridItemView( - album: album, photo: coverPhoto, aspectRatio: 16.0 / 7.0, showsMediaTypeIcon: false @@ -111,7 +95,7 @@ struct PhotosGridView: View { Button { openingPhoto = photo } label: { - PhotoGridItemView(album: album, photo: photo) + PhotoGridItemView(photo: photo) } .disabled(openingPhoto != nil) .contextMenu { diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index e9f9cbba66..677d243372 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -60,15 +60,6 @@ struct AlbumGridItemView: View { return key.md5() } - private var preferredPhotoId: String? { - guard let photoId = album.lastPhotoId, - !photoId.isEmpty, - photoId != "-1" else { - return nil - } - return photoId - } - @MainActor private func loadThumbnail() async { guard !Task.isCancelled else { return } @@ -84,63 +75,38 @@ struct AlbumGridItemView: View { } } - let preferredPhoto = preferredPhoto() - if let preferredPhoto { - if let image = await loadImage(for: preferredPhoto) { - guard !Task.isCancelled else { return } - imageState = .thumbnail(image) - return - } - } - - for photo in await alternativePhotos(excluding: preferredPhoto?.id) { - guard !Task.isCancelled else { return } - if let image = await loadImage(for: photo) { - imageState = .thumbnail(image) - return - } - } - - guard !Task.isCancelled else { return } - imageState = .empty - } - - private func preferredPhoto() -> AlbumPhoto? { - guard let preferredPhotoId, - let metadata = NCManageDatabase.shared.getMetadataFromFileId(preferredPhotoId, account: localAccount) else { - return nil + guard let coverPhoto = await coverPhoto(), + !Task.isCancelled, + let image = await loadImage(for: coverPhoto), + !Task.isCancelled else { + return } - return AlbumPhoto(metadata: metadata) + imageState = .thumbnail(image) } @MainActor - private func alternativePhotos(excluding photoId: String?) async -> [AlbumPhoto] { + private func coverPhoto() async -> AlbumPhoto? { let cached = NCManageDatabase.shared.getAlbumPhotos(album: album) - var photos: [AlbumPhoto] - if let cached { - photos = cached.map { AlbumPhoto(metadata: $0) } + let cachedPhotos = cached?.map { AlbumPhoto(metadata: $0) } + let cacheIsCurrent: Bool + if let lastPhotoId = album.lastPhotoId, !lastPhotoId.isEmpty, lastPhotoId != "-1" { + cacheIsCurrent = cached?.contains(where: { $0.fileId == lastPhotoId }) == true } else { - photos = (try? await AlbumsManager.shared.refreshAlbumPhotos(album)) ?? [] + cacheIsCurrent = true } - - if cached != nil, - let preferredPhotoId, - !photos.contains(where: { $0.id == preferredPhotoId }), - let refreshed = try? await AlbumsManager.shared.refreshAlbumPhotos(album) { - photos = refreshed + let photos: [AlbumPhoto] + if let cachedPhotos, cacheIsCurrent { + photos = cachedPhotos + } else { + photos = (try? await AlbumsManager.shared.refreshAlbumPhotos(album)) ?? cachedPhotos ?? [] } - return Array(photos.filter { - $0.metadata.isImageOrVideo && $0.id != photoId - }.sorted { - let lhsIsPreferred = $0.id == preferredPhotoId - let rhsIsPreferred = $1.id == preferredPhotoId - if lhsIsPreferred != rhsIsPreferred { return lhsIsPreferred } + return photos.filter(\.metadata.hasPreview).max { if $0.metadata.date != $1.metadata.date { - return $0.metadata.date.compare($1.metadata.date as Date) == .orderedDescending + return $0.metadata.date.compare($1.metadata.date as Date) == .orderedAscending } return $0.id < $1.id - }.prefix(1)) + } } @MainActor From 6859fd6d4dc72685acbb6aa463d37dde9305996c Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 11:53:27 +0200 Subject: [PATCH 6/7] fix: prevent duplicate photo selection handling Reset selection handling for each newly created album and process sheet completion only once. Signed-off-by: Marino Faggiana --- .../Albums/Presentation/List/AlbumsListViewModel.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift index 174193d7f2..34bef5ef45 100644 --- a/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift +++ b/iOSClient/Albums/Presentation/List/AlbumsListViewModel.swift @@ -29,6 +29,7 @@ class AlbumsListViewModel: ObservableObject { private var cancellables: Set = [] private var isNavigatingToDetails: Bool = false + private var didHandlePhotoSelectionResult: Bool = false init(controller: NCMainTabBarController, navigator: AlbumsNavigator = AlbumsNavigator()) { self.account = controller.account @@ -153,6 +154,7 @@ class AlbumsListViewModel: ObservableObject { AlbumsManager.shared.syncAlbums(for: account) { [weak self] resultAlbums in if let newAlbum = resultAlbums.first(where: { $0.name == name }) { self?.newlyCreatedAlbum = newAlbum + self?.didHandlePhotoSelectionResult = false self?.isPhotoSelectionSheetVisible = true } } @@ -165,6 +167,10 @@ class AlbumsListViewModel: ObservableObject { } func onPhotosSelected(selectedPhotos: [String]) { + // Closing the sheet also invokes this method through `onDismiss`. + // Handle either the toolbar action or the dismissal, never both. + guard !didHandlePhotoSelectionResult else { return } + didHandlePhotoSelectionResult = true isPhotoSelectionSheetVisible = false guard let album = newlyCreatedAlbum else { return } @@ -208,8 +214,8 @@ class AlbumsListViewModel: ObservableObject { if hadAnySuccess { Task { @MainActor in AlbumsManager.shared.invalidatePhotoRequest(for: album) - AlbumsManager.shared.syncAlbums(for: self.account) { [weak self] _ in - self?.onAlbumClicked(album) + AlbumsManager.shared.syncAlbums(for: self.account) { _ in + self.onAlbumClicked(album) } } } else { From ea933cf0c0650a8eb868a36b2ab0c4e6863e45d6 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 24 Sep 2026 12:07:30 +0200 Subject: [PATCH 7/7] refactor: centralize album preview loading Reuse a shared preview loader for photo and album grids, including cancellation handling, network task tracking, and preview caching. Signed-off-by: Marino Faggiana --- .../Details/PhotoGridItemView.swift | 56 ++++++++++--------- .../Presentation/List/AlbumGridItemView.swift | 46 +-------------- 2 files changed, 32 insertions(+), 70 deletions(-) diff --git a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift index cdd059f6d3..f8d7f84a00 100644 --- a/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift +++ b/iOSClient/Albums/Presentation/Details/PhotoGridItemView.swift @@ -81,12 +81,20 @@ struct PhotoGridItemView: View { isLoading = !photo.id.isEmpty defer { isLoading = false } - // 1. Validate the photo ID. guard !photo.id.isEmpty else { return } - // 2. Setup parameters from Photo object and Metadata fallback. + let image = await Self.loadPreview(for: photo, account: localAccount) + guard !Task.isCancelled else { return } + thumbnail = image + } + + @MainActor + static func loadPreview(for photo: AlbumPhoto, account: String) async -> UIImage? { + guard !Task.isCancelled, !photo.id.isEmpty else { return nil } + + let metadata = photo.metadata let fileId = photo.id let ocId = metadata.ocId let userId = metadata.userId @@ -95,36 +103,34 @@ struct PhotoGridItemView: View { let previewExt = NCGlobal.shared.previewExt512 let utility = NCUtility() - // 3. Look in the preview cache first. if let cachedImage = utility.getImage(ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) { - thumbnail = cachedImage - return + return cachedImage } - // 4. If the original file is local, generate its preview locally. - if NCUtilityFileSystem().fileProviderStorageExists(metadata) { - utility.createImageFileFrom(metadata: metadata) - if let localImage = utility.getImage(ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) { - thumbnail = localImage - return + guard metadata.hasPreview else { return nil } + + let results = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: etag, account: account) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: account, + path: fileId, + name: "DownloadPreview" + ) + await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) } } + guard !Task.isCancelled else { return nil } - // 5. Download the server preview only as the final fallback. - guard metadata.hasPreview else { return } - - let results = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: etag, account: localAccount) { _ in } - guard !Task.isCancelled else { return } - - if results.error == .success, - let data = results.responseData?.data, - let image = UIImage(data: data) { - thumbnail = image + guard results.error == .success, + let data = results.responseData?.data, + let image = UIImage(data: data) else { + return nil + } - // 6. Save the downloaded preview to the cache. - Task.detached(priority: .background) { - NCUtility().createImageFileFrom(data: data, ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) - } + Task.detached(priority: .background) { + NCUtility().createImageFileFrom(data: data, ocId: ocId, etag: etag, ext: previewExt, userId: userId, urlBase: urlBase) } + + return image } } diff --git a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift index 677d243372..5064dd4a75 100644 --- a/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift +++ b/iOSClient/Albums/Presentation/List/AlbumGridItemView.swift @@ -4,7 +4,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI -import NextcloudKit struct AlbumGridItemView: View { let album: Album @@ -77,7 +76,7 @@ struct AlbumGridItemView: View { guard let coverPhoto = await coverPhoto(), !Task.isCancelled, - let image = await loadImage(for: coverPhoto), + let image = await PhotoGridItemView.loadPreview(for: coverPhoto, account: localAccount), !Task.isCancelled else { return } @@ -109,49 +108,6 @@ struct AlbumGridItemView: View { } } - @MainActor - private func loadImage(for photo: AlbumPhoto) async -> UIImage? { - guard !Task.isCancelled else { return nil } - - let metadata = photo.metadata - let utility = NCUtility() - let previewExt = NCGlobal.shared.previewExt512 - - if let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: previewExt, userId: metadata.userId, urlBase: metadata.urlBase) { - return image - } - - if NCUtilityFileSystem().fileProviderStorageExists(metadata) { - utility.createImageFileFrom(metadata: metadata) - if let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: previewExt, userId: metadata.userId, urlBase: metadata.urlBase) { - return image - } - } - - guard metadata.hasPreview else { return nil } - return await downloadThumbnail(metadata: metadata) - } - - @MainActor - private func downloadThumbnail(metadata: tableMetadata) async -> UIImage? { - guard !Task.isCancelled else { return nil } - let fileId = metadata.fileId - let resultsPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: fileId, etag: metadata.etag, account: localAccount) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: localAccount, path: fileId, name: "DownloadPreview") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) - } - } - // A cancelled request must not overwrite a newer album cover. - guard !Task.isCancelled else { return nil } - guard resultsPreview.error == .success, - let data = resultsPreview.responseData?.data else { - return nil - } - - return NCUtility().createImageFileFrom(data: data, metadata: metadata, ext: NCGlobal.shared.previewExt512) - } - private var frame: some View { RoundedRectangle( cornerRadius: 8