Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/AppState.swift
Hendrik Hogertz 151e69ff28 Avoid reusing category thumbnail images in all recipes preview mosaic
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>
2026-02-15 07:52:18 +01:00

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)
}
}