You've already forked immich
mirror of
https://github.com/immich-app/immich.git
synced 2025-08-08 23:07:06 +02:00
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>
This commit is contained in:
166
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
166
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
@ -0,0 +1,166 @@
|
||||
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.")
|
||||
}
|
||||
}
|
170
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
170
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
@ -0,0 +1,170 @@
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: Widget Configuration
|
||||
|
||||
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 {
|
||||
identifiers.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [Album] {
|
||||
var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? []
|
||||
albums.insert(NO_ALBUM, at: 0)
|
||||
|
||||
return albums
|
||||
}
|
||||
}
|
||||
|
||||
static var defaultQuery = AlbumQuery()
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(
|
||||
name: "Album"
|
||||
)
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(albumName)")
|
||||
}
|
||||
}
|
||||
|
||||
let NO_ALBUM = Album(id: "NONE", albumName: "None")
|
||||
|
||||
struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "Select Album" }
|
||||
static var description: IntentDescription {
|
||||
"Choose an album to show images from"
|
||||
}
|
||||
|
||||
@Parameter(title: "Album", default: NO_ALBUM)
|
||||
var album: Album?
|
||||
|
||||
@Parameter(title: "Show Album Name", default: false)
|
||||
var showAlbumName: Bool
|
||||
}
|
||||
|
||||
// MARK: Provider
|
||||
|
||||
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> ImageEntry {
|
||||
ImageEntry(date: Date(), image: nil)
|
||||
}
|
||||
|
||||
func snapshot(
|
||||
for configuration: RandomConfigurationAppIntent,
|
||||
in context: Context
|
||||
) async
|
||||
-> ImageEntry
|
||||
{
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return ImageEntry(date: Date(), image: nil, error: .noLogin)
|
||||
}
|
||||
|
||||
guard
|
||||
let randomImage = try? await api.fetchSearchResults(
|
||||
with: SearchFilters(size: 1)
|
||||
).first
|
||||
else {
|
||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
||||
}
|
||||
|
||||
guard
|
||||
var entry = try? await buildEntry(
|
||||
api: api,
|
||||
asset: randomImage,
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
||||
}
|
||||
|
||||
entry.resize()
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: RandomConfigurationAppIntent,
|
||||
in context: Context
|
||||
) async
|
||||
-> Timeline<ImageEntry>
|
||||
{
|
||||
var entries: [ImageEntry] = []
|
||||
let now = Date()
|
||||
|
||||
// If we don't have a server config, return an entry with an error
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
|
||||
// nil if album is NONE or nil
|
||||
let albumId =
|
||||
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
||||
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil
|
||||
|
||||
if albumId != nil {
|
||||
// make sure the album exists on server, otherwise show error
|
||||
guard let albums = try? await api.fetchAlbums() else {
|
||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
|
||||
if !albums.contains(where: { $0.id == albumId }) {
|
||||
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound))
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
||||
entries.append(
|
||||
contentsOf: (try? await generateRandomEntries(
|
||||
api: api,
|
||||
now: now,
|
||||
count: 12,
|
||||
albumId: albumId,
|
||||
subtitle: configuration.showAlbumName ? albumName : nil
|
||||
))
|
||||
?? []
|
||||
)
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
return Timeline(entries: entries, policy: .atEnd)
|
||||
}
|
||||
}
|
||||
|
||||
struct ImmichRandomWidget: Widget {
|
||||
let kind: String = "com.immich.widget.random"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(
|
||||
kind: kind,
|
||||
intent: RandomConfigurationAppIntent.self,
|
||||
provider: ImmichRandomProvider()
|
||||
) { entry in
|
||||
ImmichWidgetView(entry: entry)
|
||||
.containerBackground(.regularMaterial, for: .widget)
|
||||
}
|
||||
// allow image to take up entire widget
|
||||
.contentMarginsDisabled()
|
||||
|
||||
// widget picker info
|
||||
.configurationDisplayName("Random")
|
||||
.description("View a random image from your library or a specific album.")
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user