diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index 5239702d4b..ee371703a8 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -14,19 +14,6 @@ struct ImageEntry: TimelineEntry { var deepLink: URL? = nil } - // Resizes the stored image to a maximum width of 450 pixels - mutating func resize() { - if image == nil || image!.size.height < 450 || image!.size.width < 450 { - return - } - - image = image?.resized(toWidth: 450) - - if image == nil { - metadata.error = .unableToResize - } - } - static func build( api: ImmichAPI, asset: Asset, @@ -83,7 +70,10 @@ struct ImageEntry: TimelineEntry { guard let imageData = try? Data(contentsOf: imageURL), let metadataJSON = try? Data(contentsOf: metadataURL), - let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON) + let decodedMetadata = try? JSONDecoder().decode( + Metadata.self, + from: metadataJSON + ) else { return nil } @@ -98,7 +88,7 @@ struct ImageEntry: TimelineEntry { return nil } - static func handleCacheFallback( + static func handleError( for key: String, error: WidgetError = .fetchFailed ) -> Timeline { @@ -108,35 +98,32 @@ struct ImageEntry: TimelineEntry { metadata: EntryMetadata(error: error) ) - // skip cache if album not found or no login - // we want to show these errors to the user since without intervention, + // use cache if generic failed error + // we want to show the other errors to the user since without intervention, // it will never succeed - if error != .noLogin && error != .albumNotFound { - if let cachedEntry = ImageEntry.loadCached(for: key) { - timelineEntry = cachedEntry - } + if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key) + { + timelineEntry = cachedEntry } return Timeline(entries: [timelineEntry], policy: .atEnd) } + } func generateRandomEntries( api: ImmichAPI, now: Date, count: Int, - albumId: String? = nil, + filter: SearchFilter = Album.NONE.filter, subtitle: String? = nil ) async throws -> [ImageEntry] { var entries: [ImageEntry] = [] - let albumIds = albumId != nil ? [albumId!] : [] - let randomAssets = try await api.fetchSearchResults( - with: SearchFilters(size: count, albumIds: albumIds) - ) + let randomAssets = try await api.fetchSearchResults(with: filter) await withTaskGroup(of: ImageEntry?.self) { group in for (dateOffset, asset) in randomAssets.enumerated() { diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 24072b0607..8e810b051e 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -1,6 +1,18 @@ import SwiftUI import WidgetKit +extension Image { + @ViewBuilder + func tintedWidgetImageModifier() -> some View { + if #available(iOS 18.0, *) { + self + .widgetAccentedRenderingMode(.accentedDesaturated) + } else { + self + } + } +} + struct ImmichWidgetView: View { var entry: ImageEntry @@ -8,6 +20,7 @@ struct ImmichWidgetView: View { if entry.image == nil { VStack { Image("LaunchImage") + .tintedWidgetImageModifier() Text(entry.metadata.error?.errorDescription ?? "") .minimumScaleFactor(0.25) .multilineTextAlignment(.center) @@ -19,7 +32,9 @@ struct ImmichWidgetView: View { Color.clear.overlay( Image(uiImage: entry.image!) .resizable() + .tintedWidgetImageModifier() .scaledToFill() + ) VStack { Spacer() diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index 7cefd9d5ee..36758b824c 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -2,14 +2,20 @@ import Foundation import SwiftUI import WidgetKit +let IMMICH_SHARE_GROUP = "group.app.immich.share" + enum WidgetError: Error, Codable { case noLogin case fetchFailed - case unknown case albumNotFound + case noAssetsAvailable +} + +enum FetchError: Error { case unableToResize case invalidImage case invalidURL + case fetchFailed } extension WidgetError: LocalizedError { @@ -24,14 +30,8 @@ extension WidgetError: LocalizedError { case .albumNotFound: return "Album not found" - case .invalidURL: - return "An invalid URL was used" - - case .invalidImage: - return "An invalid image was received" - - default: - return "An unknown error occured" + case .noAssetsAvailable: + return "No assets available" } } } @@ -52,10 +52,11 @@ struct Asset: Codable { } } -struct SearchFilters: Codable { - var type: AssetType = .image - let size: Int +struct SearchFilter: Codable { + var type = AssetType.image + var size = 1 var albumIds: [String] = [] + var isFavorite: Bool? = nil } struct MemoryResult: Codable { @@ -70,12 +71,35 @@ struct MemoryResult: Codable { let data: MemoryData } -struct Album: Codable { +struct Album: Codable, Equatable { let id: String let albumName: String -} -let IMMICH_SHARE_GROUP = "group.app.immich.share" + static let NONE = Album(id: "NONE", albumName: "None") + static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites") + + var filter: SearchFilter { + switch self { + case Album.NONE: + return SearchFilter() + case Album.FAVORITES: + return SearchFilter(isFavorite: true) + + // regular album + default: + return SearchFilter(albumIds: [id]) + } + } + + var isVirtual: Bool { + switch self { + case Album.NONE, Album.FAVORITES: + return true + default: + return false + } + } +} // MARK: API @@ -132,7 +156,8 @@ class ImmichAPI { return components?.url } - func fetchSearchResults(with filters: SearchFilters) async throws + func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter) + async throws -> [Asset] { // get URL @@ -178,7 +203,7 @@ class ImmichAPI { return try JSONDecoder().decode([MemoryResult].self, from: data) } - func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage { + func fetchImage(asset: Asset) async throws(FetchError) -> UIImage { let thumbnailParams = [URLQueryItem(name: "size", value: "preview")] let assetEndpoint = "/assets/" + asset.id + "/thumbnail" @@ -199,7 +224,7 @@ class ImmichAPI { let decodeOptions: [NSString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 400, + kCGImageSourceThumbnailMaxPixelSize: 512, kCGImageSourceCreateThumbnailWithTransform: true, ] diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index da7b887c37..d0a3e8c29d 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -23,26 +23,27 @@ struct ImmichMemoryProvider: TimelineProvider { Task { guard let api = try? await ImmichAPI() else { - completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!) + completion( + ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! + ) return } guard let memories = try? await api.fetchMemory(for: Date.now) else { - completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) + completion(ImageEntry.handleError(for: cacheKey).entries.first!) return } for memory in memories { if let asset = memory.assets.first(where: { $0.type == .image }), - var entry = try? await ImageEntry.build( + let entry = try? await ImageEntry.build( api: api, asset: asset, dateOffset: 0, subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) ) { - entry.resize() completion(entry) return } @@ -50,20 +51,17 @@ struct ImmichMemoryProvider: TimelineProvider { // fallback to random image guard - let randomImage = try? await api.fetchSearchResults( - with: SearchFilters(size: 1) - ).first, - var imageEntry = try? await ImageEntry.build( + let randomImage = try? await api.fetchSearchResults().first, + let imageEntry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) + completion(ImageEntry.handleError(for: cacheKey).entries.first!) return } - imageEntry.resize() completion(imageEntry) } } @@ -80,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider { guard let api = try? await ImmichAPI() else { completion( - ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) + ImageEntry.handleError(for: cacheKey, error: .noLogin) ) return } @@ -119,25 +117,28 @@ struct ImmichMemoryProvider: TimelineProvider { // If we didnt add any memory images (some failure occured or no images in memory), // default to 12 hours of random photos if entries.count == 0 { - entries.append( - contentsOf: (try? await generateRandomEntries( + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( api: api, now: now, count: 12 - )) ?? [] - ) - } + ) - // If we fail to fetch images, we still want to add an entry - // with a nil image and an error - if entries.count == 0 { - completion(ImageEntry.handleCacheFallback(for: cacheKey)) - return - } + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + completion( + ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + ) + return + } - // Resize all images to something that can be stored by iOS - for i in entries.indices { - entries[i].resize() + entries.append(contentsOf: search) + } catch { + completion(ImageEntry.handleError(for: cacheKey)) + return + } } // cache the last image diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 8f9143cedd..37f3c5e596 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -8,20 +8,21 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable { struct AlbumQuery: EntityQuery { func entities(for identifiers: [Album.ID]) async throws -> [Album] { - // use cached albums to search - var albums = (try? await AlbumCache.shared.getAlbums()) ?? [] - albums.insert(NO_ALBUM, at: 0) - - return albums.filter { + return await suggestedEntities().filter { identifiers.contains($0.id) } } - func suggestedEntities() async throws -> [Album] { - var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? [] - albums.insert(NO_ALBUM, at: 0) + func suggestedEntities() async -> [Album] { + let albums = (try? await AlbumCache.shared.getAlbums()) ?? [] - return albums + let options = + [ + NONE, + FAVORITES, + ] + albums + + return options } } @@ -35,8 +36,6 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable { } } -let NO_ALBUM = Album(id: "NONE", albumName: "None") - struct RandomConfigurationAppIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "Select Album" } static var description: IntentDescription { @@ -64,26 +63,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { -> ImageEntry { let cacheKey = "random_none_\(context.family.rawValue)" - + guard let api = try? await ImmichAPI() else { - return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first! + return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + .first! } guard let randomImage = try? await api.fetchSearchResults( - with: SearchFilters(size: 1) + with: Album.NONE.filter ).first, - var entry = try? await ImageEntry.build( + let entry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - return ImageEntry.handleCacheFallback(for: cacheKey).entries.first! + return ImageEntry.handleError(for: cacheKey).entries.first! } - entry.resize() - return entry } @@ -97,52 +95,36 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let now = Date() // nil if album is NONE or nil - let albumId = - configuration.album?.id != "NONE" ? configuration.album?.id : nil - let albumName: String? = - albumId != nil ? configuration.album?.albumName : nil + let album = configuration.album ?? Album.NONE + let albumName = album.isVirtual ? nil : album.albumName - let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)" + let cacheKey = "random_\(album.id)_\(context.family.rawValue)" // If we don't have a server config, return an entry with an error guard let api = try? await ImmichAPI() else { - return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) - } - - if albumId != nil { - // make sure the album exists on server, otherwise show error - guard let albums = try? await api.fetchAlbums() else { - return ImageEntry.handleCacheFallback(for: cacheKey) - } - - if !albums.contains(where: { $0.id == albumId }) { - return ImageEntry.handleCacheFallback( - for: cacheKey, - error: .albumNotFound - ) - } + return ImageEntry.handleError(for: cacheKey, error: .noLogin) } // build entries - entries.append( - contentsOf: (try? await generateRandomEntries( + // this must be a do/catch since we need to + // distinguish between a network fail and an empty search + do { + let search = try await generateRandomEntries( api: api, now: now, count: 12, - albumId: albumId, + filter: album.filter, subtitle: configuration.showAlbumName ? albumName : nil - )) - ?? [] - ) + ) - // Load or save a cached asset for when network conditions are bad - if entries.count == 0 { - return ImageEntry.handleCacheFallback(for: cacheKey) - } + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + } - // Resize all images to something that can be stored by iOS - for i in entries.indices { - entries[i].resize() + entries.append(contentsOf: search) + } catch { + return ImageEntry.handleError(for: cacheKey) } // cache the last image