Track which recipe IDs are used as category thumbnails and prefer different images for the all recipes 2x2 mosaic, falling back to category thumbnails only when not enough other images are available. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
562 lines
19 KiB
Swift
562 lines
19 KiB
Swift
//
|
|
// MainViewModel.swift
|
|
// Nextcloud Cookbook iOS Client
|
|
//
|
|
// Created by Vincent Meilinger on 06.09.23.
|
|
//
|
|
|
|
import Foundation
|
|
import OSLog
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
|
|
@MainActor class AppState: ObservableObject {
|
|
@Published var categories: [Category] = []
|
|
@Published var recipes: [String: [Recipe]] = [:]
|
|
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
|
@Published var timers: [String: RecipeTimer] = [:]
|
|
@Published var categoryImages: [String: UIImage] = [:]
|
|
@Published var categoryImageRecipeIds: Set<Int> = []
|
|
@Published var recentRecipes: [Recipe] = []
|
|
@Published var categoryAccessDates: [String: Date] = [:]
|
|
@Published var manualCategoryOrder: [String] = []
|
|
var recipeImages: [Int: [String: UIImage]] = [:]
|
|
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
|
var lastUpdates: [String: Date] = [:]
|
|
var allKeywords: [RecipeKeyword] = []
|
|
|
|
private let dataStore: DataStore
|
|
private let api: CookbookApiProtocol
|
|
|
|
init(api: CookbookApiProtocol? = nil) {
|
|
Logger.network.debug("Created AppState")
|
|
self.dataStore = DataStore()
|
|
self.api = api ?? CookbookApiFactory.makeClient()
|
|
|
|
if UserSettings.shared.authString == "" {
|
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
|
let loginData = loginString.data(using: String.Encoding.utf8)!
|
|
UserSettings.shared.authString = loginData.base64EncodedString()
|
|
}
|
|
}
|
|
|
|
enum FetchMode {
|
|
case preferLocal, preferServer, onlyLocal, onlyServer
|
|
}
|
|
|
|
|
|
// MARK: - Categories
|
|
|
|
func getCategories() async {
|
|
do {
|
|
let categories = try await api.getCategories()
|
|
Logger.data.debug("Successfully loaded categories")
|
|
self.categories = categories
|
|
await saveLocal(self.categories, path: "categories.data")
|
|
} catch {
|
|
Logger.data.debug("Loading categories from store ...")
|
|
if let categories: [Category] = await loadLocal(path: "categories.data") {
|
|
self.categories = categories
|
|
Logger.data.debug("Loaded categories from local store")
|
|
} else {
|
|
Logger.data.error("Failed to load categories from local store")
|
|
}
|
|
}
|
|
|
|
for category in self.categories {
|
|
lastUpdates[category.name] = Date.distantPast
|
|
}
|
|
}
|
|
|
|
func getCategory(named name: String, fetchMode: FetchMode) async {
|
|
Logger.data.debug("getCategory(\(name), fetchMode: \(String(describing: fetchMode)))")
|
|
func getLocal() async -> Bool {
|
|
let categoryString = name == "*" ? "_" : name
|
|
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
|
|
self.recipes[name] = recipes
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func getServer(store: Bool = false) async -> Bool {
|
|
let categoryString = name == "*" ? "_" : name
|
|
do {
|
|
let recipes = try await api.getCategory(named: categoryString)
|
|
self.recipes[name] = recipes
|
|
if store {
|
|
await saveLocal(recipes, path: "category_\(categoryString).data")
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
switch fetchMode {
|
|
case .preferLocal:
|
|
if await getLocal() { return }
|
|
if await getServer(store: true) { return }
|
|
case .preferServer:
|
|
if await getServer(store: true) { return }
|
|
if await getLocal() { return }
|
|
case .onlyLocal:
|
|
if await getLocal() { return }
|
|
case .onlyServer:
|
|
if await getServer() { return }
|
|
}
|
|
}
|
|
|
|
// MARK: - Recipe details
|
|
|
|
func updateAllRecipeDetails() async {
|
|
for category in self.categories {
|
|
await updateRecipeDetails(in: category.name)
|
|
}
|
|
UserSettings.shared.lastUpdate = Date()
|
|
}
|
|
|
|
func updateRecipeDetails(in category: String) async {
|
|
guard UserSettings.shared.storeRecipes else { return }
|
|
guard let recipes = self.recipes[category] else { return }
|
|
for recipe in recipes {
|
|
if let dateModified = recipe.dateModified {
|
|
if needsUpdate(category: category, lastModified: dateModified) {
|
|
Logger.data.debug("\(recipe.name) needs an update. (last modified: \(recipe.dateModified ?? "unknown"))")
|
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
|
} else {
|
|
Logger.data.debug("\(recipe.name) is up to date.")
|
|
}
|
|
} else {
|
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
|
}
|
|
}
|
|
}
|
|
|
|
func getRecipes() async -> [Recipe] {
|
|
do {
|
|
return try await api.getRecipes()
|
|
} catch {
|
|
Logger.network.error("Failed to fetch recipes: \(error.localizedDescription)")
|
|
}
|
|
var allRecipes: [Recipe] = []
|
|
for category in categories {
|
|
if let recipeArray = self.recipes[category.name] {
|
|
allRecipes.append(contentsOf: recipeArray)
|
|
}
|
|
}
|
|
return allRecipes.sorted(by: { $0.name < $1.name })
|
|
}
|
|
|
|
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
|
|
func getLocal() async -> RecipeDetail? {
|
|
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
|
|
return nil
|
|
}
|
|
|
|
func getServer() async -> RecipeDetail? {
|
|
do {
|
|
let recipe = try await api.getRecipe(id: id)
|
|
if save {
|
|
self.recipeDetails[id] = recipe
|
|
await self.saveLocal(recipe, path: "recipe\(id).data")
|
|
}
|
|
return recipe
|
|
} catch {
|
|
Logger.network.error("Failed to fetch recipe \(id): \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch fetchMode {
|
|
case .preferLocal:
|
|
if let recipe = await getLocal() { return recipe }
|
|
if let recipe = await getServer() { return recipe }
|
|
case .preferServer:
|
|
if let recipe = await getServer() { return recipe }
|
|
if let recipe = await getLocal() { return recipe }
|
|
case .onlyLocal:
|
|
if let recipe = await getLocal() { return recipe }
|
|
case .onlyServer:
|
|
if let recipe = await getServer() { return recipe }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func updateRecipeDetail(id: Int, withThumb: Bool, withImage: Bool) async {
|
|
if let recipeDetail = await getRecipe(id: id, fetchMode: .onlyServer) {
|
|
await saveLocal(recipeDetail, path: "recipe\(id).data")
|
|
}
|
|
|
|
if withThumb {
|
|
let thumbnail = await getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
|
|
guard let thumbnail = thumbnail else { return }
|
|
guard let thumbnailData = thumbnail.pngData() else { return }
|
|
await saveLocal(thumbnailData.base64EncodedString(), path: "image\(id)_thumb")
|
|
}
|
|
|
|
if withImage {
|
|
let image = await getImage(id: id, size: .FULL, fetchMode: .onlyServer)
|
|
guard let image = image else { return }
|
|
guard let imageData = image.pngData() else { return }
|
|
await saveLocal(imageData.base64EncodedString(), path: "image\(id)_full")
|
|
}
|
|
}
|
|
|
|
func recipeDetailExists(recipeId: Int) -> Bool {
|
|
return dataStore.recipeDetailExists(recipeId: recipeId)
|
|
}
|
|
|
|
// MARK: - Images
|
|
|
|
func getImage(id: Int, size: RecipeImage.RecipeImageSize, fetchMode: FetchMode) async -> UIImage? {
|
|
func getLocal() async -> UIImage? {
|
|
return await imageFromStore(id: id, size: size)
|
|
}
|
|
|
|
func getServer() async -> UIImage? {
|
|
do {
|
|
return try await api.getImage(id: id, size: size)
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch fetchMode {
|
|
case .preferLocal:
|
|
if let image = imageFromCache(id: id, size: size) {
|
|
return image
|
|
}
|
|
if !imageUpdateNeeded(id: id, size: size) { return nil }
|
|
if let image = await getLocal() {
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
if let image = await getServer() {
|
|
await imageToStore(id: id, size: size, image: image)
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
case .preferServer:
|
|
if let image = await getServer() {
|
|
await imageToStore(id: id, size: size, image: image)
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
if let image = imageFromCache(id: id, size: size) {
|
|
return image
|
|
}
|
|
if let image = await getLocal() {
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
case .onlyLocal:
|
|
if let image = imageFromCache(id: id, size: size) {
|
|
return image
|
|
}
|
|
if !imageUpdateNeeded(id: id, size: size) { return nil }
|
|
if let image = await getLocal() {
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
case .onlyServer:
|
|
if let image = imageFromCache(id: id, size: size) {
|
|
return image
|
|
}
|
|
if let image = await getServer() {
|
|
imageToCache(id: id, size: size, image: image)
|
|
return image
|
|
}
|
|
}
|
|
imagesNeedUpdate[id] = [size.rawValue: false]
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Keywords
|
|
|
|
func getKeywords(fetchMode: FetchMode) async -> [RecipeKeyword] {
|
|
func getLocal() async -> [RecipeKeyword]? {
|
|
return await loadLocal(path: "keywords.data")
|
|
}
|
|
|
|
func getServer() async -> [RecipeKeyword]? {
|
|
do {
|
|
return try await api.getTags()
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch fetchMode {
|
|
case .preferLocal:
|
|
if let keywords = await getLocal() { return keywords }
|
|
if let keywords = await getServer() {
|
|
await saveLocal(keywords, path: "keywords.data")
|
|
return keywords
|
|
}
|
|
case .preferServer:
|
|
if let keywords = await getServer() {
|
|
await saveLocal(keywords, path: "keywords.data")
|
|
return keywords
|
|
}
|
|
if let keywords = await getLocal() { return keywords }
|
|
case .onlyLocal:
|
|
if let keywords = await getLocal() { return keywords }
|
|
case .onlyServer:
|
|
if let keywords = await getServer() { return keywords }
|
|
}
|
|
return []
|
|
}
|
|
|
|
// MARK: - Category images
|
|
|
|
func getCategoryImage(for categoryName: String) async {
|
|
guard categoryImages[categoryName] == nil else { return }
|
|
// Ensure recipes for this category are loaded
|
|
if self.recipes[categoryName] == nil || self.recipes[categoryName]!.isEmpty {
|
|
await getCategory(named: categoryName, fetchMode: .preferLocal)
|
|
}
|
|
guard let recipes = self.recipes[categoryName], !recipes.isEmpty else { return }
|
|
for recipe in recipes {
|
|
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
|
self.categoryImages[categoryName] = image
|
|
self.categoryImageRecipeIds.insert(recipe.recipe_id)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent recipes
|
|
|
|
func addToRecentRecipes(_ recipe: Recipe) {
|
|
recentRecipes.removeAll { $0.recipe_id == recipe.recipe_id }
|
|
recentRecipes.insert(recipe, at: 0)
|
|
if recentRecipes.count > 10 {
|
|
recentRecipes = Array(recentRecipes.prefix(10))
|
|
}
|
|
Task {
|
|
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
|
}
|
|
}
|
|
|
|
func loadRecentRecipes() async {
|
|
if let loaded: [Recipe] = await loadLocal(path: "recent_recipes.data") {
|
|
self.recentRecipes = loaded
|
|
}
|
|
}
|
|
|
|
func clearRecentRecipes() {
|
|
recentRecipes = []
|
|
dataStore.delete(path: "recent_recipes.data")
|
|
}
|
|
|
|
// MARK: - Category sorting
|
|
|
|
func trackCategoryAccess(_ categoryName: String) {
|
|
categoryAccessDates[categoryName] = Date()
|
|
Task {
|
|
await saveLocal(categoryAccessDates, path: "category_access_dates.data")
|
|
}
|
|
}
|
|
|
|
func loadCategoryAccessDates() async {
|
|
if let loaded: [String: Date] = await loadLocal(path: "category_access_dates.data") {
|
|
self.categoryAccessDates = loaded
|
|
}
|
|
}
|
|
|
|
func updateManualCategoryOrder(_ order: [String]) {
|
|
manualCategoryOrder = order
|
|
Task {
|
|
await saveLocal(manualCategoryOrder, path: "manual_category_order.data")
|
|
}
|
|
}
|
|
|
|
func loadManualCategoryOrder() async {
|
|
if let loaded: [String] = await loadLocal(path: "manual_category_order.data") {
|
|
self.manualCategoryOrder = loaded
|
|
}
|
|
}
|
|
|
|
// MARK: - Data management
|
|
|
|
func deleteAllData() {
|
|
if dataStore.clearAll() {
|
|
self.categories = []
|
|
self.recipes = [:]
|
|
self.recipeDetails = [:]
|
|
self.recipeImages = [:]
|
|
self.imagesNeedUpdate = [:]
|
|
}
|
|
}
|
|
|
|
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
|
do {
|
|
try await api.deleteRecipe(id: id)
|
|
} catch {
|
|
return .REQUEST_DROPPED
|
|
}
|
|
|
|
let path = "recipe\(id).data"
|
|
dataStore.delete(path: path)
|
|
if recipes[categoryName] != nil {
|
|
recipes[categoryName]!.removeAll(where: { recipe in
|
|
recipe.recipe_id == id
|
|
})
|
|
recipeDetails.removeValue(forKey: id)
|
|
}
|
|
recentRecipes.removeAll { $0.recipe_id == id }
|
|
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
|
return nil
|
|
}
|
|
|
|
func checkServerConnection() async -> Bool {
|
|
do {
|
|
let categories = try await api.getCategories()
|
|
self.categories = categories
|
|
await saveLocal(categories, path: "categories.data")
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> (Int?, RequestAlert?) {
|
|
do {
|
|
if createNew {
|
|
let id = try await api.createRecipe(recipeDetail)
|
|
return (id, nil)
|
|
} else {
|
|
let id = try await api.updateRecipe(recipeDetail)
|
|
return (id, nil)
|
|
}
|
|
} catch {
|
|
return (nil, .REQUEST_DROPPED)
|
|
}
|
|
}
|
|
|
|
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
|
do {
|
|
let recipeDetail = try await api.importRecipe(url: url)
|
|
return (recipeDetail, nil)
|
|
} catch {
|
|
return (nil, .REQUEST_DROPPED)
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: - Local storage helpers
|
|
|
|
extension AppState {
|
|
func loadLocal<T: Codable>(path: String) async -> T? {
|
|
do {
|
|
return try await dataStore.load(fromPath: path)
|
|
} catch {
|
|
Logger.data.debug("Failed to load local data: \(error.localizedDescription)")
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func saveLocal<T: Codable>(_ object: T, path: String) async {
|
|
await dataStore.save(data: object, toPath: path)
|
|
}
|
|
|
|
private func imageFromStore(id: Int, size: RecipeImage.RecipeImageSize) async -> UIImage? {
|
|
do {
|
|
let localPath = "image\(id)_\(size == .FULL ? "full" : "thumb")"
|
|
if let data: String = try await dataStore.load(fromPath: localPath) {
|
|
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
|
|
let image = UIImage(data: dataDecoded)
|
|
return image
|
|
}
|
|
} catch {
|
|
Logger.data.debug("Could not find image in local storage.")
|
|
return nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func imageToStore(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) async {
|
|
if let data = image.pngData() {
|
|
await saveLocal(data.base64EncodedString(), path: "image\(id)_\(size.rawValue)")
|
|
}
|
|
}
|
|
|
|
private func imageToCache(id: Int, size: RecipeImage.RecipeImageSize, image: UIImage) {
|
|
if recipeImages[id] != nil {
|
|
recipeImages[id]![size.rawValue] = image
|
|
} else {
|
|
recipeImages[id] = [size.rawValue : image]
|
|
}
|
|
if imagesNeedUpdate[id] != nil {
|
|
imagesNeedUpdate[id]![size.rawValue] = false
|
|
} else {
|
|
imagesNeedUpdate[id] = [size.rawValue: false]
|
|
}
|
|
}
|
|
|
|
private func imageFromCache(id: Int, size: RecipeImage.RecipeImageSize) -> UIImage? {
|
|
if recipeImages[id] != nil {
|
|
return recipeImages[id]![size.rawValue]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func imageUpdateNeeded(id: Int, size: RecipeImage.RecipeImageSize) -> Bool {
|
|
if imagesNeedUpdate[id] != nil {
|
|
if imagesNeedUpdate[id]![size.rawValue] != nil {
|
|
return imagesNeedUpdate[id]![size.rawValue]!
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func needsUpdate(category: String, lastModified: String) -> Bool {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
|
|
if let date = dateFormatter.date(from: lastModified), let lastUpdate = lastUpdates[category] {
|
|
if date < lastUpdate {
|
|
Logger.data.debug("No update needed for \(category)")
|
|
return false
|
|
} else {
|
|
Logger.data.debug("Update needed for \(category)")
|
|
return true
|
|
}
|
|
}
|
|
Logger.data.debug("Date parse failed, update needed for \(category)")
|
|
return true
|
|
}
|
|
}
|
|
|
|
|
|
extension DateFormatter {
|
|
static func utcToString(date: Date) -> String {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
|
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
return dateFormatter.string(from: date)
|
|
}
|
|
}
|
|
|
|
|
|
// Timer logic
|
|
extension AppState {
|
|
func createTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
|
|
let timer = RecipeTimer(duration: duration)
|
|
timers[recipeId] = timer
|
|
return timer
|
|
}
|
|
|
|
func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
|
|
return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration)
|
|
}
|
|
|
|
func deleteTimer(forRecipe recipeId: String) {
|
|
timers.removeValue(forKey: recipeId)
|
|
}
|
|
}
|