mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
feat(mobile): cache latest ios widget entry for fallback (#19824)
* cache the last image an ios widget fetched and use if a fetch fails in a future timeline build * code review fixes * downgrade pbx for flutter * use cache in snapshots
This commit is contained in:
parent
a201665b7e
commit
a918481c0b
@ -1,59 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import WidgetKit
|
|
||||||
|
|
||||||
func buildEntry(
|
|
||||||
api: ImmichAPI,
|
|
||||||
asset: Asset,
|
|
||||||
dateOffset: Int,
|
|
||||||
subtitle: String? = nil
|
|
||||||
)
|
|
||||||
async throws -> ImageEntry
|
|
||||||
{
|
|
||||||
let entryDate = Calendar.current.date(
|
|
||||||
byAdding: .minute,
|
|
||||||
value: dateOffset * 20,
|
|
||||||
to: Date.now
|
|
||||||
)!
|
|
||||||
let image = try await api.fetchImage(asset: asset)
|
|
||||||
|
|
||||||
return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink)
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateRandomEntries(
|
|
||||||
api: ImmichAPI,
|
|
||||||
now: Date,
|
|
||||||
count: Int,
|
|
||||||
albumId: String? = nil,
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
|
|
||||||
await withTaskGroup(of: ImageEntry?.self) { group in
|
|
||||||
for (dateOffset, asset) in randomAssets.enumerated() {
|
|
||||||
group.addTask {
|
|
||||||
return try? await buildEntry(
|
|
||||||
api: api,
|
|
||||||
asset: asset,
|
|
||||||
dateOffset: dateOffset,
|
|
||||||
subtitle: subtitle
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for await result in group {
|
|
||||||
if let entry = result {
|
|
||||||
entries.append(entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
161
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
161
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
typealias EntryMetadata = ImageEntry.Metadata
|
||||||
|
|
||||||
|
struct ImageEntry: TimelineEntry {
|
||||||
|
let date: Date
|
||||||
|
var image: UIImage?
|
||||||
|
var metadata: Metadata = Metadata()
|
||||||
|
|
||||||
|
struct Metadata: Codable {
|
||||||
|
var subtitle: String? = nil
|
||||||
|
var error: WidgetError? = nil
|
||||||
|
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,
|
||||||
|
dateOffset: Int,
|
||||||
|
subtitle: String? = nil
|
||||||
|
)
|
||||||
|
async throws -> Self
|
||||||
|
{
|
||||||
|
let entryDate = Calendar.current.date(
|
||||||
|
byAdding: .minute,
|
||||||
|
value: dateOffset * 20,
|
||||||
|
to: Date.now
|
||||||
|
)!
|
||||||
|
let image = try await api.fetchImage(asset: asset)
|
||||||
|
|
||||||
|
return Self(
|
||||||
|
date: entryDate,
|
||||||
|
image: image,
|
||||||
|
metadata: EntryMetadata(
|
||||||
|
subtitle: subtitle,
|
||||||
|
deepLink: asset.deepLink
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cache(for key: String) throws {
|
||||||
|
if let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||||
|
) {
|
||||||
|
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
|
||||||
|
let metadataURL = containerURL.appendingPathComponent(
|
||||||
|
"\(key)_metadata.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// build metadata JSON
|
||||||
|
let entryMetadata = try JSONEncoder().encode(self.metadata)
|
||||||
|
|
||||||
|
// write to disk
|
||||||
|
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
|
||||||
|
try entryMetadata.write(to: metadataURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadCached(for key: String, at date: Date = Date.now)
|
||||||
|
-> ImageEntry?
|
||||||
|
{
|
||||||
|
if let containerURL = FileManager.default.containerURL(
|
||||||
|
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
|
||||||
|
) {
|
||||||
|
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
|
||||||
|
let metadataURL = containerURL.appendingPathComponent(
|
||||||
|
"\(key)_metadata.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let imageData = try? Data(contentsOf: imageURL),
|
||||||
|
let metadataJSON = try? Data(contentsOf: metadataURL),
|
||||||
|
let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ImageEntry(
|
||||||
|
date: date,
|
||||||
|
image: UIImage(data: imageData),
|
||||||
|
metadata: decodedMetadata
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func handleCacheFallback(
|
||||||
|
for key: String,
|
||||||
|
error: WidgetError = .fetchFailed
|
||||||
|
) -> Timeline<ImageEntry> {
|
||||||
|
var timelineEntry = ImageEntry(
|
||||||
|
date: Date.now,
|
||||||
|
image: nil,
|
||||||
|
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,
|
||||||
|
// it will never succeed
|
||||||
|
if error != .noLogin && error != .albumNotFound {
|
||||||
|
if 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,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
await withTaskGroup(of: ImageEntry?.self) { group in
|
||||||
|
for (dateOffset, asset) in randomAssets.enumerated() {
|
||||||
|
group.addTask {
|
||||||
|
return try? await ImageEntry.build(
|
||||||
|
api: api,
|
||||||
|
asset: asset,
|
||||||
|
dateOffset: dateOffset,
|
||||||
|
subtitle: subtitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group {
|
||||||
|
if let entry = result {
|
||||||
|
entries.append(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
}
|
@ -1,27 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
struct ImageEntry: TimelineEntry {
|
|
||||||
let date: Date
|
|
||||||
var image: UIImage?
|
|
||||||
var subtitle: String? = nil
|
|
||||||
var error: WidgetError? = nil
|
|
||||||
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 {
|
|
||||||
error = .unableToResize
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ImmichWidgetView: View {
|
struct ImmichWidgetView: View {
|
||||||
var entry: ImageEntry
|
var entry: ImageEntry
|
||||||
|
|
||||||
@ -29,7 +8,7 @@ struct ImmichWidgetView: View {
|
|||||||
if entry.image == nil {
|
if entry.image == nil {
|
||||||
VStack {
|
VStack {
|
||||||
Image("LaunchImage")
|
Image("LaunchImage")
|
||||||
Text(entry.error?.errorDescription ?? "")
|
Text(entry.metadata.error?.errorDescription ?? "")
|
||||||
.minimumScaleFactor(0.25)
|
.minimumScaleFactor(0.25)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@ -44,7 +23,7 @@ struct ImmichWidgetView: View {
|
|||||||
)
|
)
|
||||||
VStack {
|
VStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
if let subtitle = entry.subtitle {
|
if let subtitle = entry.metadata.subtitle {
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@ -55,7 +34,7 @@ struct ImmichWidgetView: View {
|
|||||||
}
|
}
|
||||||
.padding(16)
|
.padding(16)
|
||||||
}
|
}
|
||||||
.widgetURL(entry.deepLink)
|
.widgetURL(entry.metadata.deepLink)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -70,7 +49,9 @@ struct ImmichWidgetView: View {
|
|||||||
ImageEntry(
|
ImageEntry(
|
||||||
date: date,
|
date: date,
|
||||||
image: UIImage(named: "ImmichLogo"),
|
image: UIImage(named: "ImmichLogo"),
|
||||||
subtitle: "1 year ago"
|
metadata: EntryMetadata(
|
||||||
|
subtitle: "1 year ago"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -2,7 +2,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
enum WidgetError: Error {
|
enum WidgetError: Error, Codable {
|
||||||
case noLogin
|
case noLogin
|
||||||
case fetchFailed
|
case fetchFailed
|
||||||
case unknown
|
case unknown
|
||||||
@ -23,10 +23,10 @@ extension WidgetError: LocalizedError {
|
|||||||
|
|
||||||
case .albumNotFound:
|
case .albumNotFound:
|
||||||
return "Album not found"
|
return "Album not found"
|
||||||
|
|
||||||
case .invalidURL:
|
case .invalidURL:
|
||||||
return "An invalid URL was used"
|
return "An invalid URL was used"
|
||||||
|
|
||||||
case .invalidImage:
|
case .invalidImage:
|
||||||
return "An invalid image was received"
|
return "An invalid image was received"
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ enum AssetType: String, Codable {
|
|||||||
struct Asset: Codable {
|
struct Asset: Codable {
|
||||||
let id: String
|
let id: String
|
||||||
let type: AssetType
|
let type: AssetType
|
||||||
|
|
||||||
var deepLink: URL? {
|
var deepLink: URL? {
|
||||||
return URL(string: "immich://asset?id=\(id)")
|
return URL(string: "immich://asset?id=\(id)")
|
||||||
}
|
}
|
||||||
@ -75,6 +75,8 @@ struct Album: Codable {
|
|||||||
let albumName: String
|
let albumName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
class ImmichAPI {
|
class ImmichAPI {
|
||||||
@ -86,7 +88,7 @@ class ImmichAPI {
|
|||||||
|
|
||||||
init() async throws {
|
init() async throws {
|
||||||
// fetch the credentials from the UserDefaults store that dart placed here
|
// fetch the credentials from the UserDefaults store that dart placed here
|
||||||
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
|
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
|
||||||
let serverURL = defaults.string(forKey: "widget_server_url"),
|
let serverURL = defaults.string(forKey: "widget_server_url"),
|
||||||
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
let sessionKey = defaults.string(forKey: "widget_auth_token")
|
||||||
else {
|
else {
|
||||||
@ -189,18 +191,25 @@ class ImmichAPI {
|
|||||||
else {
|
else {
|
||||||
throw .invalidURL
|
throw .invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else {
|
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
|
||||||
|
else {
|
||||||
throw .invalidURL
|
throw .invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
let decodeOptions: [NSString: Any] = [
|
let decodeOptions: [NSString: Any] = [
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
kCGImageSourceThumbnailMaxPixelSize: 400,
|
kCGImageSourceThumbnailMaxPixelSize: 400,
|
||||||
kCGImageSourceCreateThumbnailWithTransform: true
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
]
|
]
|
||||||
|
|
||||||
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else {
|
guard
|
||||||
|
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
|
||||||
|
imageSource,
|
||||||
|
0,
|
||||||
|
decodeOptions as CFDictionary
|
||||||
|
)
|
||||||
|
else {
|
||||||
throw .fetchFailed
|
throw .fetchFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,14 +7,17 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension UIImage {
|
extension UIImage {
|
||||||
/// Crops the image to ensure width and height do not exceed maxSize.
|
/// Crops the image to ensure width and height do not exceed maxSize.
|
||||||
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
|
||||||
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
|
||||||
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
|
let canvas = CGSize(
|
||||||
let format = imageRendererFormat
|
width: width,
|
||||||
format.opaque = isOpaque
|
height: CGFloat(ceil(width / size.width * size.height))
|
||||||
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
)
|
||||||
_ in draw(in: CGRect(origin: .zero, size: canvas))
|
let format = imageRendererFormat
|
||||||
}
|
format.opaque = isOpaque
|
||||||
|
return UIGraphicsImageRenderer(size: canvas, format: format).image {
|
||||||
|
_ in draw(in: CGRect(origin: .zero, size: canvas))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,21 +19,23 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
in context: Context,
|
in context: Context,
|
||||||
completion: @escaping @Sendable (ImageEntry) -> Void
|
completion: @escaping @Sendable (ImageEntry) -> Void
|
||||||
) {
|
) {
|
||||||
|
let cacheKey = "memory_\(context.family.rawValue)"
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .noLogin))
|
completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let memories = try? await api.fetchMemory(for: Date.now)
|
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||||
else {
|
else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for memory in memories {
|
for memory in memories {
|
||||||
if let asset = memory.assets.first(where: { $0.type == .image }),
|
if let asset = memory.assets.first(where: { $0.type == .image }),
|
||||||
var entry = try? await buildEntry(
|
var entry = try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: asset,
|
asset: asset,
|
||||||
dateOffset: 0,
|
dateOffset: 0,
|
||||||
@ -50,20 +52,14 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
guard
|
guard
|
||||||
let randomImage = try? await api.fetchSearchResults(
|
let randomImage = try? await api.fetchSearchResults(
|
||||||
with: SearchFilters(size: 1)
|
with: SearchFilters(size: 1)
|
||||||
).first
|
).first,
|
||||||
else {
|
var imageEntry = try? await ImageEntry.build(
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
var imageEntry = try? await buildEntry(
|
|
||||||
api: api,
|
api: api,
|
||||||
asset: randomImage,
|
asset: randomImage,
|
||||||
dateOffset: 0
|
dateOffset: 0
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed))
|
completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,9 +76,12 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
var entries: [ImageEntry] = []
|
var entries: [ImageEntry] = []
|
||||||
let now = Date()
|
let now = Date()
|
||||||
|
|
||||||
|
let cacheKey = "memory_\(context.family.rawValue)"
|
||||||
|
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .noLogin))
|
completion(
|
||||||
completion(Timeline(entries: entries, policy: .atEnd))
|
ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +94,7 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
for asset in memory.assets {
|
for asset in memory.assets {
|
||||||
if asset.type == .image && totalAssets < 12 {
|
if asset.type == .image && totalAssets < 12 {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
try? await buildEntry(
|
try? await ImageEntry.build(
|
||||||
api: api,
|
api: api,
|
||||||
asset: asset,
|
asset: asset,
|
||||||
dateOffset: totalAssets,
|
dateOffset: totalAssets,
|
||||||
@ -132,7 +131,8 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
// If we fail to fetch images, we still want to add an entry
|
// If we fail to fetch images, we still want to add an entry
|
||||||
// with a nil image and an error
|
// with a nil image and an error
|
||||||
if entries.count == 0 {
|
if entries.count == 0 {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
completion(ImageEntry.handleCacheFallback(for: cacheKey))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize all images to something that can be stored by iOS
|
// Resize all images to something that can be stored by iOS
|
||||||
@ -140,6 +140,9 @@ struct ImmichMemoryProvider: TimelineProvider {
|
|||||||
entries[i].resize()
|
entries[i].resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cache the last image
|
||||||
|
try? entries.last!.cache(for: cacheKey)
|
||||||
|
|
||||||
completion(Timeline(entries: entries, policy: .atEnd))
|
completion(Timeline(entries: entries, policy: .atEnd))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
|||||||
|
|
||||||
@Parameter(title: "Album")
|
@Parameter(title: "Album")
|
||||||
var album: Album?
|
var album: Album?
|
||||||
|
|
||||||
@Parameter(title: "Show Album Name", default: false)
|
@Parameter(title: "Show Album Name", default: false)
|
||||||
var showAlbumName: Bool
|
var showAlbumName: Bool
|
||||||
}
|
}
|
||||||
@ -54,7 +54,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
|||||||
|
|
||||||
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
struct ImmichRandomProvider: AppIntentTimelineProvider {
|
||||||
func placeholder(in context: Context) -> ImageEntry {
|
func placeholder(in context: Context) -> ImageEntry {
|
||||||
ImageEntry(date: Date(), image: nil)
|
ImageEntry(date: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func snapshot(
|
func snapshot(
|
||||||
@ -63,26 +63,23 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
) async
|
) async
|
||||||
-> ImageEntry
|
-> ImageEntry
|
||||||
{
|
{
|
||||||
|
let cacheKey = "random_none_\(context.family.rawValue)"
|
||||||
|
|
||||||
guard let api = try? await ImmichAPI() else {
|
guard let api = try? await ImmichAPI() else {
|
||||||
return ImageEntry(date: Date(), image: nil, error: .noLogin)
|
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard
|
||||||
let randomImage = try? await api.fetchSearchResults(
|
let randomImage = try? await api.fetchSearchResults(
|
||||||
with: SearchFilters(size: 1)
|
with: SearchFilters(size: 1)
|
||||||
).first
|
).first,
|
||||||
else {
|
var entry = try? await ImageEntry.build(
|
||||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
var entry = try? await buildEntry(
|
|
||||||
api: api,
|
api: api,
|
||||||
asset: randomImage,
|
asset: randomImage,
|
||||||
dateOffset: 0
|
dateOffset: 0
|
||||||
)
|
)
|
||||||
else {
|
else {
|
||||||
return ImageEntry(date: Date(), image: nil, error: .fetchFailed)
|
return ImageEntry.handleCacheFallback(for: cacheKey).entries.first!
|
||||||
}
|
}
|
||||||
|
|
||||||
entry.resize()
|
entry.resize()
|
||||||
@ -99,30 +96,34 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
var entries: [ImageEntry] = []
|
var entries: [ImageEntry] = []
|
||||||
let now = Date()
|
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
|
// nil if album is NONE or nil
|
||||||
let albumId =
|
let albumId =
|
||||||
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
configuration.album?.id != "NONE" ? configuration.album?.id : nil
|
||||||
var albumName: String? = albumId != nil ? configuration.album?.albumName : nil
|
let albumName: String? =
|
||||||
|
albumId != nil ? configuration.album?.albumName : nil
|
||||||
|
|
||||||
|
let cacheKey = "random_\(albumId ?? "none")_\(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 {
|
if albumId != nil {
|
||||||
// make sure the album exists on server, otherwise show error
|
// make sure the album exists on server, otherwise show error
|
||||||
guard let albums = try? await api.fetchAlbums() else {
|
guard let albums = try? await api.fetchAlbums() else {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
return ImageEntry.handleCacheFallback(for: cacheKey)
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !albums.contains(where: { $0.id == albumId }) {
|
if !albums.contains(where: { $0.id == albumId }) {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound))
|
return ImageEntry.handleCacheFallback(
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
for: cacheKey,
|
||||||
|
error: .albumNotFound
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// build entries
|
||||||
entries.append(
|
entries.append(
|
||||||
contentsOf: (try? await generateRandomEntries(
|
contentsOf: (try? await generateRandomEntries(
|
||||||
api: api,
|
api: api,
|
||||||
@ -134,9 +135,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
?? []
|
?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
// If we fail to fetch images, we still want to add an entry with a nil image and an error
|
// Load or save a cached asset for when network conditions are bad
|
||||||
if entries.count == 0 {
|
if entries.count == 0 {
|
||||||
entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed))
|
return ImageEntry.handleCacheFallback(for: cacheKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize all images to something that can be stored by iOS
|
// Resize all images to something that can be stored by iOS
|
||||||
@ -144,6 +145,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
|
|||||||
entries[i].resize()
|
entries[i].resize()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cache the last image
|
||||||
|
try? entries.last!.cache(for: cacheKey)
|
||||||
|
|
||||||
return Timeline(entries: entries, policy: .atEnd)
|
return Timeline(entries: entries, policy: .atEnd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user