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:
Brandon Wees
2025-06-17 09:43:09 -05:00
committed by GitHub
parent 15c488ccd9
commit a0f44f147b
19 changed files with 1092 additions and 1 deletions
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,58 @@
import SwiftUI
import WidgetKit
func buildEntry(
api: ImmichAPI,
asset: SearchResult,
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)
}
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
}
@@ -0,0 +1,90 @@
import SwiftUI
import WidgetKit
struct ImageEntry: TimelineEntry {
let date: Date
var image: UIImage?
var subtitle: String? = nil
var error: WidgetError? = 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 {
var entry: ImageEntry
func getErrorText(_ error: WidgetError?) -> String {
switch error {
case .noLogin:
return "Login to Immich"
case .fetchFailed:
return "Unable to connect to your Immich instance"
case .albumNotFound:
return "Album not found"
default:
return "An unknown error occured"
}
}
var body: some View {
if entry.image == nil {
VStack {
Image("LaunchImage")
Text(getErrorText(entry.error))
.minimumScaleFactor(0.25)
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
}
.padding(16)
} else {
ZStack(alignment: .leading) {
Color.clear.overlay(
Image(uiImage: entry.image!)
.resizable()
.scaledToFill()
)
VStack {
Spacer()
if let subtitle = entry.subtitle {
Text(subtitle)
.foregroundColor(.white)
.padding(8)
.background(Color.black.opacity(0.6))
.cornerRadius(8)
.font(.system(size: 16))
}
}
.padding(16)
}
}
}
}
#Preview(
as: .systemMedium,
widget: {
ImmichRandomWidget()
},
timeline: {
let date = Date()
ImageEntry(
date: date,
image: UIImage(named: "ImmichLogo"),
subtitle: "1 year ago"
)
}
)
+219
View File
@@ -0,0 +1,219 @@
import Foundation
import SwiftUI
import WidgetKit
enum WidgetError: Error {
case noLogin
case fetchFailed
case unknown
case albumNotFound
case unableToResize
}
enum AssetType: String, Codable {
case image = "IMAGE"
case video = "VIDEO"
case audio = "AUDIO"
case other = "OTHER"
}
struct SearchResult: Codable {
let id: String
let type: AssetType
}
struct SearchFilters: Codable {
var type: AssetType = .image
let size: Int
var albumIds: [String] = []
}
struct MemoryResult: Codable {
let id: String
var assets: [SearchResult]
let type: String
struct MemoryData: Codable {
let year: Int
}
let data: MemoryData
}
struct Album: Codable {
let id: String
let albumName: String
}
// MARK: API
class ImmichAPI {
struct ServerConfig {
let serverEndpoint: String
let sessionKey: String
}
let serverConfig: ServerConfig
init() async throws {
// fetch the credentials from the UserDefaults store that dart placed here
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
let serverURL = defaults.string(forKey: "widget_server_url"),
let sessionKey = defaults.string(forKey: "widget_auth_token")
else {
throw WidgetError.noLogin
}
if serverURL == "" || sessionKey == "" {
throw WidgetError.noLogin
}
serverConfig = ServerConfig(
serverEndpoint: serverURL,
sessionKey: sessionKey
)
}
private func buildRequestURL(
serverConfig: ServerConfig,
endpoint: String,
params: [URLQueryItem] = []
) -> URL? {
guard let baseURL = URL(string: serverConfig.serverEndpoint) else {
fatalError("Invalid base URL")
}
// Combine the base URL and API path
let fullPath = baseURL.appendingPathComponent(
endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
)
// Add the session key as a query parameter
var components = URLComponents(
url: fullPath,
resolvingAgainstBaseURL: false
)
components?.queryItems = [
URLQueryItem(name: "sessionKey", value: serverConfig.sessionKey)
]
components?.queryItems?.append(contentsOf: params)
return components?.url
}
func fetchSearchResults(with filters: SearchFilters) async throws
-> [SearchResult]
{
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/search/random"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "POST"
request.httpBody = try JSONEncoder().encode(filters)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([SearchResult].self, from: data)
}
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/memories",
params: memoryParams
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([MemoryResult].self, from: data)
}
func fetchImage(asset: SearchResult) async throws -> UIImage {
let thumbnailParams = [URLQueryItem(name: "size", value: "preview")]
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
guard
let fetchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: assetEndpoint,
params: thumbnailParams
)
else {
throw URLError(.badURL)
}
let (data, _) = try await URLSession.shared.data(from: fetchURL)
guard let img = UIImage(data: data) else {
throw URLError(.badServerResponse)
}
return img
}
func fetchAlbums() async throws -> [Album] {
// get URL
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,
endpoint: "/albums"
)
else {
throw URLError(.badURL)
}
var request = URLRequest(url: searchURL)
request.httpMethod = "GET"
let (data, _) = try await URLSession.shared.data(for: request)
// decode data
return try JSONDecoder().decode([Album].self, from: data)
}
}
// We need a shared cache for albums to efficiently handle the album picker queries
actor AlbumCache {
static let shared = AlbumCache()
private var api: ImmichAPI? = nil
private var albums: [Album]? = nil
func getAlbums(refresh: Bool = false) async throws -> [Album] {
// Check the API before we try to show cached albums
// Sometimes iOS caches this object and keeps it around
// even after nuking the timeline
api = try? await ImmichAPI()
guard api != nil else {
throw WidgetError.noLogin
}
if let albums, !refresh {
return albums
}
let fetched = try await api!.fetchAlbums()
albums = fetched
return fetched
}
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -0,0 +1,20 @@
//
// Utils.swift
// Runner
//
// Created by Alex Tran and Brandon Wees on 6/16/25.
//
import UIKit
extension UIImage {
/// Crops the image to ensure width and height do not exceed maxSize.
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
let format = imageRendererFormat
format.opaque = isOpaque
return UIGraphicsImageRenderer(size: canvas, format: format).image {
_ in draw(in: CGRect(origin: .zero, size: canvas))
}
}
}
@@ -0,0 +1,10 @@
import SwiftUI
import WidgetKit
@main
struct ImmichWidgetBundle: WidgetBundle {
var body: some Widget {
ImmichRandomWidget()
ImmichMemoryWidget()
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.immich.share</string>
</array>
</dict>
</plist>
@@ -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.")
}
}
@@ -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.")
}
}