immich/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Brandon Wees 743b6644e9
feat(widgets): iOS widget improvements (#19893)
* improvements to error handling, ability to select "Favorites" as a virtual album, fix widgets not showing image when tinting homescreen

* dont include isFavorite all the time

* remove check for if the album exists

this will never run because we default to Album.NONE and its impossible to distinguish between no album selected and album DNE (we dont know what the store ID is, only what iOS gives)
2025-07-15 21:17:24 -05:00

171 lines
4.5 KiB
Swift

import AppIntents
import SwiftUI
import WidgetKit
struct ImmichMemoryProvider: TimelineProvider {
func getYearDifferenceSubtitle(assetYear: Int) -> String {
let currentYear = Calendar.current.component(.year, from: Date.now)
// construct a "X years ago" subtitle
let yearDifference = currentYear - assetYear
return "\(yearDifference) year\(yearDifference == 1 ? "" : "s") ago"
}
func placeholder(in context: Context) -> ImageEntry {
ImageEntry(date: Date(), image: nil)
}
func getSnapshot(
in context: Context,
completion: @escaping @Sendable (ImageEntry) -> Void
) {
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)
}
}
func getTimeline(
in context: Context,
completion: @escaping @Sendable (Timeline<ImageEntry>) -> Void
) {
Task {
var entries: [ImageEntry] = []
let now = Date()
let cacheKey = "memory_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else {
completion(
ImageEntry.handleError(for: cacheKey, error: .noLogin)
)
return
}
let 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)
}
}
}
// 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 {
// 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))
}
}
}
struct ImmichMemoryWidget: Widget {
let kind: String = "com.immich.widget.memory"
var body: some WidgetConfiguration {
StaticConfiguration(
kind: kind,
provider: ImmichMemoryProvider()
) { entry in
ImmichWidgetView(entry: entry)
.containerBackground(.regularMaterial, for: .widget)
}
// allow image to take up entire widget
.contentMarginsDisabled()
// widget picker info
.configurationDisplayName("Memories")
.description("See memories from Immich.")
}
}