diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index ae272d7..1ad6293 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -59,6 +59,9 @@ A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; }; A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.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 */; }; A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.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 = ""; }; A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = ""; }; A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = ""; }; + A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = ""; }; + A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = ""; }; + A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = ""; }; A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = ""; }; A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = ""; }; @@ -300,6 +306,7 @@ A70171C72AB4C4A100064C43 /* Data */ = { isa = PBXGroup; children = ( + A9AAB0512DE911C300A4C74B /* AuthManager.swift */, A70171C32AB4A31200064C43 /* DataStore.swift */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */, A9BBB38F2B91BE31002DA7FF /* Recipe.swift */, @@ -382,6 +389,7 @@ A977D0DC2B6002DA009783A9 /* Tabs */ = { isa = PBXGroup; children = ( + A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */, A977D0DD2B600300009783A9 /* SearchTabView.swift */, A977D0DF2B600318009783A9 /* RecipeTabView.swift */, A977D0E12B60034E009783A9 /* GroceryListTabView.swift */, @@ -427,6 +435,7 @@ A9C3BE522B630F1300562C79 /* ReusableViews */ = { isa = PBXGroup; children = ( + A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */, A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, @@ -618,6 +627,7 @@ A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, + A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */, @@ -626,12 +636,14 @@ A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, + A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */, A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */, + A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */, A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */, A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */, A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */, diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index 3e004e3..c9d1202 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift index d7e36f5..33542ff 100644 --- a/Nextcloud Cookbook iOS Client/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -9,6 +9,10 @@ import Foundation import SwiftUI import UIKit +@Observable class AppState { + +} + /* @MainActor class AppState: ObservableObject { @Published var categories: [Category] = [] diff --git a/Nextcloud Cookbook iOS Client/Data/AuthManager.swift b/Nextcloud Cookbook iOS Client/Data/AuthManager.swift new file mode 100644 index 0000000..d295009 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/AuthManager.swift @@ -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) + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/Recipe.swift b/Nextcloud Cookbook iOS Client/Data/Recipe.swift index 227593e..9552d53 100644 --- a/Nextcloud Cookbook iOS Client/Data/Recipe.swift +++ b/Nextcloud Cookbook iOS Client/Data/Recipe.swift @@ -51,7 +51,7 @@ class Recipe { var name: String var keywords: [String] @Attribute(.externalStorage) var image: RecipeImage? - var thumbnail: RecipeThumbnail? + @Attribute(.externalStorage) var thumbnail: RecipeThumbnail? var dateCreated: String? = nil var dateModified: String? = nil var prepTime: String @@ -70,6 +70,16 @@ class Recipe { @Transient var ingredientMultiplier: Double = 1.0 + var prepTimeDurationComponent: DurationComponents { + DurationComponents.fromPTString(prepTime) + } + var cookTimeDurationComponent: DurationComponents { + DurationComponents.fromPTString(cookTime) + } + var totalTimeDurationComponent: DurationComponents { + DurationComponents.fromPTString(totalTime) + } + init( id: String, @@ -109,50 +119,24 @@ class Recipe { self.ingredientMultiplier = ingredientMultiplier } - required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - keywords = try container.decode([String].self, forKey: .keywords) - dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated) - dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified) - prepTime = try container.decode(String.self, forKey: .prepTime) - cookTime = try container.decode(String.self, forKey: .cookTime) - totalTime = try container.decode(String.self, forKey: .totalTime) - recipeDescription = try container.decode(String.self, forKey: .recipeDescription) - url = try container.decodeIfPresent(String.self, forKey: .url) - yield = try container.decode(Int.self, forKey: .yield) - category = try container.decode(String.self, forKey: .category) - tools = try container.decode([String].self, forKey: .tools) - ingredients = try container.decode([String].self, forKey: .ingredients) - instructions = try container.decode([String].self, forKey: .instructions) - nutrition = try container.decode([String: String].self, forKey: .nutrition) - } -} - -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) + init() { + self.id = UUID().uuidString + self.name = String(localized: "New Recipe") + self.keywords = [] + self.dateCreated = nil + self.dateModified = nil + self.prepTime = "0" + self.cookTime = "0" + self.totalTime = "0" + self.recipeDescription = "" + self.url = "" + self.yield = 1 + self.category = "" + self.tools = [] + self.ingredients = [] + self.instructions = [] + self.nutrition = [:] + self.ingredientMultiplier = 1 } } // MARK: - Recipe Stub diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 26d53e7..7f69cc7 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -136,7 +136,6 @@ } }, "%@: %@" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -400,7 +399,6 @@ } }, "A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -423,7 +421,6 @@ } }, "About" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -446,7 +443,6 @@ } }, "Acknowledgements" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -491,7 +487,6 @@ } }, "Add" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -514,7 +509,6 @@ } }, "Add cooking steps for fellow chefs to follow." : { - "extractionState" : "stale", "localizations" : { "de" : { "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." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -581,9 +574,11 @@ } } } + }, + "All Recipes" : { + }, "An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -762,6 +757,9 @@ } } } + }, + "Categories" : { + }, "Category" : { "extractionState" : "stale", @@ -855,11 +853,10 @@ } } }, - "Client error" : { + "Client error: %lld" : { }, "Comma (e.g. 1,42)" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -882,7 +879,6 @@ } }, "Configure what is stored on your device." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -905,7 +901,6 @@ } }, "Configure which sections in your recipes are expanded by default." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1061,9 +1056,11 @@ } } } + }, + "Copy Error" : { + }, "Copy Link" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1108,7 +1105,6 @@ } }, "Created: %@" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1134,7 +1130,6 @@ }, "Decimal number format" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1182,7 +1177,6 @@ } }, "Delete local data" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1228,7 +1222,6 @@ } }, "Delete Recipe" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1273,7 +1266,6 @@ } }, "Deleting local data will not affect the recipe data stored on your server." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1318,7 +1310,6 @@ } }, "Description" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1339,6 +1330,9 @@ } } } + }, + "Dismiss" : { + }, "Done" : { "localizations" : { @@ -1363,7 +1357,6 @@ } }, "Downloads" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1454,7 +1447,6 @@ } }, "Edit" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1500,6 +1492,9 @@ } } } + }, + "Error: Login URL not available." : { + }, "Error." : { "localizations" : { @@ -1522,9 +1517,11 @@ } } } + }, + "example.com" : { + }, "Expand information section" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1547,7 +1544,6 @@ } }, "Expand keyword section" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1570,7 +1566,6 @@ } }, "Expand nutrition section" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1685,7 +1680,6 @@ } }, "Get support" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1708,7 +1702,6 @@ } }, "Grocery List" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1731,7 +1724,6 @@ } }, "Hours" : { - "extractionState" : "stale", "localizations" : { "de" : { "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." : { - "extractionState" : "stale", "localizations" : { "de" : { "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." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1823,7 +1813,6 @@ } }, "If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : { - "extractionState" : "stale", "localizations" : { "de" : { "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" : { "extractionState" : "stale", @@ -1892,7 +1884,6 @@ } }, "Ingredient" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1915,7 +1906,6 @@ } }, "Ingredients" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1984,7 +1974,6 @@ } }, "Instruction" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2007,7 +1996,6 @@ } }, "Instructions" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2036,7 +2024,6 @@ }, "Keep screen awake when viewing recipes" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2082,7 +2069,6 @@ } }, "Language" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2105,7 +2091,6 @@ } }, "Last modified: %@" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2151,7 +2136,6 @@ } }, "List your tools here. 🍴" : { - "extractionState" : "stale", "localizations" : { "de" : { "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" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2197,7 +2186,6 @@ } }, "Login" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2288,7 +2276,6 @@ } }, "Marked ingredients could not be adjusted!" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2311,7 +2298,6 @@ } }, "Minutes" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2428,7 +2414,6 @@ } }, "More information" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2471,9 +2456,6 @@ } } } - }, - "New" : { - }, "New recipe" : { "extractionState" : "stale", @@ -2519,9 +2501,11 @@ } } } + }, + "Nextcloud" : { + }, "Nextcloud Login" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2567,7 +2551,6 @@ } }, "No nutritional information." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2636,7 +2619,6 @@ } }, "Nutrition" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2659,7 +2641,6 @@ } }, "Nutrition (%@)" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2682,7 +2663,6 @@ } }, "Offline recipes" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2727,7 +2707,6 @@ } }, "Other" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2889,7 +2868,6 @@ } }, "Point (e.g. 1.42)" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2912,7 +2890,6 @@ } }, "Preparation" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3003,7 +2980,6 @@ } }, "Recipe Name" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3048,7 +3024,6 @@ } }, "Recipes" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3255,6 +3230,9 @@ } } } + }, + "Select a Recipe" : { + }, "Select Keywords" : { "extractionState" : "stale", @@ -3302,7 +3280,13 @@ } } }, - "Server error" : { + "Server address:" : { + + }, + "Server error: %lld" : { + + }, + "Server Protocol:" : { }, "Serving size" : { @@ -3464,7 +3448,6 @@ } }, "Share Recipe" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3533,7 +3516,6 @@ } }, "Start by adding your first ingredient! 🥬" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3556,7 +3538,6 @@ } }, "Store recipe images locally" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3579,7 +3560,6 @@ } }, "Store recipe thumbnails locally" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3670,7 +3650,6 @@ } }, "Support" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3693,7 +3672,6 @@ } }, "SwiftSoup" : { - "extractionState" : "stale", "localizations" : { "de" : { "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" : { "extractionState" : "stale", @@ -3899,7 +3880,6 @@ } }, "This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : { - "extractionState" : "stale", "localizations" : { "de" : { "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" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3990,7 +3972,6 @@ } }, "Tools" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4036,7 +4017,6 @@ } }, "Total time" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4059,7 +4039,6 @@ } }, "TPPDF" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4255,7 +4234,6 @@ } }, "Upload Changes" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4278,7 +4256,6 @@ } }, "Upload Recipe" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4324,7 +4301,6 @@ } }, "URL:" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4347,7 +4323,6 @@ } }, "Username: %@" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4393,7 +4368,6 @@ } }, "Visit the GitHub page" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4416,7 +4390,6 @@ } }, "You're all set for cooking 🍓" : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -4439,7 +4412,6 @@ } }, "Your grocery list is stored locally and therefore not synchronized across your devices." : { - "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { diff --git a/Nextcloud Cookbook iOS Client/Networking/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Networking/ApiRequest.swift index 4971767..ee51c85 100644 --- a/Nextcloud Cookbook iOS Client/Networking/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Networking/ApiRequest.swift @@ -69,7 +69,7 @@ struct ApiRequest { var response: URLResponse? = nil do { (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) { print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)") return (nil, error) @@ -94,9 +94,8 @@ struct ApiRequest { switch response.statusCode { case 200...299: return (nil) case 300...399: return (NetworkError.redirectionError) - case 400...499: return (NetworkError.clientError) - case 500...599: return (NetworkError.serverError) - case 600: return (NetworkError.invalidRequest) + case 400...499: return (NetworkError.clientError(statusCode: response.statusCode)) + case 500...599: return (NetworkError.serverError(statusCode: response.statusCode)) default: return (NetworkError.unknownError) } } diff --git a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApi.swift b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApi.swift index d4fa2d5..b7b963d 100644 --- a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApi.swift +++ b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApi.swift @@ -99,7 +99,7 @@ protocol CookbookApi { /// - Returns: A list of categories. A NetworkError if the request fails. static func getCategories( auth: String - ) async -> ([Category]?, NetworkError?) + ) async -> ([CookbookApiCategory]?, NetworkError?) /// Get all recipes of a specified category. /// - Parameters: diff --git a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApiV1.swift index 34d81be..9107076 100644 --- a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookApiV1.swift @@ -77,7 +77,7 @@ class CookbookApiV1: CookbookApi { if let id = json as? Int { return nil } else if let dict = json as? [String: Any] { - return .serverError + return .unknownError } } catch { return .decodingFailed @@ -103,7 +103,7 @@ class CookbookApiV1: CookbookApi { static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) { let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe) - guard let recipeData = JSONEncoder.safeEncode(recipe) else { + guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else { return .dataError } let request = ApiRequest( @@ -121,7 +121,7 @@ class CookbookApiV1: CookbookApi { if let id = json as? Int { return nil } else if let dict = json as? [String: Any] { - return .serverError + return .unknownError } } catch { return .decodingFailed @@ -143,7 +143,7 @@ class CookbookApiV1: CookbookApi { return nil } - static func getCategories(auth: String) async -> ([Category]?, NetworkError?) { + static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) { let request = ApiRequest( path: basePath + "/categories", method: .GET, diff --git a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookModelsV1.swift b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookModelsV1.swift index 986447b..5d3ae98 100644 --- a/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookModelsV1.swift +++ b/Nextcloud Cookbook iOS Client/Networking/CookbookApi/CookbookModelsV1.swift @@ -8,10 +8,10 @@ import Foundation import SwiftUI -struct Category: Codable, Identifiable, Hashable { +struct CookbookApiCategory: Codable, Identifiable, Hashable { var id: String { name } - let name: String - let recipe_count: Int + var name: String + var recipe_count: Int private enum CodingKeys: String, CodingKey { case name, recipe_count diff --git a/Nextcloud Cookbook iOS Client/Networking/NetworkError.swift b/Nextcloud Cookbook iOS Client/Networking/NetworkError.swift index 3e26c9e..0508bce 100644 --- a/Nextcloud Cookbook iOS Client/Networking/NetworkError.swift +++ b/Nextcloud Cookbook iOS Client/Networking/NetworkError.swift @@ -15,8 +15,8 @@ public enum NetworkError: UserAlert { case encodingFailed case decodingFailed case redirectionError - case clientError - case serverError + case clientError(statusCode: Int) + case serverError(statusCode: Int) case invalidRequest case unknownError case dataError @@ -33,10 +33,10 @@ public enum NetworkError: UserAlert { "Data decoding failed." case .redirectionError: "Redirection error" - case .clientError: - "Client error" - case .serverError: - "Server error" + case .clientError(let code): + "Client error: \(code)" + case .serverError(let code): + "Server error: \(code)" case .invalidRequest: "Invalid request" case .unknownError: @@ -47,7 +47,7 @@ public enum NetworkError: UserAlert { } var localizedDescription: LocalizedStringKey { - return "" // TODO: Add description + return self.localizedTitle } var alertButtons: [AlertButton] { diff --git a/Nextcloud Cookbook iOS Client/Networking/NextcloudApi/NextcloudApi.swift b/Nextcloud Cookbook iOS Client/Networking/NextcloudApi/NextcloudApi.swift index e4326cb..4e7371f 100644 --- a/Nextcloud Cookbook iOS Client/Networking/NextcloudApi/NextcloudApi.swift +++ b/Nextcloud Cookbook iOS Client/Networking/NextcloudApi/NextcloudApi.swift @@ -20,10 +20,9 @@ class NextcloudApi { /// - `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. - static func loginV2Request() async -> (LoginV2Request?, NetworkError?) { - let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress + static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) { let request = ApiRequest( - path: path + "/index.php/login/v2", + path: baseAddress + "/index.php/login/v2", method: .POST ) @@ -52,16 +51,16 @@ class NextcloudApi { /// - `LoginV2Response?`: An object representing the response of the login process, if successful. /// - `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( - path: req.poll.endpoint, + path: pollURL, method: .POST, headerFields: [ HeaderField.ocsRequest(value: true), HeaderField.accept(value: .JSON), 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) diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index fc21939..c2f2701 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -21,7 +21,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App { EmptyView() } else { MainView() - .modelContainer(for: Recipe.self) + .modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self]) } } .transition(.slide) @@ -30,6 +30,10 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App { .init(identifier: language == SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language) ) + .onAppear { + AuthManager.shared.loadAuthString() // Load the auth string as soon as possible + } } + } } diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index f3c4495..95f912e 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -8,89 +8,58 @@ import SwiftUI import SwiftData -struct MainView: View { - //@State var cookbookState: CookbookState = CookbookState() - @Environment(\.modelContext) var modelContext - @Query var recipes: [Recipe] = [] - +struct MainView: View { + // Tab ViewModels + enum Tab { + case recipes, settings, groceryList + } var body: some View { - VStack { - List { - ForEach(recipes) { recipe in - Text(recipe.name) + TabView { + RecipeTabView() + .tabItem { + 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) - } + .tag(Tab.recipes) + + GroceryListTabView() + .tabItem { + if #available(iOS 17.0, *) { + Label("Grocery List", systemImage: "storefront") + } else { + Label("Grocery List", systemImage: "heart.text.square") + } + } + .tag(Tab.groceryList) + + SettingsTabView() + .tabItem { + Label("Settings", systemImage: "gear") + } + .tag(Tab.settings) + } - /*NavigationSplitView { - VStack { - List(selection: $cookbookState.selectedCategory) { - ForEach(cookbookState.categories) { category in - Text(category.name) - .tag(category) - } - } - .listStyle(.plain) - .onAppear { - Task { - await cookbookState.loadCategories() + .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 } + return false + }) { + recipeViewModel.selectedCategory = cat } } - } 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) - } - } - } else { - Text("Please select a category.") - .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) - } - } - } - } else { - Text("Please select a recipe.") - .foregroundColor(.secondary) - } + await groceryList.load() + recipeViewModel.presentLoadingIndicator = false + */ } - .toolbar { - ToolbarItem(placement: .bottomBar) { - Button(action: { - cookbookState.showGroceries = true - }) { - Label("Grocery List", systemImage: "cart") - } - } - ToolbarItem(placement: .topBarLeading) { - Button(action: { - cookbookState.showSettings = true - }) { - Label("Settings", systemImage: "gearshape") - } - } - }*/ } } diff --git a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift index da65d42..5b8c9b9 100644 --- a/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Onboarding/V2LoginView.swift @@ -8,7 +8,14 @@ import Foundation import SwiftUI import WebKit -/* +import AuthenticationServices + + +protocol LoginStage { + func next() -> Self + func previous() -> Self +} + enum V2LoginStage: LoginStage { case login, validate @@ -30,12 +37,27 @@ enum V2LoginStage: LoginStage { struct V2LoginView: View { - @Binding var showAlert: Bool - @Binding var alertMessage: String + @Environment(\.dismiss) var dismiss + @State var showAlert: Bool = false + @State var alertMessage: String = "" @State var loginStage: V2LoginStage = .login @State var loginRequest: LoginV2Request? = nil @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? = nil + + enum ServerProtocol: String { + case https="https://", http="http://" + + static let all = [https, http] + } // TextField handling enum Field { @@ -45,114 +67,205 @@ struct V2LoginView: View { } var body: some View { - ScrollView { - VStack(alignment: .leading) { - ServerAddressField() - CollapsibleView { - VStack(alignment: .leading) { - Text("Make sure to enter the server address in the form 'example.com', or \n':'\n when a non-standard port is used.") - .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'.") - .padding(.bottom) - 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() - - if loginRequest != nil { - Button("Copy Link") { - UIPasteboard.general.string = loginRequest!.login - } - .font(.headline) - .foregroundStyle(.white) - .padding() + VStack { + HStack { + Button("Cancel") { + dismiss() } + Spacer() - HStack { - Button { - if UserSettings.shared.serverAddress == "" { - alertMessage = "Please enter a valid server address." - showAlert = true - return - } - - Task { - let error = await sendLoginV2Request() - if let error = error { - alertMessage = "A network error occured (\(error.localizedDescription))." - showAlert = true - } - 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 isLoading { + ProgressView() + } + }.padding() + + Form { + Section { + HStack { + Text("Server address:") + TextField("example.com", text: $serverAddress) + .multilineTextAlignment(.trailing) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } - 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) - ) + Picker("Server Protocol:", selection: $serverProtocol) { + ForEach(ServerProtocol.all, id: \.self) { + Text($0.rawValue) } - .disabled(loginRequest == nil ? true : false) - .padding() + } + + HStack { + Button("Login") { + initiateLoginV2() + } + 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, onDismiss: { - Task { - let (response, error) = await fetchLoginV2Response() - checkLogin(response: response, error: error) + .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.") } - }) { - if let loginRequest = loginRequest { - WebViewSheet(url: loginRequest.login) + } + .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 sendLoginV2Request() async -> NetworkError? { - let (req, error) = await NextcloudApi.loginV2Request() - self.loginRequest = req - return error + 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 + } + + guard let req = req else { + self.alertMessage = "Failed to get login URL from server." + self.showAlert = true + self.isLoading = false + self.loginPressed = false + return + } + + self.loginRequest = req + + // 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?) { - guard let loginRequest = loginRequest else { return (nil, .parametersNil) } - return await NextcloudApi.loginV2Response(req: loginRequest) + func startPolling(pollURL: String, pollToken: String) { + // Cancel any existing poll task first + pollTask?.cancel() + var pollingFailed = true + + pollTask = Task { + let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute + for _ in 0..) -> Void -struct WebViewSheet: View { - @Environment(\.dismiss) var dismiss - @State var url: String + func makeUIViewController(context: Context) -> UIViewController { + UIViewController() + } - var body: some View { - NavigationView { - WebView(url: URL(string: url)!) - .navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline) - .navigationBarItems(trailing: Button("Done") { - dismiss() - }) + func updateUIViewController(_ uiViewController: UIViewController, context: Context) { + + if !context.coordinator.sessionStarted { + context.coordinator.sessionStarted = true + + 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 { - return WKWebView() - } - - func updateUIView(_ uiView: WKWebView, context: Context) { - let request = URLRequest(url: url) - uiView.load(request) - } +#Preview { + V2LoginView() } -*/ diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift index e5611ed..0a038ec 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift @@ -8,10 +8,10 @@ import Foundation import SwiftUI -/* + struct RecipeCardView: View { - @EnvironmentObject var appState: AppState - @State var recipe: CookbookApiRecipeV1 + //@EnvironmentObject var appState: AppState + @State var recipe: Recipe @State var recipeThumb: UIImage? @State var isDownloaded: Bool? = nil @@ -50,6 +50,7 @@ struct RecipeCardView: View { .background(Color.backgroundHighlight) .clipShape(RoundedRectangle(cornerRadius: 17)) .task { + /* recipeThumb = await appState.getImage( id: recipe.recipe_id, size: .THUMB, @@ -59,18 +60,20 @@ struct RecipeCardView: View { recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id) } isDownloaded = recipe.storedLocally + */ } .refreshable { + /* recipeThumb = await appState.getImage( id: recipe.recipe_id, size: .THUMB, fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer - ) + )*/ } .frame(height: 80) } } -*/ + /* struct RecipeCardView: View { @State var state: AccountState diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift index 9f92c1d..d4569e6 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeListView.swift @@ -7,6 +7,53 @@ import Foundation 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, selectedRecipe: Binding) { + var predicate: Predicate? = nil + + if let category = selectedCategory.wrappedValue, category != "*" { + predicate = #Predicate { + $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 { + + } + } + +} /* diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift index 4d77500..7e74af6 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -8,15 +8,15 @@ import Foundation import SwiftUI -/* + struct RecipeView: View { - @EnvironmentObject var appState: AppState + @Bindable var recipe: Recipe @Environment(\.dismiss) private var dismiss @StateObject var viewModel: ViewModel @GestureState private var dragOffset = CGSize.zero 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 200 @@ -33,8 +33,8 @@ struct RecipeView: View { coordinateSpace: CoordinateSpaces.scrollView, defaultHeight: imageHeight ) { - if let recipeImage = viewModel.recipeImage { - Image(uiImage: recipeImage) + if let recipeImage = recipe.image, let image = recipeImage.image { + Image(uiImage: image) .resizable() .scaledToFill() .frame(maxHeight: imageHeight + 200) @@ -54,15 +54,12 @@ struct RecipeView: View { VStack(alignment: .leading) { if viewModel.editMode { - RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe) - } - - if viewModel.editMode { - RecipeMetadataSection(viewModel: viewModel) + //RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe) + //RecipeMetadataSection(viewModel: viewModel) } HStack { - EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") + EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name") .font(.title) .bold() @@ -74,36 +71,37 @@ struct RecipeView: View { } }.padding([.top, .horizontal]) - if viewModel.observableRecipeDetail.description != "" || viewModel.editMode { - EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical) + if recipe.recipeDescription != "" || viewModel.editMode { + EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical) .fontWeight(.medium) .padding(.horizontal) .padding(.top, 2) } // Recipe Body Section - RecipeDurationSection(viewModel: viewModel) + RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode) Divider() 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) { - RecipeInstructionSection(viewModel: viewModel) + if(!recipe.instructions.isEmpty || viewModel.editMode) { + RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView) } - if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) { - RecipeToolSection(viewModel: viewModel) + if(!recipe.tools.isEmpty || viewModel.editMode) { + RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView) } - RecipeNutritionSection(viewModel: viewModel) + RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode) } if !viewModel.editMode { Divider() LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { - RecipeKeywordSection(viewModel: viewModel) - MoreInformationSection(viewModel: viewModel) + //RecipeKeywordSection(viewModel: viewModel) + MoreInformationSection(recipe: recipe) } } } @@ -115,21 +113,21 @@ struct RecipeView: View { .ignoresSafeArea(.container, edges: .top) .navigationBarTitleDisplayMode(.inline) .toolbar(.visible, for: .navigationBar) - //.toolbarTitleDisplayMode(.inline) + .navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "") .toolbar { RecipeViewToolBar(viewModel: viewModel) } .sheet(isPresented: $viewModel.presentShareSheet) { - ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), + /*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), recipeImage: viewModel.recipeImage, - presentShareSheet: $viewModel.presentShareSheet) + presentShareSheet: $viewModel.presentShareSheet)*/ } .sheet(isPresented: $viewModel.presentInstructionEditView) { EditableListView( isPresented: $viewModel.presentInstructionEditView, - items: $viewModel.observableRecipeDetail.recipeInstructions, + items: $recipe.instructions, title: "Instructions", emptyListText: "Add cooking steps for fellow chefs to follow.", titleKey: "Instruction", @@ -139,7 +137,7 @@ struct RecipeView: View { .sheet(isPresented: $viewModel.presentIngredientEditView) { EditableListView( isPresented: $viewModel.presentIngredientEditView, - items: $viewModel.observableRecipeDetail.recipeIngredient, + items: $recipe.ingredients, title: "Ingredients", emptyListText: "Start by adding your first ingredient! 🥬", titleKey: "Ingredient", @@ -149,7 +147,7 @@ struct RecipeView: View { .sheet(isPresented: $viewModel.presentToolEditView) { EditableListView( isPresented: $viewModel.presentToolEditView, - items: $viewModel.observableRecipeDetail.tool, + items: $recipe.tools, title: "Tools", emptyListText: "List your tools here. 🍴", titleKey: "Tool", @@ -158,6 +156,7 @@ struct RecipeView: View { } .task { + /* // Load recipe detail if !viewModel.newRecipe { // For existing recipes, load the recipeDetail and image @@ -185,7 +184,7 @@ struct RecipeView: View { viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1()) viewModel.editMode = true viewModel.isDownloaded = false - } + }*/ } .alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) { ForEach(viewModel.alertType.alertButtons) { buttonType in @@ -217,13 +216,14 @@ struct RecipeView: View { UIApplication.shared.isIdleTimerDisabled = false } .onChange(of: viewModel.editMode) { newValue in + /* if newValue && appState.allKeywords.isEmpty { Task { appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in a.recipe_count > b.recipe_count }) } - } + }*/ } } @@ -231,9 +231,8 @@ struct RecipeView: View { // MARK: - RecipeView ViewModel class ViewModel: ObservableObject { - @Published var observableRecipeDetail: Recipe = Recipe() - @Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error - @Published var recipeImage: UIImage? = nil + @Published var recipe: Recipe + @Published var editMode: Bool = false @Published var showTitle: Bool = false @Published var isDownloaded: Bool? = nil @@ -244,7 +243,6 @@ struct RecipeView: View { @Published var presentIngredientEditView: Bool = false @Published var presentToolEditView: Bool = false - var recipe: CookbookApiRecipeV1 var sharedURL: URL? = nil var newRecipe: Bool = false @@ -254,26 +252,13 @@ struct RecipeView: View { var alertAction: () async -> () = { } // Initializers - init(recipe: CookbookApiRecipeV1) { + init(recipe: Recipe) { self.recipe = recipe } init() { self.newRecipe = true - self.recipe = CookbookApiRecipeV1( - 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) + self.recipe = Recipe() } func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) { @@ -285,7 +270,7 @@ struct RecipeView: View { } - +/* extension RecipeView { func importRecipe(from url: String) async -> UserAlert? { let (scrapedRecipe, error) = await appState.importRecipe(url: url) @@ -309,13 +294,12 @@ extension RecipeView { return nil } } - +*/ // MARK: - Tool Bar struct RecipeViewToolBar: ToolbarContent { - @EnvironmentObject var appState: AppState @Environment(\.dismiss) private var dismiss @ObservedObject var viewModel: RecipeView.ViewModel @@ -385,6 +369,7 @@ struct RecipeViewToolBar: ToolbarContent { } func handleUpload() async { + /* if viewModel.newRecipe { print("Uploading new recipe.") if let recipeValidationError = recipeValid() { @@ -416,9 +401,11 @@ struct RecipeViewToolBar: ToolbarContent { } viewModel.editMode = false viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS) + */ } func handleDelete() async { + /* let category = viewModel.observableRecipeDetail.recipeCategory guard let id = Int(viewModel.observableRecipeDetail.id) else { viewModel.presentAlert(RequestAlert.REQUEST_DROPPED) @@ -432,11 +419,13 @@ struct RecipeViewToolBar: ToolbarContent { await appState.getCategory(named: category, fetchMode: .preferServer) viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS) dismiss() + */ } func recipeValid() -> RecipeAlert? { + /* // 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 } @@ -454,12 +443,11 @@ struct RecipeViewToolBar: ToolbarContent { } } } + */ return nil } } -*/ - diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift index 3c1e0c3..c0a29a9 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift @@ -9,19 +9,20 @@ import Foundation import SwiftUI // MARK: - RecipeView Duration Section -/* + struct RecipeDurationSection: View { - @State var viewModel: RecipeView.ViewModel + @Bindable var recipe: Recipe + @Binding var editMode: Bool @State var presentPopover: Bool = false var body: some View { VStack(alignment: .leading) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) { - DurationView(time: viewModel.recipe.prepTime, title: LocalizedStringKey("Preparation")) - DurationView(time: viewModel.recipe.cookTime, title: LocalizedStringKey("Cooking")) - DurationView(time: viewModel.recipe.totalTime, title: LocalizedStringKey("Total time")) + DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation")) + DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking")) + DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time")) } - if viewModel.editMode { + if editMode { Button { presentPopover.toggle() } label: { @@ -34,9 +35,9 @@ struct RecipeDurationSection: View { .padding() .popover(isPresented: $presentPopover) { EditableDurationView( - prepTime: viewModel.recipe.prepTime, - cookTime: viewModel.recipe.cookTime, - totalTime: viewModel.recipe.totalTime + prepTime: recipe.prepTimeDurationComponent, + cookTime: recipe.cookTimeDurationComponent, + totalTime: recipe.totalTimeDurationComponent ) } } @@ -143,4 +144,4 @@ fileprivate struct TimePickerView: View { } } -*/ + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift index 61ba3c3..3d7b980 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -7,18 +7,24 @@ import Foundation import SwiftUI +import SwiftData // MARK: - RecipeView Ingredients Section -/* + struct RecipeIngredientSection: View { - @Environment(CookbookState.self) var cookbookState - @State var viewModel: RecipeView.ViewModel + @Environment(\.modelContext) var modelContext + @Bindable var recipe: Recipe + @Binding var editMode: Bool + @Binding var presentIngredientEditView: Bool + @State var recipeGroceries: RecipeGroceries? = nil + var body: some View { VStack(alignment: .leading) { HStack { Button { withAnimation { + /* if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) { cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id) } else { @@ -28,6 +34,7 @@ struct RecipeIngredientSection: View { recipeName: viewModel.recipe.name ) } + */ } } label: { if #available(iOS 17.0, *) { @@ -35,7 +42,7 @@ struct RecipeIngredientSection: View { } else { Image(systemName: "heart.text.square") } - }.disabled(viewModel.editMode) + }.disabled(editMode) SecondaryLabel(text: LocalizedStringKey("Ingredients")) @@ -45,26 +52,30 @@ struct RecipeIngredientSection: View { .foregroundStyle(.secondary) .bold() - ServingPickerView(selectedServingSize: $viewModel.recipe.ingredientMultiplier) + ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier) } - ForEach(0.. { $0.id == categoryId } + let fetchDescriptor = FetchDescriptor(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 { $0.id == categoryId } + let fetchDescriptor = FetchDescriptor(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 - +/* fileprivate struct IngredientListItem: View { - @Environment(CookbookState.self) var cookbookState + @Environment(\.modelContext) var modelContext + @Bindable var recipeGroceries: RecipeGroceries @Binding var ingredient: String @Binding var servings: Double @State var recipeYield: Double @State var recipeId: String - let addToGroceryListAction: () -> Void + @State var modifiedIngredient: AttributedString = "" @State var isSelected: Bool = false @@ -110,7 +182,7 @@ fileprivate struct IngredientListItem: View { var body: some View { HStack(alignment: .top) { - if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) { + if recipeGroceries.items.contains(ingredient) { if #available(iOS 17.0, *) { Image(systemName: "storefront") .foregroundStyle(Color.green) @@ -168,7 +240,7 @@ fileprivate struct IngredientListItem: View { .onEnded { gesture in withAnimation { 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) } else { addToGroceryListAction() @@ -182,7 +254,7 @@ fileprivate struct IngredientListItem: View { ) } } - +*/ struct ServingPickerView: View { @@ -217,4 +289,4 @@ struct ServingPickerView: View { } } -*/ + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift index de56f77..353c058 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift @@ -9,22 +9,26 @@ import Foundation import SwiftUI // MARK: - RecipeView Instructions Section -/* -struct RecipeInstructionSection: View { - @State var viewModel: RecipeView.ViewModel +struct RecipeInstructionSection: View { + @Bindable var recipe: Recipe + @Binding var editMode: Bool + @Binding var presentInstructionEditView: Bool + + var body: some View { + VStack(alignment: .leading) { HStack { SecondaryLabel(text: LocalizedStringKey("Instructions")) Spacer() } - ForEach(viewModel.recipe.recipeInstructions.indices, id: \.self) { ix in - RecipeInstructionListItem(instruction: $viewModel.recipe.recipeInstructions[ix], index: ix+1) + ForEach(recipe.instructions.indices, id: \.self) { ix in + RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1) } - if viewModel.editMode { + if editMode { Button { - viewModel.presentInstructionEditView.toggle() + presentInstructionEditView.toggle() } label: { Text("Edit") } @@ -32,11 +36,10 @@ struct RecipeInstructionSection: View { } } .padding() - } } - +// MARK: - Preview fileprivate struct RecipeInstructionListItem: View { @Binding var instruction: String @@ -56,4 +59,45 @@ fileprivate struct RecipeInstructionListItem: View { .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 + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift index 3027574..d5a519d 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView Binding { Binding( - get: { viewModel.recipe.nutrition[key, default: ""] }, - set: { viewModel.recipe.nutrition[key] = $0 } + get: { recipe.nutrition[key, default: ""] }, + set: { recipe.nutrition[key] = $0 } ) } func nutritionEmpty() -> Bool { for nutrition in Nutrition.allCases { - if let value = viewModel.recipe.nutrition[nutrition.dictKey] { + if let value = recipe.nutrition[nutrition.dictKey] { return false } } @@ -71,4 +72,3 @@ struct RecipeNutritionSection: View { } } -*/ diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift index 5f23660..de51ac0 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift @@ -9,9 +9,11 @@ import Foundation import SwiftUI // MARK: - RecipeView Tool Section -/* + struct RecipeToolSection: View { - @State var viewModel: RecipeView.ViewModel + @Bindable var recipe: Recipe + @Binding var editMode: Bool + @Binding var presentToolEditView: Bool var body: some View { VStack(alignment: .leading) { @@ -20,11 +22,11 @@ struct RecipeToolSection: View { Spacer() } - RecipeListSection(list: $viewModel.recipe.tool) + RecipeListSection(list: $recipe.tools) - if viewModel.editMode { + if editMode { Button { - viewModel.presentToolEditView.toggle() + presentToolEditView.toggle() } label: { Text("Edit") } @@ -36,4 +38,4 @@ struct RecipeToolSection: View { } -*/ + diff --git a/Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift index c0d16f4..fa64d32 100644 --- a/Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift +++ b/Nextcloud Cookbook iOS Client/Views/ReusableViews/CollapsibleView.swift @@ -7,7 +7,7 @@ import Foundation import SwiftUI -/* + struct CollapsibleView: View { @State var titleColor: Color = .white @State var isCollapsed: Bool = true @@ -48,4 +48,4 @@ struct CollapsibleView: View { } } } -*/ + diff --git a/Nextcloud Cookbook iOS Client/Views/ReusableViews/ListVStack.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/ListVStack.swift new file mode 100644 index 0000000..0a34cb6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/ReusableViews/ListVStack.swift @@ -0,0 +1,38 @@ +// +// ListVStack.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 29.05.25. +// +import SwiftUI + +struct ListVStack: View { + @Binding var items: [Element] + let header: () -> HeaderContent + let rows: (Int, Binding) -> RowContent + + init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding) -> 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) + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift index ad273b9..2a3e64d 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/GroceryListTabView.swift @@ -7,55 +7,158 @@ import Foundation 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 { - @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 { NavigationStack { - if cookbookState.groceryList.groceryDict.isEmpty { - EmptyGroceryListView() - } else { - List { - ForEach(cookbookState.groceryList.groceryDict.keys.sorted(), id: \.self) { key in - Section { - ForEach(cookbookState.groceryList.groceryDict[key]!.items) { item in - GroceryListItemView(item: item, toggleAction: { - cookbookState.groceryList.toggleItemChecked(item) - }, deleteAction: { - withAnimation { - cookbookState.groceryList.deleteItem(item.name, fromRecipe: key) - } - }) + List { + HStack(alignment: .top) { + TextEditor(text: $newGroceries) + .padding(4) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(Color.secondary).opacity(0.5)) + .focused($isFocused) + Button { + 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")) } - } header: { - HStack { - Text(cookbookState.groceryList.groceryDict[key]!.name) + } + 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: { + HStack { + Text(category.name) + .foregroundStyle(Color.nextcloudBlue) + Spacer() + Button { + modelContext.delete(category) + } label: { + Image(systemName: "trash") .foregroundStyle(Color.nextcloudBlue) - Spacer() - Button { - cookbookState.groceryList.deleteGroceryRecipe(key) - } label: { - Image(systemName: "trash") - .foregroundStyle(Color.nextcloudBlue) - } } } } } - .listStyle(.plain) - .navigationTitle("Grocery List") - .toolbar { - Button { - cookbookState.groceryList.deleteAll() - } label: { - Text("Delete") - .foregroundStyle(Color.nextcloudBlue) - } + 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) + .navigationTitle("Grocery List") + .toolbar { + Button { + do { + try modelContext.delete(model: RecipeGroceries.self) + } catch { + print("Failed to delete all GroceryCategory models.") + } + } label: { + Text("Delete") + .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 { $0.id == categoryId } + let fetchDescriptor = FetchDescriptor(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) } } } @@ -63,9 +166,8 @@ struct GroceryListTabView: View { fileprivate struct GroceryListItemView: View { - let item: GroceryRecipeItem - let toggleAction: () -> Void - let deleteAction: () -> Void + @Environment(\.modelContext) var modelContext + @Bindable var item: GroceryItem var body: some View { HStack(alignment: .top) { @@ -81,149 +183,13 @@ fileprivate struct GroceryListItemView: View { } .padding(5) .foregroundStyle(item.isChecked ? Color.secondary : Color.primary) - .onTapGesture(perform: toggleAction) + .onTapGesture(perform: { item.isChecked.toggle() }) .animation(.easeInOut, value: item.isChecked) .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(action: deleteAction) { + Button(action: { modelContext.delete(item) }) { Label("Delete", systemImage: "trash") } .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") - } - } -} - -*/ diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 580bb42..0b5c396 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -7,88 +7,86 @@ import Foundation import SwiftUI +import SwiftData + -/* struct RecipeTabView: View { - @EnvironmentObject var appState: AppState - @EnvironmentObject var groceryList: GroceryList - @EnvironmentObject var viewModel: RecipeTabView.ViewModel + //@State var cookbookState: CookbookState = CookbookState() + @Environment(\.modelContext) var modelContext + @Query var recipes: [Recipe] + @State var categories: [(String, Int)] = [] + @State private var selectedRecipe: Recipe? + @State private var selectedCategory: String? = "*" var body: some View { NavigationSplitView { - List(selection: $viewModel.selectedCategory) { - // Categories - ForEach(appState.categories) { category in - NavigationLink(value: category) { - HStack(alignment: .center) { - if viewModel.selectedCategory != nil && - category.name == viewModel.selectedCategory!.name { - Image(systemName: "book") - } else { - Image(systemName: "book.closed.fill") - } - - if category.name == "*" { - Text("Other") - .font(.system(size: 20, weight: .medium, design: .default)) - } else { - Text(category.name) - .font(.system(size: 20, weight: .medium, design: .default)) - } - - Spacer() - Text("\(category.recipe_count)") - .font(.system(size: 15, weight: .bold, design: .default)) - .foregroundStyle(Color.background) - .frame(width: 25, height: 25, alignment: .center) - .minimumScaleFactor(0.5) - .background { - Circle() - .foregroundStyle(Color.secondary) - } - }.padding(7) + List(selection: $selectedCategory) { + CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*") + .tag("*") // Tag nil to select all recipes + + Section("Categories") { + ForEach(categories, id: \.0.self) { category in + CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0) + .tag(category.0) } } } - .navigationTitle("Cookbooks") - .toolbar { - RecipeTabViewToolBar() - } - .navigationDestination(isPresented: $viewModel.presentSettingsView) { - SettingsView() - .environmentObject(appState) - } - .navigationDestination(isPresented: $viewModel.presentEditView) { - RecipeView(viewModel: RecipeView.ViewModel()) - .environmentObject(appState) - .environmentObject(groceryList) - } + .navigationTitle("Categories") + } content: { + RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe) } 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 - } - + // 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") } } - .tint(.nextcloudBlue) .task { - let connection = await appState.checkServerConnection() - DispatchQueue.main.async { - viewModel.serverConnection = connection + 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 } } - .refreshable { - let connection = await appState.checkServerConnection() - DispatchQueue.main.async { - viewModel.serverConnection = connection - } - await appState.getCategories() - } + categories = categoryDict.map { + ($0.key, $0.value) + }.sorted { $0.0 < $1.0 } } class ViewModel: ObservableObject { @@ -98,13 +96,40 @@ struct RecipeTabView: View { @Published var presentLoadingIndicator: Bool = false @Published var presentConnectionPopover: Bool = false @Published var serverConnection: Bool = false - - @Published var selectedCategory: Category? = nil } } - +fileprivate struct CategoryListItem: View { + var category: String + var count: Int + var isSelected: Bool + + var body: some View { + HStack(alignment: .center) { + if isSelected { + Image(systemName: "book") + } else { + Image(systemName: "book.closed.fill") + } + + Text(category) + .font(.system(size: 20, weight: .medium, design: .default)) + + Spacer() + Text("\(count)") + .font(.system(size: 15, weight: .bold, design: .default)) + .foregroundStyle(Color.background) + .frame(width: 25, height: 25, alignment: .center) + .minimumScaleFactor(0.5) + .background { + Circle() + .foregroundStyle(Color.secondary) + } + }.padding(7) + } +} +/* fileprivate struct RecipeTabViewToolBar: ToolbarContent { @EnvironmentObject var appState: AppState @EnvironmentObject var viewModel: RecipeTabView.ViewModel diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift new file mode 100644 index 0000000..cc75ee9 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift @@ -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") + } +}