Updated RecipeView

This commit is contained in:
VincentMeilinger
2024-03-01 14:17:24 +01:00
parent d3e0366ce6
commit 744ea76a34
41 changed files with 588 additions and 383 deletions

View File

@@ -42,7 +42,7 @@
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; }; A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.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 */; }; A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.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 */; }; A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; }; A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; }; 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 */; }; A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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; }; 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 */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
A70171C52AB4C43A00064C43 /* DataModels.swift */, A70171C52AB4C43A00064C43 /* DataModels.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -352,6 +359,7 @@
A70171BD2AB4987900064C43 /* RecipeListView.swift */, A70171BD2AB4987900064C43 /* RecipeListView.swift */,
A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeView.swift */, A70171BF2AB498A900064C43 /* RecipeView.swift */,
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
A97B4D342B80B82A00EC1A88 /* ShareView.swift */, A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
); );
@@ -362,8 +370,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, A7F3F8E92ACC221C0076C227 /* CategoryPickerViewOld.swift */,
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
); );
path = RecipeEditing; path = RecipeEditing;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -371,8 +378,10 @@
A9C3BE522B630F1300562C79 /* ReusableViews */ = { A9C3BE522B630F1300562C79 /* ReusableViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A95364662B7E89F1001018B0 /* ReorderableForEach.swift */, A95364662B7E89F1001018B0 /* ReorderableForEach.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
); );
path = ReusableViews; path = ReusableViews;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -538,8 +547,10 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */, A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */, A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
@@ -554,6 +565,7 @@
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */, A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */, A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */, A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
@@ -562,7 +574,7 @@
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */, A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, A7F3F8EA2ACC221C0076C227 /* CategoryPickerViewOld.swift in Sources */,
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */, A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,

View File

@@ -18,6 +18,7 @@ import UIKit
var recipeImages: [Int: [String: UIImage]] = [:] var recipeImages: [Int: [String: UIImage]] = [:]
var imagesNeedUpdate: [Int: [String: Bool]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:]
var lastUpdates: [String: Date] = [:] var lastUpdates: [String: Date] = [:]
var allKeywords: [RecipeKeyword] = []
private let dataStore: DataStore private let dataStore: DataStore

View File

@@ -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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "cookbook-icon.png", "filename" : "Hintergrund-1024.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -18,16 +18,12 @@ struct Category: Codable {
} }
} }
extension Category: Identifiable, Hashable { extension Category: Identifiable, Hashable {
var id: String { name } var id: String { name }
} }
// MARK: - Login flow // MARK: - Login flow
struct LoginV2Request: Codable { struct LoginV2Request: Codable {

View File

@@ -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
)
}
}

View File

@@ -146,7 +146,8 @@ struct RecipeKeyword: Codable {
enum Nutrition: CaseIterable { enum Nutrition: CaseIterable {
case calories, case servingSize,
calories,
carbohydrateContent, carbohydrateContent,
cholesterolContent, cholesterolContent,
fatContent, fatContent,
@@ -158,35 +159,39 @@ enum Nutrition: CaseIterable {
sodiumContent, sodiumContent,
sugarContent sugarContent
var localizedDescription: LocalizedStringKey { var localizedDescription: String {
switch self { switch self {
case .servingSize:
return NSLocalizedString("Serving size", comment: "Serving size")
case .calories: case .calories:
"Calories" return NSLocalizedString("Calories", comment: "Calories")
case .carbohydrateContent: case .carbohydrateContent:
"Carbohydrate content" return NSLocalizedString("Carbohydrate content", comment: "Carbohydrate content")
case .cholesterolContent: case .cholesterolContent:
"Cholesterol content" return NSLocalizedString("Cholesterol content", comment: "Cholesterol content")
case .fatContent: case .fatContent:
"Fat content" return NSLocalizedString("Fat content", comment: "Fat content")
case .saturatedFatContent: case .saturatedFatContent:
"Saturated fat content" return NSLocalizedString("Saturated fat content", comment: "Saturated fat content")
case .unsaturatedFatContent: case .unsaturatedFatContent:
"Unsaturated fat content" return NSLocalizedString("Unsaturated fat content", comment: "Unsaturated fat content")
case .transFatContent: case .transFatContent:
"Trans fat content" return NSLocalizedString("Trans fat content", comment: "Trans fat content")
case .fiberContent: case .fiberContent:
"Fiber content" return NSLocalizedString("Fiber content", comment: "Fiber content")
case .proteinContent: case .proteinContent:
"Protein content" return NSLocalizedString("Protein content", comment: "Protein content")
case .sodiumContent: case .sodiumContent:
"Sodium content" return NSLocalizedString("Sodium content", comment: "Sodium content")
case .sugarContent: case .sugarContent:
"Sugar content" return NSLocalizedString("Sugar content", comment: "Sugar content")
} }
} }
var dictKey: String { var dictKey: String {
switch self { switch self {
case .servingSize:
"servingSize"
case .calories: case .calories:
"calories" "calories"
case .carbohydrateContent: case .carbohydrateContent:

View File

@@ -111,6 +111,16 @@
} }
} }
}, },
"%@: %@" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "%1$@: %2$@"
}
}
}
},
"%lld" : { "%lld" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "%lld h, %lld min" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -559,7 +579,7 @@
} }
}, },
"Calories" : { "Calories" : {
"comment" : "Calories"
}, },
"Cancel" : { "Cancel" : {
"localizations" : { "localizations" : {
@@ -584,7 +604,7 @@
} }
}, },
"Carbohydrate content" : { "Carbohydrate content" : {
"comment" : "Carbohydrate content"
}, },
"Category" : { "Category" : {
"localizations" : { "localizations" : {
@@ -631,7 +651,7 @@
} }
}, },
"Cholesterol content" : { "Cholesterol content" : {
"comment" : "Cholesterol content"
}, },
"Configure what is stored on your device." : { "Configure what is stored on your device." : {
"localizations" : { "localizations" : {
@@ -1152,6 +1172,9 @@
} }
} }
} }
},
"Edit keywords" : {
}, },
"Enable deletion" : { "Enable deletion" : {
@@ -1267,10 +1290,10 @@
} }
}, },
"Fat content" : { "Fat content" : {
"comment" : "Fat content"
}, },
"Fiber content" : { "Fiber content" : {
"comment" : "Fiber content"
}, },
"General" : { "General" : {
"localizations" : { "localizations" : {
@@ -1636,9 +1659,6 @@
} }
} }
} }
},
"Keyword" : {
}, },
"Keywords" : { "Keywords" : {
"localizations" : { "localizations" : {
@@ -1971,6 +1991,9 @@
} }
} }
} }
},
"New Recipe" : {
}, },
"Nextcloud Login" : { "Nextcloud Login" : {
"localizations" : { "localizations" : {
@@ -2347,7 +2370,7 @@
} }
}, },
"Protein content" : { "Protein content" : {
"comment" : "Protein content"
}, },
"Recipe" : { "Recipe" : {
"localizations" : { "localizations" : {
@@ -2370,6 +2393,9 @@
} }
} }
} }
},
"Recipe Name" : {
}, },
"Recipes" : { "Recipes" : {
"localizations" : { "localizations" : {
@@ -2438,7 +2464,7 @@
} }
}, },
"Saturated fat content" : { "Saturated fat content" : {
"comment" : "Saturated fat content"
}, },
"Search" : { "Search" : {
"localizations" : { "localizations" : {
@@ -2527,6 +2553,9 @@
} }
} }
} }
},
"Select Item" : {
}, },
"Selected keywords:" : { "Selected keywords:" : {
"localizations" : { "localizations" : {
@@ -2550,6 +2579,9 @@
} }
} }
}, },
"Serving size" : {
"comment" : "Serving size"
},
"Servings:" : { "Servings:" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2683,7 +2715,7 @@
} }
}, },
"Sodium content" : { "Sodium content" : {
"comment" : "Sodium content"
}, },
"Store recipe images locally" : { "Store recipe images locally" : {
"localizations" : { "localizations" : {
@@ -2752,7 +2784,7 @@
} }
}, },
"Sugar content" : { "Sugar content" : {
"comment" : "Sugar content"
}, },
"Support" : { "Support" : {
"localizations" : { "localizations" : {
@@ -3090,7 +3122,7 @@
} }
}, },
"Trans fat content" : { "Trans fat content" : {
"comment" : "Trans fat content"
}, },
"Unable to complete action." : { "Unable to complete action." : {
"localizations" : { "localizations" : {
@@ -3181,7 +3213,7 @@
} }
}, },
"Unsaturated fat content" : { "Unsaturated fat content" : {
"comment" : "Unsaturated fat content"
}, },
"Upload" : { "Upload" : {
"localizations" : { "localizations" : {

View File

@@ -11,7 +11,6 @@ import SwiftUI
@main @main
struct Nextcloud_Cookbook_iOS_ClientApp: App { struct Nextcloud_Cookbook_iOS_ClientApp: App {
@StateObject var mainViewModel = AppState()
@AppStorage("onboarding") var onboarding = true @AppStorage("onboarding") var onboarding = true
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en" @AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
@@ -21,7 +20,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
if onboarding { if onboarding {
OnboardingView() OnboardingView()
} else { } else {
MainView(viewModel: mainViewModel) MainView()
} }
} }
.transition(.slide) .transition(.slide)

View File

@@ -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 { static func fromPTString(_ PTRepresentation: String) -> DurationComponents {
let duration = DurationComponents() let duration = DurationComponents()
let hourRegex = /([0-9]{1,2})H/ let hourRegex = /([0-9]{1,2})H/
@@ -86,21 +101,6 @@ class DurationComponents: ObservableObject {
return "PT\(hourComponent)H\(minuteComponent)M00S" 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 { func toTimerText() -> String {
var timeString = "" var timeString = ""
if hourComponent != "00" { if hourComponent != "00" {
@@ -152,4 +152,5 @@ class DurationComponents: ObservableObject {
return nil return nil
} }
} }
} }

View File

@@ -9,7 +9,7 @@ import SwiftUI
import SimilaritySearchKit import SimilaritySearchKit
struct MainView: View { struct MainView: View {
@StateObject var viewModel = AppState() @StateObject var appState = AppState()
@StateObject var groceryList = GroceryList() @StateObject var groceryList = GroceryList()
// Tab ViewModels // Tab ViewModels
@@ -24,7 +24,7 @@ struct MainView: View {
TabView { TabView {
RecipeTabView() RecipeTabView()
.environmentObject(recipeViewModel) .environmentObject(recipeViewModel)
.environmentObject(viewModel) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.tabItem { .tabItem {
Label("Recipes", systemImage: "book.closed.fill") Label("Recipes", systemImage: "book.closed.fill")
@@ -33,7 +33,7 @@ struct MainView: View {
SearchTabView() SearchTabView()
.environmentObject(searchViewModel) .environmentObject(searchViewModel)
.environmentObject(viewModel) .environmentObject(appState)
.environmentObject(groceryList) .environmentObject(groceryList)
.tabItem { .tabItem {
Label("Search", systemImage: "magnifyingglass") Label("Search", systemImage: "magnifyingglass")
@@ -53,12 +53,12 @@ struct MainView: View {
} }
.task { .task {
recipeViewModel.presentLoadingIndicator = true recipeViewModel.presentLoadingIndicator = true
await viewModel.getCategories() await appState.getCategories()
await viewModel.updateAllRecipeDetails() await appState.updateAllRecipeDetails()
// Open detail view for default category // Open detail view for default category
if UserSettings.shared.defaultCategory != "" { 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 { if c.name == UserSettings.shared.defaultCategory {
return true return true
} }

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct CategoryPickerView: View { struct CategoryPickerViewOld: View {
@State var title: String @State var title: String
@State var searchSuggestions: [String] @State var searchSuggestions: [String]
@Binding var selection: String @Binding var selection: String

View File

@@ -132,7 +132,7 @@ struct RecipeEditView: View {
Section() { Section() {
NavigationLink(viewModel.recipe.recipeCategory == "" ? "Category" : "Category: \(viewModel.recipe.recipeCategory)") { NavigationLink(viewModel.recipe.recipeCategory == "" ? "Category" : "Category: \(viewModel.recipe.recipeCategory)") {
CategoryPickerView( CategoryPickerViewOld(
title: "Category", title: "Category",
searchSuggestions: viewModel.mainViewModel.categories.map({ category in searchSuggestions: viewModel.mainViewModel.categories.map({ category in
category.name == "*" ? "Other" : category.name category.name == "*" ? "Other" : category.name

View File

@@ -11,6 +11,7 @@ import SwiftUI
struct KeywordPickerView: View { struct KeywordPickerView: View {
@Environment(\.presentationMode) var presentationMode
@State var title: String @State var title: String
@State var searchSuggestions: [RecipeKeyword] @State var searchSuggestions: [RecipeKeyword]
@Binding var selection: [String] @Binding var selection: [String]
@@ -20,6 +21,14 @@ struct KeywordPickerView: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack {
Spacer()
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("Done")
}.padding()
}
TextField(title, text: $searchText) TextField(title, text: $searchText)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.padding() .padding()

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI import SwiftUI
struct RecipeCardView: View { struct RecipeCardView: View {
@State var viewModel: AppState @EnvironmentObject var appState: AppState
@State var recipe: Recipe @State var recipe: Recipe
@State var recipeThumb: UIImage? @State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil @State var isDownloaded: Bool? = nil
@@ -50,18 +50,18 @@ struct RecipeCardView: View {
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 17))
.padding(.horizontal) .padding(.horizontal)
.task { .task {
recipeThumb = await viewModel.getImage( recipeThumb = await appState.getImage(
id: recipe.recipe_id, id: recipe.recipe_id,
size: .THUMB, size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
) )
if recipe.storedLocally == nil { if recipe.storedLocally == nil {
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
} }
isDownloaded = recipe.storedLocally isDownloaded = recipe.storedLocally
} }
.refreshable { .refreshable {
recipeThumb = await viewModel.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

View File

@@ -11,9 +11,9 @@ import SwiftUI
struct RecipeListView: View { struct RecipeListView: View {
@EnvironmentObject var appState: AppState
@State var categoryName: String @State var categoryName: String
@State var searchText: String = "" @State var searchText: String = ""
@ObservedObject var viewModel: AppState
@Binding var showEditView: Bool @Binding var showEditView: Bool
@State var selectedRecipe: Recipe? = nil @State var selectedRecipe: Recipe? = nil
@State var presentRecipeView: Bool = false @State var presentRecipeView: Bool = false
@@ -23,7 +23,7 @@ struct RecipeListView: View {
LazyVStack { LazyVStack {
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe) RecipeCardView(recipe: recipe)
.shadow(radius: 2) .shadow(radius: 2)
} }
@@ -36,7 +36,7 @@ struct RecipeListView: View {
} }
} }
.navigationDestination(for: Recipe.self) { recipe in .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) .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.toolbar { .toolbar {
@@ -51,13 +51,13 @@ struct RecipeListView: View {
} }
.searchable(text: $searchText, prompt: "Search recipes/keywords") .searchable(text: $searchText, prompt: "Search recipes/keywords")
.task { .task {
await viewModel.getCategory( await appState.getCategory(
named: categoryName, named: categoryName,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) )
} }
.refreshable { .refreshable {
await viewModel.getCategory( await appState.getCategory(
named: categoryName, named: categoryName,
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
) )
@@ -65,7 +65,7 @@ struct RecipeListView: View {
} }
func recipesFiltered() -> [Recipe] { func recipesFiltered() -> [Recipe] {
guard let recipes = viewModel.recipes[categoryName] else { return [] } guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes } guard searchText != "" else { return recipes }
return recipes.filter { recipe in return recipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term

View File

@@ -10,71 +10,85 @@ import SwiftUI
struct RecipeView: View { struct RecipeView: View {
@ObservedObject var appState: AppState @Environment(\.presentationMode) var presentationMode
@EnvironmentObject var appState: AppState
@StateObject var viewModel: ViewModel @StateObject var viewModel: ViewModel
@State var imageHeight: CGFloat = 350
private enum CoordinateSpaces {
case scrollView
}
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading) { VStack(spacing: 0) {
ZStack { ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage { if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage) Image(uiImage: recipeImage)
.resizable() .resizable()
.scaledToFill() .scaledToFill()
.frame(maxHeight: 300)
.clipped()
} }
}.animation(.easeInOut, value: viewModel.recipeImage) }
VStack(alignment: .leading) {
LazyVStack (alignment: .leading) {
HStack { HStack {
EditableText(text: $viewModel.recipeDetail.name, editMode: $viewModel.editMode) EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title) .font(.title)
.bold() .bold()
.padding()
.onDisappear { Spacer()
viewModel.showTitle = true
}
.onAppear {
viewModel.showTitle = false
}
if let isDownloaded = viewModel.isDownloaded { if let isDownloaded = viewModel.isDownloaded {
Spacer()
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down") Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding()
}
} }
}.padding([.top, .horizontal])
if viewModel.recipeDetail.description != "" || viewModel.editMode { if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipeDetail.description, editMode: $viewModel.editMode, lineLimit: 0...10, axis: .vertical) EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.padding([.bottom, .horizontal]) .padding([.bottom, .horizontal])
} }
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider() Divider()
RecipeDurationSection(viewModel: appState, recipeDetail: viewModel.recipeDetail) if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) { 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) 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) 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) RecipeToolSection(viewModel: viewModel)
} }
RecipeNutritionSection(viewModel: viewModel) RecipeNutritionSection(viewModel: viewModel)
if !viewModel.editMode {
RecipeKeywordSection(viewModel: viewModel) RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(recipeDetail: viewModel.recipeDetail)
} }
MoreInformationSection(viewModel: viewModel)
}.padding(.horizontal, 5)
} }
} }
.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) .navigationBarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "") .navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar { .toolbar {
@@ -87,9 +101,13 @@ struct RecipeView: View {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button("Done") { Button("Done") {
// TODO: POST edited recipe // TODO: POST edited recipe
if viewModel.newRecipe {
presentationMode.wrappedValue.dismiss()
} else {
viewModel.editMode = false viewModel.editMode = false
} }
} }
}
} else { } else {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Menu { Menu {
@@ -116,36 +134,44 @@ struct RecipeView: View {
} }
} }
.sheet(isPresented: $viewModel.presentShareSheet) { .sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.recipeDetail, ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage, recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet) presentShareSheet: $viewModel.presentShareSheet)
} }
.task { .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, id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? RecipeDetail.error ) ?? 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( viewModel.recipeImage = await appState.getImage(
id: viewModel.recipe.recipe_id, id: viewModel.recipe.recipe_id,
size: .FULL, size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
) )
if viewModel.recipe.storedLocally == nil { if let image = viewModel.recipeImage {
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id) 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 { .onAppear {
if UserSettings.shared.keepScreenAwake { if UserSettings.shared.keepScreenAwake {
@@ -155,21 +181,29 @@ struct RecipeView: View {
.onDisappear { .onDisappear {
UIApplication.shared.isIdleTimerDisabled = false 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 // MARK: - RecipeView ViewModel
class ViewModel: ObservableObject { class ViewModel: ObservableObject {
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
@Published var recipeDetail: RecipeDetail = RecipeDetail.error @Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var recipeImage: UIImage? = nil @Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false @Published var editMode: Bool = false
@Published var presentShareSheet: Bool = false @Published var presentShareSheet: Bool = false
@Published var showTitle: Bool = false @Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil @Published var isDownloaded: Bool? = nil
var newRecipe: Bool = false
@Published var keywords: [String] = []
@Published var nutrition: [String] = []
var recipe: Recipe var recipe: Recipe
var sharedURL: URL? = nil var sharedURL: URL? = nil
@@ -179,62 +213,152 @@ struct RecipeView: View {
self.recipe = recipe 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) { 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 // MARK: - Duration Section
fileprivate struct RecipeDurationSection: View { fileprivate struct RecipeDurationSection: View {
@ObservedObject var viewModel: AppState @EnvironmentObject var appState: AppState
@State var recipeDetail: RecipeDetail @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 { 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) { VStack(alignment: .leading) {
HStack { HStack {
SecondaryLabel(text: LocalizedStringKey("Preparation")) SecondaryLabel(text: title)
Spacer() Spacer()
} }
HStack {
Image(systemName: "clock")
.foregroundStyle(.secondary)
Text(time) Text(time)
.lineLimit(1) .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() .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 // MARK: - Nutrition Section
@@ -244,7 +368,7 @@ fileprivate struct RecipeNutritionSection: View {
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
Group { VStack(alignment: .leading) {
if viewModel.editMode { if viewModel.editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack { HStack {
@@ -254,15 +378,12 @@ fileprivate struct RecipeNutritionSection: View {
.lineLimit(1) .lineLimit(1)
} }
} }
} else { } else if !nutritionEmpty() {
if !viewModel.recipeDetail.nutrition.isEmpty {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in 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) { HStack(alignment: .top) {
Text(nutrition.localizedDescription) Text("\(nutrition.localizedDescription): \(value)")
Text(":")
Text(value)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
.padding(4) .padding(4)
@@ -273,10 +394,9 @@ fileprivate struct RecipeNutritionSection: View {
Text(LocalizedStringKey("No nutritional information.")) Text(LocalizedStringKey("No nutritional information."))
} }
} }
}
} title: { } title: {
HStack { HStack {
if let servingSize = viewModel.recipeDetail.nutrition["servingSize"] { if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))") SecondaryLabel(text: "Nutrition (\(servingSize))")
} else { } else {
SecondaryLabel(text: LocalizedStringKey("Nutrition")) SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -289,10 +409,19 @@ fileprivate struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> { func binding(for key: String) -> Binding<String> {
Binding( Binding(
get: { viewModel.recipeDetail.nutrition[key, default: ""] }, get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] },
set: { viewModel.recipeDetail.nutrition[key] = $0 } 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 { fileprivate struct RecipeKeywordSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel @ObservedObject var viewModel: RecipeView.ViewModel
@State var keywords: [String] = [] let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
var body: some View { var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) { CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group { Group {
if !keywords.isEmpty || viewModel.editMode { if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
//RecipeListSection(list: keywords) RecipeListSection(list: viewModel.observableRecipeDetail.keywords)
EditableStringList(items: $keywords, editMode: $viewModel.editMode, titleKey: "Keyword", lineLimit: 0...1, axis: .horizontal) {
RecipeListSection(list: keywords)
}
} else { } else {
Text(LocalizedStringKey("No keywords.")) Text(LocalizedStringKey("No keywords."))
} }
} }
.onAppear {
self.keywords = viewModel.recipeDetail.keywords.components(separatedBy: ",")
}
.onDisappear {
viewModel.recipeDetail.keywords = keywords.joined(separator: ",")
}
} title: { } title: {
HStack { HStack {
SecondaryLabel(text: LocalizedStringKey("Keywords")) SecondaryLabel(text: LocalizedStringKey("Keywords"))
@@ -334,18 +454,18 @@ fileprivate struct RecipeKeywordSection: View {
// MARK: - More Information Section // MARK: - More Information Section
fileprivate struct MoreInformationSection: View { fileprivate struct MoreInformationSection: View {
let recipeDetail: RecipeDetail @ObservedObject var viewModel: RecipeView.ViewModel
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) {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")") Text("Created: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateCreated) ?? "")")
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")") Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: viewModel.recipeDetail.dateModified) ?? "")")
if recipeDetail.url != "", let url = URL(string: recipeDetail.url) { if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) {
HStack() { HStack() {
Text("URL:") Text("URL:")
Link(destination: url) { Link(destination: url) {
Text(recipeDetail.url) Text(viewModel.observableRecipeDetail.url)
} }
} }
} }
@@ -402,23 +522,23 @@ fileprivate struct RecipeIngredientSection: View {
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack { HStack {
if viewModel.recipeDetail.recipeYield == 0 { if viewModel.observableRecipeDetail.recipeYield == 0 {
SecondaryLabel(text: LocalizedStringKey("Ingredients")) SecondaryLabel(text: LocalizedStringKey("Ingredients"))
} else if viewModel.recipeDetail.recipeYield == 1 { } else if viewModel.observableRecipeDetail.recipeYield == 1 {
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving")) SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
} else { } else {
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.recipeDetail.recipeYield) servings")) SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings"))
} }
Spacer() Spacer()
Button { Button {
withAnimation { withAnimation {
if groceryList.containsRecipe(viewModel.recipeDetail.id) { if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
groceryList.deleteGroceryRecipe(viewModel.recipeDetail.id) groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
} else { } else {
groceryList.addItems( groceryList.addItems(
viewModel.recipeDetail.recipeIngredient, ReorderableItem.items(viewModel.observableRecipeDetail.recipeIngredient),
toRecipe: viewModel.recipeDetail.id, toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.recipeDetail.name 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) { EditableStringList(items: $viewModel.observableRecipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) {
ForEach(0..<viewModel.recipeDetail.recipeIngredient.count, id: \.self) { ix in ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(ingredient: viewModel.recipeDetail.recipeIngredient[ix], recipeId: viewModel.recipeDetail.id) { IngredientListItem(ingredient: viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) {
groceryList.addItem( groceryList.addItem(
viewModel.recipeDetail.recipeIngredient[ix], viewModel.observableRecipeDetail.recipeIngredient[ix].item,
toRecipe: viewModel.recipeDetail.id, toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.recipeDetail.name recipeName: viewModel.observableRecipeDetail.name
) )
} }
.padding(4) .padding(4)
@@ -449,7 +569,7 @@ fileprivate struct RecipeIngredientSection: View {
fileprivate struct IngredientListItem: View { fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList @EnvironmentObject var groceryList: GroceryList
@State var ingredient: String @State var ingredient: ReorderableItem<String>
@State var recipeId: String @State var recipeId: String
let addToGroceryListAction: () -> Void let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false @State var isSelected: Bool = false
@@ -461,7 +581,7 @@ fileprivate struct IngredientListItem: View {
var body: some View { var body: some View {
HStack(alignment: .top) { HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient) { if groceryList.containsItem(at: recipeId, item: ingredient.item) {
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
Image(systemName: "storefront") Image(systemName: "storefront")
.foregroundStyle(Color.green) .foregroundStyle(Color.green)
@@ -476,7 +596,7 @@ fileprivate struct IngredientListItem: View {
Image(systemName: "circle") Image(systemName: "circle")
} }
Text("\(ingredient)") Text("\(ingredient.item)")
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(5) .lineLimit(5)
Spacer() Spacer()
@@ -502,8 +622,8 @@ 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 groceryList.containsItem(at: recipeId, item: ingredient) { if groceryList.containsItem(at: recipeId, item: ingredient.item) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId) groceryList.deleteItem(ingredient.item, fromRecipe: recipeId)
} else { } else {
addToGroceryListAction() addToGroceryListAction()
} }
@@ -531,9 +651,9 @@ fileprivate struct RecipeInstructionSection: View {
SecondaryLabel(text: LocalizedStringKey("Instructions")) SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer() Spacer()
} }
EditableStringList(items: $viewModel.recipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) { EditableStringList(items: $viewModel.observableRecipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) {
ForEach(0..<viewModel.recipeDetail.recipeInstructions.count, id: \.self) { ix in ForEach(0..<viewModel.observableRecipeDetail.recipeInstructions.count, id: \.self) { ix in
RecipeInstructionListItem(instruction: viewModel.recipeDetail.recipeInstructions[ix], index: ix+1) RecipeInstructionListItem(instruction: viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
} }
} }
}.padding() }.padding()
@@ -541,7 +661,7 @@ fileprivate struct RecipeInstructionSection: View {
} }
fileprivate struct RecipeInstructionListItem: View { fileprivate struct RecipeInstructionListItem: View {
@State var instruction: String @State var instruction: ReorderableItem<String>
@State var index: Int @State var index: Int
@State var isSelected: Bool = false @State var isSelected: Bool = false
@@ -549,7 +669,7 @@ fileprivate struct RecipeInstructionListItem: View {
HStack(alignment: .top) { HStack(alignment: .top) {
Text("\(index)") Text("\(index)")
.monospaced() .monospaced()
Text(instruction) Text(instruction.item)
}.padding(4) }.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary) .foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture { .onTapGesture {
@@ -571,8 +691,8 @@ fileprivate struct RecipeToolSection: View {
SecondaryLabel(text: "Tools") SecondaryLabel(text: "Tools")
Spacer() Spacer()
} }
EditableStringList(items: $viewModel.recipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) { EditableStringList(items: $viewModel.observableRecipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) {
RecipeListSection(list: viewModel.recipeDetail.tool) RecipeListSection(list: ReorderableItem.items(viewModel.observableRecipeDetail.tool))
} }
}.padding() }.padding()
} }
@@ -601,31 +721,24 @@ fileprivate struct EditableText: View {
fileprivate struct EditableStringList<Content: View>: View { fileprivate struct EditableStringList<Content: View>: View {
@Binding var items: [String] @Binding var items: [ReorderableItem<String>]
@Binding var editMode: Bool @Binding var editMode: Bool
@State var titleKey: LocalizedStringKey = "" @State var titleKey: LocalizedStringKey = ""
@State var lineLimit: ClosedRange<Int> = 0...50 @State var lineLimit: ClosedRange<Int> = 0...50
@State var axis: Axis = .vertical @State var axis: Axis = .vertical
@State var editableItems: [ReorderableItem<String>] = []
var content: () -> Content var content: () -> Content
var body: some View { var body: some View {
if editMode { if editMode {
VStack { VStack {
ReorderableForEach(items: $editableItems, defaultItem: ReorderableItem(item: "")) { ix, item in ReorderableForEach(items: $items, defaultItem: ReorderableItem(item: "")) { ix, item in
TextField("", text: $editableItems[ix].item, axis: axis) TextField("", text: $items[ix].item, axis: axis)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.lineLimit(lineLimit) .lineLimit(lineLimit)
} }
} }
.onAppear { .transition(.slide)
editableItems = ReorderableItem.list(items: items)
}
.onDisappear {
items = ReorderableItem.items(editableItems)
}
} else { } else {
content() content()
} }

View File

@@ -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))
}
}

View File

@@ -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)
}
}

View File

@@ -58,8 +58,6 @@ struct ReorderableForEach<Item: Any, Content: View>: View {
} label: { } label: {
Text(allowDeletion ? "Disable deletion" : "Enable deletion") Text(allowDeletion ? "Disable deletion" : "Enable deletion")
.bold() .bold()
.padding(.vertical, 3)
.padding(.horizontal)
} }
.tint(Color.red) .tint(Color.red)
Spacer() Spacer()
@@ -68,11 +66,11 @@ struct ReorderableForEach<Item: Any, Content: View>: View {
} label: { } label: {
Image(systemName: "plus") Image(systemName: "plus")
.bold() .bold()
.padding(.vertical, 3) .padding(.vertical, 2)
.padding(.horizontal) .padding(.horizontal)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
} }.padding(.top, 3)
}.animation(.default, value: allowDeletion) }.animation(.default, value: allowDeletion)
} }
} }

View File

@@ -50,12 +50,14 @@ struct RecipeTabView: View {
.navigationDestination(isPresented: $viewModel.presentSettingsView) { .navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView() SettingsView()
} }
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
}
} detail: { } detail: {
NavigationStack { NavigationStack {
if let category = viewModel.selectedCategory { if let category = viewModel.selectedCategory {
RecipeListView( RecipeListView(
categoryName: category.name, categoryName: category.name,
viewModel: mainViewModel,
showEditView: $viewModel.presentEditView showEditView: $viewModel.presentEditView
) )
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes .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) .tint(.nextcloudBlue)
.sheet(isPresented: $viewModel.presentEditView) {
/*.sheet(isPresented: $viewModel.presentEditView) {
RecipeEditView( RecipeEditView(
viewModel: viewModel:
RecipeEditViewModel( RecipeEditViewModel(
@@ -72,7 +75,7 @@ struct RecipeTabView: View {
), ),
isPresented: $viewModel.presentEditView isPresented: $viewModel.presentEditView
) )
} }*/
.task { .task {
viewModel.serverConnection = await mainViewModel.checkServerConnection() viewModel.serverConnection = await mainViewModel.checkServerConnection()
} }

View File

@@ -11,7 +11,7 @@ import SimilaritySearchKit
struct SearchTabView: View { struct SearchTabView: View {
@EnvironmentObject var viewModel: SearchTabView.ViewModel @EnvironmentObject var viewModel: SearchTabView.ViewModel
@EnvironmentObject var mainViewModel: AppState @EnvironmentObject var appState: AppState
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -27,7 +27,7 @@ struct SearchTabView: View {
LazyVStack { LazyVStack {
ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: mainViewModel, recipe: recipe) RecipeCardView(recipe: recipe)
.shadow(radius: 2) .shadow(radius: 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -35,7 +35,7 @@ struct SearchTabView: View {
} }
} }
.navigationDestination(for: Recipe.self) { recipe in .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") .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
} }
@@ -43,11 +43,11 @@ struct SearchTabView: View {
} }
.task { .task {
if viewModel.allRecipes.isEmpty { if viewModel.allRecipes.isEmpty {
viewModel.allRecipes = await mainViewModel.getRecipes() viewModel.allRecipes = await appState.getRecipes()
} }
} }
.refreshable { .refreshable {
viewModel.allRecipes = await mainViewModel.getRecipes() viewModel.allRecipes = await appState.getRecipes()
} }
} }

View File

@@ -1,17 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIcons</key>
<dict/> <dict/>
<key>UTTypeIdentifier</key>
<string>com.cookbook-client.uuid</string>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist> </plist>