Source Code added
This commit is contained in:
parent
800376eafd
commit
9efa9bc6dd
3912 changed files with 754770 additions and 2 deletions
6
mobile/ios/WidgetExtension/Assets.xcassets/Contents.json
Normal file
6
mobile/ios/WidgetExtension/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
148
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
148
mobile/ios/WidgetExtension/ImageEntry.swift
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
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
|
||||
}
|
||||
|
||||
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 handleError(
|
||||
for key: String,
|
||||
error: WidgetError = .fetchFailed
|
||||
) -> Timeline<ImageEntry> {
|
||||
var timelineEntry = ImageEntry(
|
||||
date: Date.now,
|
||||
image: nil,
|
||||
metadata: EntryMetadata(error: error)
|
||||
)
|
||||
|
||||
// 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 == .fetchFailed, let cachedEntry = ImageEntry.loadCached(for: key)
|
||||
{
|
||||
timelineEntry = cachedEntry
|
||||
}
|
||||
|
||||
return Timeline(entries: [timelineEntry], policy: .atEnd)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func generateRandomEntries(
|
||||
api: ImmichAPI,
|
||||
now: Date,
|
||||
count: Int,
|
||||
filter: SearchFilter = Album.NONE.filter,
|
||||
subtitle: String? = nil
|
||||
)
|
||||
async throws -> [ImageEntry]
|
||||
{
|
||||
|
||||
var entries: [ImageEntry] = []
|
||||
|
||||
let randomAssets = try await api.fetchSearchResults(with: filter)
|
||||
|
||||
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
|
||||
}
|
||||
72
mobile/ios/WidgetExtension/ImageWidgetView.swift
Normal file
72
mobile/ios/WidgetExtension/ImageWidgetView.swift
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
|
||||
var body: some View {
|
||||
if entry.image == nil {
|
||||
VStack {
|
||||
Image("LaunchImage")
|
||||
.tintedWidgetImageModifier()
|
||||
Text(entry.metadata.error?.errorDescription ?? "")
|
||||
.minimumScaleFactor(0.25)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
} else {
|
||||
ZStack(alignment: .leading) {
|
||||
Color.clear.overlay(
|
||||
Image(uiImage: entry.image!)
|
||||
.resizable()
|
||||
.tintedWidgetImageModifier()
|
||||
.scaledToFill()
|
||||
|
||||
)
|
||||
VStack {
|
||||
Spacer()
|
||||
if let subtitle = entry.metadata.subtitle {
|
||||
Text(subtitle)
|
||||
.foregroundColor(.white)
|
||||
.padding(8)
|
||||
.background(Color.black.opacity(0.6))
|
||||
.cornerRadius(8)
|
||||
.font(.system(size: 16))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.widgetURL(entry.metadata.deepLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview(
|
||||
as: .systemMedium,
|
||||
widget: {
|
||||
ImmichRandomWidget()
|
||||
},
|
||||
timeline: {
|
||||
let date = Date()
|
||||
ImageEntry(
|
||||
date: date,
|
||||
image: UIImage(named: "ImmichLogo"),
|
||||
metadata: EntryMetadata(
|
||||
subtitle: "1 year ago"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
313
mobile/ios/WidgetExtension/ImmichAPI.swift
Normal file
313
mobile/ios/WidgetExtension/ImmichAPI.swift
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
let IMMICH_SHARE_GROUP = "group.app.immich.share"
|
||||
|
||||
enum WidgetError: Error, Codable {
|
||||
case noLogin
|
||||
case fetchFailed
|
||||
case albumNotFound
|
||||
case noAssetsAvailable
|
||||
}
|
||||
|
||||
enum FetchError: Error {
|
||||
case unableToResize
|
||||
case invalidImage
|
||||
case invalidURL
|
||||
case fetchFailed
|
||||
}
|
||||
|
||||
extension WidgetError: LocalizedError {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noLogin:
|
||||
return "Login to Immich"
|
||||
|
||||
case .fetchFailed:
|
||||
return "Unable to connect to your Immich instance"
|
||||
|
||||
case .albumNotFound:
|
||||
return "Album not found"
|
||||
|
||||
case .noAssetsAvailable:
|
||||
return "No assets available"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AssetType: String, Codable {
|
||||
case image = "IMAGE"
|
||||
case video = "VIDEO"
|
||||
case audio = "AUDIO"
|
||||
case other = "OTHER"
|
||||
}
|
||||
|
||||
struct Asset: Codable {
|
||||
let id: String
|
||||
let type: AssetType
|
||||
|
||||
var deepLink: URL? {
|
||||
return URL(string: "immich://asset?id=\(id)")
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchFilter: Codable {
|
||||
var type = AssetType.image
|
||||
var size = 1
|
||||
var albumIds: [String] = []
|
||||
var isFavorite: Bool? = nil
|
||||
}
|
||||
|
||||
struct MemoryResult: Codable {
|
||||
let id: String
|
||||
var assets: [Asset]
|
||||
let type: String
|
||||
|
||||
struct MemoryData: Codable {
|
||||
let year: Int
|
||||
}
|
||||
|
||||
let data: MemoryData
|
||||
}
|
||||
|
||||
struct Album: Codable, Equatable {
|
||||
let id: String
|
||||
let albumName: String
|
||||
|
||||
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
|
||||
|
||||
class ImmichAPI {
|
||||
typealias CustomHeaders = [String:String]
|
||||
struct ServerConfig {
|
||||
let serverEndpoint: String
|
||||
let sessionKey: String
|
||||
let customHeaders: CustomHeaders
|
||||
}
|
||||
|
||||
let serverConfig: ServerConfig
|
||||
|
||||
init() async throws {
|
||||
// fetch the credentials from the UserDefaults store that dart placed here
|
||||
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
|
||||
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
|
||||
}
|
||||
|
||||
// custom headers come in the form of KV pairs in JSON
|
||||
var customHeadersJSON = (defaults.string(forKey: "widget_custom_headers") ?? "")
|
||||
var customHeaders: CustomHeaders = [:]
|
||||
|
||||
if customHeadersJSON != "",
|
||||
let parsedHeaders = try? JSONDecoder().decode(CustomHeaders.self, from: customHeadersJSON.data(using: .utf8)!) {
|
||||
customHeaders = parsedHeaders
|
||||
}
|
||||
|
||||
serverConfig = ServerConfig(
|
||||
serverEndpoint: serverURL,
|
||||
sessionKey: sessionKey,
|
||||
customHeaders: customHeaders
|
||||
)
|
||||
}
|
||||
|
||||
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 applyCustomHeaders(for request: inout URLRequest) {
|
||||
for (header, value) in serverConfig.customHeaders {
|
||||
request.addValue(value, forHTTPHeaderField: header)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchSearchResults(with filters: SearchFilter = Album.NONE.filter)
|
||||
async throws
|
||||
-> [Asset]
|
||||
{
|
||||
// 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")
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
return try JSONDecoder().decode([Asset].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"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// decode data
|
||||
return try JSONDecoder().decode([MemoryResult].self, from: data)
|
||||
}
|
||||
|
||||
func fetchImage(asset: Asset) async throws(FetchError) -> UIImage {
|
||||
let thumbnailParams = [URLQueryItem(name: "size", value: "preview"), URLQueryItem(name: "edited", value: "true")]
|
||||
let assetEndpoint = "/assets/" + asset.id + "/thumbnail"
|
||||
|
||||
guard
|
||||
let fetchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
endpoint: assetEndpoint,
|
||||
params: thumbnailParams
|
||||
)
|
||||
else {
|
||||
throw .invalidURL
|
||||
}
|
||||
|
||||
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
|
||||
else {
|
||||
throw .invalidURL
|
||||
}
|
||||
|
||||
let decodeOptions: [NSString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 512,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
]
|
||||
|
||||
guard
|
||||
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
|
||||
imageSource,
|
||||
0,
|
||||
decodeOptions as CFDictionary
|
||||
)
|
||||
else {
|
||||
throw .fetchFailed
|
||||
}
|
||||
|
||||
return UIImage(cgImage: thumbnail)
|
||||
}
|
||||
|
||||
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"
|
||||
applyCustomHeaders(for: &request)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
16
mobile/ios/WidgetExtension/Info.plist
Normal file
16
mobile/ios/WidgetExtension/Info.plist
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<?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>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
23
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
23
mobile/ios/WidgetExtension/UIImage+Resize.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
10
mobile/ios/WidgetExtension/WidgetBundle.swift
Normal file
10
mobile/ios/WidgetExtension/WidgetBundle.swift
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct ImmichWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
ImmichRandomWidget()
|
||||
ImmichMemoryWidget()
|
||||
}
|
||||
}
|
||||
10
mobile/ios/WidgetExtension/WidgetExtension.entitlements
Normal file
10
mobile/ios/WidgetExtension/WidgetExtension.entitlements
Normal file
|
|
@ -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>
|
||||
170
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
170
mobile/ios/WidgetExtension/widgets/MemoryWidget.swift
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
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
|
||||
) {
|
||||
let cacheKey = "memory_\(context.family.rawValue)"
|
||||
|
||||
Task {
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin).entries.first!
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
guard let memories = try? await api.fetchMemory(for: Date.now)
|
||||
else {
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
for memory in memories {
|
||||
if let asset = memory.assets.first(where: { $0.type == .image }),
|
||||
let entry = try? await ImageEntry.build(
|
||||
api: api,
|
||||
asset: asset,
|
||||
dateOffset: 0,
|
||||
subtitle: getYearDifferenceSubtitle(assetYear: memory.data.year)
|
||||
)
|
||||
{
|
||||
completion(entry)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to random image
|
||||
guard
|
||||
let randomImage = try? await api.fetchSearchResults().first,
|
||||
let imageEntry = try? await ImageEntry.build(
|
||||
api: api,
|
||||
asset: randomImage,
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
completion(ImageEntry.handleError(for: cacheKey).entries.first!)
|
||||
return
|
||||
}
|
||||
|
||||
completion(imageEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func getTimeline(
|
||||
in context: Context,
|
||||
completion: @escaping @Sendable (Timeline<ImageEntry>) -> Void
|
||||
) {
|
||||
Task {
|
||||
var entries: [ImageEntry] = []
|
||||
let now = Date()
|
||||
|
||||
let cacheKey = "memory_\(context.family.rawValue)"
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noLogin)
|
||||
)
|
||||
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 ImageEntry.build(
|
||||
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 didn't add any memory images (some failure occurred or no images in memory),
|
||||
// default to 12 hours of random photos
|
||||
if entries.count == 0 {
|
||||
// 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
|
||||
)
|
||||
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
completion(
|
||||
ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
completion(ImageEntry.handleError(for: cacheKey))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
156
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
156
mobile/ios/WidgetExtension/widgets/RandomWidget.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
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] {
|
||||
return await suggestedEntities().filter {
|
||||
identifiers.contains($0.id)
|
||||
}
|
||||
}
|
||||
|
||||
func suggestedEntities() async -> [Album] {
|
||||
let albums = (try? await AlbumCache.shared.getAlbums()) ?? []
|
||||
|
||||
let options =
|
||||
[
|
||||
NONE,
|
||||
FAVORITES,
|
||||
] + albums
|
||||
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
static var defaultQuery = AlbumQuery()
|
||||
static var typeDisplayRepresentation = TypeDisplayRepresentation(
|
||||
name: "Album"
|
||||
)
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
DisplayRepresentation(title: "\(albumName)")
|
||||
}
|
||||
}
|
||||
|
||||
struct RandomConfigurationAppIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource { "Select Album" }
|
||||
static var description: IntentDescription {
|
||||
"Choose an album to show images from"
|
||||
}
|
||||
|
||||
@Parameter(title: "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())
|
||||
}
|
||||
|
||||
func snapshot(
|
||||
for configuration: RandomConfigurationAppIntent,
|
||||
in context: Context
|
||||
) async
|
||||
-> ImageEntry
|
||||
{
|
||||
let cacheKey = "random_none_\(context.family.rawValue)"
|
||||
|
||||
guard let api = try? await ImmichAPI() else {
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noLogin).entries
|
||||
.first!
|
||||
}
|
||||
|
||||
guard
|
||||
let randomImage = try? await api.fetchSearchResults(
|
||||
with: Album.NONE.filter
|
||||
).first,
|
||||
let entry = try? await ImageEntry.build(
|
||||
api: api,
|
||||
asset: randomImage,
|
||||
dateOffset: 0
|
||||
)
|
||||
else {
|
||||
return ImageEntry.handleError(for: cacheKey).entries.first!
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func timeline(
|
||||
for configuration: RandomConfigurationAppIntent,
|
||||
in context: Context
|
||||
) async
|
||||
-> Timeline<ImageEntry>
|
||||
{
|
||||
var entries: [ImageEntry] = []
|
||||
let now = Date()
|
||||
|
||||
// nil if album is NONE or nil
|
||||
let album = configuration.album ?? Album.NONE
|
||||
let albumName = album.isVirtual ? nil : album.albumName
|
||||
|
||||
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.handleError(for: cacheKey, error: .noLogin)
|
||||
}
|
||||
|
||||
// build entries
|
||||
// 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,
|
||||
filter: album.filter,
|
||||
subtitle: configuration.showAlbumName ? albumName : nil
|
||||
)
|
||||
|
||||
// Load or save a cached asset for when network conditions are bad
|
||||
if search.count == 0 {
|
||||
return ImageEntry.handleError(for: cacheKey, error: .noAssetsAvailable)
|
||||
}
|
||||
|
||||
entries.append(contentsOf: search)
|
||||
} catch {
|
||||
return ImageEntry.handleError(for: cacheKey)
|
||||
}
|
||||
|
||||
// cache the last image
|
||||
try? entries.last!.cache(for: cacheKey)
|
||||
|
||||
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.")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue