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
}
// 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,
@ -83,7 +70,10 @@ struct ImageEntry: TimelineEntry {
guard let imageData = try? Data(contentsOf: imageURL),
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 {
return nil
}
@ -98,7 +88,7 @@ struct ImageEntry: TimelineEntry {
return nil
}
static func handleCacheFallback(
static func handleError(
for key: String,
error: WidgetError = .fetchFailed
) -> Timeline<ImageEntry> {
@ -108,35 +98,32 @@ struct ImageEntry: TimelineEntry {
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,
// use cache if generic failed error
// we want to show the other 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
}
if error == .fetchFailed, 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,
filter: SearchFilter = Album.NONE.filter,
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)
)
let randomAssets = try await api.fetchSearchResults(with: filter)
await withTaskGroup(of: ImageEntry?.self) { group in
for (dateOffset, asset) in randomAssets.enumerated() {

View File

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

View File

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

View File

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

View File

@ -8,20 +8,21 @@ 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 {
return await suggestedEntities().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)
func suggestedEntities() async -> [Album] {
let albums = (try? await AlbumCache.shared.getAlbums()) ?? []
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 {
static var title: LocalizedStringResource { "Select Album" }
static var description: IntentDescription {
@ -64,26 +63,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
-> ImageEntry
{
let cacheKey = "random_none_\(context.family.rawValue)"
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
let randomImage = try? await api.fetchSearchResults(
with: SearchFilters(size: 1)
with: Album.NONE.filter
).first,
var entry = try? await ImageEntry.build(
let entry = try? await ImageEntry.build(
api: api,
asset: randomImage,
dateOffset: 0
)
else {
return ImageEntry.handleCacheFallback(for: cacheKey).entries.first!
return ImageEntry.handleError(for: cacheKey).entries.first!
}
entry.resize()
return entry
}
@ -97,52 +95,36 @@ struct ImmichRandomProvider: AppIntentTimelineProvider {
let now = Date()
// nil if album is NONE or nil
let albumId =
configuration.album?.id != "NONE" ? configuration.album?.id : nil
let albumName: String? =
albumId != nil ? configuration.album?.albumName : nil
let album = configuration.album ?? Album.NONE
let albumName = album.isVirtual ? nil : album.albumName
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
guard let api = try? await ImmichAPI() else {
return ImageEntry.handleCacheFallback(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
)
}
return ImageEntry.handleError(for: cacheKey, error: .noLogin)
}
// build entries
entries.append(
contentsOf: (try? await generateRandomEntries(
// 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,
albumId: albumId,
filter: album.filter,
subtitle: configuration.showAlbumName ? albumName : nil
))
?? []
)
)
// Load or save a cached asset for when network conditions are bad
if entries.count == 0 {
return ImageEntry.handleCacheFallback(for: cacheKey)
}
// Load or save a cached asset for when network conditions are bad
if search.count == 0 {
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
}
// Resize all images to something that can be stored by iOS
for i in entries.indices {
entries[i].resize()
entries.append(contentsOf: search)
} catch {
return ImageEntry.handleError(for: cacheKey)
}
// cache the last image