Nextcloud Login refactoring
This commit is contained in:
@@ -59,6 +59,9 @@
|
|||||||
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
|
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
|
||||||
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
|
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
|
||||||
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
|
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
|
||||||
|
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */; };
|
||||||
|
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */; };
|
||||||
|
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB0512DE911C300A4C74B /* AuthManager.swift */; };
|
||||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
|
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
|
||||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
|
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
|
||||||
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
|
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
|
||||||
@@ -147,6 +150,9 @@
|
|||||||
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
|
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
|
||||||
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
|
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
|
||||||
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
|
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
|
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
|
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
|
||||||
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
|
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
|
||||||
@@ -300,6 +306,7 @@
|
|||||||
A70171C72AB4C4A100064C43 /* Data */ = {
|
A70171C72AB4C4A100064C43 /* Data */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB0512DE911C300A4C74B /* AuthManager.swift */,
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
||||||
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
|
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
|
||||||
@@ -382,6 +389,7 @@
|
|||||||
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */,
|
||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
@@ -427,6 +435,7 @@
|
|||||||
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */,
|
||||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
||||||
@@ -618,6 +627,7 @@
|
|||||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||||
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
|
||||||
|
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */,
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||||
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
|
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
|
||||||
@@ -626,12 +636,14 @@
|
|||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
||||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
||||||
|
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */,
|
||||||
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
|
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||||
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
|
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||||
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
|
||||||
|
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */,
|
||||||
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
|
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
|
||||||
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
|
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
|
||||||
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,
|
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,10 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
@Observable class AppState {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@MainActor class AppState: ObservableObject {
|
@MainActor class AppState: ObservableObject {
|
||||||
@Published var categories: [Category] = []
|
@Published var categories: [Category] = []
|
||||||
|
|||||||
44
Nextcloud Cookbook iOS Client/Data/AuthManager.swift
Normal file
44
Nextcloud Cookbook iOS Client/Data/AuthManager.swift
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// AuthManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 30.05.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import KeychainSwift
|
||||||
|
|
||||||
|
class AuthManager {
|
||||||
|
static let shared = AuthManager()
|
||||||
|
let keychain = KeychainSwift()
|
||||||
|
|
||||||
|
var authString: String? = nil
|
||||||
|
|
||||||
|
private let nextcloudUsernameKey = "nextcloud_username"
|
||||||
|
private let nextcloudAuthStringKey = "nextcloud_auth_string" // Stored as base64
|
||||||
|
|
||||||
|
func saveNextcloudCredentials(username: String, appPassword: String) {
|
||||||
|
keychain.set(username, forKey: nextcloudUsernameKey)
|
||||||
|
|
||||||
|
let loginString = "\(username):\(appPassword)"
|
||||||
|
if let loginData = loginString.data(using: .utf8) {
|
||||||
|
keychain.set(loginData.base64EncodedString(), forKey: nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNextcloudCredentials() -> (username: String?, authString: String?) {
|
||||||
|
let username = keychain.get(nextcloudUsernameKey)
|
||||||
|
let authString = keychain.get(nextcloudAuthStringKey)
|
||||||
|
|
||||||
|
return (username, authString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAuthString() {
|
||||||
|
authString = keychain.get(nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteNextcloudCredentials() {
|
||||||
|
keychain.delete(nextcloudUsernameKey)
|
||||||
|
keychain.delete(nextcloudAuthStringKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ class Recipe {
|
|||||||
var name: String
|
var name: String
|
||||||
var keywords: [String]
|
var keywords: [String]
|
||||||
@Attribute(.externalStorage) var image: RecipeImage?
|
@Attribute(.externalStorage) var image: RecipeImage?
|
||||||
var thumbnail: RecipeThumbnail?
|
@Attribute(.externalStorage) var thumbnail: RecipeThumbnail?
|
||||||
var dateCreated: String? = nil
|
var dateCreated: String? = nil
|
||||||
var dateModified: String? = nil
|
var dateModified: String? = nil
|
||||||
var prepTime: String
|
var prepTime: String
|
||||||
@@ -70,6 +70,16 @@ class Recipe {
|
|||||||
@Transient
|
@Transient
|
||||||
var ingredientMultiplier: Double = 1.0
|
var ingredientMultiplier: Double = 1.0
|
||||||
|
|
||||||
|
var prepTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(prepTime)
|
||||||
|
}
|
||||||
|
var cookTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(cookTime)
|
||||||
|
}
|
||||||
|
var totalTimeDurationComponent: DurationComponents {
|
||||||
|
DurationComponents.fromPTString(totalTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: String,
|
id: String,
|
||||||
@@ -109,50 +119,24 @@ class Recipe {
|
|||||||
self.ingredientMultiplier = ingredientMultiplier
|
self.ingredientMultiplier = ingredientMultiplier
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(from decoder: Decoder) throws {
|
init() {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
self.id = UUID().uuidString
|
||||||
id = try container.decode(String.self, forKey: .id)
|
self.name = String(localized: "New Recipe")
|
||||||
name = try container.decode(String.self, forKey: .name)
|
self.keywords = []
|
||||||
keywords = try container.decode([String].self, forKey: .keywords)
|
self.dateCreated = nil
|
||||||
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
|
self.dateModified = nil
|
||||||
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
|
self.prepTime = "0"
|
||||||
prepTime = try container.decode(String.self, forKey: .prepTime)
|
self.cookTime = "0"
|
||||||
cookTime = try container.decode(String.self, forKey: .cookTime)
|
self.totalTime = "0"
|
||||||
totalTime = try container.decode(String.self, forKey: .totalTime)
|
self.recipeDescription = ""
|
||||||
recipeDescription = try container.decode(String.self, forKey: .recipeDescription)
|
self.url = ""
|
||||||
url = try container.decodeIfPresent(String.self, forKey: .url)
|
self.yield = 1
|
||||||
yield = try container.decode(Int.self, forKey: .yield)
|
self.category = ""
|
||||||
category = try container.decode(String.self, forKey: .category)
|
self.tools = []
|
||||||
tools = try container.decode([String].self, forKey: .tools)
|
self.ingredients = []
|
||||||
ingredients = try container.decode([String].self, forKey: .ingredients)
|
self.instructions = []
|
||||||
instructions = try container.decode([String].self, forKey: .instructions)
|
self.nutrition = [:]
|
||||||
nutrition = try container.decode([String: String].self, forKey: .nutrition)
|
self.ingredientMultiplier = 1
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Recipe: Codable {
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case id, name, keywords, dateCreated, dateModified, prepTime, cookTime, totalTime, recipeDescription, url, yield, category, tools, ingredients, instructions, nutrition
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
|
||||||
try container.encode(id, forKey: .id)
|
|
||||||
try container.encode(name, forKey: .name)
|
|
||||||
try container.encode(keywords, forKey: .keywords)
|
|
||||||
try container.encode(dateCreated, forKey: .dateCreated)
|
|
||||||
try container.encode(dateModified, forKey: .dateModified)
|
|
||||||
try container.encode(prepTime, forKey: .prepTime)
|
|
||||||
try container.encode(cookTime, forKey: .cookTime)
|
|
||||||
try container.encode(totalTime, forKey: .totalTime)
|
|
||||||
try container.encode(recipeDescription, forKey: .recipeDescription)
|
|
||||||
try container.encode(url, forKey: .url)
|
|
||||||
try container.encode(yield, forKey: .yield)
|
|
||||||
try container.encode(category, forKey: .category)
|
|
||||||
try container.encode(tools, forKey: .tools)
|
|
||||||
try container.encode(ingredients, forKey: .ingredients)
|
|
||||||
try container.encode(instructions, forKey: .instructions)
|
|
||||||
try container.encode(nutrition, forKey: .nutrition)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MARK: - Recipe Stub
|
// MARK: - Recipe Stub
|
||||||
|
|||||||
@@ -136,7 +136,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"%@: %@" : {
|
"%@: %@" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -400,7 +399,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : {
|
"A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -423,7 +421,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -446,7 +443,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Acknowledgements" : {
|
"Acknowledgements" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -491,7 +487,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Add" : {
|
"Add" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -514,7 +509,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Add cooking steps for fellow chefs to follow." : {
|
"Add cooking steps for fellow chefs to follow." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -537,7 +531,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
|
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -581,9 +574,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"All Recipes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
|
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -762,6 +757,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Categories" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Category" : {
|
"Category" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -855,11 +853,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Client error" : {
|
"Client error: %lld" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Comma (e.g. 1,42)" : {
|
"Comma (e.g. 1,42)" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -882,7 +879,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Configure what is stored on your device." : {
|
"Configure what is stored on your device." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -905,7 +901,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Configure which sections in your recipes are expanded by default." : {
|
"Configure which sections in your recipes are expanded by default." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1061,9 +1056,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Copy Error" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Copy Link" : {
|
"Copy Link" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1108,7 +1105,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Created: %@" : {
|
"Created: %@" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1134,7 +1130,6 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Decimal number format" : {
|
"Decimal number format" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1182,7 +1177,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Delete local data" : {
|
"Delete local data" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1228,7 +1222,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Delete Recipe" : {
|
"Delete Recipe" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1273,7 +1266,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Deleting local data will not affect the recipe data stored on your server." : {
|
"Deleting local data will not affect the recipe data stored on your server." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1318,7 +1310,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Description" : {
|
"Description" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1339,6 +1330,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Dismiss" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Done" : {
|
"Done" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1363,7 +1357,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Downloads" : {
|
"Downloads" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1454,7 +1447,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Edit" : {
|
"Edit" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1500,6 +1492,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Error: Login URL not available." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Error." : {
|
"Error." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1522,9 +1517,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"example.com" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Expand information section" : {
|
"Expand information section" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1547,7 +1544,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Expand keyword section" : {
|
"Expand keyword section" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1570,7 +1566,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Expand nutrition section" : {
|
"Expand nutrition section" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1685,7 +1680,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Get support" : {
|
"Get support" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1708,7 +1702,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Grocery List" : {
|
"Grocery List" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1731,7 +1724,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Hours" : {
|
"Hours" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1754,7 +1746,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1800,7 +1791,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
|
"If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1823,7 +1813,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : {
|
"If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1844,6 +1833,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Import" : {
|
"Import" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -1892,7 +1884,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ingredient" : {
|
"Ingredient" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1915,7 +1906,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Ingredients" : {
|
"Ingredients" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1984,7 +1974,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Instruction" : {
|
"Instruction" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2007,7 +1996,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Instructions" : {
|
"Instructions" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2036,7 +2024,6 @@
|
|||||||
|
|
||||||
},
|
},
|
||||||
"Keep screen awake when viewing recipes" : {
|
"Keep screen awake when viewing recipes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2082,7 +2069,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Language" : {
|
"Language" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2105,7 +2091,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Last modified: %@" : {
|
"Last modified: %@" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2151,7 +2136,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"List your tools here. 🍴" : {
|
"List your tools here. 🍴" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2172,9 +2156,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Log in" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Log out" : {
|
"Log out" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2197,7 +2186,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Login" : {
|
"Login" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2288,7 +2276,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Marked ingredients could not be adjusted!" : {
|
"Marked ingredients could not be adjusted!" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2311,7 +2298,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Minutes" : {
|
"Minutes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2428,7 +2414,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"More information" : {
|
"More information" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2471,9 +2456,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"New" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"New recipe" : {
|
"New recipe" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -2519,9 +2501,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Nextcloud" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Nextcloud Login" : {
|
"Nextcloud Login" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2567,7 +2551,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"No nutritional information." : {
|
"No nutritional information." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2636,7 +2619,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Nutrition" : {
|
"Nutrition" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2659,7 +2641,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Nutrition (%@)" : {
|
"Nutrition (%@)" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2682,7 +2663,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Offline recipes" : {
|
"Offline recipes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2727,7 +2707,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Other" : {
|
"Other" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2889,7 +2868,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Point (e.g. 1.42)" : {
|
"Point (e.g. 1.42)" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2912,7 +2890,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Preparation" : {
|
"Preparation" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3003,7 +2980,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Recipe Name" : {
|
"Recipe Name" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3048,7 +3024,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Recipes" : {
|
"Recipes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3255,6 +3230,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Select a Recipe" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Select Keywords" : {
|
"Select Keywords" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -3302,7 +3280,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Server error" : {
|
"Server address:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Server error: %lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Server Protocol:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Serving size" : {
|
"Serving size" : {
|
||||||
@@ -3464,7 +3448,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Share Recipe" : {
|
"Share Recipe" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3533,7 +3516,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Start by adding your first ingredient! 🥬" : {
|
"Start by adding your first ingredient! 🥬" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3556,7 +3538,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Store recipe images locally" : {
|
"Store recipe images locally" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3579,7 +3560,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Store recipe thumbnails locally" : {
|
"Store recipe thumbnails locally" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3670,7 +3650,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Support" : {
|
"Support" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3693,7 +3672,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SwiftSoup" : {
|
"SwiftSoup" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3760,6 +3738,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.\nIf the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"The recipe has no image whose MIME type matches the Accept header" : {
|
"The recipe has no image whose MIME type matches the Accept header" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
@@ -3899,7 +3880,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : {
|
"This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3965,9 +3945,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tool" : {
|
"Tool" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -3990,7 +3972,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Tools" : {
|
"Tools" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4036,7 +4017,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Total time" : {
|
"Total time" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4059,7 +4039,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"TPPDF" : {
|
"TPPDF" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4255,7 +4234,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Upload Changes" : {
|
"Upload Changes" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4278,7 +4256,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Upload Recipe" : {
|
"Upload Recipe" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4324,7 +4301,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"URL:" : {
|
"URL:" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4347,7 +4323,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Username: %@" : {
|
"Username: %@" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4393,7 +4368,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Visit the GitHub page" : {
|
"Visit the GitHub page" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4416,7 +4390,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"You're all set for cooking 🍓" : {
|
"You're all set for cooking 🍓" : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -4439,7 +4412,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Your grocery list is stored locally and therefore not synchronized across your devices." : {
|
"Your grocery list is stored locally and therefore not synchronized across your devices." : {
|
||||||
"extractionState" : "stale",
|
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct ApiRequest {
|
|||||||
var response: URLResponse? = nil
|
var response: URLResponse? = nil
|
||||||
do {
|
do {
|
||||||
(data, response) = try await URLSession.shared.data(for: request)
|
(data, response) = try await URLSession.shared.data(for: request)
|
||||||
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
|
Logger.network.debug("\(method.rawValue) \(path) received response ...")
|
||||||
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
|
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
|
||||||
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
|
||||||
return (nil, error)
|
return (nil, error)
|
||||||
@@ -94,9 +94,8 @@ struct ApiRequest {
|
|||||||
switch response.statusCode {
|
switch response.statusCode {
|
||||||
case 200...299: return (nil)
|
case 200...299: return (nil)
|
||||||
case 300...399: return (NetworkError.redirectionError)
|
case 300...399: return (NetworkError.redirectionError)
|
||||||
case 400...499: return (NetworkError.clientError)
|
case 400...499: return (NetworkError.clientError(statusCode: response.statusCode))
|
||||||
case 500...599: return (NetworkError.serverError)
|
case 500...599: return (NetworkError.serverError(statusCode: response.statusCode))
|
||||||
case 600: return (NetworkError.invalidRequest)
|
|
||||||
default: return (NetworkError.unknownError)
|
default: return (NetworkError.unknownError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ protocol CookbookApi {
|
|||||||
/// - Returns: A list of categories. A NetworkError if the request fails.
|
/// - Returns: A list of categories. A NetworkError if the request fails.
|
||||||
static func getCategories(
|
static func getCategories(
|
||||||
auth: String
|
auth: String
|
||||||
) async -> ([Category]?, NetworkError?)
|
) async -> ([CookbookApiCategory]?, NetworkError?)
|
||||||
|
|
||||||
/// Get all recipes of a specified category.
|
/// Get all recipes of a specified category.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class CookbookApiV1: CookbookApi {
|
|||||||
if let id = json as? Int {
|
if let id = json as? Int {
|
||||||
return nil
|
return nil
|
||||||
} else if let dict = json as? [String: Any] {
|
} else if let dict = json as? [String: Any] {
|
||||||
return .serverError
|
return .unknownError
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return .decodingFailed
|
return .decodingFailed
|
||||||
@@ -103,7 +103,7 @@ class CookbookApiV1: CookbookApi {
|
|||||||
|
|
||||||
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
|
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
|
||||||
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
|
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
|
||||||
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
|
guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else {
|
||||||
return .dataError
|
return .dataError
|
||||||
}
|
}
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
@@ -121,7 +121,7 @@ class CookbookApiV1: CookbookApi {
|
|||||||
if let id = json as? Int {
|
if let id = json as? Int {
|
||||||
return nil
|
return nil
|
||||||
} else if let dict = json as? [String: Any] {
|
} else if let dict = json as? [String: Any] {
|
||||||
return .serverError
|
return .unknownError
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return .decodingFailed
|
return .decodingFailed
|
||||||
@@ -143,7 +143,7 @@ class CookbookApiV1: CookbookApi {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getCategories(auth: String) async -> ([Category]?, NetworkError?) {
|
static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) {
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
path: basePath + "/categories",
|
path: basePath + "/categories",
|
||||||
method: .GET,
|
method: .GET,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct Category: Codable, Identifiable, Hashable {
|
struct CookbookApiCategory: Codable, Identifiable, Hashable {
|
||||||
var id: String { name }
|
var id: String { name }
|
||||||
let name: String
|
var name: String
|
||||||
let recipe_count: Int
|
var recipe_count: Int
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name, recipe_count
|
case name, recipe_count
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ public enum NetworkError: UserAlert {
|
|||||||
case encodingFailed
|
case encodingFailed
|
||||||
case decodingFailed
|
case decodingFailed
|
||||||
case redirectionError
|
case redirectionError
|
||||||
case clientError
|
case clientError(statusCode: Int)
|
||||||
case serverError
|
case serverError(statusCode: Int)
|
||||||
case invalidRequest
|
case invalidRequest
|
||||||
case unknownError
|
case unknownError
|
||||||
case dataError
|
case dataError
|
||||||
@@ -33,10 +33,10 @@ public enum NetworkError: UserAlert {
|
|||||||
"Data decoding failed."
|
"Data decoding failed."
|
||||||
case .redirectionError:
|
case .redirectionError:
|
||||||
"Redirection error"
|
"Redirection error"
|
||||||
case .clientError:
|
case .clientError(let code):
|
||||||
"Client error"
|
"Client error: \(code)"
|
||||||
case .serverError:
|
case .serverError(let code):
|
||||||
"Server error"
|
"Server error: \(code)"
|
||||||
case .invalidRequest:
|
case .invalidRequest:
|
||||||
"Invalid request"
|
"Invalid request"
|
||||||
case .unknownError:
|
case .unknownError:
|
||||||
@@ -47,7 +47,7 @@ public enum NetworkError: UserAlert {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var localizedDescription: LocalizedStringKey {
|
var localizedDescription: LocalizedStringKey {
|
||||||
return "" // TODO: Add description
|
return self.localizedTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
var alertButtons: [AlertButton] {
|
var alertButtons: [AlertButton] {
|
||||||
|
|||||||
@@ -20,10 +20,9 @@ class NextcloudApi {
|
|||||||
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
|
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
|
||||||
/// - `NetworkError?`: An error encountered during the network request, if any.
|
/// - `NetworkError?`: An error encountered during the network request, if any.
|
||||||
|
|
||||||
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) {
|
static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) {
|
||||||
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
|
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
path: path + "/index.php/login/v2",
|
path: baseAddress + "/index.php/login/v2",
|
||||||
method: .POST
|
method: .POST
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,16 +51,16 @@ class NextcloudApi {
|
|||||||
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
|
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
|
||||||
/// - `NetworkError?`: An error encountered during the network request, if any.
|
/// - `NetworkError?`: An error encountered during the network request, if any.
|
||||||
|
|
||||||
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) {
|
static func loginV2Poll(pollURL: String, pollToken: String) async -> (LoginV2Response?, NetworkError?) {
|
||||||
let request = ApiRequest(
|
let request = ApiRequest(
|
||||||
path: req.poll.endpoint,
|
path: pollURL,
|
||||||
method: .POST,
|
method: .POST,
|
||||||
headerFields: [
|
headerFields: [
|
||||||
HeaderField.ocsRequest(value: true),
|
HeaderField.ocsRequest(value: true),
|
||||||
HeaderField.accept(value: .JSON),
|
HeaderField.accept(value: .JSON),
|
||||||
HeaderField.contentType(value: .FORM)
|
HeaderField.contentType(value: .FORM)
|
||||||
],
|
],
|
||||||
body: "token=\(req.poll.token)".data(using: .utf8)
|
body: "token=\(pollToken)".data(using: .utf8)
|
||||||
)
|
)
|
||||||
let (data, error) = await request.send(pathCompletion: false)
|
let (data, error) = await request.send(pathCompletion: false)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
|||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
MainView()
|
MainView()
|
||||||
.modelContainer(for: Recipe.self)
|
.modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.slide)
|
.transition(.slide)
|
||||||
@@ -30,6 +30,10 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
|||||||
.init(identifier: language ==
|
.init(identifier: language ==
|
||||||
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
||||||
)
|
)
|
||||||
|
.onAppear {
|
||||||
|
AuthManager.shared.loadAuthString() // Load the auth string as soon as possible
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,89 +8,58 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
//@State var cookbookState: CookbookState = CookbookState()
|
// Tab ViewModels
|
||||||
@Environment(\.modelContext) var modelContext
|
enum Tab {
|
||||||
@Query var recipes: [Recipe] = []
|
case recipes, settings, groceryList
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
TabView {
|
||||||
List {
|
RecipeTabView()
|
||||||
ForEach(recipes) { recipe in
|
.tabItem {
|
||||||
Text(recipe.name)
|
Label("Recipes", systemImage: "book.closed.fill")
|
||||||
}
|
}
|
||||||
}
|
.tag(Tab.recipes)
|
||||||
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)
|
GroceryListTabView()
|
||||||
modelContext.insert(recipe)
|
.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 {
|
.task {
|
||||||
VStack {
|
/*
|
||||||
List(selection: $cookbookState.selectedCategory) {
|
recipeViewModel.presentLoadingIndicator = true
|
||||||
ForEach(cookbookState.categories) { category in
|
await appState.getCategories()
|
||||||
Text(category.name)
|
await appState.updateAllRecipeDetails()
|
||||||
.tag(category)
|
|
||||||
}
|
// Open detail view for default category
|
||||||
}
|
if UserSettings.shared.defaultCategory != "" {
|
||||||
.listStyle(.plain)
|
if let cat = appState.categories.first(where: { c in
|
||||||
.onAppear {
|
if c.name == UserSettings.shared.defaultCategory {
|
||||||
Task {
|
return true
|
||||||
await cookbookState.loadCategories()
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
recipeViewModel.selectedCategory = cat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} content: {
|
await groceryList.load()
|
||||||
if let selectedCategory = cookbookState.selectedCategory {
|
recipeViewModel.presentLoadingIndicator = false
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WebKit
|
import WebKit
|
||||||
/*
|
import AuthenticationServices
|
||||||
|
|
||||||
|
|
||||||
|
protocol LoginStage {
|
||||||
|
func next() -> Self
|
||||||
|
func previous() -> Self
|
||||||
|
}
|
||||||
|
|
||||||
enum V2LoginStage: LoginStage {
|
enum V2LoginStage: LoginStage {
|
||||||
case login, validate
|
case login, validate
|
||||||
|
|
||||||
@@ -30,12 +37,27 @@ enum V2LoginStage: LoginStage {
|
|||||||
|
|
||||||
|
|
||||||
struct V2LoginView: View {
|
struct V2LoginView: View {
|
||||||
@Binding var showAlert: Bool
|
@Environment(\.dismiss) var dismiss
|
||||||
@Binding var alertMessage: String
|
@State var showAlert: Bool = false
|
||||||
|
@State var alertMessage: String = ""
|
||||||
|
|
||||||
@State var loginStage: V2LoginStage = .login
|
@State var loginStage: V2LoginStage = .login
|
||||||
@State var loginRequest: LoginV2Request? = nil
|
@State var loginRequest: LoginV2Request? = nil
|
||||||
@State var presentBrowser = false
|
@State var presentBrowser = false
|
||||||
|
|
||||||
|
@State var serverAddress: String = ""
|
||||||
|
@State var serverProtocol: ServerProtocol = .https
|
||||||
|
@State var loginPressed: Bool = false
|
||||||
|
@State var isLoading: Bool = false
|
||||||
|
|
||||||
|
// Task reference for polling, to cancel if needed
|
||||||
|
@State private var pollTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
|
enum ServerProtocol: String {
|
||||||
|
case https="https://", http="http://"
|
||||||
|
|
||||||
|
static let all = [https, http]
|
||||||
|
}
|
||||||
|
|
||||||
// TextField handling
|
// TextField handling
|
||||||
enum Field {
|
enum Field {
|
||||||
@@ -45,114 +67,205 @@ struct V2LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
VStack {
|
||||||
VStack(alignment: .leading) {
|
HStack {
|
||||||
ServerAddressField()
|
Button("Cancel") {
|
||||||
CollapsibleView {
|
dismiss()
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.")
|
|
||||||
.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()
|
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
|
||||||
HStack {
|
if isLoading {
|
||||||
Button {
|
ProgressView()
|
||||||
if UserSettings.shared.serverAddress == "" {
|
}
|
||||||
alertMessage = "Please enter a valid server address."
|
}.padding()
|
||||||
showAlert = true
|
|
||||||
return
|
Form {
|
||||||
}
|
Section {
|
||||||
|
HStack {
|
||||||
Task {
|
Text("Server address:")
|
||||||
let error = await sendLoginV2Request()
|
TextField("example.com", text: $serverAddress)
|
||||||
if let error = error {
|
.multilineTextAlignment(.trailing)
|
||||||
alertMessage = "A network error occured (\(error.localizedDescription))."
|
.autocorrectionDisabled()
|
||||||
showAlert = true
|
.textInputAutocapitalization(.never)
|
||||||
}
|
}
|
||||||
if let loginRequest = loginRequest {
|
|
||||||
presentBrowser = true
|
|
||||||
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
|
|
||||||
} else {
|
|
||||||
alertMessage = "Unable to reach server. Please check your server address and internet connection."
|
|
||||||
showAlert = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loginStage = loginStage.next()
|
|
||||||
} label: {
|
|
||||||
Text("Login")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.headline)
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(Color.white, lineWidth: 2)
|
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
|
||||||
}.padding()
|
|
||||||
|
|
||||||
if loginStage == .validate {
|
Picker("Server Protocol:", selection: $serverProtocol) {
|
||||||
Spacer()
|
ForEach(ServerProtocol.all, id: \.self) {
|
||||||
|
Text($0.rawValue)
|
||||||
Button {
|
|
||||||
// fetch login v2 response
|
|
||||||
Task {
|
|
||||||
let (response, error) = await fetchLoginV2Response()
|
|
||||||
checkLogin(response: response, error: error)
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Text("Validate")
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.headline)
|
|
||||||
.padding()
|
|
||||||
.background(
|
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.stroke(Color.white, lineWidth: 2)
|
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.disabled(loginRequest == nil ? true : false)
|
}
|
||||||
.padding()
|
|
||||||
|
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: {
|
.sheet(isPresented: $presentBrowser) {
|
||||||
Task {
|
if let loginReq = loginRequest {
|
||||||
let (response, error) = await fetchLoginV2Response()
|
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
|
||||||
checkLogin(response: response, error: error)
|
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 {
|
.alert("Error", isPresented: $showAlert) {
|
||||||
WebViewSheet(url: loginRequest.login)
|
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? {
|
func initiateLoginV2() {
|
||||||
let (req, error) = await NextcloudApi.loginV2Request()
|
isLoading = true
|
||||||
self.loginRequest = req
|
loginPressed = true
|
||||||
return error
|
|
||||||
|
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?) {
|
func startPolling(pollURL: String, pollToken: String) {
|
||||||
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
|
// Cancel any existing poll task first
|
||||||
return await NextcloudApi.loginV2Response(req: loginRequest)
|
pollTask?.cancel()
|
||||||
|
var pollingFailed = true
|
||||||
|
|
||||||
|
pollTask = Task {
|
||||||
|
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
|
||||||
|
for _ in 0..<maxRetries {
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("Task cancelled.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("Task cancelled.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if let response = response {
|
||||||
|
// Success
|
||||||
|
print("Task succeeded.")
|
||||||
|
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
|
||||||
|
pollingFailed = false
|
||||||
|
|
||||||
|
await MainActor.run {
|
||||||
|
self.checkLogin(response: response, error: nil)
|
||||||
|
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if let error = error {
|
||||||
|
if case .clientError(statusCode: 404) = error {
|
||||||
|
// Continue polling
|
||||||
|
print("Polling unsuccessful, continuing.")
|
||||||
|
} else {
|
||||||
|
// A more serious error occurred during polling
|
||||||
|
print("Polling error: \(error.localizedDescription)")
|
||||||
|
await MainActor.run {
|
||||||
|
self.alertMessage = "Polling error: \(error.localizedDescription)"
|
||||||
|
self.showAlert = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If polling finishes without success
|
||||||
|
if !Task.isCancelled && pollingFailed {
|
||||||
|
await MainActor.run {
|
||||||
|
self.alertMessage = "Login timed out. Please try again."
|
||||||
|
self.showAlert = true
|
||||||
|
self.isLoading = false
|
||||||
|
self.loginPressed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||||
@@ -180,33 +293,72 @@ struct V2LoginView: View {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Login WebView logic
|
struct LoginBrowserView: UIViewControllerRepresentable {
|
||||||
|
let authURL: URL
|
||||||
|
let callbackURLScheme: String
|
||||||
|
var completion: (Result<URL, Error>) -> Void
|
||||||
|
|
||||||
struct WebViewSheet: View {
|
func makeUIViewController(context: Context) -> UIViewController {
|
||||||
@Environment(\.dismiss) var dismiss
|
UIViewController()
|
||||||
@State var url: String
|
}
|
||||||
|
|
||||||
var body: some View {
|
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
|
||||||
NavigationView {
|
|
||||||
WebView(url: URL(string: url)!)
|
if !context.coordinator.sessionStarted {
|
||||||
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
|
context.coordinator.sessionStarted = true
|
||||||
.navigationBarItems(trailing: Button("Done") {
|
|
||||||
dismiss()
|
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
|
||||||
})
|
context.coordinator.sessionStarted = false // Reset for potential retry
|
||||||
|
if let callbackURL = callbackURL {
|
||||||
|
completion(.success(callbackURL))
|
||||||
|
} else if let error = error {
|
||||||
|
completion(.failure(error))
|
||||||
|
} else {
|
||||||
|
// Handle unexpected nil URL and error
|
||||||
|
completion(.failure(LoginError.unknownError))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session.presentationContextProvider = context.coordinator
|
||||||
|
|
||||||
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
|
session.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||||
|
var parent: LoginBrowserView
|
||||||
|
var sessionStarted: Bool = false // Prevent starting multiple sessions
|
||||||
|
|
||||||
|
init(_ parent: LoginBrowserView) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
||||||
|
return windowScene.windows.first!
|
||||||
|
}
|
||||||
|
return ASPresentationAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LoginError: Error, LocalizedError {
|
||||||
|
case unknownError
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unknownError: return "An unknown error occurred during login."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct WebView: UIViewRepresentable {
|
|
||||||
let url: URL
|
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
#Preview {
|
||||||
return WKWebView()
|
V2LoginView()
|
||||||
}
|
|
||||||
|
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {
|
|
||||||
let request = URLRequest(url: url)
|
|
||||||
uiView.load(request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/*
|
|
||||||
struct RecipeCardView: View {
|
struct RecipeCardView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
//@EnvironmentObject var appState: AppState
|
||||||
@State var recipe: CookbookApiRecipeV1
|
@State var recipe: Recipe
|
||||||
@State var recipeThumb: UIImage?
|
@State var recipeThumb: UIImage?
|
||||||
@State var isDownloaded: Bool? = nil
|
@State var isDownloaded: Bool? = nil
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ struct RecipeCardView: View {
|
|||||||
.background(Color.backgroundHighlight)
|
.background(Color.backgroundHighlight)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
.task {
|
.task {
|
||||||
|
/*
|
||||||
recipeThumb = await appState.getImage(
|
recipeThumb = await appState.getImage(
|
||||||
id: recipe.recipe_id,
|
id: recipe.recipe_id,
|
||||||
size: .THUMB,
|
size: .THUMB,
|
||||||
@@ -59,18 +60,20 @@ struct RecipeCardView: View {
|
|||||||
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
|
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||||
}
|
}
|
||||||
isDownloaded = recipe.storedLocally
|
isDownloaded = recipe.storedLocally
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
|
/*
|
||||||
recipeThumb = await appState.getImage(
|
recipeThumb = await appState.getImage(
|
||||||
id: recipe.recipe_id,
|
id: recipe.recipe_id,
|
||||||
size: .THUMB,
|
size: .THUMB,
|
||||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||||
)
|
)*/
|
||||||
}
|
}
|
||||||
.frame(height: 80)
|
.frame(height: 80)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
/*
|
/*
|
||||||
struct RecipeCardView: View {
|
struct RecipeCardView: View {
|
||||||
@State var state: AccountState
|
@State var state: AccountState
|
||||||
|
|||||||
@@ -7,6 +7,53 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
|
struct RecipeListView: View {
|
||||||
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Query var recipes: [Recipe]
|
||||||
|
@Binding var selectedRecipe: Recipe?
|
||||||
|
@Binding var selectedCategory: String?
|
||||||
|
|
||||||
|
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
|
||||||
|
var predicate: Predicate<Recipe>? = nil
|
||||||
|
|
||||||
|
if let category = selectedCategory.wrappedValue, category != "*" {
|
||||||
|
predicate = #Predicate<Recipe> {
|
||||||
|
$0.category == category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_recipes = Query(filter: predicate, sort: \.name)
|
||||||
|
_selectedRecipe = selectedRecipe
|
||||||
|
_selectedCategory = selectedCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List(selection: $selectedRecipe) {
|
||||||
|
ForEach(recipes) { recipe in
|
||||||
|
RecipeCardView(recipe: recipe)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
.background(
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.opacity(0)
|
||||||
|
)
|
||||||
|
.frame(height: 85)
|
||||||
|
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
|
||||||
|
.listRowSeparatorTint(.clear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Recipes")
|
||||||
|
.toolbar {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -8,15 +8,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/*
|
|
||||||
struct RecipeView: View {
|
struct RecipeView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@Bindable var recipe: Recipe
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject var viewModel: ViewModel
|
@StateObject var viewModel: ViewModel
|
||||||
@GestureState private var dragOffset = CGSize.zero
|
@GestureState private var dragOffset = CGSize.zero
|
||||||
|
|
||||||
var imageHeight: CGFloat {
|
var imageHeight: CGFloat {
|
||||||
if let image = viewModel.recipeImage {
|
if let recipeImage = recipe.image, let image = recipeImage.image {
|
||||||
return image.size.height < 350 ? image.size.height : 350
|
return image.size.height < 350 ? image.size.height : 350
|
||||||
}
|
}
|
||||||
return 200
|
return 200
|
||||||
@@ -33,8 +33,8 @@ struct RecipeView: View {
|
|||||||
coordinateSpace: CoordinateSpaces.scrollView,
|
coordinateSpace: CoordinateSpaces.scrollView,
|
||||||
defaultHeight: imageHeight
|
defaultHeight: imageHeight
|
||||||
) {
|
) {
|
||||||
if let recipeImage = viewModel.recipeImage {
|
if let recipeImage = recipe.image, let image = recipeImage.image {
|
||||||
Image(uiImage: recipeImage)
|
Image(uiImage: image)
|
||||||
.resizable()
|
.resizable()
|
||||||
.scaledToFill()
|
.scaledToFill()
|
||||||
.frame(maxHeight: imageHeight + 200)
|
.frame(maxHeight: imageHeight + 200)
|
||||||
@@ -54,15 +54,12 @@ struct RecipeView: View {
|
|||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if viewModel.editMode {
|
if viewModel.editMode {
|
||||||
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
|
//RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
|
||||||
}
|
//RecipeMetadataSection(viewModel: viewModel)
|
||||||
|
|
||||||
if viewModel.editMode {
|
|
||||||
RecipeMetadataSection(viewModel: viewModel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
|
EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
|
||||||
.font(.title)
|
.font(.title)
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
@@ -74,36 +71,37 @@ struct RecipeView: View {
|
|||||||
}
|
}
|
||||||
}.padding([.top, .horizontal])
|
}.padding([.top, .horizontal])
|
||||||
|
|
||||||
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
|
if recipe.recipeDescription != "" || viewModel.editMode {
|
||||||
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
|
EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recipe Body Section
|
// Recipe Body Section
|
||||||
RecipeDurationSection(viewModel: viewModel)
|
RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
|
|
||||||
RecipeIngredientSection(viewModel: viewModel)
|
if(!recipe.ingredients.isEmpty || viewModel.editMode) {
|
||||||
|
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
|
||||||
}
|
}
|
||||||
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
|
if(!recipe.instructions.isEmpty || viewModel.editMode) {
|
||||||
RecipeInstructionSection(viewModel: viewModel)
|
RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
|
||||||
}
|
}
|
||||||
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
|
if(!recipe.tools.isEmpty || viewModel.editMode) {
|
||||||
RecipeToolSection(viewModel: viewModel)
|
RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
|
||||||
}
|
}
|
||||||
RecipeNutritionSection(viewModel: viewModel)
|
RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !viewModel.editMode {
|
if !viewModel.editMode {
|
||||||
Divider()
|
Divider()
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
RecipeKeywordSection(viewModel: viewModel)
|
//RecipeKeywordSection(viewModel: viewModel)
|
||||||
MoreInformationSection(viewModel: viewModel)
|
MoreInformationSection(recipe: recipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,21 +113,21 @@ struct RecipeView: View {
|
|||||||
.ignoresSafeArea(.container, edges: .top)
|
.ignoresSafeArea(.container, edges: .top)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar(.visible, for: .navigationBar)
|
.toolbar(.visible, for: .navigationBar)
|
||||||
//.toolbarTitleDisplayMode(.inline)
|
|
||||||
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
|
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
RecipeViewToolBar(viewModel: viewModel)
|
RecipeViewToolBar(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $viewModel.presentShareSheet) {
|
.sheet(isPresented: $viewModel.presentShareSheet) {
|
||||||
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
|
/*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
|
||||||
recipeImage: viewModel.recipeImage,
|
recipeImage: viewModel.recipeImage,
|
||||||
presentShareSheet: $viewModel.presentShareSheet)
|
presentShareSheet: $viewModel.presentShareSheet)*/
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $viewModel.presentInstructionEditView) {
|
.sheet(isPresented: $viewModel.presentInstructionEditView) {
|
||||||
EditableListView(
|
EditableListView(
|
||||||
isPresented: $viewModel.presentInstructionEditView,
|
isPresented: $viewModel.presentInstructionEditView,
|
||||||
items: $viewModel.observableRecipeDetail.recipeInstructions,
|
items: $recipe.instructions,
|
||||||
title: "Instructions",
|
title: "Instructions",
|
||||||
emptyListText: "Add cooking steps for fellow chefs to follow.",
|
emptyListText: "Add cooking steps for fellow chefs to follow.",
|
||||||
titleKey: "Instruction",
|
titleKey: "Instruction",
|
||||||
@@ -139,7 +137,7 @@ struct RecipeView: View {
|
|||||||
.sheet(isPresented: $viewModel.presentIngredientEditView) {
|
.sheet(isPresented: $viewModel.presentIngredientEditView) {
|
||||||
EditableListView(
|
EditableListView(
|
||||||
isPresented: $viewModel.presentIngredientEditView,
|
isPresented: $viewModel.presentIngredientEditView,
|
||||||
items: $viewModel.observableRecipeDetail.recipeIngredient,
|
items: $recipe.ingredients,
|
||||||
title: "Ingredients",
|
title: "Ingredients",
|
||||||
emptyListText: "Start by adding your first ingredient! 🥬",
|
emptyListText: "Start by adding your first ingredient! 🥬",
|
||||||
titleKey: "Ingredient",
|
titleKey: "Ingredient",
|
||||||
@@ -149,7 +147,7 @@ struct RecipeView: View {
|
|||||||
.sheet(isPresented: $viewModel.presentToolEditView) {
|
.sheet(isPresented: $viewModel.presentToolEditView) {
|
||||||
EditableListView(
|
EditableListView(
|
||||||
isPresented: $viewModel.presentToolEditView,
|
isPresented: $viewModel.presentToolEditView,
|
||||||
items: $viewModel.observableRecipeDetail.tool,
|
items: $recipe.tools,
|
||||||
title: "Tools",
|
title: "Tools",
|
||||||
emptyListText: "List your tools here. 🍴",
|
emptyListText: "List your tools here. 🍴",
|
||||||
titleKey: "Tool",
|
titleKey: "Tool",
|
||||||
@@ -158,6 +156,7 @@ struct RecipeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task {
|
.task {
|
||||||
|
/*
|
||||||
// Load recipe detail
|
// Load recipe detail
|
||||||
if !viewModel.newRecipe {
|
if !viewModel.newRecipe {
|
||||||
// For existing recipes, load the recipeDetail and image
|
// For existing recipes, load the recipeDetail and image
|
||||||
@@ -185,7 +184,7 @@ struct RecipeView: View {
|
|||||||
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
|
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
|
||||||
viewModel.editMode = true
|
viewModel.editMode = true
|
||||||
viewModel.isDownloaded = false
|
viewModel.isDownloaded = false
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
|
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
|
||||||
ForEach(viewModel.alertType.alertButtons) { buttonType in
|
ForEach(viewModel.alertType.alertButtons) { buttonType in
|
||||||
@@ -217,13 +216,14 @@ struct RecipeView: View {
|
|||||||
UIApplication.shared.isIdleTimerDisabled = false
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.editMode) { newValue in
|
.onChange(of: viewModel.editMode) { newValue in
|
||||||
|
/*
|
||||||
if newValue && appState.allKeywords.isEmpty {
|
if newValue && appState.allKeywords.isEmpty {
|
||||||
Task {
|
Task {
|
||||||
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
|
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
|
||||||
a.recipe_count > b.recipe_count
|
a.recipe_count > b.recipe_count
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,9 +231,8 @@ struct RecipeView: View {
|
|||||||
// MARK: - RecipeView ViewModel
|
// MARK: - RecipeView ViewModel
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var observableRecipeDetail: Recipe = Recipe()
|
@Published var recipe: Recipe
|
||||||
@Published var recipeDetail: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1.error
|
|
||||||
@Published var recipeImage: UIImage? = nil
|
|
||||||
@Published var editMode: Bool = false
|
@Published var editMode: Bool = false
|
||||||
@Published var showTitle: Bool = false
|
@Published var showTitle: Bool = false
|
||||||
@Published var isDownloaded: Bool? = nil
|
@Published var isDownloaded: Bool? = nil
|
||||||
@@ -244,7 +243,6 @@ struct RecipeView: View {
|
|||||||
@Published var presentIngredientEditView: Bool = false
|
@Published var presentIngredientEditView: Bool = false
|
||||||
@Published var presentToolEditView: Bool = false
|
@Published var presentToolEditView: Bool = false
|
||||||
|
|
||||||
var recipe: CookbookApiRecipeV1
|
|
||||||
var sharedURL: URL? = nil
|
var sharedURL: URL? = nil
|
||||||
var newRecipe: Bool = false
|
var newRecipe: Bool = false
|
||||||
|
|
||||||
@@ -254,26 +252,13 @@ struct RecipeView: View {
|
|||||||
var alertAction: () async -> () = { }
|
var alertAction: () async -> () = { }
|
||||||
|
|
||||||
// Initializers
|
// Initializers
|
||||||
init(recipe: CookbookApiRecipeV1) {
|
init(recipe: Recipe) {
|
||||||
self.recipe = recipe
|
self.recipe = recipe
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.newRecipe = true
|
self.newRecipe = true
|
||||||
self.recipe = CookbookApiRecipeV1(
|
self.recipe = Recipe()
|
||||||
name: String(localized: "New Recipe"),
|
|
||||||
keywords: "",
|
|
||||||
dateCreated: "",
|
|
||||||
dateModified: "",
|
|
||||||
imageUrl: "",
|
|
||||||
imagePlaceholderUrl: "",
|
|
||||||
recipe_id: 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// View setup
|
|
||||||
func setupView(recipeDetail: CookbookApiRecipeDetailV1) {
|
|
||||||
self.recipeDetail = recipeDetail
|
|
||||||
self.observableRecipeDetail = Recipe(recipeDetail)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
|
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
|
||||||
@@ -285,7 +270,7 @@ struct RecipeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
extension RecipeView {
|
extension RecipeView {
|
||||||
func importRecipe(from url: String) async -> UserAlert? {
|
func importRecipe(from url: String) async -> UserAlert? {
|
||||||
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
|
||||||
@@ -309,13 +294,12 @@ extension RecipeView {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// MARK: - Tool Bar
|
// MARK: - Tool Bar
|
||||||
|
|
||||||
|
|
||||||
struct RecipeViewToolBar: ToolbarContent {
|
struct RecipeViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
@@ -385,6 +369,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleUpload() async {
|
func handleUpload() async {
|
||||||
|
/*
|
||||||
if viewModel.newRecipe {
|
if viewModel.newRecipe {
|
||||||
print("Uploading new recipe.")
|
print("Uploading new recipe.")
|
||||||
if let recipeValidationError = recipeValid() {
|
if let recipeValidationError = recipeValid() {
|
||||||
@@ -416,9 +401,11 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
viewModel.editMode = false
|
viewModel.editMode = false
|
||||||
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
|
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDelete() async {
|
func handleDelete() async {
|
||||||
|
/*
|
||||||
let category = viewModel.observableRecipeDetail.recipeCategory
|
let category = viewModel.observableRecipeDetail.recipeCategory
|
||||||
guard let id = Int(viewModel.observableRecipeDetail.id) else {
|
guard let id = Int(viewModel.observableRecipeDetail.id) else {
|
||||||
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
|
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
|
||||||
@@ -432,11 +419,13 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
await appState.getCategory(named: category, fetchMode: .preferServer)
|
await appState.getCategory(named: category, fetchMode: .preferServer)
|
||||||
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
||||||
dismiss()
|
dismiss()
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
func recipeValid() -> RecipeAlert? {
|
func recipeValid() -> RecipeAlert? {
|
||||||
|
/*
|
||||||
// Check if the recipe has a name
|
// Check if the recipe has a name
|
||||||
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" {
|
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
|
||||||
return RecipeAlert.NO_TITLE
|
return RecipeAlert.NO_TITLE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,12 +443,11 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,19 +9,20 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - RecipeView Duration Section
|
// MARK: - RecipeView Duration Section
|
||||||
/*
|
|
||||||
struct RecipeDurationSection: View {
|
struct RecipeDurationSection: View {
|
||||||
@State var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
@State var presentPopover: Bool = false
|
@State var presentPopover: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
|
||||||
DurationView(time: viewModel.recipe.prepTime, title: LocalizedStringKey("Preparation"))
|
DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
|
||||||
DurationView(time: viewModel.recipe.cookTime, title: LocalizedStringKey("Cooking"))
|
DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
|
||||||
DurationView(time: viewModel.recipe.totalTime, title: LocalizedStringKey("Total time"))
|
DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
|
||||||
}
|
}
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
Button {
|
Button {
|
||||||
presentPopover.toggle()
|
presentPopover.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
@@ -34,9 +35,9 @@ struct RecipeDurationSection: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.popover(isPresented: $presentPopover) {
|
.popover(isPresented: $presentPopover) {
|
||||||
EditableDurationView(
|
EditableDurationView(
|
||||||
prepTime: viewModel.recipe.prepTime,
|
prepTime: recipe.prepTimeDurationComponent,
|
||||||
cookTime: viewModel.recipe.cookTime,
|
cookTime: recipe.cookTimeDurationComponent,
|
||||||
totalTime: viewModel.recipe.totalTime
|
totalTime: recipe.totalTimeDurationComponent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,4 +144,4 @@ fileprivate struct TimePickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -7,18 +7,24 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
// MARK: - RecipeView Ingredients Section
|
// MARK: - RecipeView Ingredients Section
|
||||||
/*
|
|
||||||
struct RecipeIngredientSection: View {
|
struct RecipeIngredientSection: View {
|
||||||
@Environment(CookbookState.self) var cookbookState
|
@Environment(\.modelContext) var modelContext
|
||||||
@State var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@Binding var presentIngredientEditView: Bool
|
||||||
|
@State var recipeGroceries: RecipeGroceries? = nil
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
Button {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
/*
|
||||||
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
|
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
|
||||||
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
|
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
|
||||||
} else {
|
} else {
|
||||||
@@ -28,6 +34,7 @@ struct RecipeIngredientSection: View {
|
|||||||
recipeName: viewModel.recipe.name
|
recipeName: viewModel.recipe.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
@@ -35,7 +42,7 @@ struct RecipeIngredientSection: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(systemName: "heart.text.square")
|
Image(systemName: "heart.text.square")
|
||||||
}
|
}
|
||||||
}.disabled(viewModel.editMode)
|
}.disabled(editMode)
|
||||||
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
|
|
||||||
@@ -45,26 +52,30 @@ struct RecipeIngredientSection: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.bold()
|
.bold()
|
||||||
|
|
||||||
ServingPickerView(selectedServingSize: $viewModel.recipe.ingredientMultiplier)
|
ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(0..<viewModel.recipe.recipeIngredient.count, id: \.self) { ix in
|
|
||||||
IngredientListItem(
|
|
||||||
ingredient: $viewModel.recipe.recipeIngredient[ix],
|
ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
|
||||||
servings: $viewModel.recipe.ingredientMultiplier,
|
/*IngredientListItem(
|
||||||
recipeYield: Double(viewModel.recipe.recipeYield),
|
ingredient: $recipe.recipeIngredient[ix],
|
||||||
recipeId: viewModel.recipe.id
|
servings: $recipe.ingredientMultiplier,
|
||||||
|
recipeYield: Double(recipe.recipeYield),
|
||||||
|
recipeId: recipe.id
|
||||||
) {
|
) {
|
||||||
|
/*
|
||||||
cookbookState.groceryList.addItem(
|
cookbookState.groceryList.addItem(
|
||||||
viewModel.recipe.recipeIngredient[ix],
|
recipe.recipeIngredient[ix],
|
||||||
toRecipe: viewModel.recipe.id,
|
toRecipe: recipe.id,
|
||||||
recipeName: viewModel.recipe.name
|
recipeName: recipe.name
|
||||||
)
|
)*/
|
||||||
}
|
}
|
||||||
.padding(4)
|
.padding(4)*/
|
||||||
|
Text(recipe.ingredients[ix])
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.recipe.ingredientMultiplier != Double(viewModel.recipe.recipeYield) {
|
if recipe.ingredientMultiplier != Double(recipe.yield) {
|
||||||
HStack() {
|
HStack() {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -73,9 +84,9 @@ struct RecipeIngredientSection: View {
|
|||||||
}.padding(.top)
|
}.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
Button {
|
Button {
|
||||||
viewModel.presentIngredientEditView.toggle()
|
presentIngredientEditView.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Edit")
|
Text("Edit")
|
||||||
}
|
}
|
||||||
@@ -83,19 +94,80 @@ struct RecipeIngredientSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.animation(.easeInOut, value: viewModel.recipe.ingredientMultiplier)
|
.animation(.easeInOut, value: recipe.ingredientMultiplier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
|
||||||
|
do {
|
||||||
|
// Find or create the target category
|
||||||
|
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
|
// Delete category if it exists
|
||||||
|
modelContext.delete(existingCategory)
|
||||||
|
} else {
|
||||||
|
// Create the category if it doesn't exist
|
||||||
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
|
modelContext.insert(newCategory)
|
||||||
|
|
||||||
|
// Add new GroceryItems to the category
|
||||||
|
for itemName in itemNames {
|
||||||
|
let newItem = GroceryItem(name: itemName, isChecked: false)
|
||||||
|
newCategory.items.append(newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
|
||||||
|
do {
|
||||||
|
// Find or create the target category
|
||||||
|
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
|
// Delete item if it exists
|
||||||
|
if existingCategory.items.contains(where: { $0.name == itemName }) {
|
||||||
|
existingCategory.items.removeAll { $0.name == itemName }
|
||||||
|
|
||||||
|
// Delete category if empty
|
||||||
|
if existingCategory.items.isEmpty {
|
||||||
|
modelContext.delete(existingCategory)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add the category if it doesn't exist
|
||||||
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
|
modelContext.insert(newCategory)
|
||||||
|
|
||||||
|
// Add the item to the new category
|
||||||
|
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - RecipeIngredientSection List Item
|
// MARK: - RecipeIngredientSection List Item
|
||||||
|
/*
|
||||||
fileprivate struct IngredientListItem: View {
|
fileprivate struct IngredientListItem: View {
|
||||||
@Environment(CookbookState.self) var cookbookState
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Bindable var recipeGroceries: RecipeGroceries
|
||||||
@Binding var ingredient: String
|
@Binding var ingredient: String
|
||||||
@Binding var servings: Double
|
@Binding var servings: Double
|
||||||
@State var recipeYield: Double
|
@State var recipeYield: Double
|
||||||
@State var recipeId: String
|
@State var recipeId: String
|
||||||
let addToGroceryListAction: () -> Void
|
|
||||||
|
|
||||||
@State var modifiedIngredient: AttributedString = ""
|
@State var modifiedIngredient: AttributedString = ""
|
||||||
@State var isSelected: Bool = false
|
@State var isSelected: Bool = false
|
||||||
@@ -110,7 +182,7 @@ fileprivate struct IngredientListItem: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
|
if recipeGroceries.items.contains(ingredient) {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
Image(systemName: "storefront")
|
Image(systemName: "storefront")
|
||||||
.foregroundStyle(Color.green)
|
.foregroundStyle(Color.green)
|
||||||
@@ -168,7 +240,7 @@ fileprivate struct IngredientListItem: View {
|
|||||||
.onEnded { gesture in
|
.onEnded { gesture in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
||||||
if cookbookState.groceryList.containsItem(at: recipeId, item: ingredient) {
|
if recipeGroceries.items.contains(ingredient) {
|
||||||
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
||||||
} else {
|
} else {
|
||||||
addToGroceryListAction()
|
addToGroceryListAction()
|
||||||
@@ -182,7 +254,7 @@ fileprivate struct IngredientListItem: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
struct ServingPickerView: View {
|
struct ServingPickerView: View {
|
||||||
@@ -217,4 +289,4 @@ struct ServingPickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -9,22 +9,26 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - RecipeView Instructions Section
|
// 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 {
|
var body: some View {
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
ForEach(viewModel.recipe.recipeInstructions.indices, id: \.self) { ix in
|
ForEach(recipe.instructions.indices, id: \.self) { ix in
|
||||||
RecipeInstructionListItem(instruction: $viewModel.recipe.recipeInstructions[ix], index: ix+1)
|
RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
|
||||||
}
|
}
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
Button {
|
Button {
|
||||||
viewModel.presentInstructionEditView.toggle()
|
presentInstructionEditView.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Edit")
|
Text("Edit")
|
||||||
}
|
}
|
||||||
@@ -32,11 +36,10 @@ struct RecipeInstructionSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
fileprivate struct RecipeInstructionListItem: View {
|
fileprivate struct RecipeInstructionListItem: View {
|
||||||
@Binding var instruction: String
|
@Binding var instruction: String
|
||||||
@@ -56,4 +59,45 @@ fileprivate struct RecipeInstructionListItem: View {
|
|||||||
.animation(.easeInOut, value: isSelected)
|
.animation(.easeInOut, value: isSelected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
struct RecipeInstructionSection_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
// Create a mock recipe
|
||||||
|
@State var mockRecipe = createRecipe()
|
||||||
|
|
||||||
|
// Create mock state variables for the @Binding properties
|
||||||
|
@State var mockEditMode = true
|
||||||
|
@State var mockPresentInstructionEditView = false
|
||||||
|
|
||||||
|
// Provide the mock data to the view
|
||||||
|
RecipeInstructionSection(
|
||||||
|
recipe: mockRecipe,
|
||||||
|
editMode: $mockEditMode,
|
||||||
|
presentInstructionEditView: $mockPresentInstructionEditView
|
||||||
|
)
|
||||||
|
.previewDisplayName("Instructions - Edit Mode")
|
||||||
|
|
||||||
|
RecipeInstructionSection(
|
||||||
|
recipe: mockRecipe,
|
||||||
|
editMode: $mockEditMode,
|
||||||
|
presentInstructionEditView: $mockPresentInstructionEditView
|
||||||
|
)
|
||||||
|
.previewDisplayName("Instructions - Read Only")
|
||||||
|
.environment(\.editMode, .constant(.inactive))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createRecipe() -> Recipe {
|
||||||
|
let recipe = Recipe()
|
||||||
|
recipe.name = "Mock Recipe"
|
||||||
|
recipe.instructions = [
|
||||||
|
"Step 1: Gather all ingredients and equipment.",
|
||||||
|
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
|
||||||
|
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
|
||||||
|
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
|
||||||
|
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
|
||||||
|
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
|
||||||
|
"Step 7: Let cool before serving. Enjoy!"
|
||||||
|
]
|
||||||
|
return recipe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
// MARK: - RecipeView More Information Section
|
// MARK: - RecipeView More Information Section
|
||||||
|
|
||||||
struct MoreInformationSection: View {
|
struct MoreInformationSection: View {
|
||||||
@State var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let dateCreated = viewModel.recipe.dateCreated {
|
if let dateCreated = recipe.dateCreated {
|
||||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
|
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
|
||||||
}
|
}
|
||||||
if let dateModified = viewModel.recipe.dateModified {
|
if let dateModified = recipe.dateModified {
|
||||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
|
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
|
||||||
}
|
}
|
||||||
if viewModel.recipe.url != "", let url = URL(string: viewModel.recipe.url ?? "") {
|
if recipe.url != "", let url = URL(string: recipe.url ?? "") {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("URL:")
|
Text("URL:")
|
||||||
Link(destination: url) {
|
Link(destination: url) {
|
||||||
Text(viewModel.recipe.url ?? "")
|
Text(recipe.url ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,5 +157,3 @@ struct MoreInformationSection: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -9,14 +9,15 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - RecipeView Nutrition Section
|
// MARK: - RecipeView Nutrition Section
|
||||||
/*
|
|
||||||
struct RecipeNutritionSection: View {
|
struct RecipeNutritionSection: View {
|
||||||
@State var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||||
HStack {
|
HStack {
|
||||||
Text(nutrition.localizedDescription)
|
Text(nutrition.localizedDescription)
|
||||||
@@ -28,7 +29,7 @@ struct RecipeNutritionSection: View {
|
|||||||
} else if !nutritionEmpty() {
|
} else if !nutritionEmpty() {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||||
if let value = viewModel.recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
|
if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("\(nutrition.localizedDescription): \(value)")
|
Text("\(nutrition.localizedDescription): \(value)")
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
@@ -43,7 +44,7 @@ struct RecipeNutritionSection: View {
|
|||||||
}
|
}
|
||||||
} title: {
|
} title: {
|
||||||
HStack {
|
HStack {
|
||||||
if let servingSize = viewModel.recipe.nutrition["servingSize"] {
|
if let servingSize = recipe.nutrition["servingSize"] {
|
||||||
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
||||||
} else {
|
} else {
|
||||||
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
||||||
@@ -56,14 +57,14 @@ struct RecipeNutritionSection: View {
|
|||||||
|
|
||||||
func binding(for key: String) -> Binding<String> {
|
func binding(for key: String) -> Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { viewModel.recipe.nutrition[key, default: ""] },
|
get: { recipe.nutrition[key, default: ""] },
|
||||||
set: { viewModel.recipe.nutrition[key] = $0 }
|
set: { recipe.nutrition[key] = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func nutritionEmpty() -> Bool {
|
func nutritionEmpty() -> Bool {
|
||||||
for nutrition in Nutrition.allCases {
|
for nutrition in Nutrition.allCases {
|
||||||
if let value = viewModel.recipe.nutrition[nutrition.dictKey] {
|
if let value = recipe.nutrition[nutrition.dictKey] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,4 +72,3 @@ struct RecipeNutritionSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - RecipeView Tool Section
|
// MARK: - RecipeView Tool Section
|
||||||
/*
|
|
||||||
struct RecipeToolSection: View {
|
struct RecipeToolSection: View {
|
||||||
@State var viewModel: RecipeView.ViewModel
|
@Bindable var recipe: Recipe
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@Binding var presentToolEditView: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -20,11 +22,11 @@ struct RecipeToolSection: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
|
||||||
RecipeListSection(list: $viewModel.recipe.tool)
|
RecipeListSection(list: $recipe.tools)
|
||||||
|
|
||||||
if viewModel.editMode {
|
if editMode {
|
||||||
Button {
|
Button {
|
||||||
viewModel.presentToolEditView.toggle()
|
presentToolEditView.toggle()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Edit")
|
Text("Edit")
|
||||||
}
|
}
|
||||||
@@ -36,4 +38,4 @@ struct RecipeToolSection: View {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
/*
|
|
||||||
struct CollapsibleView<C: View, T: View>: View {
|
struct CollapsibleView<C: View, T: View>: View {
|
||||||
@State var titleColor: Color = .white
|
@State var titleColor: Color = .white
|
||||||
@State var isCollapsed: Bool = true
|
@State var isCollapsed: Bool = true
|
||||||
@@ -48,4 +48,4 @@ struct CollapsibleView<C: View, T: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
//
|
||||||
|
// ListVStack.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 29.05.25.
|
||||||
|
//
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
|
||||||
|
@Binding var items: [Element]
|
||||||
|
let header: () -> HeaderContent
|
||||||
|
let rows: (Int, Binding<Element>) -> RowContent
|
||||||
|
|
||||||
|
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
|
||||||
|
self._items = items
|
||||||
|
self.header = header
|
||||||
|
self.rows = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
header()
|
||||||
|
.padding(.horizontal, 30)
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
ForEach(items.indices, id: \.self) { index in
|
||||||
|
rows(index, $items[index])
|
||||||
|
.padding(10)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
.background(Color.secondary.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 15))
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,55 +7,158 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model class GroceryItem {
|
||||||
|
var name: String
|
||||||
|
var isChecked: Bool
|
||||||
|
|
||||||
|
init(name: String, isChecked: Bool) {
|
||||||
|
self.name = name
|
||||||
|
self.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Model class RecipeGroceries: Identifiable {
|
||||||
|
var id: String
|
||||||
|
var name: String
|
||||||
|
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
|
||||||
|
var multiplier: Double
|
||||||
|
|
||||||
|
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.items = items
|
||||||
|
self.multiplier = multiplier
|
||||||
|
}
|
||||||
|
|
||||||
|
init(id: String, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.items = []
|
||||||
|
self.multiplier = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
struct GroceryListTabView: View {
|
struct GroceryListTabView: View {
|
||||||
@Environment(CookbookState.self) var cookbookState
|
@Environment(\.modelContext) var modelContext
|
||||||
|
@Query var groceryList: [RecipeGroceries] = []
|
||||||
|
@State var newGroceries: String = ""
|
||||||
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if cookbookState.groceryList.groceryDict.isEmpty {
|
List {
|
||||||
EmptyGroceryListView()
|
HStack(alignment: .top) {
|
||||||
} else {
|
TextEditor(text: $newGroceries)
|
||||||
List {
|
.padding(4)
|
||||||
ForEach(cookbookState.groceryList.groceryDict.keys.sorted(), id: \.self) { key in
|
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||||
Section {
|
.stroke(Color.secondary).opacity(0.5))
|
||||||
ForEach(cookbookState.groceryList.groceryDict[key]!.items) { item in
|
.focused($isFocused)
|
||||||
GroceryListItemView(item: item, toggleAction: {
|
Button {
|
||||||
cookbookState.groceryList.toggleItemChecked(item)
|
if !newGroceries.isEmpty {
|
||||||
}, deleteAction: {
|
let items = newGroceries
|
||||||
withAnimation {
|
.split(separator: "\n")
|
||||||
cookbookState.groceryList.deleteItem(item.name, fromRecipe: key)
|
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
}
|
.filter { !$0.isEmpty }
|
||||||
})
|
Task {
|
||||||
|
await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
|
||||||
}
|
}
|
||||||
} header: {
|
}
|
||||||
HStack {
|
newGroceries = ""
|
||||||
Text(cookbookState.groceryList.groceryDict[key]!.name)
|
|
||||||
|
} 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)
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
cookbookState.groceryList.deleteGroceryRecipe(key)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "trash")
|
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.listStyle(.plain)
|
if groceryList.isEmpty {
|
||||||
.navigationTitle("Grocery List")
|
Text("You're all set for cooking 🍓")
|
||||||
.toolbar {
|
.font(.headline)
|
||||||
Button {
|
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.")
|
||||||
cookbookState.groceryList.deleteAll()
|
.foregroundStyle(.secondary)
|
||||||
} label: {
|
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.")
|
||||||
Text("Delete")
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
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<RecipeGroceries> { $0.id == categoryId }
|
||||||
|
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
|
||||||
|
|
||||||
|
var targetCategory: RecipeGroceries?
|
||||||
|
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
|
||||||
|
targetCategory = existingCategory
|
||||||
|
} else {
|
||||||
|
// Create the category if it doesn't exist
|
||||||
|
let newCategory = RecipeGroceries(id: categoryId, name: name)
|
||||||
|
modelContext.insert(newCategory)
|
||||||
|
targetCategory = newCategory
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let category = targetCategory else { return }
|
||||||
|
|
||||||
|
// Add new GroceryItems to the category
|
||||||
|
for itemName in itemNames {
|
||||||
|
let newItem = GroceryItem(name: itemName, isChecked: false)
|
||||||
|
category.items.append(newItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
try modelContext.save()
|
||||||
|
} catch {
|
||||||
|
print("Error adding grocery items: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
|
||||||
|
for index in offsets {
|
||||||
|
let itemToDelete = category.items[index]
|
||||||
|
modelContext.delete(itemToDelete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,9 +166,8 @@ struct GroceryListTabView: View {
|
|||||||
|
|
||||||
|
|
||||||
fileprivate struct GroceryListItemView: View {
|
fileprivate struct GroceryListItemView: View {
|
||||||
let item: GroceryRecipeItem
|
@Environment(\.modelContext) var modelContext
|
||||||
let toggleAction: () -> Void
|
@Bindable var item: GroceryItem
|
||||||
let deleteAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
@@ -81,149 +183,13 @@ fileprivate struct GroceryListItemView: View {
|
|||||||
}
|
}
|
||||||
.padding(5)
|
.padding(5)
|
||||||
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
||||||
.onTapGesture(perform: toggleAction)
|
.onTapGesture(perform: { item.isChecked.toggle() })
|
||||||
.animation(.easeInOut, value: item.isChecked)
|
.animation(.easeInOut, value: item.isChecked)
|
||||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
Button(action: deleteAction) {
|
Button(action: { modelContext.delete(item) }) {
|
||||||
Label("Delete", systemImage: "trash")
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
}
|
||||||
.tint(.red)
|
.tint(.red)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct EmptyGroceryListView: View {
|
|
||||||
var body: some View {
|
|
||||||
List {
|
|
||||||
Text("You're all set for cooking 🍓")
|
|
||||||
.font(.headline)
|
|
||||||
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
.navigationTitle("Grocery List")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Grocery List Logic
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryRecipe: Identifiable, Codable {
|
|
||||||
let name: String
|
|
||||||
var items: [GroceryRecipeItem]
|
|
||||||
|
|
||||||
init(name: String, items: [GroceryRecipeItem]) {
|
|
||||||
self.name = name
|
|
||||||
self.items = items
|
|
||||||
}
|
|
||||||
|
|
||||||
init(name: String, item: GroceryRecipeItem) {
|
|
||||||
self.name = name
|
|
||||||
self.items = [item]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class GroceryRecipeItem: Identifiable, Codable {
|
|
||||||
let name: String
|
|
||||||
var isChecked: Bool
|
|
||||||
|
|
||||||
init(_ name: String, isChecked: Bool = false) {
|
|
||||||
self.name = name
|
|
||||||
self.isChecked = isChecked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Observable class GroceryList {
|
|
||||||
let dataStore: DataStore = DataStore()
|
|
||||||
var groceryDict: [String: GroceryRecipe] = [:]
|
|
||||||
var sortBySimilarity: Bool = false
|
|
||||||
|
|
||||||
|
|
||||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
|
||||||
print("Adding item of recipe \(String(describing: recipeName))")
|
|
||||||
if self.groceryDict[recipeId] != nil {
|
|
||||||
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
|
||||||
} else {
|
|
||||||
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
|
|
||||||
self.groceryDict[recipeId] = newRecipe
|
|
||||||
}
|
|
||||||
if saveGroceryDict {
|
|
||||||
self.save()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
|
||||||
for item in items {
|
|
||||||
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
|
|
||||||
}
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
|
||||||
print("Deleting item \(itemName)")
|
|
||||||
guard let recipe = groceryDict[recipeId] else { return }
|
|
||||||
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
|
||||||
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
|
||||||
if groceryDict[recipeId]!.items.isEmpty {
|
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
|
||||||
}
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteGroceryRecipe(_ recipeId: String) {
|
|
||||||
print("Deleting grocery recipe with id \(recipeId)")
|
|
||||||
groceryDict.removeValue(forKey: recipeId)
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deleteAll() {
|
|
||||||
print("Deleting all grocery items")
|
|
||||||
groceryDict = [:]
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
|
||||||
print("Item checked: \(groceryItem.name)")
|
|
||||||
groceryItem.isChecked.toggle()
|
|
||||||
save()
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsItem(at recipeId: String, item: String) -> Bool {
|
|
||||||
guard let recipe = groceryDict[recipeId] else { return false }
|
|
||||||
if recipe.items.contains(where: { $0.name == item }) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func containsRecipe(_ recipeId: String) -> Bool {
|
|
||||||
return groceryDict[recipeId] != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func save() {
|
|
||||||
Task {
|
|
||||||
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func load() async {
|
|
||||||
do {
|
|
||||||
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
|
|
||||||
fromPath: "grocery_list.data"
|
|
||||||
) else { return }
|
|
||||||
self.groceryDict = groceryDict
|
|
||||||
} catch {
|
|
||||||
print("Unable to load grocery list")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -7,88 +7,86 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
struct RecipeTabView: View {
|
struct RecipeTabView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
//@State var cookbookState: CookbookState = CookbookState()
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@Environment(\.modelContext) var modelContext
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@Query var recipes: [Recipe]
|
||||||
|
@State var categories: [(String, Int)] = []
|
||||||
|
@State private var selectedRecipe: Recipe?
|
||||||
|
@State private var selectedCategory: String? = "*"
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
List(selection: $viewModel.selectedCategory) {
|
List(selection: $selectedCategory) {
|
||||||
// Categories
|
CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
|
||||||
ForEach(appState.categories) { category in
|
.tag("*") // Tag nil to select all recipes
|
||||||
NavigationLink(value: category) {
|
|
||||||
HStack(alignment: .center) {
|
Section("Categories") {
|
||||||
if viewModel.selectedCategory != nil &&
|
ForEach(categories, id: \.0.self) { category in
|
||||||
category.name == viewModel.selectedCategory!.name {
|
CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
|
||||||
Image(systemName: "book")
|
.tag(category.0)
|
||||||
} 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Cookbooks")
|
.navigationTitle("Categories")
|
||||||
.toolbar {
|
} content: {
|
||||||
RecipeTabViewToolBar()
|
RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
|
|
||||||
SettingsView()
|
|
||||||
.environmentObject(appState)
|
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: $viewModel.presentEditView) {
|
|
||||||
RecipeView(viewModel: RecipeView.ViewModel())
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
}
|
|
||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
// Use a conditional view based on selection
|
||||||
if let category = viewModel.selectedCategory {
|
if let selectedRecipe {
|
||||||
RecipeListView(
|
//RecipeDetailView(recipe: recipe) // Create a dedicated detail view
|
||||||
categoryName: category.name,
|
RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
|
||||||
showEditView: $viewModel.presentEditView
|
} else {
|
||||||
)
|
ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
|
||||||
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.nextcloudBlue)
|
|
||||||
.task {
|
.task {
|
||||||
let connection = await appState.checkServerConnection()
|
initCategories()
|
||||||
DispatchQueue.main.async {
|
return
|
||||||
viewModel.serverConnection = connection
|
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 {
|
categories = categoryDict.map {
|
||||||
let connection = await appState.checkServerConnection()
|
($0.key, $0.value)
|
||||||
DispatchQueue.main.async {
|
}.sorted { $0.0 < $1.0 }
|
||||||
viewModel.serverConnection = connection
|
|
||||||
}
|
|
||||||
await appState.getCategories()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@@ -98,13 +96,40 @@ struct RecipeTabView: View {
|
|||||||
@Published var presentLoadingIndicator: Bool = false
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
@Published var presentConnectionPopover: Bool = false
|
@Published var presentConnectionPopover: Bool = false
|
||||||
@Published var serverConnection: 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 {
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
|||||||
234
Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift
Normal file
234
Nextcloud Cookbook iOS Client/Views/Tabs/SettingsTabView.swift
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
//
|
||||||
|
// SettingsTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 29.05.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct SettingsTabView: View {
|
||||||
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
|
|
||||||
|
@State private var avatarImage: UIImage?
|
||||||
|
@State private var userData: UserData?
|
||||||
|
|
||||||
|
@State private var showAlert: Bool = false
|
||||||
|
@State private var alertType: SettingsAlert = .NONE
|
||||||
|
|
||||||
|
@State private var presentLoginSheet: Bool = false
|
||||||
|
|
||||||
|
enum SettingsAlert {
|
||||||
|
case LOG_OUT,
|
||||||
|
DELETE_CACHE,
|
||||||
|
NONE
|
||||||
|
|
||||||
|
func getTitle() -> String {
|
||||||
|
switch self {
|
||||||
|
case .LOG_OUT: return "Log out"
|
||||||
|
case .DELETE_CACHE: return "Delete local data"
|
||||||
|
default: return "Please confirm your action."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMessage() -> String {
|
||||||
|
switch self {
|
||||||
|
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
|
||||||
|
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
|
||||||
|
default: return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
if userSettings.authString.isEmpty {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if let avatarImage = avatarImage {
|
||||||
|
Image(uiImage: avatarImage)
|
||||||
|
.resizable()
|
||||||
|
.clipShape(Circle())
|
||||||
|
.frame(width: 100, height: 100)
|
||||||
|
|
||||||
|
}
|
||||||
|
if let userData = userData {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(userData.userDisplayName)
|
||||||
|
.font(.title)
|
||||||
|
.padding(.leading)
|
||||||
|
Text("Username: \(userData.userId)")
|
||||||
|
.font(.subheadline)
|
||||||
|
.padding(.leading)
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Add actions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Log out") {
|
||||||
|
print("Log out.")
|
||||||
|
alertType = .LOG_OUT
|
||||||
|
showAlert = true
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
} else {
|
||||||
|
Button("Log in") {
|
||||||
|
print("Log in.")
|
||||||
|
presentLoginSheet.toggle()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Nextcloud")
|
||||||
|
} footer: {
|
||||||
|
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.expandNutritionSection) {
|
||||||
|
Text("Expand nutrition section")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.expandKeywordSection) {
|
||||||
|
Text("Expand keyword section")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.expandInfoSection) {
|
||||||
|
Text("Expand information section")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Recipes")
|
||||||
|
} footer: {
|
||||||
|
Text("Configure which sections in your recipes are expanded by default.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.keepScreenAwake) {
|
||||||
|
Text("Keep screen awake when viewing recipes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Text("Decimal number format")
|
||||||
|
Spacer()
|
||||||
|
Picker("", selection: $userSettings.decimalNumberSeparator) {
|
||||||
|
Text("Point (e.g. 1.42)").tag(".")
|
||||||
|
Text("Comma (e.g. 1,42)").tag(",")
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.storeRecipes) {
|
||||||
|
Text("Offline recipes")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.storeImages) {
|
||||||
|
Text("Store recipe images locally")
|
||||||
|
}
|
||||||
|
Toggle(isOn: $userSettings.storeThumb) {
|
||||||
|
Text("Store recipe thumbnails locally")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Downloads")
|
||||||
|
} footer: {
|
||||||
|
Text("Configure what is stored on your device.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Language", selection: $userSettings.language) {
|
||||||
|
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
|
||||||
|
Text(lang.descriptor()).tag(lang.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
|
||||||
|
} header: {
|
||||||
|
Text("About")
|
||||||
|
} footer: {
|
||||||
|
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
|
||||||
|
} header: {
|
||||||
|
Text("Support")
|
||||||
|
} footer: {
|
||||||
|
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button("Delete local data") {
|
||||||
|
print("Clear cache.")
|
||||||
|
alertType = .DELETE_CACHE
|
||||||
|
showAlert = true
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
|
||||||
|
} header: {
|
||||||
|
Text("Other")
|
||||||
|
} footer: {
|
||||||
|
Text("Deleting local data will not affect the recipe data stored on your server.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Acknowledgements")) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
|
||||||
|
Link("SwiftSoup", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
||||||
|
Link("TPPDF", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.alert(alertType.getTitle(), isPresented: $showAlert) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
if alertType == .DELETE_CACHE {
|
||||||
|
Button("Delete", role: .destructive) { deleteCachedData() }
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
Text(alertType.getMessage())
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await getUserData()
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
|
||||||
|
V2LoginView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserData() async {
|
||||||
|
let (data, _) = await NextcloudApi.getAvatar()
|
||||||
|
let (userData, _) = await NextcloudApi.getHoverCard()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.avatarImage = data
|
||||||
|
self.userData = userData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteCachedData() {
|
||||||
|
print("TODO: Delete cached data\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user