mirror of https://github.com/immich-app/immich.git
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>pull/19127/head
parent
15c488ccd9
commit
a0f44f147b
@ -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"
|
||||
)
|
||||
}
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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.")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
abstract interface class IWidgetRepository {
|
||||
Future<void> saveData(String key, String value);
|
||||
Future<void> refresh(String name);
|
||||
Future<void> setAppGroupId(String appGroupId);
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
import 'package:home_widget/home_widget.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/widget.interface.dart';
|
||||
|
||||
final widgetRepositoryProvider = Provider((_) => WidgetRepository());
|
||||
|
||||
class WidgetRepository implements IWidgetRepository {
|
||||
WidgetRepository();
|
||||
|
||||
@override
|
||||
Future<void> saveData(String key, String value) async {
|
||||
await HomeWidget.saveWidgetData<String>(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> refresh(String name) async {
|
||||
await HomeWidget.updateWidget(name: name, iOSName: name);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setAppGroupId(String appGroupId) async {
|
||||
await HomeWidget.setAppGroupId(appGroupId);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/interfaces/widget.interface.dart';
|
||||
import 'package:immich_mobile/repositories/widget.repository.dart';
|
||||
|
||||
final widgetServiceProvider = Provider((ref) {
|
||||
return WidgetService(
|
||||
ref.watch(widgetRepositoryProvider),
|
||||
);
|
||||
});
|
||||
|
||||
class WidgetService {
|
||||
final IWidgetRepository _repository;
|
||||
|
||||
WidgetService(this._repository);
|
||||
|
||||
Future<void> writeCredentials(String serverURL, String sessionKey) async {
|
||||
await _repository.setAppGroupId(appShareGroupId);
|
||||
await _repository.saveData(kWidgetServerEndpoint, serverURL);
|
||||
await _repository.saveData(kWidgetAuthToken, sessionKey);
|
||||
|
||||
// wait 3 seconds to ensure the widget is updated, dont block
|
||||
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||
}
|
||||
|
||||
Future<void> clearCredentials() async {
|
||||
await _repository.setAppGroupId(appShareGroupId);
|
||||
await _repository.saveData(kWidgetServerEndpoint, "");
|
||||
await _repository.saveData(kWidgetAuthToken, "");
|
||||
|
||||
// wait 3 seconds to ensure the widget is updated, dont block
|
||||
Future.delayed(const Duration(seconds: 3), refreshWidgets);
|
||||
}
|
||||
|
||||
Future<void> refreshWidgets() async {
|
||||
for (final name in kWidgetNames) {
|
||||
await _repository.refresh(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue