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)
This commit is contained in:
Brandon Wees 2025-07-15 21:17:24 -05:00 committed by GitHub
parent 34620e1e9a
commit 743b6644e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 130 additions and 120 deletions

View File

@ -14,19 +14,6 @@ struct ImageEntry: TimelineEntry {
var deepLink: URL? = 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( static func build(
api: ImmichAPI, api: ImmichAPI,
asset: Asset, asset: Asset,
@ -83,7 +70,10 @@ struct ImageEntry: TimelineEntry {
guard let imageData = try? Data(contentsOf: imageURL), guard let imageData = try? Data(contentsOf: imageURL),
let metadataJSON = try? Data(contentsOf: metadataURL), let metadataJSON = try? Data(contentsOf: metadataURL),
let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON) let decodedMetadata = try? JSONDecoder().decode(
Metadata.self,
from: metadataJSON
)
else { else {
return nil return nil
} }
@ -98,7 +88,7 @@ struct ImageEntry: TimelineEntry {
return nil return nil
} }
static func handleCacheFallback( static func handleError(
for key: String, for key: String,
error: WidgetError = .fetchFailed error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> { ) -> Timeline<ImageEntry> {
@ -108,35 +98,32 @@ struct ImageEntry: TimelineEntry {
metadata: EntryMetadata(error: error) metadata: EntryMetadata(error: error)
) )
// skip cache if album not found or no login // use cache if generic failed error
// we want to show these errors to the user since without intervention, // we want to show the other errors to the user since without intervention,
// it will never succeed // it will never succeed
if error != .noLogin && error != .albumNotFound { if error == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
if let cachedEntry = ImageEntry.loadCached(for: key) { {
timelineEntry = cachedEntry timelineEntry = cachedEntry
}
} }
return Timeline(entries: [timelineEntry], policy: .atEnd) return Timeline(entries: [timelineEntry], policy: .atEnd)
} }
} }
func generateRandomEntries( func generateRandomEntries(
api: ImmichAPI, api: ImmichAPI,
now: Date, now: Date,
count: Int, count: Int,
albumId: String? = nil, filter: SearchFilter = Album.NONE.filter,
subtitle: String? = nil subtitle: String? = nil
) )
async throws -> [ImageEntry] async throws -> [ImageEntry]
{ {
var entries: [ImageEntry] = [] var entries: [ImageEntry] = []
let albumIds = albumId != nil ? [albumId!] : []
let randomAssets = try await api.fetchSearchResults( let randomAssets = try await api.fetchSearchResults(with: filter)
with: SearchFilters(size: count, albumIds: albumIds)
)
await withTaskGroup(of: ImageEntry?.self) { group in await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() { for (dateOffset, asset) in randomAssets.enumerated() {

View File

@ -1,6 +1,18 @@
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
extension Image {
@ViewBuilder
func tintedWidgetImageModifier() -> some View {
if #available(iOS 18.0, *) {
self
.widgetAccentedRenderingMode(.accentedDesaturated)
} else {
self
}
}
}
struct ImmichWidgetView: View { struct ImmichWidgetView: View {
var entry: ImageEntry var entry: ImageEntry
@ -8,6 +20,7 @@ struct ImmichWidgetView: View {
if entry.image == nil { if entry.image == nil {
VStack { VStack {
Image("LaunchImage") Image("LaunchImage")
.tintedWidgetImageModifier()
Text(entry.metadata.error?.errorDescription ?? "") Text(entry.metadata.error?.errorDescription ?? "")
.minimumScaleFactor(0.25) .minimumScaleFactor(0.25)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -19,7 +32,9 @@ struct ImmichWidgetView: View {
Color.clear.overlay( Color.clear.overlay(
Image(uiImage: entry.image!) Image(uiImage: entry.image!)
.resizable() .resizable()
.tintedWidgetImageModifier()
.scaledToFill() .scaledToFill()
) )
VStack { VStack {
Spacer() Spacer()

View File

@ -2,14 +2,20 @@ import Foundation
import SwiftUI import SwiftUI
import WidgetKit import WidgetKit
let IMMICH_SHARE_GROUP = "group.app.immich.share"
enum WidgetError: Error, Codable { enum WidgetError: Error, Codable {
case noLogin case noLogin
case fetchFailed case fetchFailed
case unknown
case albumNotFound case albumNotFound
case noAssetsAvailable
}
enum FetchError: Error {
case unableToResize case unableToResize
case invalidImage case invalidImage
case invalidURL case invalidURL
case fetchFailed
} }
extension WidgetError: LocalizedError { extension WidgetError: LocalizedError {
@ -24,14 +30,8 @@ extension WidgetError: LocalizedError {
case .albumNotFound: case .albumNotFound:
return "Album not found" return "Album not found"
case .invalidURL: case .noAssetsAvailable:
return "An invalid URL was used" return "No assets available"
case .invalidImage:
return "An invalid image was received"
default:
return "An unknown error occured"
} }
} }
} }
@ -52,10 +52,11 @@ struct Asset: Codable {
} }
} }
struct SearchFilters: Codable { struct SearchFilter: Codable {
var type: AssetType = .image var type = AssetType.image
let size: Int var size = 1
var albumIds: [String] = [] var albumIds: [String] = []
var isFavorite: Bool? = nil
} }
struct MemoryResult: Codable { struct MemoryResult: Codable {
@ -70,12 +71,35 @@ struct MemoryResult: Codable {
let data: MemoryData let data: MemoryData
} }
struct Album: Codable { struct Album: Codable, Equatable {
let id: String let id: String
let albumName: String let albumName: String
}
let IMMICH_SHARE_GROUP = "group.app.immich.share" static let NONE = Album(id: "NONE", albumName: "None")
static let FAVORITES = Album(id: "FAVORITES", albumName: "Favorites")
var filter: SearchFilter {
switch self {
case Album.NONE:
return SearchFilter()
case Album.FAVORITES:
return SearchFilter(isFavorite: true)
// regular album
default:
return SearchFilter(albumIds: [id])
}
}
var isVirtual: Bool {
switch self {
case Album.NONE, Album.FAVORITES:
return true
default:
return false
}
}
}
// MARK: API // MARK: API
@ -132,7 +156,8 @@ class ImmichAPI {
return components?.url return components?.url
} }
func fetchSearchResults(with filters: SearchFilters) async throws func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
async throws
-> [Asset] -> [Asset]
{ {
// get URL // get URL
@ -178,7 +203,7 @@ class ImmichAPI {
return try JSONDecoder().decode([MemoryResult].self, from: data) return try JSONDecoder().decode([MemoryResult].self, from: data)
} }
func fetchImage(asset: Asset) async throws(WidgetError) -> UIImage { func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")] let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail" let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
@ -199,7 +224,7 @@ class ImmichAPI {
let decodeOptions: [NSString: Any] = [ let decodeOptions: [NSString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 400, kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceCreateThumbnailWithTransform: true,
] ]

View File

@ -23,26 +23,27 @@ struct ImmichMemoryProvider: TimelineProvider {
Task { Task {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!) completion(
ImageEntry.handleError(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.handleCacheFallback(for: cacheKey).entries.first!) completion(ImageEntry.handleError(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 ImageEntry.build( let entry = try? await ImageEntry.build(
api: api, api: api,
asset: asset, asset: asset,
dateOffset: 0, dateOffset: 0,
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year) subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
) )
{ {
entry.resize()
completion(entry) completion(entry)
return return
} }
@ -50,20 +51,17 @@ struct ImmichMemoryProvider: TimelineProvider {
// fallback to random image // fallback to random image
guard guard
let randomImage = try? await api.fetchSearchResults( let randomImage = try? await api.fetchSearchResults().first,
with: SearchFilters(size: 1) let imageEntry = try? await ImageEntry.build(
).first,
var imageEntry = try? await ImageEntry.build(
api: api, api: api,
asset: randomImage, asset: randomImage,
dateOffset: 0 dateOffset: 0
) )
else { else {
completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) completion(ImageEntry.handleError(for: cacheKey).entries.first!)
return return
} }
imageEntry.resize()
completion(imageEntry) completion(imageEntry)
} }
} }
@ -80,7 +78,7 @@ struct ImmichMemoryProvider: TimelineProvider {
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
completion( completion(
ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) ImageEntry.handleError(for: cacheKey, error: .noLogin)
) )
return return
} }
@ -119,25 +117,28 @@ struct ImmichMemoryProvider: TimelineProvider {
// If we didnt add any memory images (some failure occured or no images in memory), // If we didnt add any memory images (some failure occured or no images in memory),
// default to 12 hours of random photos // default to 12 hours of random photos
if entries.count == 0 { if entries.count == 0 {
entries.append( // this must be a do/catch since we need to
contentsOf: (try? await generateRandomEntries( // distinguish between a network fail and an empty search
do {
let search = try await generateRandomEntries(
api: api, api: api,
now: now, now: now,
count: 12 count: 12
)) ?? [] )
)
}
// If we fail to fetch images, we still want to add an entry // Load or save a cached asset for when network conditions are bad
// with a nil image and an error if search.count == 0 {
if entries.count == 0 { completion(
completion(ImageEntry.handleCacheFallback(for: cacheKey)) ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
return )
} return
}
// Resize all images to something that can be stored by iOS entries.append(contentsOf: search)
for i in entries.indices { } catch {
entries[i].resize() completion(ImageEntry.handleError(for: cacheKey))
return
}
} }
// cache the last image // cache the last image

View File

@ -8,20 +8,21 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
struct AlbumQuery: EntityQuery { struct AlbumQuery: EntityQuery {
func entities(for identifiers: [Album.ID]) async throws -> [Album] { func entities(for identifiers: [Album.ID]) async throws -> [Album] {
// use cached albums to search return await suggestedEntities().filter {
var albums = (try? await AlbumCache.shared.getAlbums()) ?? []
albums.insert(NO_ALBUM, at: 0)
return albums.filter {
identifiers.contains($0.id) identifiers.contains($0.id)
} }
} }
func suggestedEntities() async throws -> [Album] { func suggestedEntities() async -> [Album] {
var albums = (try? await AlbumCache.shared.getAlbums(refresh: true)) ?? [] let albums = (try? await AlbumCache.shared.getAlbums()) ?? []
albums.insert(NO_ALBUM, at: 0)
return albums let options =
[
NONE,
FAVORITES,
] + albums
return options
} }
} }
@ -35,8 +36,6 @@ extension Album: @unchecked Sendable, AppEntity, Identifiable {
} }
} }
let NO_ALBUM = Album(id: "NONE", albumName: "None")
struct RandomConfigurationAppIntent: WidgetConfigurationIntent { struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Select Album" } static var title: LocalizedStringResource { "Select Album" }
static var description: IntentDescription { static var description: IntentDescription {
@ -64,26 +63,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
-> ImageEntry -> ImageEntry
{ {
let cacheKey = "random_none_\(context.family.rawValue)" let cacheKey = "random_none_\(context.family.rawValue)"
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first! return ImageEntry.handleError(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: Album.NONE.filter
).first, ).first,
var entry = try? await ImageEntry.build( let entry = try? await ImageEntry.build(
api: api, api: api,
asset: randomImage, asset: randomImage,
dateOffset: 0 dateOffset: 0
) )
else { else {
return ImageEntry.handleCacheFallback(for: cacheKey).entries.first! return ImageEntry.handleError(for: cacheKey).entries.first!
} }
entry.resize()
return entry return entry
} }
@ -97,52 +95,36 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let now = Date() let now = Date()
// nil if album is NONE or nil // nil if album is NONE or nil
let albumId = let album = configuration.album ?? Album.NONE
configuration.album?.id != "NONE" ? configuration.album?.id : nil let albumName = album.isVirtual ? nil : album.albumName
let albumName: String? =
albumId != nil ? configuration.album?.albumName : nil
let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)" let cacheKey = "random_\(album.id)_\(context.family.rawValue)"
// If we don't have a server config, return an entry with an error // If we don't have a server config, return an entry with an error
guard let api = try? await ImmichAPI() else { guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
if albumId != nil {
// make sure the album exists on server, otherwise show error
guard let albums = try? await api.fetchAlbums() else {
return ImageEntry.handleCacheFallback(for: cacheKey)
}
if !albums.contains(where: { $0.id == albumId }) {
return ImageEntry.handleCacheFallback(
for: cacheKey,
error: .albumNotFound
)
}
} }
// build entries // build entries
entries.append( // this must be a do/catch since we need to
contentsOf: (try? await generateRandomEntries( // distinguish between a network fail and an empty search
do {
let search = try await generateRandomEntries(
api: api, api: api,
now: now, now: now,
count: 12, count: 12,
albumId: albumId, filter: album.filter,
subtitle: configuration.showAlbumName ? albumName : nil subtitle: configuration.showAlbumName ? albumName : nil
)) )
?? []
)
// Load or save a cached asset for when network conditions are bad // Load or save a cached asset for when network conditions are bad
if entries.count == 0 { if search.count == 0 {
return ImageEntry.handleCacheFallback(for: cacheKey) return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
} }
// Resize all images to something that can be stored by iOS entries.append(contentsOf: search)
for i in entries.indices { } catch {
entries[i].resize() return ImageEntry.handleError(for: cacheKey)
} }
// cache the last image // cache the last image