Updated RecipeView
@@ -42,7 +42,7 @@
|
||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
||||
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; };
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; };
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerViewOld.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */; };
|
||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; };
|
||||
@@ -53,6 +53,9 @@
|
||||
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
|
||||
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
|
||||
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; };
|
||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
|
||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
|
||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */; };
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
||||
@@ -115,7 +118,7 @@
|
||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
||||
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = "<group>"; };
|
||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = "<group>"; };
|
||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerViewOld.swift; sourceTree = "<group>"; };
|
||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
||||
@@ -125,6 +128,9 @@
|
||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
||||
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; };
|
||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.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>"; };
|
||||
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = "<group>"; };
|
||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
||||
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
|
||||
@@ -269,6 +275,7 @@
|
||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
||||
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
||||
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
|
||||
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
|
||||
);
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
@@ -352,6 +359,7 @@
|
||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
|
||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||
);
|
||||
@@ -362,8 +370,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
|
||||
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
|
||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
|
||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */,
|
||||
);
|
||||
path = RecipeEditing;
|
||||
sourceTree = "<group>";
|
||||
@@ -371,8 +378,10 @@
|
||||
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
|
||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||
A95364662B7E89F1001018B0 /* ReorderableForEach.swift */,
|
||||
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
|
||||
);
|
||||
path = ReusableViews;
|
||||
sourceTree = "<group>";
|
||||
@@ -538,8 +547,10 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
|
||||
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
||||
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
|
||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
||||
@@ -554,6 +565,7 @@
|
||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
|
||||
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
|
||||
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
|
||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
|
||||
@@ -562,7 +574,7 @@
|
||||
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
|
||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
|
||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerViewOld.swift in Sources */,
|
||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
|
||||
|
||||
@@ -18,6 +18,7 @@ import UIKit
|
||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||
var lastUpdates: [String: Date] = [:]
|
||||
var allKeywords: [RecipeKeyword] = []
|
||||
|
||||
private let dataStore: DataStore
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cookbook-icon-20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-20@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-29@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-40@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "cookbook-icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "cookbook-icon.png",
|
||||
"filename" : "Hintergrund-1024.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
|
||||
BIN
Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Hintergrund-1024.png
vendored
Normal file
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 58 KiB |
@@ -18,16 +18,12 @@ struct Category: Codable {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Category: Identifiable, Hashable {
|
||||
var id: String { name }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// MARK: - Login flow
|
||||
|
||||
struct LoginV2Request: Codable {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//
|
||||
// ObservableRecipeDetail.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 01.03.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ObservableRecipeDetail: ObservableObject {
|
||||
var id: String
|
||||
@Published var name: String
|
||||
@Published var keywords: [String]
|
||||
@Published var imageUrl: String
|
||||
@Published var prepTime: DurationComponents
|
||||
@Published var cookTime: DurationComponents
|
||||
@Published var totalTime: DurationComponents
|
||||
@Published var description: String
|
||||
@Published var url: String
|
||||
@Published var recipeYield: Int
|
||||
@Published var recipeCategory: String
|
||||
@Published var tool: [ReorderableItem<String>]
|
||||
@Published var recipeIngredient: [ReorderableItem<String>]
|
||||
@Published var recipeInstructions: [ReorderableItem<String>]
|
||||
@Published var nutrition: [String:String]
|
||||
|
||||
init() {
|
||||
id = ""
|
||||
name = String(localized: "New Recipe")
|
||||
keywords = []
|
||||
imageUrl = ""
|
||||
prepTime = DurationComponents()
|
||||
cookTime = DurationComponents()
|
||||
totalTime = DurationComponents()
|
||||
description = ""
|
||||
url = ""
|
||||
recipeYield = 0
|
||||
recipeCategory = ""
|
||||
tool = []
|
||||
recipeIngredient = []
|
||||
recipeInstructions = []
|
||||
nutrition = [:]
|
||||
}
|
||||
|
||||
init(_ recipeDetail: RecipeDetail) {
|
||||
id = recipeDetail.id
|
||||
name = recipeDetail.name
|
||||
keywords = recipeDetail.keywords.components(separatedBy: ",")
|
||||
imageUrl = recipeDetail.imageUrl
|
||||
prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "")
|
||||
cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "")
|
||||
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
|
||||
description = recipeDetail.description
|
||||
url = recipeDetail.url
|
||||
recipeYield = recipeDetail.recipeYield
|
||||
recipeCategory = recipeDetail.recipeCategory
|
||||
tool = ReorderableItem.list(items: recipeDetail.tool)
|
||||
recipeIngredient = ReorderableItem.list(items: recipeDetail.recipeIngredient)
|
||||
recipeInstructions = ReorderableItem.list(items: recipeDetail.recipeInstructions)
|
||||
nutrition = recipeDetail.nutrition
|
||||
}
|
||||
|
||||
func toRecipeDetail() -> RecipeDetail {
|
||||
return RecipeDetail(
|
||||
name: self.name,
|
||||
keywords: self.keywords.joined(separator: ","),
|
||||
dateCreated: "",
|
||||
dateModified: "",
|
||||
imageUrl: self.imageUrl,
|
||||
id: self.id,
|
||||
prepTime: self.prepTime.toPTString(),
|
||||
cookTime: self.cookTime.toPTString(),
|
||||
totalTime: self.totalTime.toPTString(),
|
||||
description: self.description,
|
||||
url: self.url,
|
||||
recipeYield: self.recipeYield,
|
||||
recipeCategory: self.recipeCategory,
|
||||
tool: ReorderableItem.items(self.tool),
|
||||
recipeIngredient: ReorderableItem.items(self.recipeIngredient),
|
||||
recipeInstructions: ReorderableItem.items(self.recipeInstructions),
|
||||
nutrition: self.nutrition
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -146,7 +146,8 @@ struct RecipeKeyword: Codable {
|
||||
|
||||
|
||||
enum Nutrition: CaseIterable {
|
||||
case calories,
|
||||
case servingSize,
|
||||
calories,
|
||||
carbohydrateContent,
|
||||
cholesterolContent,
|
||||
fatContent,
|
||||
@@ -158,35 +159,39 @@ enum Nutrition: CaseIterable {
|
||||
sodiumContent,
|
||||
sugarContent
|
||||
|
||||
var localizedDescription: LocalizedStringKey {
|
||||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .servingSize:
|
||||
return NSLocalizedString("Serving size", comment: "Serving size")
|
||||
case .calories:
|
||||
"Calories"
|
||||
return NSLocalizedString("Calories", comment: "Calories")
|
||||
case .carbohydrateContent:
|
||||
"Carbohydrate content"
|
||||
return NSLocalizedString("Carbohydrate content", comment: "Carbohydrate content")
|
||||
case .cholesterolContent:
|
||||
"Cholesterol content"
|
||||
return NSLocalizedString("Cholesterol content", comment: "Cholesterol content")
|
||||
case .fatContent:
|
||||
"Fat content"
|
||||
return NSLocalizedString("Fat content", comment: "Fat content")
|
||||
case .saturatedFatContent:
|
||||
"Saturated fat content"
|
||||
return NSLocalizedString("Saturated fat content", comment: "Saturated fat content")
|
||||
case .unsaturatedFatContent:
|
||||
"Unsaturated fat content"
|
||||
return NSLocalizedString("Unsaturated fat content", comment: "Unsaturated fat content")
|
||||
case .transFatContent:
|
||||
"Trans fat content"
|
||||
return NSLocalizedString("Trans fat content", comment: "Trans fat content")
|
||||
case .fiberContent:
|
||||
"Fiber content"
|
||||
return NSLocalizedString("Fiber content", comment: "Fiber content")
|
||||
case .proteinContent:
|
||||
"Protein content"
|
||||
return NSLocalizedString("Protein content", comment: "Protein content")
|
||||
case .sodiumContent:
|
||||
"Sodium content"
|
||||
return NSLocalizedString("Sodium content", comment: "Sodium content")
|
||||
case .sugarContent:
|
||||
"Sugar content"
|
||||
return NSLocalizedString("Sugar content", comment: "Sugar content")
|
||||
}
|
||||
}
|
||||
|
||||
var dictKey: String {
|
||||
switch self {
|
||||
case .servingSize:
|
||||
"servingSize"
|
||||
case .calories:
|
||||
"calories"
|
||||
case .carbohydrateContent:
|
||||
|
||||
@@ -111,6 +111,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@: %@" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -155,6 +165,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld h %lld min" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$lld h %2$lld min"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld h, %lld min" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -559,7 +579,7 @@
|
||||
}
|
||||
},
|
||||
"Calories" : {
|
||||
|
||||
"comment" : "Calories"
|
||||
},
|
||||
"Cancel" : {
|
||||
"localizations" : {
|
||||
@@ -584,7 +604,7 @@
|
||||
}
|
||||
},
|
||||
"Carbohydrate content" : {
|
||||
|
||||
"comment" : "Carbohydrate content"
|
||||
},
|
||||
"Category" : {
|
||||
"localizations" : {
|
||||
@@ -631,7 +651,7 @@
|
||||
}
|
||||
},
|
||||
"Cholesterol content" : {
|
||||
|
||||
"comment" : "Cholesterol content"
|
||||
},
|
||||
"Configure what is stored on your device." : {
|
||||
"localizations" : {
|
||||
@@ -1152,6 +1172,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Edit keywords" : {
|
||||
|
||||
},
|
||||
"Enable deletion" : {
|
||||
|
||||
@@ -1267,10 +1290,10 @@
|
||||
}
|
||||
},
|
||||
"Fat content" : {
|
||||
|
||||
"comment" : "Fat content"
|
||||
},
|
||||
"Fiber content" : {
|
||||
|
||||
"comment" : "Fiber content"
|
||||
},
|
||||
"General" : {
|
||||
"localizations" : {
|
||||
@@ -1636,9 +1659,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Keyword" : {
|
||||
|
||||
},
|
||||
"Keywords" : {
|
||||
"localizations" : {
|
||||
@@ -1971,6 +1991,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Recipe" : {
|
||||
|
||||
},
|
||||
"Nextcloud Login" : {
|
||||
"localizations" : {
|
||||
@@ -2347,7 +2370,7 @@
|
||||
}
|
||||
},
|
||||
"Protein content" : {
|
||||
|
||||
"comment" : "Protein content"
|
||||
},
|
||||
"Recipe" : {
|
||||
"localizations" : {
|
||||
@@ -2370,6 +2393,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Recipe Name" : {
|
||||
|
||||
},
|
||||
"Recipes" : {
|
||||
"localizations" : {
|
||||
@@ -2438,7 +2464,7 @@
|
||||
}
|
||||
},
|
||||
"Saturated fat content" : {
|
||||
|
||||
"comment" : "Saturated fat content"
|
||||
},
|
||||
"Search" : {
|
||||
"localizations" : {
|
||||
@@ -2527,6 +2553,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Select Item" : {
|
||||
|
||||
},
|
||||
"Selected keywords:" : {
|
||||
"localizations" : {
|
||||
@@ -2550,6 +2579,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Serving size" : {
|
||||
"comment" : "Serving size"
|
||||
},
|
||||
"Servings:" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2683,7 +2715,7 @@
|
||||
}
|
||||
},
|
||||
"Sodium content" : {
|
||||
|
||||
"comment" : "Sodium content"
|
||||
},
|
||||
"Store recipe images locally" : {
|
||||
"localizations" : {
|
||||
@@ -2752,7 +2784,7 @@
|
||||
}
|
||||
},
|
||||
"Sugar content" : {
|
||||
|
||||
"comment" : "Sugar content"
|
||||
},
|
||||
"Support" : {
|
||||
"localizations" : {
|
||||
@@ -3090,7 +3122,7 @@
|
||||
}
|
||||
},
|
||||
"Trans fat content" : {
|
||||
|
||||
"comment" : "Trans fat content"
|
||||
},
|
||||
"Unable to complete action." : {
|
||||
"localizations" : {
|
||||
@@ -3181,7 +3213,7 @@
|
||||
}
|
||||
},
|
||||
"Unsaturated fat content" : {
|
||||
|
||||
"comment" : "Unsaturated fat content"
|
||||
},
|
||||
"Upload" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -11,7 +11,6 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
||||
@StateObject var mainViewModel = AppState()
|
||||
@AppStorage("onboarding") var onboarding = true
|
||||
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||
|
||||
@@ -21,7 +20,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
||||
if onboarding {
|
||||
OnboardingView()
|
||||
} else {
|
||||
MainView(viewModel: mainViewModel)
|
||||
MainView()
|
||||
}
|
||||
}
|
||||
.transition(.slide)
|
||||
|
||||
@@ -58,6 +58,21 @@ class DurationComponents: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var displayString: String {
|
||||
let intHour = Int(hourComponent) ?? 0
|
||||
let intMinute = Int(minuteComponent) ?? 0
|
||||
|
||||
if intHour != 0 && intMinute != 0 {
|
||||
return "\(intHour) h \(intMinute) min"
|
||||
} else if intHour == 0 && intMinute != 0 {
|
||||
return "\(intMinute) min"
|
||||
} else if intHour != 0 && intMinute == 0 {
|
||||
return "\(intHour) h"
|
||||
} else {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
static func fromPTString(_ PTRepresentation: String) -> DurationComponents {
|
||||
let duration = DurationComponents()
|
||||
let hourRegex = /([0-9]{1,2})H/
|
||||
@@ -86,21 +101,6 @@ class DurationComponents: ObservableObject {
|
||||
return "PT\(hourComponent)H\(minuteComponent)M00S"
|
||||
}
|
||||
|
||||
func toText() -> LocalizedStringKey {
|
||||
let intHour = Int(hourComponent) ?? 0
|
||||
let intMinute = Int(minuteComponent) ?? 0
|
||||
|
||||
if intHour != 0 && intMinute != 0 {
|
||||
return "\(intHour) h, \(intMinute) min"
|
||||
} else if intHour == 0 && intMinute != 0 {
|
||||
return "\(intMinute) min"
|
||||
} else if intHour != 0 && intMinute == 0 {
|
||||
return "\(intHour) h"
|
||||
} else {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
func toTimerText() -> String {
|
||||
var timeString = ""
|
||||
if hourComponent != "00" {
|
||||
@@ -152,4 +152,5 @@ class DurationComponents: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import SwiftUI
|
||||
import SimilaritySearchKit
|
||||
|
||||
struct MainView: View {
|
||||
@StateObject var viewModel = AppState()
|
||||
@StateObject var appState = AppState()
|
||||
@StateObject var groceryList = GroceryList()
|
||||
|
||||
// Tab ViewModels
|
||||
@@ -24,7 +24,7 @@ struct MainView: View {
|
||||
TabView {
|
||||
RecipeTabView()
|
||||
.environmentObject(recipeViewModel)
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.tabItem {
|
||||
Label("Recipes", systemImage: "book.closed.fill")
|
||||
@@ -33,7 +33,7 @@ struct MainView: View {
|
||||
|
||||
SearchTabView()
|
||||
.environmentObject(searchViewModel)
|
||||
.environmentObject(viewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
@@ -53,12 +53,12 @@ struct MainView: View {
|
||||
}
|
||||
.task {
|
||||
recipeViewModel.presentLoadingIndicator = true
|
||||
await viewModel.getCategories()
|
||||
await viewModel.updateAllRecipeDetails()
|
||||
await appState.getCategories()
|
||||
await appState.updateAllRecipeDetails()
|
||||
|
||||
// Open detail view for default category
|
||||
if UserSettings.shared.defaultCategory != "" {
|
||||
if let cat = viewModel.categories.first(where: { c in
|
||||
if let cat = appState.categories.first(where: { c in
|
||||
if c.name == UserSettings.shared.defaultCategory {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
|
||||
|
||||
struct CategoryPickerView: View {
|
||||
struct CategoryPickerViewOld: View {
|
||||
@State var title: String
|
||||
@State var searchSuggestions: [String]
|
||||
@Binding var selection: String
|
||||
@@ -132,7 +132,7 @@ struct RecipeEditView: View {
|
||||
|
||||
Section() {
|
||||
NavigationLink(viewModel.recipe.recipeCategory == "" ? "Category" : "Category: \(viewModel.recipe.recipeCategory)") {
|
||||
CategoryPickerView(
|
||||
CategoryPickerViewOld(
|
||||
title: "Category",
|
||||
searchSuggestions: viewModel.mainViewModel.categories.map({ category in
|
||||
category.name == "*" ? "Other" : category.name
|
||||
|
||||
@@ -11,6 +11,7 @@ import SwiftUI
|
||||
|
||||
|
||||
struct KeywordPickerView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State var title: String
|
||||
@State var searchSuggestions: [RecipeKeyword]
|
||||
@Binding var selection: [String]
|
||||
@@ -20,6 +21,14 @@ struct KeywordPickerView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} label: {
|
||||
Text("Done")
|
||||
}.padding()
|
||||
}
|
||||
TextField(title, text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding()
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct RecipeCardView: View {
|
||||
@State var viewModel: AppState
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State var recipe: Recipe
|
||||
@State var recipeThumb: UIImage?
|
||||
@State var isDownloaded: Bool? = nil
|
||||
@@ -50,18 +50,18 @@ struct RecipeCardView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||
.padding(.horizontal)
|
||||
.task {
|
||||
recipeThumb = await viewModel.getImage(
|
||||
recipeThumb = await appState.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||
)
|
||||
if recipe.storedLocally == nil {
|
||||
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
|
||||
}
|
||||
isDownloaded = recipe.storedLocally
|
||||
}
|
||||
.refreshable {
|
||||
recipeThumb = await viewModel.getImage(
|
||||
recipeThumb = await appState.getImage(
|
||||
id: recipe.recipe_id,
|
||||
size: .THUMB,
|
||||
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
|
||||
|
||||
@@ -11,9 +11,9 @@ import SwiftUI
|
||||
|
||||
|
||||
struct RecipeListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State var categoryName: String
|
||||
@State var searchText: String = ""
|
||||
@ObservedObject var viewModel: AppState
|
||||
@Binding var showEditView: Bool
|
||||
@State var selectedRecipe: Recipe? = nil
|
||||
@State var presentRecipeView: Bool = false
|
||||
@@ -23,7 +23,7 @@ struct RecipeListView: View {
|
||||
LazyVStack {
|
||||
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
NavigationLink(value: recipe) {
|
||||
RecipeCardView(viewModel: viewModel, recipe: recipe)
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
|
||||
}
|
||||
@@ -36,7 +36,7 @@ struct RecipeListView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(appState: viewModel, viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
}
|
||||
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
||||
.toolbar {
|
||||
@@ -51,13 +51,13 @@ struct RecipeListView: View {
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||
.task {
|
||||
await viewModel.getCategory(
|
||||
await appState.getCategory(
|
||||
named: categoryName,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||
)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.getCategory(
|
||||
await appState.getCategory(
|
||||
named: categoryName,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
||||
)
|
||||
@@ -65,7 +65,7 @@ struct RecipeListView: View {
|
||||
}
|
||||
|
||||
func recipesFiltered() -> [Recipe] {
|
||||
guard let recipes = viewModel.recipes[categoryName] else { return [] }
|
||||
guard let recipes = appState.recipes[categoryName] else { return [] }
|
||||
guard searchText != "" else { return recipes }
|
||||
return recipes.filter { recipe in
|
||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||
|
||||
@@ -10,71 +10,85 @@ import SwiftUI
|
||||
|
||||
|
||||
struct RecipeView: View {
|
||||
@ObservedObject var appState: AppState
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@EnvironmentObject var appState: AppState
|
||||
@StateObject var viewModel: ViewModel
|
||||
@State var imageHeight: CGFloat = 350
|
||||
|
||||
private enum CoordinateSpaces {
|
||||
case scrollView
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading) {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
ParallaxHeader(
|
||||
coordinateSpace: CoordinateSpaces.scrollView,
|
||||
defaultHeight: imageHeight
|
||||
) {
|
||||
if let recipeImage = viewModel.recipeImage {
|
||||
Image(uiImage: recipeImage)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(maxHeight: 300)
|
||||
.clipped()
|
||||
}
|
||||
}.animation(.easeInOut, value: viewModel.recipeImage)
|
||||
}
|
||||
|
||||
|
||||
LazyVStack (alignment: .leading) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
EditableText(text: $viewModel.recipeDetail.name, editMode: $viewModel.editMode)
|
||||
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
|
||||
.font(.title)
|
||||
.bold()
|
||||
.padding()
|
||||
.onDisappear {
|
||||
viewModel.showTitle = true
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.showTitle = false
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let isDownloaded = viewModel.isDownloaded {
|
||||
Spacer()
|
||||
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||
.foregroundColor(.secondary)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}.padding([.top, .horizontal])
|
||||
|
||||
if viewModel.recipeDetail.description != "" || viewModel.editMode {
|
||||
EditableText(text: $viewModel.recipeDetail.description, editMode: $viewModel.editMode, lineLimit: 0...10, axis: .vertical)
|
||||
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
|
||||
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
|
||||
.padding([.bottom, .horizontal])
|
||||
}
|
||||
|
||||
|
||||
// Recipe Body Section
|
||||
RecipeDurationSection(viewModel: viewModel)
|
||||
|
||||
Divider()
|
||||
|
||||
RecipeDurationSection(viewModel: appState, recipeDetail: viewModel.recipeDetail)
|
||||
if viewModel.editMode {
|
||||
RecipeMetadataSection(viewModel: viewModel)
|
||||
}
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||
if(!viewModel.recipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
|
||||
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
|
||||
RecipeIngredientSection(viewModel: viewModel)
|
||||
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(.ultraThinMaterial))
|
||||
.padding(5)
|
||||
}
|
||||
if(!viewModel.recipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
|
||||
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
|
||||
RecipeInstructionSection(viewModel: viewModel)
|
||||
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(.ultraThinMaterial))
|
||||
.padding(5)
|
||||
}
|
||||
if(!viewModel.recipeDetail.tool.isEmpty || viewModel.editMode) {
|
||||
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
|
||||
RecipeToolSection(viewModel: viewModel)
|
||||
}
|
||||
RecipeNutritionSection(viewModel: viewModel)
|
||||
if !viewModel.editMode {
|
||||
RecipeKeywordSection(viewModel: viewModel)
|
||||
MoreInformationSection(recipeDetail: viewModel.recipeDetail)
|
||||
}
|
||||
|
||||
}.padding(.horizontal, 5)
|
||||
MoreInformationSection(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
|
||||
}
|
||||
}
|
||||
.coordinateSpace(name: CoordinateSpaces.scrollView)
|
||||
.ignoresSafeArea(.container, edges: .top)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
|
||||
.toolbar {
|
||||
@@ -87,9 +101,13 @@ struct RecipeView: View {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("Done") {
|
||||
// TODO: POST edited recipe
|
||||
if viewModel.newRecipe {
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
} else {
|
||||
viewModel.editMode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
@@ -116,36 +134,44 @@ struct RecipeView: View {
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $viewModel.presentShareSheet) {
|
||||
ShareView(recipeDetail: viewModel.recipeDetail,
|
||||
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
|
||||
recipeImage: viewModel.recipeImage,
|
||||
presentShareSheet: $viewModel.presentShareSheet)
|
||||
}
|
||||
|
||||
.task {
|
||||
viewModel.recipeDetail = await appState.getRecipe(
|
||||
// Load recipe detail
|
||||
if !viewModel.newRecipe {
|
||||
// For existing recipes, load the recipeDetail and image
|
||||
let recipeDetail = await appState.getRecipe(
|
||||
id: viewModel.recipe.recipe_id,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||
) ?? RecipeDetail.error
|
||||
viewModel.setupView(recipeDetail: recipeDetail)
|
||||
|
||||
// Show download badge
|
||||
if viewModel.recipe.storedLocally == nil {
|
||||
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
||||
}
|
||||
viewModel.isDownloaded = viewModel.recipe.storedLocally
|
||||
|
||||
// Load recipe image
|
||||
viewModel.recipeImage = await appState.getImage(
|
||||
id: viewModel.recipe.recipe_id,
|
||||
size: .FULL,
|
||||
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
||||
)
|
||||
if viewModel.recipe.storedLocally == nil {
|
||||
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
||||
if let image = viewModel.recipeImage {
|
||||
imageHeight = image.size.height < 350 ? image.size.height : 350
|
||||
} else {
|
||||
imageHeight = 100
|
||||
}
|
||||
viewModel.isDownloaded = viewModel.recipe.storedLocally
|
||||
} else {
|
||||
// Prepare view for a new recipe
|
||||
viewModel.setupView(recipeDetail: RecipeDetail())
|
||||
viewModel.editMode = true
|
||||
viewModel.isDownloaded = false
|
||||
}
|
||||
.refreshable {
|
||||
viewModel.recipeDetail = await appState.getRecipe(
|
||||
id: viewModel.recipe.recipe_id,
|
||||
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
||||
) ?? RecipeDetail.error
|
||||
viewModel.recipeImage = await appState.getImage(
|
||||
id: viewModel.recipe.recipe_id,
|
||||
size: .FULL,
|
||||
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
if UserSettings.shared.keepScreenAwake {
|
||||
@@ -155,21 +181,29 @@ struct RecipeView: View {
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
.onChange(of: viewModel.editMode) { newValue in
|
||||
if newValue && appState.allKeywords.isEmpty {
|
||||
Task {
|
||||
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
|
||||
a.recipe_count > b.recipe_count
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - RecipeView ViewModel
|
||||
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
|
||||
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
|
||||
@Published var recipeImage: UIImage? = nil
|
||||
@Published var editMode: Bool = false
|
||||
@Published var presentShareSheet: Bool = false
|
||||
@Published var showTitle: Bool = false
|
||||
@Published var isDownloaded: Bool? = nil
|
||||
|
||||
@Published var keywords: [String] = []
|
||||
@Published var nutrition: [String] = []
|
||||
var newRecipe: Bool = false
|
||||
|
||||
var recipe: Recipe
|
||||
var sharedURL: URL? = nil
|
||||
@@ -179,62 +213,152 @@ struct RecipeView: View {
|
||||
self.recipe = recipe
|
||||
}
|
||||
|
||||
init() {
|
||||
self.newRecipe = true
|
||||
self.recipe = Recipe(
|
||||
name: String(localized: "New Recipe"),
|
||||
keywords: "",
|
||||
dateCreated: "",
|
||||
dateModified: "",
|
||||
imageUrl: "",
|
||||
imagePlaceholderUrl: "",
|
||||
recipe_id: 0)
|
||||
}
|
||||
|
||||
func setupView(recipeDetail: RecipeDetail) {
|
||||
self.keywords = recipeDetail.keywords.components(separatedBy: ",")
|
||||
self.recipeDetail = recipeDetail
|
||||
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Recipe Metadata Section
|
||||
|
||||
struct RecipeMetadataSection: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
|
||||
@State var categories: [String] = []
|
||||
@State var keywords: [RecipeKeyword] = []
|
||||
@State var presentKeywordPopover: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
CategoryPickerView(items: $categories, input: $viewModel.observableRecipeDetail.recipeCategory, titleKey: "Category")
|
||||
|
||||
SecondaryLabel(text: "Keywords")
|
||||
.padding()
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
|
||||
Text(keyword)
|
||||
}
|
||||
}
|
||||
}.padding(.horizontal)
|
||||
|
||||
Button {
|
||||
presentKeywordPopover.toggle()
|
||||
} label: {
|
||||
Text("Edit keywords")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
}
|
||||
.task {
|
||||
categories = appState.categories.map({ category in category.name })
|
||||
}
|
||||
.sheet(isPresented: $presentKeywordPopover) {
|
||||
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
struct CategoryPickerView: View {
|
||||
@Binding var items: [String]
|
||||
@Binding var input: String
|
||||
@State private var pickerChoice: String = ""
|
||||
|
||||
var titleKey: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
SecondaryLabel(text: "Category")
|
||||
.padding([.top, .horizontal])
|
||||
HStack {
|
||||
TextField(titleKey, text: $input)
|
||||
.lineLimit(1)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding()
|
||||
.onSubmit {
|
||||
pickerChoice = ""
|
||||
}
|
||||
|
||||
Picker("Select Item", selection: $pickerChoice) {
|
||||
Text("").tag("")
|
||||
ForEach(items, id: \.self) { item in
|
||||
Text(item)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.padding()
|
||||
.onChange(of: pickerChoice) { newValue in
|
||||
if pickerChoice != "" {
|
||||
input = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
pickerChoice = input
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Duration Section
|
||||
|
||||
fileprivate struct RecipeDurationSection: View {
|
||||
@ObservedObject var viewModel: AppState
|
||||
@State var recipeDetail: RecipeDetail
|
||||
@EnvironmentObject var appState: AppState
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
|
||||
DurationView(time: viewModel.observableRecipeDetail.prepTime.displayString, title: LocalizedStringKey("Preparation"))
|
||||
DurationView(time: viewModel.observableRecipeDetail.cookTime.displayString, title: LocalizedStringKey("Cooking"))
|
||||
DurationView(time: viewModel.observableRecipeDetail.totalTime.displayString, title: LocalizedStringKey("Total time"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
struct DurationView: View {
|
||||
@State var time: String
|
||||
@State var title: LocalizedStringKey
|
||||
|
||||
var body: some View {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 250), alignment: .leading)]) {
|
||||
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Preparation"))
|
||||
SecondaryLabel(text: title)
|
||||
Spacer()
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "clock")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}
|
||||
/*
|
||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
||||
TimerView(timer: viewModel.getTimer(forRecipe: recipeDetail.id, duration: DurationComponents.fromPTString(cookTime)))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
*/
|
||||
|
||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Cooking"))
|
||||
Spacer()
|
||||
}
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}
|
||||
|
||||
if let totalTime = recipeDetail.totalTime, let time = DurationComponents.ptToText(totalTime) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Total time"))
|
||||
Spacer()
|
||||
}
|
||||
Text(time)
|
||||
.lineLimit(1)
|
||||
}.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Nutrition Section
|
||||
@@ -244,7 +368,7 @@ fileprivate struct RecipeNutritionSection: View {
|
||||
|
||||
var body: some View {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
||||
Group {
|
||||
VStack(alignment: .leading) {
|
||||
if viewModel.editMode {
|
||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||
HStack {
|
||||
@@ -254,15 +378,12 @@ fileprivate struct RecipeNutritionSection: View {
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !viewModel.recipeDetail.nutrition.isEmpty {
|
||||
} else if !nutritionEmpty() {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||
if let value = viewModel.recipeDetail.nutrition[nutrition.dictKey] {
|
||||
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
|
||||
HStack(alignment: .top) {
|
||||
Text(nutrition.localizedDescription)
|
||||
Text(":")
|
||||
Text(value)
|
||||
Text("\(nutrition.localizedDescription): \(value)")
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(4)
|
||||
@@ -273,10 +394,9 @@ fileprivate struct RecipeNutritionSection: View {
|
||||
Text(LocalizedStringKey("No nutritional information."))
|
||||
}
|
||||
}
|
||||
}
|
||||
} title: {
|
||||
HStack {
|
||||
if let servingSize = viewModel.recipeDetail.nutrition["servingSize"] {
|
||||
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
|
||||
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
||||
} else {
|
||||
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
||||
@@ -289,10 +409,19 @@ fileprivate struct RecipeNutritionSection: View {
|
||||
|
||||
func binding(for key: String) -> Binding<String> {
|
||||
Binding(
|
||||
get: { viewModel.recipeDetail.nutrition[key, default: ""] },
|
||||
set: { viewModel.recipeDetail.nutrition[key] = $0 }
|
||||
get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] },
|
||||
set: { viewModel.observableRecipeDetail.nutrition[key] = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
func nutritionEmpty() -> Bool {
|
||||
for nutrition in Nutrition.allCases {
|
||||
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -300,26 +429,17 @@ fileprivate struct RecipeNutritionSection: View {
|
||||
|
||||
fileprivate struct RecipeKeywordSection: View {
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
@State var keywords: [String] = []
|
||||
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
|
||||
|
||||
var body: some View {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
||||
Group {
|
||||
if !keywords.isEmpty || viewModel.editMode {
|
||||
//RecipeListSection(list: keywords)
|
||||
EditableStringList(items: $keywords, editMode: $viewModel.editMode, titleKey: "Keyword", lineLimit: 0...1, axis: .horizontal) {
|
||||
RecipeListSection(list: keywords)
|
||||
}
|
||||
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
|
||||
RecipeListSection(list: viewModel.observableRecipeDetail.keywords)
|
||||
} else {
|
||||
Text(LocalizedStringKey("No keywords."))
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.keywords = viewModel.recipeDetail.keywords.components(separatedBy: ",")
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.recipeDetail.keywords = keywords.joined(separator: ",")
|
||||
}
|
||||
} title: {
|
||||
HStack {
|
||||
SecondaryLabel(text: LocalizedStringKey("Keywords"))
|
||||
@@ -334,18 +454,18 @@ fileprivate struct RecipeKeywordSection: View {
|
||||
// MARK: - More Information Section
|
||||
|
||||
fileprivate struct MoreInformationSection: View {
|
||||
let recipeDetail: RecipeDetail
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
|
||||
var body: some View {
|
||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
|
||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
|
||||
if recipeDetail.url != "", let url = URL(string: recipeDetail.url) {
|
||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateCreated) ?? "")")
|
||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateModified) ?? "")")
|
||||
if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) {
|
||||
HStack() {
|
||||
Text("URL:")
|
||||
Link(destination: url) {
|
||||
Text(recipeDetail.url)
|
||||
Text(viewModel.observableRecipeDetail.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,23 +522,23 @@ fileprivate struct RecipeIngredientSection: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
if viewModel.recipeDetail.recipeYield == 0 {
|
||||
if viewModel.observableRecipeDetail.recipeYield == 0 {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||
} else if viewModel.recipeDetail.recipeYield == 1 {
|
||||
} else if viewModel.observableRecipeDetail.recipeYield == 1 {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
|
||||
} else {
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.recipeDetail.recipeYield) servings"))
|
||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings"))
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation {
|
||||
if groceryList.containsRecipe(viewModel.recipeDetail.id) {
|
||||
groceryList.deleteGroceryRecipe(viewModel.recipeDetail.id)
|
||||
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
||||
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
|
||||
} else {
|
||||
groceryList.addItems(
|
||||
viewModel.recipeDetail.recipeIngredient,
|
||||
toRecipe: viewModel.recipeDetail.id,
|
||||
recipeName: viewModel.recipeDetail.name
|
||||
ReorderableItem.items(viewModel.observableRecipeDetail.recipeIngredient),
|
||||
toRecipe: viewModel.observableRecipeDetail.id,
|
||||
recipeName: viewModel.observableRecipeDetail.name
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -431,13 +551,13 @@ fileprivate struct RecipeIngredientSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
EditableStringList(items: $viewModel.recipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) {
|
||||
ForEach(0..<viewModel.recipeDetail.recipeIngredient.count, id: \.self) { ix in
|
||||
IngredientListItem(ingredient: viewModel.recipeDetail.recipeIngredient[ix], recipeId: viewModel.recipeDetail.id) {
|
||||
EditableStringList(items: $viewModel.observableRecipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) {
|
||||
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
|
||||
IngredientListItem(ingredient: viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) {
|
||||
groceryList.addItem(
|
||||
viewModel.recipeDetail.recipeIngredient[ix],
|
||||
toRecipe: viewModel.recipeDetail.id,
|
||||
recipeName: viewModel.recipeDetail.name
|
||||
viewModel.observableRecipeDetail.recipeIngredient[ix].item,
|
||||
toRecipe: viewModel.observableRecipeDetail.id,
|
||||
recipeName: viewModel.observableRecipeDetail.name
|
||||
)
|
||||
}
|
||||
.padding(4)
|
||||
@@ -449,7 +569,7 @@ fileprivate struct RecipeIngredientSection: View {
|
||||
|
||||
fileprivate struct IngredientListItem: View {
|
||||
@EnvironmentObject var groceryList: GroceryList
|
||||
@State var ingredient: String
|
||||
@State var ingredient: ReorderableItem<String>
|
||||
@State var recipeId: String
|
||||
let addToGroceryListAction: () -> Void
|
||||
@State var isSelected: Bool = false
|
||||
@@ -461,7 +581,7 @@ fileprivate struct IngredientListItem: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient.item) {
|
||||
if #available(iOS 17.0, *) {
|
||||
Image(systemName: "storefront")
|
||||
.foregroundStyle(Color.green)
|
||||
@@ -476,7 +596,7 @@ fileprivate struct IngredientListItem: View {
|
||||
Image(systemName: "circle")
|
||||
}
|
||||
|
||||
Text("\(ingredient)")
|
||||
Text("\(ingredient.item)")
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(5)
|
||||
Spacer()
|
||||
@@ -502,8 +622,8 @@ fileprivate struct IngredientListItem: View {
|
||||
.onEnded { gesture in
|
||||
withAnimation {
|
||||
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
||||
if groceryList.containsItem(at: recipeId, item: ingredient.item) {
|
||||
groceryList.deleteItem(ingredient.item, fromRecipe: recipeId)
|
||||
} else {
|
||||
addToGroceryListAction()
|
||||
}
|
||||
@@ -531,9 +651,9 @@ fileprivate struct RecipeInstructionSection: View {
|
||||
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
||||
Spacer()
|
||||
}
|
||||
EditableStringList(items: $viewModel.recipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) {
|
||||
ForEach(0..<viewModel.recipeDetail.recipeInstructions.count, id: \.self) { ix in
|
||||
RecipeInstructionListItem(instruction: viewModel.recipeDetail.recipeInstructions[ix], index: ix+1)
|
||||
EditableStringList(items: $viewModel.observableRecipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) {
|
||||
ForEach(0..<viewModel.observableRecipeDetail.recipeInstructions.count, id: \.self) { ix in
|
||||
RecipeInstructionListItem(instruction: viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
|
||||
}
|
||||
}
|
||||
}.padding()
|
||||
@@ -541,7 +661,7 @@ fileprivate struct RecipeInstructionSection: View {
|
||||
}
|
||||
|
||||
fileprivate struct RecipeInstructionListItem: View {
|
||||
@State var instruction: String
|
||||
@State var instruction: ReorderableItem<String>
|
||||
@State var index: Int
|
||||
@State var isSelected: Bool = false
|
||||
|
||||
@@ -549,7 +669,7 @@ fileprivate struct RecipeInstructionListItem: View {
|
||||
HStack(alignment: .top) {
|
||||
Text("\(index)")
|
||||
.monospaced()
|
||||
Text(instruction)
|
||||
Text(instruction.item)
|
||||
}.padding(4)
|
||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||
.onTapGesture {
|
||||
@@ -571,8 +691,8 @@ fileprivate struct RecipeToolSection: View {
|
||||
SecondaryLabel(text: "Tools")
|
||||
Spacer()
|
||||
}
|
||||
EditableStringList(items: $viewModel.recipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) {
|
||||
RecipeListSection(list: viewModel.recipeDetail.tool)
|
||||
EditableStringList(items: $viewModel.observableRecipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) {
|
||||
RecipeListSection(list: ReorderableItem.items(viewModel.observableRecipeDetail.tool))
|
||||
}
|
||||
}.padding()
|
||||
}
|
||||
@@ -601,31 +721,24 @@ fileprivate struct EditableText: View {
|
||||
|
||||
|
||||
fileprivate struct EditableStringList<Content: View>: View {
|
||||
@Binding var items: [String]
|
||||
@Binding var items: [ReorderableItem<String>]
|
||||
@Binding var editMode: Bool
|
||||
@State var titleKey: LocalizedStringKey = ""
|
||||
@State var lineLimit: ClosedRange<Int> = 0...50
|
||||
@State var axis: Axis = .vertical
|
||||
|
||||
@State var editableItems: [ReorderableItem<String>] = []
|
||||
|
||||
var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if editMode {
|
||||
VStack {
|
||||
ReorderableForEach(items: $editableItems, defaultItem: ReorderableItem(item: "")) { ix, item in
|
||||
TextField("", text: $editableItems[ix].item, axis: axis)
|
||||
ReorderableForEach(items: $items, defaultItem: ReorderableItem(item: "")) { ix, item in
|
||||
TextField("", text: $items[ix].item, axis: axis)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(lineLimit)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
editableItems = ReorderableItem.list(items: items)
|
||||
}
|
||||
.onDisappear {
|
||||
items = ReorderableItem.items(editableItems)
|
||||
}
|
||||
.transition(.slide)
|
||||
} else {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// BottomClipper.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 27.02.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct BottomClipper: Shape {
|
||||
let bottom: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Rectangle().path(in: CGRect(x: 0, y: rect.size.height - bottom, width: rect.size.width, height: bottom))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// ParallaxHeaderView.swift
|
||||
// Nextcloud Cookbook iOS Client
|
||||
//
|
||||
// Created by Vincent Meilinger on 26.02.24.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct ParallaxHeader<Content: View, Space: Hashable>: View {
|
||||
let content: () -> Content
|
||||
let coordinateSpace: Space
|
||||
let defaultHeight: CGFloat
|
||||
|
||||
init(
|
||||
coordinateSpace: Space,
|
||||
defaultHeight: CGFloat,
|
||||
@ViewBuilder _ content: @escaping () -> Content
|
||||
) {
|
||||
self.content = content
|
||||
self.coordinateSpace = coordinateSpace
|
||||
self.defaultHeight = defaultHeight
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
let offset = offset(for: proxy)
|
||||
let heightModifier = heightModifier(for: proxy)
|
||||
let blurRadius = min(
|
||||
heightModifier / 20,
|
||||
max(10, heightModifier / 20)
|
||||
)
|
||||
content()
|
||||
.edgesIgnoringSafeArea(.horizontal)
|
||||
.frame(
|
||||
width: proxy.size.width,
|
||||
height: proxy.size.height + heightModifier
|
||||
)
|
||||
.offset(y: offset)
|
||||
.blur(radius: blurRadius)
|
||||
}.frame(height: defaultHeight)
|
||||
}
|
||||
|
||||
|
||||
private func offset(for proxy: GeometryProxy) -> CGFloat {
|
||||
let frame = proxy.frame(in: .named(coordinateSpace))
|
||||
if frame.minY < 0 {
|
||||
return -frame.minY * 0.8
|
||||
}
|
||||
return -frame.minY
|
||||
}
|
||||
|
||||
private func heightModifier(for proxy: GeometryProxy) -> CGFloat {
|
||||
let frame = proxy.frame(in: .named(coordinateSpace))
|
||||
return max(0, frame.minY)
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,6 @@ struct ReorderableForEach<Item: Any, Content: View>: View {
|
||||
} label: {
|
||||
Text(allowDeletion ? "Disable deletion" : "Enable deletion")
|
||||
.bold()
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.tint(Color.red)
|
||||
Spacer()
|
||||
@@ -68,11 +66,11 @@ struct ReorderableForEach<Item: Any, Content: View>: View {
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.bold()
|
||||
.padding(.vertical, 3)
|
||||
.padding(.vertical, 2)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}.padding(.top, 3)
|
||||
}.animation(.default, value: allowDeletion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,14 @@ struct RecipeTabView: View {
|
||||
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
|
||||
SettingsView()
|
||||
}
|
||||
.navigationDestination(isPresented: $viewModel.presentEditView) {
|
||||
RecipeView(viewModel: RecipeView.ViewModel())
|
||||
}
|
||||
} detail: {
|
||||
NavigationStack {
|
||||
if let category = viewModel.selectedCategory {
|
||||
RecipeListView(
|
||||
categoryName: category.name,
|
||||
viewModel: mainViewModel,
|
||||
showEditView: $viewModel.presentEditView
|
||||
)
|
||||
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
||||
@@ -63,7 +65,8 @@ struct RecipeTabView: View {
|
||||
}
|
||||
}
|
||||
.tint(.nextcloudBlue)
|
||||
.sheet(isPresented: $viewModel.presentEditView) {
|
||||
|
||||
/*.sheet(isPresented: $viewModel.presentEditView) {
|
||||
RecipeEditView(
|
||||
viewModel:
|
||||
RecipeEditViewModel(
|
||||
@@ -72,7 +75,7 @@ struct RecipeTabView: View {
|
||||
),
|
||||
isPresented: $viewModel.presentEditView
|
||||
)
|
||||
}
|
||||
}*/
|
||||
.task {
|
||||
viewModel.serverConnection = await mainViewModel.checkServerConnection()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import SimilaritySearchKit
|
||||
|
||||
struct SearchTabView: View {
|
||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||
@EnvironmentObject var mainViewModel: AppState
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -27,7 +27,7 @@ struct SearchTabView: View {
|
||||
LazyVStack {
|
||||
ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||
NavigationLink(value: recipe) {
|
||||
RecipeCardView(viewModel: mainViewModel, recipe: recipe)
|
||||
RecipeCardView(recipe: recipe)
|
||||
.shadow(radius: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -35,7 +35,7 @@ struct SearchTabView: View {
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(appState: mainViewModel, viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
}
|
||||
@@ -43,11 +43,11 @@ struct SearchTabView: View {
|
||||
}
|
||||
.task {
|
||||
if viewModel.allRecipes.isEmpty {
|
||||
viewModel.allRecipes = await mainViewModel.getRecipes()
|
||||
viewModel.allRecipes = await appState.getRecipes()
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
viewModel.allRecipes = await mainViewModel.getRecipes()
|
||||
viewModel.allRecipes = await appState.getRecipes()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UTImportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIcons</key>
|
||||
<dict/>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.cookbook-client.uuid</string>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict/>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||