Files
Nextcloud-Cookbook-iOS/Nextcloud Cookbook iOS Client/AppState.swift
Hendrik Hogertz c8ddb098d1 Redesign search tab, add category cards, recent recipes, and complete German translations
Overhaul SearchTabView with search history, empty/no-results states, and dynamic
navigation title. Extract CategoryCardView and RecentRecipesSection into standalone
views. Update RecipeTabView, RecipeListView, RecipeCardView, and MainView for the
modernized UI. Add all 12 missing German translations in Localizable.xcstrings.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 01:47:16 +01:00

528 lines
18 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 recentRecipes: [Recipe] = []
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
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: - 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)
}
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)
}
}