Categories on the main page can be sorted by Recently Used, Alphabetical, or Manual (drag-to-reorder). The sort menu appears inline next to the Categories header. All Recipes is included in the sort order and manual reorder sheet. Recipes within category and all-recipes lists can be sorted by Recently Added or Alphabetical, with the sort button in the toolbar. All non-manual sort modes support order inversion via a Reverse/Default Order toggle. Date parsing handles both formatted strings and Unix timestamps, with recipe_id as fallback when dates are unavailable. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
560 lines
19 KiB
Swift
560 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 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
|
|
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)
|
|
}
|
|
}
|