Nextcloud Login refactoring

This commit is contained in:
VincentMeilinger
2025-05-31 11:12:14 +02:00
parent 5acf3b9c4f
commit 48b31a7997
29 changed files with 1277 additions and 720 deletions

View File

@@ -59,6 +59,9 @@
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; }; A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; }; A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; }; A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */; };
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */; };
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB0512DE911C300A4C74B /* AuthManager.swift */; };
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; }; A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; }; A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; }; A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
@@ -147,6 +150,9 @@
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; }; A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; }; A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; }; A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = "<group>"; };
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = "<group>"; };
A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; }; A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; }; A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; }; A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
@@ -300,6 +306,7 @@
A70171C72AB4C4A100064C43 /* Data */ = { A70171C72AB4C4A100064C43 /* Data */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB0512DE911C300A4C74B /* AuthManager.swift */,
A70171C32AB4A31200064C43 /* DataStore.swift */, A70171C32AB4A31200064C43 /* DataStore.swift */,
A70171CA2AB4CD1700064C43 /* UserSettings.swift */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */, A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
@@ -382,6 +389,7 @@
A977D0DC2B6002DA009783A9 /* Tabs */ = { A977D0DC2B6002DA009783A9 /* Tabs */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */,
A977D0DD2B600300009783A9 /* SearchTabView.swift */, A977D0DD2B600300009783A9 /* SearchTabView.swift */,
A977D0DF2B600318009783A9 /* RecipeTabView.swift */, A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
@@ -427,6 +435,7 @@
A9C3BE522B630F1300562C79 /* ReusableViews */ = { A9C3BE522B630F1300562C79 /* ReusableViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */,
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
@@ -618,6 +627,7 @@
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */, A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
@@ -626,12 +636,14 @@
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */,
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */, A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */, A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */, A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */, A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */, A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */, A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,

View File

@@ -9,6 +9,10 @@ import Foundation
import SwiftUI import SwiftUI
import UIKit import UIKit
@Observable class AppState {
}
/* /*
@MainActor class AppState: ObservableObject { @MainActor class AppState: ObservableObject {
@Published var categories: [Category] = [] @Published var categories: [Category] = []

View File

@@ -0,0 +1,44 @@
//
// AuthManager.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 30.05.25.
//
import Foundation
import KeychainSwift
class AuthManager {
static let shared = AuthManager()
let keychain = KeychainSwift()
var authString: String? = nil
private let nextcloudUsernameKey = "nextcloud_username"
private let nextcloudAuthStringKey = "nextcloud_auth_string" // Stored as base64
func saveNextcloudCredentials(username: String, appPassword: String) {
keychain.set(username, forKey: nextcloudUsernameKey)
let loginString = "\(username):\(appPassword)"
if let loginData = loginString.data(using: .utf8) {
keychain.set(loginData.base64EncodedString(), forKey: nextcloudAuthStringKey)
}
}
func getNextcloudCredentials() -> (username: String?, authString: String?) {
let username = keychain.get(nextcloudUsernameKey)
let authString = keychain.get(nextcloudAuthStringKey)
return (username, authString)
}
func loadAuthString() {
authString = keychain.get(nextcloudAuthStringKey)
}
func deleteNextcloudCredentials() {
keychain.delete(nextcloudUsernameKey)
keychain.delete(nextcloudAuthStringKey)
}
}

View File

@@ -51,7 +51,7 @@ class Recipe {
var name: String var name: String
var keywords: [String] var keywords: [String]
@Attribute(.externalStorage) var image: RecipeImage? @Attribute(.externalStorage) var image: RecipeImage?
var thumbnail: RecipeThumbnail? @Attribute(.externalStorage) var thumbnail: RecipeThumbnail?
var dateCreated: String? = nil var dateCreated: String? = nil
var dateModified: String? = nil var dateModified: String? = nil
var prepTime: String var prepTime: String
@@ -70,6 +70,16 @@ class Recipe {
@Transient @Transient
var ingredientMultiplier: Double = 1.0 var ingredientMultiplier: Double = 1.0
var prepTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(prepTime)
}
var cookTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(cookTime)
}
var totalTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(totalTime)
}
init( init(
id: String, id: String,
@@ -109,50 +119,24 @@ class Recipe {
self.ingredientMultiplier = ingredientMultiplier self.ingredientMultiplier = ingredientMultiplier
} }
required init(from decoder: Decoder) throws { init() {
let container = try decoder.container(keyedBy: CodingKeys.self) self.id = UUID().uuidString
id = try container.decode(String.self, forKey: .id) self.name = String(localized: "New Recipe")
name = try container.decode(String.self, forKey: .name) self.keywords = []
keywords = try container.decode([String].self, forKey: .keywords) self.dateCreated = nil
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated) self.dateModified = nil
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified) self.prepTime = "0"
prepTime = try container.decode(String.self, forKey: .prepTime) self.cookTime = "0"
cookTime = try container.decode(String.self, forKey: .cookTime) self.totalTime = "0"
totalTime = try container.decode(String.self, forKey: .totalTime) self.recipeDescription = ""
recipeDescription = try container.decode(String.self, forKey: .recipeDescription) self.url = ""
url = try container.decodeIfPresent(String.self, forKey: .url) self.yield = 1
yield = try container.decode(Int.self, forKey: .yield) self.category = ""
category = try container.decode(String.self, forKey: .category) self.tools = []
tools = try container.decode([String].self, forKey: .tools) self.ingredients = []
ingredients = try container.decode([String].self, forKey: .ingredients) self.instructions = []
instructions = try container.decode([String].self, forKey: .instructions) self.nutrition = [:]
nutrition = try container.decode([String: String].self, forKey: .nutrition) self.ingredientMultiplier = 1
}
}
extension Recipe: Codable {
enum CodingKeys: String, CodingKey {
case id, name, keywords, dateCreated, dateModified, prepTime, cookTime, totalTime, recipeDescription, url, yield, category, tools, ingredients, instructions, nutrition
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encode(dateCreated, forKey: .dateCreated)
try container.encode(dateModified, forKey: .dateModified)
try container.encode(prepTime, forKey: .prepTime)
try container.encode(cookTime, forKey: .cookTime)
try container.encode(totalTime, forKey: .totalTime)
try container.encode(recipeDescription, forKey: .recipeDescription)
try container.encode(url, forKey: .url)
try container.encode(yield, forKey: .yield)
try container.encode(category, forKey: .category)
try container.encode(tools, forKey: .tools)
try container.encode(ingredients, forKey: .ingredients)
try container.encode(instructions, forKey: .instructions)
try container.encode(nutrition, forKey: .nutrition)
} }
} }
// MARK: - Recipe Stub // MARK: - Recipe Stub

View File

@@ -136,7 +136,6 @@
} }
}, },
"%@: %@" : { "%@: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -400,7 +399,6 @@
} }
}, },
"A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : { "A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -423,7 +421,6 @@
} }
}, },
"About" : { "About" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -446,7 +443,6 @@
} }
}, },
"Acknowledgements" : { "Acknowledgements" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -491,7 +487,6 @@
} }
}, },
"Add" : { "Add" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -514,7 +509,6 @@
} }
}, },
"Add cooking steps for fellow chefs to follow." : { "Add cooking steps for fellow chefs to follow." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -537,7 +531,6 @@
} }
}, },
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : { "Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -581,9 +574,11 @@
} }
} }
} }
},
"All Recipes" : {
}, },
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -762,6 +757,9 @@
} }
} }
} }
},
"Categories" : {
}, },
"Category" : { "Category" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -855,11 +853,10 @@
} }
} }
}, },
"Client error" : { "Client error: %lld" : {
}, },
"Comma (e.g. 1,42)" : { "Comma (e.g. 1,42)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -882,7 +879,6 @@
} }
}, },
"Configure what is stored on your device." : { "Configure what is stored on your device." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -905,7 +901,6 @@
} }
}, },
"Configure which sections in your recipes are expanded by default." : { "Configure which sections in your recipes are expanded by default." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1061,9 +1056,11 @@
} }
} }
} }
},
"Copy Error" : {
}, },
"Copy Link" : { "Copy Link" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1108,7 +1105,6 @@
} }
}, },
"Created: %@" : { "Created: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1134,7 +1130,6 @@
}, },
"Decimal number format" : { "Decimal number format" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1182,7 +1177,6 @@
} }
}, },
"Delete local data" : { "Delete local data" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1228,7 +1222,6 @@
} }
}, },
"Delete Recipe" : { "Delete Recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1273,7 +1266,6 @@
} }
}, },
"Deleting local data will not affect the recipe data stored on your server." : { "Deleting local data will not affect the recipe data stored on your server." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1318,7 +1310,6 @@
} }
}, },
"Description" : { "Description" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1339,6 +1330,9 @@
} }
} }
} }
},
"Dismiss" : {
}, },
"Done" : { "Done" : {
"localizations" : { "localizations" : {
@@ -1363,7 +1357,6 @@
} }
}, },
"Downloads" : { "Downloads" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1454,7 +1447,6 @@
} }
}, },
"Edit" : { "Edit" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1500,6 +1492,9 @@
} }
} }
} }
},
"Error: Login URL not available." : {
}, },
"Error." : { "Error." : {
"localizations" : { "localizations" : {
@@ -1522,9 +1517,11 @@
} }
} }
} }
},
"example.com" : {
}, },
"Expand information section" : { "Expand information section" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1547,7 +1544,6 @@
} }
}, },
"Expand keyword section" : { "Expand keyword section" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1570,7 +1566,6 @@
} }
}, },
"Expand nutrition section" : { "Expand nutrition section" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1685,7 +1680,6 @@
} }
}, },
"Get support" : { "Get support" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1708,7 +1702,6 @@
} }
}, },
"Grocery List" : { "Grocery List" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1731,7 +1724,6 @@
} }
}, },
"Hours" : { "Hours" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1754,7 +1746,6 @@
} }
}, },
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : { "If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1800,7 +1791,6 @@
} }
}, },
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : { "If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1823,7 +1813,6 @@
} }
}, },
"If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : { "If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1844,6 +1833,9 @@
} }
} }
} }
},
"If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application." : {
}, },
"Import" : { "Import" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -1892,7 +1884,6 @@
} }
}, },
"Ingredient" : { "Ingredient" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1915,7 +1906,6 @@
} }
}, },
"Ingredients" : { "Ingredients" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -1984,7 +1974,6 @@
} }
}, },
"Instruction" : { "Instruction" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2007,7 +1996,6 @@
} }
}, },
"Instructions" : { "Instructions" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2036,7 +2024,6 @@
}, },
"Keep screen awake when viewing recipes" : { "Keep screen awake when viewing recipes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2082,7 +2069,6 @@
} }
}, },
"Language" : { "Language" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2105,7 +2091,6 @@
} }
}, },
"Last modified: %@" : { "Last modified: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2151,7 +2136,6 @@
} }
}, },
"List your tools here. 🍴" : { "List your tools here. 🍴" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2172,9 +2156,14 @@
} }
} }
} }
},
"Log in" : {
},
"Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed." : {
}, },
"Log out" : { "Log out" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2197,7 +2186,6 @@
} }
}, },
"Login" : { "Login" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2288,7 +2276,6 @@
} }
}, },
"Marked ingredients could not be adjusted!" : { "Marked ingredients could not be adjusted!" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2311,7 +2298,6 @@
} }
}, },
"Minutes" : { "Minutes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2428,7 +2414,6 @@
} }
}, },
"More information" : { "More information" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2471,9 +2456,6 @@
} }
} }
} }
},
"New" : {
}, },
"New recipe" : { "New recipe" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -2519,9 +2501,11 @@
} }
} }
} }
},
"Nextcloud" : {
}, },
"Nextcloud Login" : { "Nextcloud Login" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2567,7 +2551,6 @@
} }
}, },
"No nutritional information." : { "No nutritional information." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2636,7 +2619,6 @@
} }
}, },
"Nutrition" : { "Nutrition" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2659,7 +2641,6 @@
} }
}, },
"Nutrition (%@)" : { "Nutrition (%@)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2682,7 +2663,6 @@
} }
}, },
"Offline recipes" : { "Offline recipes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2727,7 +2707,6 @@
} }
}, },
"Other" : { "Other" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2889,7 +2868,6 @@
} }
}, },
"Point (e.g. 1.42)" : { "Point (e.g. 1.42)" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2912,7 +2890,6 @@
} }
}, },
"Preparation" : { "Preparation" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3003,7 +2980,6 @@
} }
}, },
"Recipe Name" : { "Recipe Name" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3048,7 +3024,6 @@
} }
}, },
"Recipes" : { "Recipes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3255,6 +3230,9 @@
} }
} }
} }
},
"Select a Recipe" : {
}, },
"Select Keywords" : { "Select Keywords" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -3302,7 +3280,13 @@
} }
} }
}, },
"Server error" : { "Server address:" : {
},
"Server error: %lld" : {
},
"Server Protocol:" : {
}, },
"Serving size" : { "Serving size" : {
@@ -3464,7 +3448,6 @@
} }
}, },
"Share Recipe" : { "Share Recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3533,7 +3516,6 @@
} }
}, },
"Start by adding your first ingredient! 🥬" : { "Start by adding your first ingredient! 🥬" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3556,7 +3538,6 @@
} }
}, },
"Store recipe images locally" : { "Store recipe images locally" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3579,7 +3560,6 @@
} }
}, },
"Store recipe thumbnails locally" : { "Store recipe thumbnails locally" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3670,7 +3650,6 @@
} }
}, },
"Support" : { "Support" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3693,7 +3672,6 @@
} }
}, },
"SwiftSoup" : { "SwiftSoup" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3760,6 +3738,9 @@
} }
} }
} }
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.\nIf the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
}, },
"The recipe has no image whose MIME type matches the Accept header" : { "The recipe has no image whose MIME type matches the Accept header" : {
"extractionState" : "stale", "extractionState" : "stale",
@@ -3899,7 +3880,6 @@
} }
}, },
"This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : { "This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3965,9 +3945,11 @@
} }
} }
} }
},
"To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line." : {
}, },
"Tool" : { "Tool" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -3990,7 +3972,6 @@
} }
}, },
"Tools" : { "Tools" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4036,7 +4017,6 @@
} }
}, },
"Total time" : { "Total time" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4059,7 +4039,6 @@
} }
}, },
"TPPDF" : { "TPPDF" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4255,7 +4234,6 @@
} }
}, },
"Upload Changes" : { "Upload Changes" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4278,7 +4256,6 @@
} }
}, },
"Upload Recipe" : { "Upload Recipe" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4324,7 +4301,6 @@
} }
}, },
"URL:" : { "URL:" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4347,7 +4323,6 @@
} }
}, },
"Username: %@" : { "Username: %@" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4393,7 +4368,6 @@
} }
}, },
"Visit the GitHub page" : { "Visit the GitHub page" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4416,7 +4390,6 @@
} }
}, },
"You're all set for cooking 🍓" : { "You're all set for cooking 🍓" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -4439,7 +4412,6 @@
} }
}, },
"Your grocery list is stored locally and therefore not synchronized across your devices." : { "Your grocery list is stored locally and therefore not synchronized across your devices." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {

View File

@@ -69,7 +69,7 @@ struct ApiRequest {
var response: URLResponse? = nil var response: URLResponse? = nil
do { do {
(data, response) = try await URLSession.shared.data(for: request) (data, response) = try await URLSession.shared.data(for: request)
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!") Logger.network.debug("\(method.rawValue) \(path) received response ...")
if let error = decodeURLResponse(response: response as? HTTPURLResponse) { if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error) return (nil, error)
@@ -94,9 +94,8 @@ struct ApiRequest {
switch response.statusCode { switch response.statusCode {
case 200...299: return (nil) case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError) case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError) case 400...499: return (NetworkError.clientError(statusCode: response.statusCode))
case 500...599: return (NetworkError.serverError) case 500...599: return (NetworkError.serverError(statusCode: response.statusCode))
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError) default: return (NetworkError.unknownError)
} }
} }

View File

@@ -99,7 +99,7 @@ protocol CookbookApi {
/// - Returns: A list of categories. A NetworkError if the request fails. /// - Returns: A list of categories. A NetworkError if the request fails.
static func getCategories( static func getCategories(
auth: String auth: String
) async -> ([Category]?, NetworkError?) ) async -> ([CookbookApiCategory]?, NetworkError?)
/// Get all recipes of a specified category. /// Get all recipes of a specified category.
/// - Parameters: /// - Parameters:

View File

@@ -77,7 +77,7 @@ class CookbookApiV1: CookbookApi {
if let id = json as? Int { if let id = json as? Int {
return nil return nil
} else if let dict = json as? [String: Any] { } else if let dict = json as? [String: Any] {
return .serverError return .unknownError
} }
} catch { } catch {
return .decodingFailed return .decodingFailed
@@ -103,7 +103,7 @@ class CookbookApiV1: CookbookApi {
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) { static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe) let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(recipe) else { guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else {
return .dataError return .dataError
} }
let request = ApiRequest( let request = ApiRequest(
@@ -121,7 +121,7 @@ class CookbookApiV1: CookbookApi {
if let id = json as? Int { if let id = json as? Int {
return nil return nil
} else if let dict = json as? [String: Any] { } else if let dict = json as? [String: Any] {
return .serverError return .unknownError
} }
} catch { } catch {
return .decodingFailed return .decodingFailed
@@ -143,7 +143,7 @@ class CookbookApiV1: CookbookApi {
return nil return nil
} }
static func getCategories(auth: String) async -> ([Category]?, NetworkError?) { static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) {
let request = ApiRequest( let request = ApiRequest(
path: basePath + "/categories", path: basePath + "/categories",
method: .GET, method: .GET,

View File

@@ -8,10 +8,10 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
struct Category: Codable, Identifiable, Hashable { struct CookbookApiCategory: Codable, Identifiable, Hashable {
var id: String { name } var id: String { name }
let name: String var name: String
let recipe_count: Int var recipe_count: Int
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case name, recipe_count case name, recipe_count

View File

@@ -15,8 +15,8 @@ public enum NetworkError: UserAlert {
case encodingFailed case encodingFailed
case decodingFailed case decodingFailed
case redirectionError case redirectionError
case clientError case clientError(statusCode: Int)
case serverError case serverError(statusCode: Int)
case invalidRequest case invalidRequest
case unknownError case unknownError
case dataError case dataError
@@ -33,10 +33,10 @@ public enum NetworkError: UserAlert {
"Data decoding failed." "Data decoding failed."
case .redirectionError: case .redirectionError:
"Redirection error" "Redirection error"
case .clientError: case .clientError(let code):
"Client error" "Client error: \(code)"
case .serverError: case .serverError(let code):
"Server error" "Server error: \(code)"
case .invalidRequest: case .invalidRequest:
"Invalid request" "Invalid request"
case .unknownError: case .unknownError:
@@ -47,7 +47,7 @@ public enum NetworkError: UserAlert {
} }
var localizedDescription: LocalizedStringKey { var localizedDescription: LocalizedStringKey {
return "" // TODO: Add description return self.localizedTitle
} }
var alertButtons: [AlertButton] { var alertButtons: [AlertButton] {

View File

@@ -20,10 +20,9 @@ class NextcloudApi {
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful. /// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any. /// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) { static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) {
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
let request = ApiRequest( let request = ApiRequest(
path: path + "/index.php/login/v2", path: baseAddress + "/index.php/login/v2",
method: .POST method: .POST
) )
@@ -52,16 +51,16 @@ class NextcloudApi {
/// - `LoginV2Response?`: An object representing the response of the login process, if successful. /// - `LoginV2Response?`: An object representing the response of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any. /// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) { static func loginV2Poll(pollURL: String, pollToken: String) async -> (LoginV2Response?, NetworkError?) {
let request = ApiRequest( let request = ApiRequest(
path: req.poll.endpoint, path: pollURL,
method: .POST, method: .POST,
headerFields: [ headerFields: [
HeaderField.ocsRequest(value: true), HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON), HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM) HeaderField.contentType(value: .FORM)
], ],
body: "token=\(req.poll.token)".data(using: .utf8) body: "token=\(pollToken)".data(using: .utf8)
) )
let (data, error) = await request.send(pathCompletion: false) let (data, error) = await request.send(pathCompletion: false)

View File

@@ -21,7 +21,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
EmptyView() EmptyView()
} else { } else {
MainView() MainView()
.modelContainer(for: Recipe.self) .modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self])
} }
} }
.transition(.slide) .transition(.slide)
@@ -30,6 +30,10 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
.init(identifier: language == .init(identifier: language ==
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language) SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
) )
.onAppear {
AuthManager.shared.loadAuthString() // Load the auth string as soon as possible
} }
} }
}
} }

View File

@@ -9,89 +9,58 @@ import SwiftUI
import SwiftData import SwiftData
struct MainView: View { struct MainView: View {
//@State var cookbookState: CookbookState = CookbookState() // Tab ViewModels
@Environment(\.modelContext) var modelContext enum Tab {
@Query var recipes: [Recipe] = [] case recipes, settings, groceryList
}
var body: some View { var body: some View {
VStack { TabView {
List { RecipeTabView()
ForEach(recipes) { recipe in .tabItem {
Text(recipe.name) Label("Recipes", systemImage: "book.closed.fill")
}
}
Button("New") {
let recipe = Recipe(id: UUID().uuidString, name: "Neues Rezept", keywords: [], prepTime: "", cookTime: "", totalTime: "", recipeDescription: "", yield: 0, category: "", tools: [], ingredients: [], instructions: [], nutrition: [:], ingredientMultiplier: 0)
modelContext.insert(recipe)
}
}
/*NavigationSplitView {
VStack {
List(selection: $cookbookState.selectedCategory) {
ForEach(cookbookState.categories) { category in
Text(category.name)
.tag(category)
}
}
.listStyle(.plain)
.onAppear {
Task {
await cookbookState.loadCategories()
}
}
}
} content: {
if let selectedCategory = cookbookState.selectedCategory {
List(selection: $cookbookState.selectedRecipeStub) {
ForEach(cookbookState.recipeStubs[selectedCategory.name] ?? [], id: \.id) { recipeStub in
Text(recipeStub.title)
.tag(recipeStub)
}
}
.onAppear {
Task {
await cookbookState.loadRecipeStubs(category: selectedCategory.name)
}
} }
.tag(Tab.recipes)
GroceryListTabView()
.tabItem {
if #available(iOS 17.0, *) {
Label("Grocery List", systemImage: "storefront")
} else { } else {
Text("Please select a category.") Label("Grocery List", systemImage: "heart.text.square")
.foregroundColor(.secondary)
}
} detail: {
if let selectedRecipe = cookbookState.selectedRecipe {
if let recipe = cookbookState.recipes[selectedRecipe.id] {
RecipeView(recipe: recipe)
} else {
ProgressView()
.onAppear {
Task {
await cookbookState.loadRecipe(id: selectedRecipe.id)
} }
} }
.tag(Tab.groceryList)
SettingsTabView()
.tabItem {
Label("Settings", systemImage: "gear")
} }
} else { .tag(Tab.settings)
Text("Please select a recipe.")
.foregroundColor(.secondary)
} }
.task {
/*
recipeViewModel.presentLoadingIndicator = true
await appState.getCategories()
await appState.updateAllRecipeDetails()
// Open detail view for default category
if UserSettings.shared.defaultCategory != "" {
if let cat = appState.categories.first(where: { c in
if c.name == UserSettings.shared.defaultCategory {
return true
} }
.toolbar { return false
ToolbarItem(placement: .bottomBar) {
Button(action: {
cookbookState.showGroceries = true
}) { }) {
Label("Grocery List", systemImage: "cart") recipeViewModel.selectedCategory = cat
} }
} }
ToolbarItem(placement: .topBarLeading) { await groceryList.load()
Button(action: { recipeViewModel.presentLoadingIndicator = false
cookbookState.showSettings = true */
}) {
Label("Settings", systemImage: "gearshape")
} }
} }
}*/
}
} }

View File

@@ -8,7 +8,14 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import WebKit import WebKit
/* import AuthenticationServices
protocol LoginStage {
func next() -> Self
func previous() -> Self
}
enum V2LoginStage: LoginStage { enum V2LoginStage: LoginStage {
case login, validate case login, validate
@@ -30,13 +37,28 @@ enum V2LoginStage: LoginStage {
struct V2LoginView: View { struct V2LoginView: View {
@Binding var showAlert: Bool @Environment(\.dismiss) var dismiss
@Binding var alertMessage: String @State var showAlert: Bool = false
@State var alertMessage: String = ""
@State var loginStage: V2LoginStage = .login @State var loginStage: V2LoginStage = .login
@State var loginRequest: LoginV2Request? = nil @State var loginRequest: LoginV2Request? = nil
@State var presentBrowser = false @State var presentBrowser = false
@State var serverAddress: String = ""
@State var serverProtocol: ServerProtocol = .https
@State var loginPressed: Bool = false
@State var isLoading: Bool = false
// Task reference for polling, to cancel if needed
@State private var pollTask: Task<Void, Never>? = nil
enum ServerProtocol: String {
case https="https://", http="http://"
static let all = [https, http]
}
// TextField handling // TextField handling
enum Field { enum Field {
case server case server
@@ -45,114 +67,205 @@ struct V2LoginView: View {
} }
var body: some View { var body: some View {
ScrollView { VStack {
VStack(alignment: .leading) { HStack {
ServerAddressField() Button("Cancel") {
CollapsibleView { dismiss()
VStack(alignment: .leading) { }
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.") Spacer()
.padding(.bottom)
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.") if isLoading {
.padding(.bottom) ProgressView()
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
} }
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding() }.padding()
if loginRequest != nil { Form {
Button("Copy Link") { Section {
UIPasteboard.general.string = loginRequest!.login HStack {
Text("Server address:")
TextField("example.com", text: $serverAddress)
.multilineTextAlignment(.trailing)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
Picker("Server Protocol:", selection: $serverProtocol) {
ForEach(ServerProtocol.all, id: \.self) {
Text($0.rawValue)
} }
.font(.headline)
.foregroundStyle(.white)
.padding()
} }
HStack { HStack {
Button { Button("Login") {
if UserSettings.shared.serverAddress == "" { initiateLoginV2()
alertMessage = "Please enter a valid server address." }
showAlert = true Spacer()
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
.foregroundStyle(Color.secondary)
}
} header: {
Text("Nextcloud Login")
} footer: {
Text(
"""
The 'Login' button will open a web browser. Please follow the login instructions provided there.
After a successful login, return to this application and press 'Validate'.
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
"""
)
}.disabled(loginPressed)
if let loginRequest = loginRequest {
Section {
Text(loginRequest.login)
.font(.caption)
.foregroundStyle(.secondary)
Button("Copy Link") {
UIPasteboard.general.string = loginRequest.login
}
} footer: {
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
}
}
}
}
.sheet(isPresented: $presentBrowser) {
if let loginReq = loginRequest {
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
switch result {
case .success(let url):
print("Login completed with URL: \(url)")
dismiss()
case .failure(let error):
print("Login failed: \(error.localizedDescription)")
self.alertMessage = error.localizedDescription
self.isLoading = false
self.loginPressed = false
self.showAlert = true
}
}
} else {
Text("Error: Login URL not available.")
}
}
.alert("Error", isPresented: $showAlert) {
Button("Copy Error") {
print("Error copied: \(alertMessage)")
UIPasteboard.general.string = alertMessage
isLoading = false
loginPressed = false
}
Button("Dismiss") {
print("Error dismissed.")
isLoading = false
loginPressed = false
}
} message: {
Text(alertMessage)
}
}
func initiateLoginV2() {
isLoading = true
loginPressed = true
Task {
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
if let error = error {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return return
} }
Task { guard let req = req else {
let error = await sendLoginV2Request() self.alertMessage = "Failed to get login URL from server."
if let error = error { self.showAlert = true
alertMessage = "A network error occured (\(error.localizedDescription))." self.isLoading = false
showAlert = true self.loginPressed = false
} return
if let loginRequest = loginRequest {
presentBrowser = true
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
if loginStage == .validate {
Spacer()
Button {
// fetch login v2 response
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.disabled(loginRequest == nil ? true : false)
.padding()
}
}
}
}
.sheet(isPresented: $presentBrowser, onDismiss: {
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
}) {
if let loginRequest = loginRequest {
WebViewSheet(url: loginRequest.login)
}
}
} }
func sendLoginV2Request() async -> NetworkError? {
let (req, error) = await NextcloudApi.loginV2Request()
self.loginRequest = req self.loginRequest = req
return error
// Present the browser session
presentBrowser = true
// Start polling in a separate task
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
}
} }
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) { func startPolling(pollURL: String, pollToken: String) {
guard let loginRequest = loginRequest else { return (nil, .parametersNil) } // Cancel any existing poll task first
return await NextcloudApi.loginV2Response(req: loginRequest) pollTask?.cancel()
var pollingFailed = true
pollTask = Task {
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
for _ in 0..<maxRetries {
if Task.isCancelled {
print("Task cancelled.")
break
}
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
if Task.isCancelled {
print("Task cancelled.")
break
}
if let response = response {
// Success
print("Task succeeded.")
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
pollingFailed = false
await MainActor.run {
self.checkLogin(response: response, error: nil)
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
self.isLoading = false
self.loginPressed = false
}
return
} else if let error = error {
if case .clientError(statusCode: 404) = error {
// Continue polling
print("Polling unsuccessful, continuing.")
} else {
// A more serious error occurred during polling
print("Polling error: \(error.localizedDescription)")
await MainActor.run {
self.alertMessage = "Polling error: \(error.localizedDescription)"
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
return
}
}
isLoading = true
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
isLoading = false
}
// If polling finishes without success
if !Task.isCancelled && pollingFailed {
await MainActor.run {
self.alertMessage = "Login timed out. Please try again."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
}
}
} }
func checkLogin(response: LoginV2Response?, error: NetworkError?) { func checkLogin(response: LoginV2Response?, error: NetworkError?) {
@@ -180,33 +293,72 @@ struct V2LoginView: View {
// Login WebView logic struct LoginBrowserView: UIViewControllerRepresentable {
let authURL: URL
let callbackURLScheme: String
var completion: (Result<URL, Error>) -> Void
struct WebViewSheet: View { func makeUIViewController(context: Context) -> UIViewController {
@Environment(\.dismiss) var dismiss UIViewController()
@State var url: String }
var body: some View { func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
NavigationView {
WebView(url: URL(string: url)!) if !context.coordinator.sessionStarted {
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline) context.coordinator.sessionStarted = true
.navigationBarItems(trailing: Button("Done") {
dismiss() let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
}) context.coordinator.sessionStarted = false // Reset for potential retry
if let callbackURL = callbackURL {
completion(.success(callbackURL))
} else if let error = error {
completion(.failure(error))
} else {
// Handle unexpected nil URL and error
completion(.failure(LoginError.unknownError))
}
}
session.presentationContextProvider = context.coordinator
session.prefersEphemeralWebBrowserSession = false
session.start()
}
}
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
var parent: LoginBrowserView
var sessionStarted: Bool = false // Prevent starting multiple sessions
init(_ parent: LoginBrowserView) {
self.parent = parent
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene.windows.first!
}
return ASPresentationAnchor()
}
}
enum LoginError: Error, LocalizedError {
case unknownError
var errorDescription: String? {
switch self {
case .unknownError: return "An unknown error occurred during login."
}
} }
} }
} }
struct WebView: UIViewRepresentable {
let url: URL
func makeUIView(context: Context) -> WKWebView { #Preview {
return WKWebView() V2LoginView()
} }
func updateUIView(_ uiView: WKWebView, context: Context) {
let request = URLRequest(url: url)
uiView.load(request)
}
}
*/

View File

@@ -8,10 +8,10 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/*
struct RecipeCardView: View { struct RecipeCardView: View {
@EnvironmentObject var appState: AppState //@EnvironmentObject var appState: AppState
@State var recipe: CookbookApiRecipeV1 @State var recipe: Recipe
@State var recipeThumb: UIImage? @State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil @State var isDownloaded: Bool? = nil
@@ -50,6 +50,7 @@ struct RecipeCardView: View {
.background(Color.backgroundHighlight) .background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 17))
.task { .task {
/*
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
@@ -59,18 +60,20 @@ struct RecipeCardView: View {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id) recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
} }
isDownloaded = recipe.storedLocally isDownloaded = recipe.storedLocally
*/
} }
.refreshable { .refreshable {
/*
recipeThumb = await appState.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
) )*/
} }
.frame(height: 80) .frame(height: 80)
} }
} }
*/
/* /*
struct RecipeCardView: View { struct RecipeCardView: View {
@State var state: AccountState @State var state: AccountState

View File

@@ -7,6 +7,53 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
struct RecipeListView: View {
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@Binding var selectedRecipe: Recipe?
@Binding var selectedCategory: String?
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
var predicate: Predicate<Recipe>? = nil
if let category = selectedCategory.wrappedValue, category != "*" {
predicate = #Predicate<Recipe> {
$0.category == category
}
}
_recipes = Query(filter: predicate, sort: \.name)
_selectedRecipe = selectedRecipe
_selectedCategory = selectedCategory
}
var body: some View {
List(selection: $selectedRecipe) {
ForEach(recipes) { recipe in
RecipeCardView(recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink(value: recipe) {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
}
.listStyle(.plain)
.navigationTitle("Recipes")
.toolbar {
}
}
}
/* /*

View File

@@ -8,15 +8,15 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/*
struct RecipeView: View { struct RecipeView: View {
@EnvironmentObject var appState: AppState @Bindable var recipe: Recipe
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero @GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat { var imageHeight: CGFloat {
if let image = viewModel.recipeImage { if let recipeImage = recipe.image, let image = recipeImage.image {
return image.size.height < 350 ? image.size.height : 350 return image.size.height < 350 ? image.size.height : 350
} }
return 200 return 200
@@ -33,8 +33,8 @@ struct RecipeView: View {
coordinateSpace: CoordinateSpaces.scrollView, coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight defaultHeight: imageHeight
) { ) {
if let recipeImage = viewModel.recipeImage { if let recipeImage = recipe.image, let image = recipeImage.image {
Image(uiImage: recipeImage) Image(uiImage: image)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(maxHeight: imageHeight + 200) .frame(maxHeight: imageHeight + 200)
@@ -54,15 +54,12 @@ struct RecipeView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.editMode { if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe) //RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
} //RecipeMetadataSection(viewModel: viewModel)
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
} }
HStack { HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title) .font(.title)
.bold() .bold()
@@ -74,36 +71,37 @@ struct RecipeView: View {
} }
}.padding([.top, .horizontal]) }.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode { if recipe.recipeDescription != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical) EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium) .fontWeight(.medium)
.padding(.horizontal) .padding(.horizontal)
.padding(.top, 2) .padding(.top, 2)
} }
// Recipe Body Section // Recipe Body Section
RecipeDurationSection(viewModel: viewModel) RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
Divider() Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel) if(!recipe.ingredients.isEmpty || viewModel.editMode) {
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
} }
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) { if(!recipe.instructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel) RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
} }
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) { if(!recipe.tools.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel) RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
} }
RecipeNutritionSection(viewModel: viewModel) RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
} }
if !viewModel.editMode { if !viewModel.editMode {
Divider() Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel) //RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel) MoreInformationSection(recipe: recipe)
} }
} }
} }
@@ -115,21 +113,21 @@ struct RecipeView: View {
.ignoresSafeArea(.container, edges: .top) .ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar) .toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "") .navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar { .toolbar {
RecipeViewToolBar(viewModel: viewModel) RecipeViewToolBar(viewModel: viewModel)
} }
.sheet(isPresented: $viewModel.presentShareSheet) { .sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), /*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage, recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet) presentShareSheet: $viewModel.presentShareSheet)*/
} }
.sheet(isPresented: $viewModel.presentInstructionEditView) { .sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentInstructionEditView, isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions, items: $recipe.instructions,
title: "Instructions", title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.", emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction", titleKey: "Instruction",
@@ -139,7 +137,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentIngredientEditView) { .sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentIngredientEditView, isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient, items: $recipe.ingredients,
title: "Ingredients", title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬", emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient", titleKey: "Ingredient",
@@ -149,7 +147,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentToolEditView) { .sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView( EditableListView(
isPresented: $viewModel.presentToolEditView, isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool, items: $recipe.tools,
title: "Tools", title: "Tools",
emptyListText: "List your tools here. 🍴", emptyListText: "List your tools here. 🍴",
titleKey: "Tool", titleKey: "Tool",
@@ -158,6 +156,7 @@ struct RecipeView: View {
} }
.task { .task {
/*
// Load recipe detail // Load recipe detail
if !viewModel.newRecipe { if !viewModel.newRecipe {
// For existing recipes, load the recipeDetail and image // For existing recipes, load the recipeDetail and image
@@ -185,7 +184,7 @@ struct RecipeView: View {
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1()) viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.editMode = true viewModel.editMode = true
viewModel.isDownloaded = false viewModel.isDownloaded = false
} }*/
} }
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in ForEach(viewModel.alertType.alertButtons) { buttonType in
@@ -217,13 +216,14 @@ struct RecipeView: View {
UIApplication.shared.isIdleTimerDisabled = false UIApplication.shared.isIdleTimerDisabled = false
} }
.onChange(of: viewModel.editMode) { newValue in .onChange(of: viewModel.editMode) { newValue in
/*
if newValue && appState.allKeywords.isEmpty { if newValue && appState.allKeywords.isEmpty {
Task { Task {
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
a.recipe_count > b.recipe_count a.recipe_count > b.recipe_count
}) })
} }
} }*/
} }
} }
@@ -231,9 +231,8 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel // MARK: - RecipeView ViewModel
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var observableRecipeDetail: Recipe = Recipe() @Published var recipe: Recipe
@Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false @Published var editMode: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
@@ -244,7 +243,6 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false @Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false @Published var presentToolEditView: Bool = false
var recipe: CookbookApiRecipeV1
var sharedURL: URL? = nil var sharedURL: URL? = nil
var newRecipe: Bool = false var newRecipe: Bool = false
@@ -254,26 +252,13 @@ struct RecipeView: View {
var alertAction: () async -> () = { } var alertAction: () async -> () = { }
// Initializers // Initializers
init(recipe: CookbookApiRecipeV1) { init(recipe: Recipe) {
self.recipe = recipe self.recipe = recipe
} }
init() { init() {
self.newRecipe = true self.newRecipe = true
self.recipe = CookbookApiRecipeV1( self.recipe = Recipe()
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
imagePlaceholderUrl: "",
recipe_id: 0)
}
// View setup
func setupView(recipeDetail: CookbookApiRecipeDetailV1) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = Recipe(recipeDetail)
} }
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) { func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -285,7 +270,7 @@ struct RecipeView: View {
} }
/*
extension RecipeView { extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? { func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url) let (scrapedRecipe, error) = await appState.importRecipe(url: url)
@@ -309,13 +294,12 @@ extension RecipeView {
return nil return nil
} }
} }
*/
// MARK: - Tool Bar // MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent { struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
@@ -385,6 +369,7 @@ struct RecipeViewToolBar: ToolbarContent {
} }
func handleUpload() async { func handleUpload() async {
/*
if viewModel.newRecipe { if viewModel.newRecipe {
print("Uploading new recipe.") print("Uploading new recipe.")
if let recipeValidationError = recipeValid() { if let recipeValidationError = recipeValid() {
@@ -416,9 +401,11 @@ struct RecipeViewToolBar: ToolbarContent {
} }
viewModel.editMode = false viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS) viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
*/
} }
func handleDelete() async { func handleDelete() async {
/*
let category = viewModel.observableRecipeDetail.recipeCategory let category = viewModel.observableRecipeDetail.recipeCategory
guard let id = Int(viewModel.observableRecipeDetail.id) else { guard let id = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
@@ -432,11 +419,13 @@ struct RecipeViewToolBar: ToolbarContent {
await appState.getCategory(named: category, fetchMode: .preferServer) await appState.getCategory(named: category, fetchMode: .preferServer)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss() dismiss()
*/
} }
func recipeValid() -> RecipeAlert? { func recipeValid() -> RecipeAlert? {
/*
// Check if the recipe has a name // Check if the recipe has a name
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" { if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE return RecipeAlert.NO_TITLE
} }
@@ -454,12 +443,11 @@ struct RecipeViewToolBar: ToolbarContent {
} }
} }
} }
*/
return nil return nil
} }
} }
*/

View File

@@ -9,19 +9,20 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Duration Section // MARK: - RecipeView Duration Section
/*
struct RecipeDurationSection: View { struct RecipeDurationSection: View {
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@State var presentPopover: Bool = false @State var presentPopover: Bool = false
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: viewModel.recipe.prepTime, title: LocalizedStringKey("Preparation")) DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.recipe.cookTime, title: LocalizedStringKey("Cooking")) DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.recipe.totalTime, title: LocalizedStringKey("Total time")) DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
} }
if viewModel.editMode { if editMode {
Button { Button {
presentPopover.toggle() presentPopover.toggle()
} label: { } label: {
@@ -34,9 +35,9 @@ struct RecipeDurationSection: View {
.padding() .padding()
.popover(isPresented: $presentPopover) { .popover(isPresented: $presentPopover) {
EditableDurationView( EditableDurationView(
prepTime: viewModel.recipe.prepTime, prepTime: recipe.prepTimeDurationComponent,
cookTime: viewModel.recipe.cookTime, cookTime: recipe.cookTimeDurationComponent,
totalTime: viewModel.recipe.totalTime totalTime: recipe.totalTimeDurationComponent
) )
} }
} }
@@ -143,4 +144,4 @@ fileprivate struct TimePickerView: View {
} }
} }
*/

View File

@@ -7,18 +7,24 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
// MARK: - RecipeView Ingredients Section // MARK: - RecipeView Ingredients Section
/*
struct RecipeIngredientSection: View { struct RecipeIngredientSection: View {
@Environment(CookbookState.self) var cookbookState @Environment(\.modelContext) var modelContext
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentIngredientEditView: Bool
@State var recipeGroceries: RecipeGroceries? = nil
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
Button { Button {
withAnimation { withAnimation {
/*
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) { if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id) cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
} else { } else {
@@ -28,6 +34,7 @@ struct RecipeIngredientSection: View {
recipeName: viewModel.recipe.name recipeName: viewModel.recipe.name
) )
} }
*/
} }
} label: { } label: {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
@@ -35,7 +42,7 @@ struct RecipeIngredientSection: View {
} else { } else {
Image(systemName: "heart.text.square") Image(systemName: "heart.text.square")
} }
}.disabled(viewModel.editMode) }.disabled(editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients")) SecondaryLabel(text: LocalizedStringKey("Ingredients"))
@@ -45,26 +52,30 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.bold() .bold()
ServingPickerView(selectedServingSize: $viewModel.recipe.ingredientMultiplier) ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
} }
ForEach(0..<viewModel.recipe.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(
ingredient: $viewModel.recipe.recipeIngredient[ix], ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
servings: $viewModel.recipe.ingredientMultiplier, /*IngredientListItem(
recipeYield: Double(viewModel.recipe.recipeYield), ingredient: $recipe.recipeIngredient[ix],
recipeId: viewModel.recipe.id servings: $recipe.ingredientMultiplier,
recipeYield: Double(recipe.recipeYield),
recipeId: recipe.id
) { ) {
/*
cookbookState.groceryList.addItem( cookbookState.groceryList.addItem(
viewModel.recipe.recipeIngredient[ix], recipe.recipeIngredient[ix],
toRecipe: viewModel.recipe.id, toRecipe: recipe.id,
recipeName: viewModel.recipe.name recipeName: recipe.name
) )*/
} }
.padding(4) .padding(4)*/
Text(recipe.ingredients[ix])
} }
if viewModel.recipe.ingredientMultiplier != Double(viewModel.recipe.recipeYield) { if recipe.ingredientMultiplier != Double(recipe.yield) {
HStack() { HStack() {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -73,9 +84,9 @@ struct RecipeIngredientSection: View {
}.padding(.top) }.padding(.top)
} }
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentIngredientEditView.toggle() presentIngredientEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -83,19 +94,80 @@ struct RecipeIngredientSection: View {
} }
} }
.padding() .padding()
.animation(.easeInOut, value: viewModel.recipe.ingredientMultiplier) .animation(.easeInOut, value: recipe.ingredientMultiplier)
}
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete category if it exists
modelContext.delete(existingCategory)
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
newCategory.items.append(newItem)
}
try modelContext.save()
}
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete item if it exists
if existingCategory.items.contains(where: { $0.name == itemName }) {
existingCategory.items.removeAll { $0.name == itemName }
// Delete category if empty
if existingCategory.items.isEmpty {
modelContext.delete(existingCategory)
}
} else {
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
} else {
// Add the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add the item to the new category
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
} }
} }
// MARK: - RecipeIngredientSection List Item // MARK: - RecipeIngredientSection List Item
/*
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {
@Environment(CookbookState.self) var cookbookState @Environment(\.modelContext) var modelContext
@Bindable var recipeGroceries: RecipeGroceries
@Binding var ingredient: String @Binding var ingredient: String
@Binding var servings: Double @Binding var servings: Double
@State var recipeYield: Double @State var recipeYield: Double
@State var recipeId: String @State var recipeId: String
let addToGroceryListAction: () -> Void
@State var modifiedIngredient: AttributedString = "" @State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false @State var isSelected: Bool = false
@@ -110,7 +182,7 @@ fileprivate struct IngredientListItem: View {
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) { if recipeGroceries.items.contains(ingredient) {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
Image(systemName: "storefront") Image(systemName: "storefront")
.foregroundStyle(Color.green) .foregroundStyle(Color.green)
@@ -168,7 +240,7 @@ fileprivate struct IngredientListItem: View {
.onEnded { gesture in .onEnded { gesture in
withAnimation { withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) { if recipeGroceries.items.contains(ingredient) {
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId) cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else { } else {
addToGroceryListAction() addToGroceryListAction()
@@ -182,7 +254,7 @@ fileprivate struct IngredientListItem: View {
) )
} }
} }
*/
struct ServingPickerView: View { struct ServingPickerView: View {
@@ -217,4 +289,4 @@ struct ServingPickerView: View {
} }
} }
*/

View File

@@ -9,22 +9,26 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Instructions Section // MARK: - RecipeView Instructions Section
/*
struct RecipeInstructionSection: View { struct RecipeInstructionSection: View {
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentInstructionEditView: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions")) SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer() Spacer()
} }
ForEach(viewModel.recipe.recipeInstructions.indices, id: \.self) { ix in ForEach(recipe.instructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.recipe.recipeInstructions[ix], index: ix+1) RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
} }
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentInstructionEditView.toggle() presentInstructionEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -32,11 +36,10 @@ struct RecipeInstructionSection: View {
} }
} }
.padding() .padding()
} }
} }
// MARK: - Preview
fileprivate struct RecipeInstructionListItem: View { fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String @Binding var instruction: String
@@ -56,4 +59,45 @@ fileprivate struct RecipeInstructionListItem: View {
.animation(.easeInOut, value: isSelected) .animation(.easeInOut, value: isSelected)
} }
} }
*/
struct RecipeInstructionSection_Previews: PreviewProvider {
static var previews: some View {
// Create a mock recipe
@State var mockRecipe = createRecipe()
// Create mock state variables for the @Binding properties
@State var mockEditMode = true
@State var mockPresentInstructionEditView = false
// Provide the mock data to the view
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Edit Mode")
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Read Only")
.environment(\.editMode, .constant(.inactive))
}
static func createRecipe() -> Recipe {
let recipe = Recipe()
recipe.name = "Mock Recipe"
recipe.instructions = [
"Step 1: Gather all ingredients and equipment.",
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
"Step 7: Let cool before serving. Enjoy!"
]
return recipe
}
}

View File

@@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
.padding() .padding()
} }
} }
*/
// MARK: - RecipeView More Information Section // MARK: - RecipeView More Information Section
struct MoreInformationSection: View { struct MoreInformationSection: View {
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let dateCreated = viewModel.recipe.dateCreated { if let dateCreated = recipe.dateCreated {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")") Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
} }
if let dateModified = viewModel.recipe.dateModified { if let dateModified = recipe.dateModified {
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")") Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
} }
if viewModel.recipe.url != "", let url = URL(string: viewModel.recipe.url ?? "") { if recipe.url != "", let url = URL(string: recipe.url ?? "") {
HStack(alignment: .top) { HStack(alignment: .top) {
Text("URL:") Text("URL:")
Link(destination: url) { Link(destination: url) {
Text(viewModel.recipe.url ?? "") Text(recipe.url ?? "")
} }
} }
} }
@@ -157,5 +157,3 @@ struct MoreInformationSection: View {
.padding() .padding()
} }
} }
*/

View File

@@ -9,14 +9,15 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Nutrition Section // MARK: - RecipeView Nutrition Section
/*
struct RecipeNutritionSection: View { struct RecipeNutritionSection: View {
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if viewModel.editMode { if editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack { HStack {
Text(nutrition.localizedDescription) Text(nutrition.localizedDescription)
@@ -28,7 +29,7 @@ struct RecipeNutritionSection: View {
} else if !nutritionEmpty() { } else if !nutritionEmpty() {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = viewModel.recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey { if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) { HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)") Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
@@ -43,7 +44,7 @@ struct RecipeNutritionSection: View {
} }
} title: { } title: {
HStack { HStack {
if let servingSize = viewModel.recipe.nutrition["servingSize"] { if let servingSize = recipe.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))") SecondaryLabel(text: "Nutrition (\(servingSize))")
} else { } else {
SecondaryLabel(text: LocalizedStringKey("Nutrition")) SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -56,14 +57,14 @@ struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> { func binding(for key: String) -> Binding<String> {
Binding( Binding(
get: { viewModel.recipe.nutrition[key, default: ""] }, get: { recipe.nutrition[key, default: ""] },
set: { viewModel.recipe.nutrition[key] = $0 } set: { recipe.nutrition[key] = $0 }
) )
} }
func nutritionEmpty() -> Bool { func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases { for nutrition in Nutrition.allCases {
if let value = viewModel.recipe.nutrition[nutrition.dictKey] { if let value = recipe.nutrition[nutrition.dictKey] {
return false return false
} }
} }
@@ -71,4 +72,3 @@ struct RecipeNutritionSection: View {
} }
} }
*/

View File

@@ -9,9 +9,11 @@ import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Tool Section // MARK: - RecipeView Tool Section
/*
struct RecipeToolSection: View { struct RecipeToolSection: View {
@State var viewModel: RecipeView.ViewModel @Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentToolEditView: Bool
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
@@ -20,11 +22,11 @@ struct RecipeToolSection: View {
Spacer() Spacer()
} }
RecipeListSection(list: $viewModel.recipe.tool) RecipeListSection(list: $recipe.tools)
if viewModel.editMode { if editMode {
Button { Button {
viewModel.presentToolEditView.toggle() presentToolEditView.toggle()
} label: { } label: {
Text("Edit") Text("Edit")
} }
@@ -36,4 +38,4 @@ struct RecipeToolSection: View {
} }
*/

View File

@@ -7,7 +7,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
/*
struct CollapsibleView<C: View, T: View>: View { struct CollapsibleView<C: View, T: View>: View {
@State var titleColor: Color = .white @State var titleColor: Color = .white
@State var isCollapsed: Bool = true @State var isCollapsed: Bool = true
@@ -48,4 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
} }
} }
} }
*/

View File

@@ -0,0 +1,38 @@
//
// ListVStack.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import SwiftUI
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
@Binding var items: [Element]
let header: () -> HeaderContent
let rows: (Int, Binding<Element>) -> RowContent
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
self._items = items
self.header = header
self.rows = rows
}
var body: some View {
VStack(alignment: .leading) {
header()
.padding(.horizontal, 30)
VStack(alignment: .leading, spacing: 0) {
ForEach(items.indices, id: \.self) { index in
rows(index, $items[index])
.padding(10)
}
}
.padding(4)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal)
}
}
}

View File

@@ -7,35 +7,86 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
@Model class GroceryItem {
var name: String
var isChecked: Bool
init(name: String, isChecked: Bool) {
self.name = name
self.isChecked = isChecked
}
}
@Model class RecipeGroceries: Identifiable {
var id: String
var name: String
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
var multiplier: Double
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
self.id = id
self.name = name
self.items = items
self.multiplier = multiplier
}
init(id: String, name: String) {
self.id = id
self.name = name
self.items = []
self.multiplier = 1
}
}
/*
struct GroceryListTabView: View { struct GroceryListTabView: View {
@Environment(CookbookState.self) var cookbookState @Environment(\.modelContext) var modelContext
@Query var groceryList: [RecipeGroceries] = []
@State var newGroceries: String = ""
@FocusState private var isFocused: Bool
var body: some View { var body: some View {
NavigationStack { NavigationStack {
if cookbookState.groceryList.groceryDict.isEmpty {
EmptyGroceryListView()
} else {
List { List {
ForEach(cookbookState.groceryList.groceryDict.keys.sorted(), id: \.self) { key in HStack(alignment: .top) {
Section { TextEditor(text: $newGroceries)
ForEach(cookbookState.groceryList.groceryDict[key]!.items) { item in .padding(4)
GroceryListItemView(item: item, toggleAction: { .overlay(RoundedRectangle(cornerRadius: 8)
cookbookState.groceryList.toggleItemChecked(item) .stroke(Color.secondary).opacity(0.5))
}, deleteAction: { .focused($isFocused)
withAnimation { Button {
cookbookState.groceryList.deleteItem(item.name, fromRecipe: key) if !newGroceries.isEmpty {
let items = newGroceries
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
Task {
await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
} }
}) }
newGroceries = ""
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
ForEach(groceryList, id: \.name) { category in
Section {
ForEach(category.items, id: \.self) { item in
GroceryListItemView(item: item)
} }
} header: { } header: {
HStack { HStack {
Text(cookbookState.groceryList.groceryDict[key]!.name) Text(category.name)
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(Color.nextcloudBlue)
Spacer() Spacer()
Button { Button {
cookbookState.groceryList.deleteGroceryRecipe(key) modelContext.delete(category)
} label: { } label: {
Image(systemName: "trash") Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(Color.nextcloudBlue)
@@ -43,29 +94,80 @@ struct GroceryListTabView: View {
} }
} }
} }
if groceryList.isEmpty {
Text("You're all set for cooking 🍓")
.font(.headline)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
} }
.listStyle(.plain) .listStyle(.plain)
.navigationTitle("Grocery List") .navigationTitle("Grocery List")
.toolbar { .toolbar {
Button { Button {
cookbookState.groceryList.deleteAll() do {
try modelContext.delete(model: RecipeGroceries.self)
} catch {
print("Failed to delete all GroceryCategory models.")
}
} label: { } label: {
Text("Delete") Text("Delete")
.foregroundStyle(Color.nextcloudBlue) .foregroundStyle(Color.nextcloudBlue)
} }
} }
} }
} }
private func addGroceryItems(_ itemNames: [String], toCategory categoryId: String, named name: String) async {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
var targetCategory: RecipeGroceries?
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
targetCategory = existingCategory
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
targetCategory = newCategory
}
guard let category = targetCategory else { return }
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
category.items.append(newItem)
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
for index in offsets {
let itemToDelete = category.items[index]
modelContext.delete(itemToDelete)
}
} }
} }
fileprivate struct GroceryListItemView: View { fileprivate struct GroceryListItemView: View {
let item: GroceryRecipeItem @Environment(\.modelContext) var modelContext
let toggleAction: () -> Void @Bindable var item: GroceryItem
let deleteAction: () -> Void
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
@@ -81,149 +183,13 @@ fileprivate struct GroceryListItemView: View {
} }
.padding(5) .padding(5)
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary) .foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture(perform: toggleAction) .onTapGesture(perform: { item.isChecked.toggle() })
.animation(.easeInOut, value: item.isChecked) .animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(action: deleteAction) { Button(action: { modelContext.delete(item) }) {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
} }
} }
fileprivate struct EmptyGroceryListView: View {
var body: some View {
List {
Text("You're all set for cooking 🍓")
.font(.headline)
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
.navigationTitle("Grocery List")
}
}
// Grocery List Logic
class GroceryRecipe: Identifiable, Codable {
let name: String
var items: [GroceryRecipeItem]
init(name: String, items: [GroceryRecipeItem]) {
self.name = name
self.items = items
}
init(name: String, item: GroceryRecipeItem) {
self.name = name
self.items = [item]
}
}
class GroceryRecipeItem: Identifiable, Codable {
let name: String
var isChecked: Bool
init(_ name: String, isChecked: Bool = false) {
self.name = name
self.isChecked = isChecked
}
}
@Observable class GroceryList {
let dataStore: DataStore = DataStore()
var groceryDict: [String: GroceryRecipe] = [:]
var sortBySimilarity: Bool = false
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
print("Adding item of recipe \(String(describing: recipeName))")
if self.groceryDict[recipeId] != nil {
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
} else {
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
self.groceryDict[recipeId] = newRecipe
}
if saveGroceryDict {
self.save()
}
}
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
for item in items {
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
}
save()
}
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
print("Deleting item \(itemName)")
guard let recipe = groceryDict[recipeId] else { return }
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
groceryDict[recipeId]?.items.remove(at: itemIndex)
if groceryDict[recipeId]!.items.isEmpty {
groceryDict.removeValue(forKey: recipeId)
}
save()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
}
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
}
func containsItem(at recipeId: String, item: String) -> Bool {
guard let recipe = groceryDict[recipeId] else { return false }
if recipe.items.contains(where: { $0.name == item }) {
return true
}
return false
}
func containsRecipe(_ recipeId: String) -> Bool {
return groceryDict[recipeId] != nil
}
func save() {
Task {
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
}
}
func load() async {
do {
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
fromPath: "grocery_list.data"
) else { return }
self.groceryDict = groceryDict
} catch {
print("Unable to load grocery list")
}
}
}
*/

View File

@@ -7,37 +7,117 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import SwiftData
/*
struct RecipeTabView: View { struct RecipeTabView: View {
@EnvironmentObject var appState: AppState //@State var cookbookState: CookbookState = CookbookState()
@EnvironmentObject var groceryList: GroceryList @Environment(\.modelContext) var modelContext
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @Query var recipes: [Recipe]
@State var categories: [(String, Int)] = []
@State private var selectedRecipe: Recipe?
@State private var selectedCategory: String? = "*"
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $viewModel.selectedCategory) { List(selection: $selectedCategory) {
// Categories CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
ForEach(appState.categories) { category in .tag("*") // Tag nil to select all recipes
NavigationLink(value: category) {
Section("Categories") {
ForEach(categories, id: \.0.self) { category in
CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
.tag(category.0)
}
}
}
.navigationTitle("Categories")
} content: {
RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
} detail: {
// Use a conditional view based on selection
if let selectedRecipe {
//RecipeDetailView(recipe: recipe) // Create a dedicated detail view
RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
} else {
ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
}
}
.task {
initCategories()
return
do {
try modelContext.delete(model: Recipe.self)
} catch {
print("Failed to delete recipes and categories.")
}
guard let categories = await CookbookApiV1.getCategories(auth: UserSettings.shared.authString).0 else { return }
for category in categories {
guard let recipeStubs = await CookbookApiV1.getCategory(auth: UserSettings.shared.authString, named: category.name).0 else { return }
for recipeStub in recipeStubs {
guard let recipe = await CookbookApiV1.getRecipe(auth: UserSettings.shared.authString, id: recipeStub.id).0 else { return }
modelContext.insert(recipe)
}
}
}/*
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
//cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
func initCategories() {
// Load Categories
var categoryDict: [String: Int] = [:]
for recipe in recipes {
// Ensure "Uncategorized" is a valid category if used
if !recipe.category.isEmpty {
categoryDict[recipe.category, default: 0] += 1
} else {
categoryDict["Other", default: 0] += 1
}
}
categories = categoryDict.map {
($0.key, $0.value)
}.sorted { $0.0 < $1.0 }
}
class ViewModel: ObservableObject {
@Published var presentEditView: Bool = false
@Published var presentSettingsView: Bool = false
@Published var presentLoadingIndicator: Bool = false
@Published var presentConnectionPopover: Bool = false
@Published var serverConnection: Bool = false
}
}
fileprivate struct CategoryListItem: View {
var category: String
var count: Int
var isSelected: Bool
var body: some View {
HStack(alignment: .center) { HStack(alignment: .center) {
if viewModel.selectedCategory != nil && if isSelected {
category.name == viewModel.selectedCategory!.name {
Image(systemName: "book") Image(systemName: "book")
} else { } else {
Image(systemName: "book.closed.fill") Image(systemName: "book.closed.fill")
} }
if category.name == "*" { Text(category)
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default)) .font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer() Spacer()
Text("\(category.recipe_count)") Text("\(count)")
.font(.system(size: 15, weight: .bold, design: .default)) .font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background) .foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center) .frame(width: 25, height: 25, alignment: .center)
@@ -49,62 +129,7 @@ struct RecipeTabView: View {
}.padding(7) }.padding(7)
} }
} }
} /*
.navigationTitle("Cookbooks")
.toolbar {
RecipeTabViewToolBar()
}
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView()
.environmentObject(appState)
}
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
.environmentObject(appState)
.environmentObject(groceryList)
}
} detail: {
NavigationStack {
if let category = viewModel.selectedCategory {
RecipeListView(
categoryName: category.name,
showEditView: $viewModel.presentEditView
)
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
}
}
.tint(.nextcloudBlue)
.task {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
}
.refreshable {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
await appState.getCategories()
}
}
class ViewModel: ObservableObject {
@Published var presentEditView: Bool = false
@Published var presentSettingsView: Bool = false
@Published var presentLoadingIndicator: Bool = false
@Published var presentConnectionPopover: Bool = false
@Published var serverConnection: Bool = false
@Published var selectedCategory: Category? = nil
}
}
fileprivate struct RecipeTabViewToolBar: ToolbarContent { fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel

View File

@@ -0,0 +1,234 @@
//
// SettingsTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import Foundation
import SwiftUI
struct SettingsTabView: View {
@ObservedObject var userSettings = UserSettings.shared
@State private var avatarImage: UIImage?
@State private var userData: UserData?
@State private var showAlert: Bool = false
@State private var alertType: SettingsAlert = .NONE
@State private var presentLoginSheet: Bool = false
enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
var body: some View {
Form {
Section {
if userSettings.authString.isEmpty {
HStack(alignment: .center) {
if let avatarImage = avatarImage {
Image(uiImage: avatarImage)
.resizable()
.clipShape(Circle())
.frame(width: 100, height: 100)
}
if let userData = userData {
VStack(alignment: .leading) {
Text(userData.userDisplayName)
.font(.title)
.padding(.leading)
Text("Username: \(userData.userId)")
.font(.subheadline)
.padding(.leading)
// TODO: Add actions
}
}
Spacer()
}
Button("Log out") {
print("Log out.")
alertType = .LOG_OUT
showAlert = true
}
.tint(.red)
} else {
Button("Log in") {
print("Log in.")
presentLoginSheet.toggle()
}
}
} header: {
Text("Nextcloud")
} footer: {
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.keepScreenAwake) {
Text("Keep screen awake when viewing recipes")
}
}
Section {
HStack {
Text("Decimal number format")
Spacer()
Picker("", selection: $userSettings.decimalNumberSeparator) {
Text("Point (e.g. 1.42)").tag(".")
Text("Comma (e.g. 1,42)").tag(",")
}
.pickerStyle(.menu)
}
} footer: {
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
Text(lang.descriptor()).tag(lang.rawValue)
}
}
} footer: {
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
}
Section {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section {
Button("Delete local data") {
print("Clear cache.")
alertType = .DELETE_CACHE
showAlert = true
}
.tint(.red)
} header: {
Text("Other")
} footer: {
Text("Deleting local data will not affect the recipe data stored on your server.")
}
Section(header: Text("Acknowledgements")) {
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
Link("SwiftSoup", destination: url)
.font(.headline)
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
}
}
VStack(alignment: .leading) {
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
Link("TPPDF", destination: url)
.font(.headline)
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
}
}
}
}
.navigationTitle("Settings")
.alert(alertType.getTitle(), isPresented: $showAlert) {
Button("Cancel", role: .cancel) { }
if alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCachedData() }
}
} message: {
Text(alertType.getMessage())
}
.task {
await getUserData()
}
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
V2LoginView()
}
}
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
func deleteCachedData() {
print("TODO: Delete cached data\n")
}
}