immich/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Brandon Wees a0f44f147b
feat(mobile): ios widgets (#19148)
* feat: working widgets

* chore/feat: cleaned up API, added album picker to random widget

* album filtering for requests

* check album and throw if not found

* fix app IDs and project configuration

* switch to repository/service model for updating widgets

* fix: remove home widget import

* revert info.plist formatting changes

* ran swift-format on widget code

* more formatting changes (this time run from xcode)

* show memory on widget picker snapshot

* fix: dart changes from code review

* fix: swift code review changes (not including task groups)

* fix: use task groups to run image retrievals concurrently, get rid of do catch in favor of if let

* chore: cleanup widget service in dart app

* chore: format swift

* fix: remove comma

why does xcode not freak out over this >:(

* switch to preview size for thumbnail

* chore: cropped large image

* fix: properly resize widgets so we dont OOM

* fix: set app group on logout

happens on first install

* fix: stupid app ids

* fix: revert back to thumbnail

we are hitting OOM exceptions due to resizing, once we have on-the-fly resizing on server this can be upgraded

* fix: more memory efficient resizing method, remove extraneous resize commands from API call

* fix: random widget use 12 entries instead of 24 to save memory

* fix: modify duration of entries to 20 minutes and only generate 10 at a time to avoid OOM

* feat: toggle to show album name on random widget

* Podfile lock

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-06-17 14:43:09 +00:00

167 lines
4.4 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
) {
Task {
guard let api = try? await ImmichAPI() else {
completion(ImageEntry(date: Date(), image: nil, error: .noLogin))
return
}
guard let memories = try? await api.fetchMemory(for: Date.now)
else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
for memory in memories {
if let asset = memory.assets.first(where: { $0.type == .image }),
var entry = try? await buildEntry(
api: api,
asset: asset,
dateOffset: 0,
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
)
{
entry.resize()
completion(entry)
return
}
}
// fallback to random image
guard
let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1)
).first
else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
guard
var imageEntry = try? await buildEntry(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
return
}
imageEntry.resize()
completion(imageEntry)
}
}
func getTimeline(
in context: Context,
completion: @escaping @Sendable (Timeline<ImageEntry>) -> Void
) {
Task {
var entries: [ImageEntry] = []
let now = Date()
guard let api = try? await ImmichAPI() else {
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
completion(Timeline(entries: entries, policy: .atEnd))
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 buildEntry(
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 {
entries.append(
contentsOf: (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 {
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
}
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
}
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.")
}
}