diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift index ee371703a8..58f89339ac 100644 --- a/mobile/ios/WidgetExtension/ImageEntry.swift +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -12,13 +12,15 @@ struct ImageEntry: TimelineEntry { var subtitle: String? = nil var error: WidgetError? = nil var deepLink: URL? = nil + var forceFullColor: Bool = true } static func build( api: ImmichAPI, asset: Asset, dateOffset: Int, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> Self { @@ -34,7 +36,8 @@ struct ImageEntry: TimelineEntry { image: image, metadata: EntryMetadata( subtitle: subtitle, - deepLink: asset.deepLink + deepLink: asset.deepLink, + forceFullColor: forceFullColor ) ) } @@ -111,12 +114,26 @@ struct ImageEntry: TimelineEntry { } +// Required so that we can decode older versions of the metadata +extension ImageEntry.Metadata { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) + error = try container.decodeIfPresent(WidgetError.self, forKey: .error) + deepLink = try container.decodeIfPresent(URL.self, forKey: .deepLink) + + forceFullColor = + try container.decodeIfPresent(Bool.self, forKey: .forceFullColor) ?? true + } +} + func generateRandomEntries( api: ImmichAPI, now: Date, count: Int, filter: SearchFilter = Album.NONE.filter, - subtitle: String? = nil + subtitle: String? = nil, + forceFullColor: Bool = true ) async throws -> [ImageEntry] { @@ -132,7 +149,8 @@ func generateRandomEntries( api: api, asset: asset, dateOffset: dateOffset, - subtitle: subtitle + subtitle: subtitle, + forceFullColor: forceFullColor ) } } diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index 17088d43b8..2e3a816d92 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -3,10 +3,10 @@ import WidgetKit extension Image { @ViewBuilder - func tintedWidgetImageModifier() -> some View { + func tintedWidgetImageModifier(forceFullColor: Bool) -> some View { if #available(iOS 18.0, *) { self - .widgetAccentedRenderingMode(.accentedDesaturated) + .widgetAccentedRenderingMode(forceFullColor ? .accentedDesaturated : .fullColor) } else { self } @@ -18,15 +18,24 @@ struct ImmichWidgetView: View { var body: some View { if let image = entry.image { - ImmichWidgetContentView(image: image, subtitle: entry.metadata.subtitle, deepLink: entry.metadata.deepLink) + ImmichWidgetContentView( + image: image, + subtitle: entry.metadata.subtitle, + deepLink: entry.metadata.deepLink, + forceFullColor: entry.metadata.forceFullColor + ) } else { - ImmichWidgetLoadingView(message: entry.metadata.error?.errorDescription) + ImmichWidgetLoadingView( + message: entry.metadata.error?.errorDescription, + forceFullColor: entry.metadata.forceFullColor + ) } } } private struct ImmichWidgetLoadingView: View { let message: String? + let forceFullColor: Bool var body: some View { let messageText = Text(message ?? "") @@ -39,7 +48,7 @@ private struct ImmichWidgetLoadingView: View { messageText.hidden() Image("LaunchImage") - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) messageText } @@ -50,13 +59,14 @@ private struct ImmichWidgetContentView: View { let image: UIImage let subtitle: String? let deepLink: URL? + let forceFullColor: Bool var body: some View { ZStack(alignment: .leading) { Color.clear.overlay( Image(uiImage: image) .resizable() - .tintedWidgetImageModifier() + .tintedWidgetImageModifier(forceFullColor: forceFullColor) .scaledToFill() ) diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index 15fa3d722e..dee5a32159 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -2,7 +2,21 @@ import AppIntents import SwiftUI import WidgetKit -struct ImmichMemoryProvider: TimelineProvider { +// MARK: Widget Configuration + +struct MemoryConfigurationAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Memories" } + static var description: IntentDescription { + "See memories from Immich." + } + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool +} + +// MARK: Provider + +struct ImmichMemoryProvider: AppIntentTimelineProvider { func getYearDifferenceSubtitle(assetYear: Int) -> String { let currentYear = Calendar.current.component(.year, from: Date.now) // construct a "X years ago" subtitle @@ -15,144 +29,132 @@ struct ImmichMemoryProvider: TimelineProvider { ImageEntry(date: Date(), image: nil) } - func getSnapshot( - in context: Context, - completion: @escaping @Sendable (ImageEntry) -> Void - ) { + func snapshot( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async -> ImageEntry { let cacheKey = "memory_\(context.family.rawValue)" - Task { - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first! - ) - return - } - - guard let memories = try? await api.fetchMemory(for: Date.now) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - for memory in memories { - if let asset = memory.assets.first(where: { $0.type == .image }), - let entry = try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: 0, - subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) - ) - { - completion(entry) - return - } - } - - // fallback to random image - guard - let randomImage = try? await api.fetchSearchResults().first, - let imageEntry = try? await ImageEntry.build( - api: api, - asset: randomImage, - dateOffset: 0 - ) - else { - completion(ImageEntry.handleError(for: cacheKey).entries.first!) - return - } - - completion(imageEntry) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries + .first! } + + guard let memories = try? await api.fetchMemory(for: Date.now) else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + for memory in memories { + if let asset = memory.assets.first(where: { $0.type == .image }), + let entry = try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: 0, + subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year), + forceFullColor: configuration.forceFullColor + ) + { + return entry + } + } + + // fallback to random image + guard + let randomImage = try? await api.fetchSearchResults().first, + let imageEntry = try? await ImageEntry.build( + api: api, + asset: randomImage, + dateOffset: 0, + forceFullColor: configuration.forceFullColor + ) + else { + return ImageEntry.handleError(for: cacheKey).entries.first! + } + + return imageEntry } - func getTimeline( - in context: Context, - completion: @escaping @Sendable (Timeline) -> Void - ) { - Task { - var entries: [ImageEntry] = [] - let now = Date() + func timeline( + for configuration: MemoryConfigurationAppIntent, + in context: Context + ) async + -> Timeline + { + var entries: [ImageEntry] = [] + let now = Date() - let cacheKey = "memory_\(context.family.rawValue)" + let cacheKey = "memory_\(context.family.rawValue)" - guard let api = try? await ImmichAPI() else { - completion( - ImageEntry.handleError(for: cacheKey, error: .noLogin) - ) - return - } - - let memories: [MemoryResult] - do { - memories = try await api.fetchMemory(for: Date.now) - - await withTaskGroup(of: ImageEntry?.self) { group in - var totalAssets = 0 - - for memory in memories { - for asset in memory.assets { - if asset.type == .image && totalAssets < 12 { - group.addTask { - try? await ImageEntry.build( - api: api, - asset: asset, - dateOffset: totalAssets, - subtitle: getYearDifferenceSubtitle( - assetYear: memory.data.year - ) - ) - } - - totalAssets += 1 - } - } - } - - for await result in group { - if let entry = result { - entries.append(entry) - } - } - } - - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - - // If we didn't add any memory images (some failure occurred or no images in memory), - // default to 12 hours of random photos - if entries.count == 0 { - // 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 - ) - - // Load or save a cached asset for when network conditions are bad - if search.count == 0 { - completion( - ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) - ) - return - } - - entries.append(contentsOf: search) - } catch { - completion(ImageEntry.handleError(for: cacheKey)) - return - } - } - - // cache the last image - try? entries.last!.cache(for: cacheKey) - - completion(Timeline(entries: entries, policy: .atEnd)) + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleError(for: cacheKey, error: .noLogin) } + + let memories: [MemoryResult] + do { + memories = try await api.fetchMemory(for: Date.now) + + await withTaskGroup(of: ImageEntry?.self) { group in + var totalAssets = 0 + + for memory in memories { + for asset in memory.assets { + if asset.type == .image && totalAssets < 12 { + group.addTask { + try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: totalAssets, + subtitle: getYearDifferenceSubtitle( + assetYear: memory.data.year + ), + forceFullColor: configuration.forceFullColor + ) + } + + totalAssets += 1 + } + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + } catch { + return ImageEntry.handleError(for: cacheKey) + } + + // If we didn't add any memory images (some failure occurred or no images in memory), + // default to 12 hours of random photos + if entries.count == 0 { + // 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, + forceFullColor: configuration.forceFullColor + ) + + // Load or save a cached asset for when network conditions are bad + if search.count == 0 { + return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable) + } + + entries.append(contentsOf: search) + } catch { + return ImageEntry.handleError(for: cacheKey) + } + } + + // cache the last image + try? entries.last!.cache(for: cacheKey) + + return Timeline(entries: entries, policy: .atEnd) } } @@ -160,8 +162,9 @@ struct ImmichMemoryWidget: Widget { let kind: String = "com.immich.widget.memory" var body: some WidgetConfiguration { - StaticConfiguration( + AppIntentConfiguration( kind: kind, + intent: MemoryConfigurationAppIntent.self, provider: ImmichMemoryProvider() ) { entry in ImmichWidgetView(entry: entry) diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 37f3c5e596..d987464152 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -47,6 +47,9 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent { @Parameter(title: "Show Album Name", default: false) var showAlbumName: Bool + + @Parameter(title: "Always Display in Full Color", default: false) + var forceFullColor: Bool } // MARK: Provider @@ -76,7 +79,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { let entry = try? await ImageEntry.build( api: api, asset: randomImage, - dateOffset: 0 + dateOffset: 0, + forceFullColor: configuration.forceFullColor ) else { return ImageEntry.handleError(for: cacheKey).entries.first! @@ -114,7 +118,8 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { now: now, count: 12, filter: album.filter, - subtitle: configuration.showAlbumName ? albumName : nil + subtitle: configuration.showAlbumName ? albumName : nil, + forceFullColor: configuration.forceFullColor ) // Load or save a cached asset for when network conditions are bad