Merge pull request #20 from VincentMeilinger/1.9.1

1.9.1
This commit is contained in:
VincentM
2024-03-10 18:13:40 +01:00
committed by GitHub
69 changed files with 3260 additions and 1921 deletions

View File

@@ -15,11 +15,10 @@
A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; }; A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; };
A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; }; A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; };
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; }; A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; };
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; }; A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* NetworkError.swift */; };
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkUtils.swift */; };
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; }; A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; };
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; }; A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; }; A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; }; A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
@@ -27,7 +26,6 @@
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; }; A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; }; A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */; };
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */; };
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; }; A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = A74D33BD2AF82AAE00D06555 /* SwiftSoup */; };
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; }; A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */; };
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; }; A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */; };
@@ -42,18 +40,30 @@
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; }; A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
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 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.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 */; };
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506122B920D9F00E86029 /* RecipeDurationSection.swift */; };
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506142B920DF200E86029 /* RecipeGenericViews.swift */; };
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506182B920EC200E86029 /* RecipeIngredientSection.swift */; };
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */; };
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */; };
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A975061E2B920FFC00E86029 /* RecipeToolSection.swift */; };
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97506202B92104700E86029 /* RecipeMetadataSection.swift */; };
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; }; A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; }; A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; }; A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; }; A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */ = {isa = PBXBuildFile; productRef = A9A43AE02B963150003D95CA /* SwipeActions */; };
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 */; };
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; };
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; }; A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -87,11 +97,10 @@
A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = "<group>"; }; A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = "<group>"; };
A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = "<group>"; }; A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = "<group>"; };
A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; }; A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = "<group>"; }; A70171B02AB211DF00064C43 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; }; A70171B32AB2122900064C43 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = "<group>"; };
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; }; A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; }; A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = "<group>"; };
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; }; A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; }; A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; }; A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
@@ -99,7 +108,6 @@
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = "<group>"; };
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = "<group>"; };
A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; }; A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = "<group>"; };
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = "<group>"; };
A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
@@ -113,16 +121,29 @@
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; }; A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
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 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.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>"; };
A97506122B920D9F00E86029 /* RecipeDurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDurationSection.swift; sourceTree = "<group>"; };
A97506142B920DF200E86029 /* RecipeGenericViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeGenericViews.swift; sourceTree = "<group>"; };
A97506182B920EC200E86029 /* RecipeIngredientSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeIngredientSection.swift; sourceTree = "<group>"; };
A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeNutritionSection.swift; sourceTree = "<group>"; };
A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeInstructionSection.swift; sourceTree = "<group>"; };
A975061E2B920FFC00E86029 /* RecipeToolSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeToolSection.swift; sourceTree = "<group>"; };
A97506202B92104700E86029 /* RecipeMetadataSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeMetadataSection.swift; sourceTree = "<group>"; };
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; }; A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; }; A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
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>"; };
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>"; };
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.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; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; }; A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -132,7 +153,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */, A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */,
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -179,16 +200,17 @@
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = { A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */, A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
A70171AC2AA8EF4700064C43 /* AppState.swift */,
A70171C72AB4C4A100064C43 /* Data */, A70171C72AB4C4A100064C43 /* Data */,
A70171BA2AB4980100064C43 /* Views */, A70171BA2AB4980100064C43 /* Views */,
A70171B72AB2445700064C43 /* Models */, A70171B72AB2445700064C43 /* Models */,
A97B4D332B80B51700EC1A88 /* Util */,
A70171B22AB211F000064C43 /* Network */, A70171B22AB211F000064C43 /* Network */,
A781E75F2AF8228100452F6F /* RecipeImport */, A781E75F2AF8228100452F6F /* RecipeImport */,
A9CA6CED2B4C084100F78AB5 /* RecipeExport */, A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
A703226B2ABAF60D00D7C4ED /* Extensions */, A703226B2ABAF60D00D7C4ED /* Extensions */,
A76B8A702AE002AE00096CEC /* Alerts.swift */,
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
A7AEAE632AD5521400135378 /* Localizable.xcstrings */, A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
A70171852AA8E71F00064C43 /* Assets.xcassets */, A70171852AA8E71F00064C43 /* Assets.xcassets */,
A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */, A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */,
@@ -226,11 +248,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */, A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A70171B32AB2122900064C43 /* NetworkRequests.swift */, A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, A70171B32AB2122900064C43 /* NetworkUtils.swift */,
A70171B02AB211DF00064C43 /* CustomError.swift */, A70171B02AB211DF00064C43 /* NetworkError.swift */,
); );
path = Network; path = Network;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -238,7 +259,6 @@
A70171B72AB2445700064C43 /* Models */ = { A70171B72AB2445700064C43 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70171AC2AA8EF4700064C43 /* AppState.swift */,
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
); );
path = Models; path = Models;
@@ -252,7 +272,6 @@
A977D0DC2B6002DA009783A9 /* Tabs */, A977D0DC2B6002DA009783A9 /* Tabs */,
A7FB0D782B25C65200A3469E /* Onboarding */, A7FB0D782B25C65200A3469E /* Onboarding */,
A9C3BE502B630E3900562C79 /* Recipes */, A9C3BE502B630E3900562C79 /* Recipes */,
A9C3BE512B630E8300562C79 /* RecipeEditing */,
A9C3BE522B630F1300562C79 /* ReusableViews */, A9C3BE522B630F1300562C79 /* ReusableViews */,
); );
path = Views; path = Views;
@@ -262,9 +281,10 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70171C32AB4A31200064C43 /* DataStore.swift */, A70171C32AB4A31200064C43 /* DataStore.swift */,
A70171C52AB4C43A00064C43 /* DataModels.swift */,
A70171CA2AB4CD1700064C43 /* UserSettings.swift */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */, A70171C52AB4C43A00064C43 /* DataModels.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -322,6 +342,22 @@
path = Onboarding; path = Onboarding;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A97506112B920D8100E86029 /* RecipeViewSections */ = {
isa = PBXGroup;
children = (
A97506122B920D9F00E86029 /* RecipeDurationSection.swift */,
A97506182B920EC200E86029 /* RecipeIngredientSection.swift */,
A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */,
A975061E2B920FFC00E86029 /* RecipeToolSection.swift */,
A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */,
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */,
A97506142B920DF200E86029 /* RecipeGenericViews.swift */,
A97506202B92104700E86029 /* RecipeMetadataSection.swift */,
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */,
);
path = RecipeViewSections;
sourceTree = "<group>";
};
A977D0DC2B6002DA009783A9 /* Tabs */ = { A977D0DC2B6002DA009783A9 /* Tabs */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -332,31 +368,35 @@
path = Tabs; path = Tabs;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A97B4D332B80B51700EC1A88 /* Util */ = {
isa = PBXGroup;
children = (
A76B8A702AE002AE00096CEC /* Alerts.swift */,
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */,
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
);
path = Util;
sourceTree = "<group>";
};
A9C3BE502B630E3900562C79 /* Recipes */ = { A9C3BE502B630E3900562C79 /* Recipes */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, A70171BD2AB4987900064C43 /* RecipeListView.swift */,
A70171C12AB498C600064C43 /* RecipeCardView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */,
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, A70171BF2AB498A900064C43 /* RecipeView.swift */,
A97506112B920D8100E86029 /* RecipeViewSections */,
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
); );
path = Recipes; path = Recipes;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A9C3BE512B630E8300562C79 /* RecipeEditing */ = {
isa = PBXGroup;
children = (
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
);
path = RecipeEditing;
sourceTree = "<group>";
};
A9C3BE522B630F1300562C79 /* ReusableViews */ = { A9C3BE522B630F1300562C79 /* ReusableViews */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
); );
path = ReusableViews; path = ReusableViews;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -396,7 +436,7 @@
packageProductDependencies = ( packageProductDependencies = (
A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
A9CA6CF52B4C63F200F78AB5 /* TPPDF */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */, A9A43AE02B963150003D95CA /* SwipeActions */,
); );
productName = "Nextcloud Cookbook iOS Client"; productName = "Nextcloud Cookbook iOS Client";
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
@@ -476,7 +516,7 @@
packageReferences = ( packageReferences = (
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */, A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */,
); );
productRefGroup = A701717F2AA8E71900064C43 /* Products */; productRefGroup = A701717F2AA8E71900064C43 /* Products */;
projectDirPath = ""; projectDirPath = "";
@@ -522,30 +562,39 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */,
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
A975061F2B920FFC00E86029 /* RecipeToolSection.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 */,
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */, A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */, A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */,
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */, A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */,
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
@@ -554,6 +603,7 @@
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */, A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
@@ -719,13 +769,14 @@
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cookbook; INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
@@ -742,7 +793,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.8.2; MARKETING_VERSION = 1.9.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -762,13 +813,14 @@
CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 2;
DEAD_CODE_STRIPPING = YES; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
DEVELOPMENT_TEAM = EF2ABA36D9; DEVELOPMENT_TEAM = EF2ABA36D9;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cookbook; INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
@@ -785,7 +837,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0; MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.8.2; MARKETING_VERSION = 1.9.1;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto; SDKROOT = auto;
@@ -941,12 +993,12 @@
minimumVersion = 2.6.1; minimumVersion = 2.6.1;
}; };
}; };
A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */ = { A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/ZachNagengast/similarity-search-kit"; repositoryURL = "https://github.com/aheze/SwipeActions";
requirement = { requirement = {
kind = upToNextMajorVersion; kind = upToNextMajorVersion;
minimumVersion = 0.0.13; minimumVersion = 1.1.0;
}; };
}; };
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = {
@@ -965,10 +1017,10 @@
package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup; productName = SwiftSoup;
}; };
A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */ = { A9A43AE02B963150003D95CA /* SwipeActions */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */; package = A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */;
productName = SimilaritySearchKit; productName = SwipeActions;
}; };
A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;

View File

@@ -0,0 +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/>
</plist>

View File

@@ -1,14 +1,5 @@
{ {
"pins" : [ "pins" : [
{
"identity" : "similarity-search-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ZachNagengast/similarity-search-kit",
"state" : {
"revision" : "ddc8e458d0e826b2fe5dbce6f6eac96a8935e8eb",
"version" : "0.0.13"
}
},
{ {
"identity" : "swiftsoup", "identity" : "swiftsoup",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -18,6 +9,15 @@
"version" : "2.6.1" "version" : "2.6.1"
} }
}, },
{
"identity" : "swipeactions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/aheze/SwipeActions",
"state" : {
"revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab",
"version" : "1.1.0"
}
},
{ {
"identity" : "tppdf", "identity" : "tppdf",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@@ -0,0 +1,14 @@
<?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>BuildLocationStyle</key>
<string>UseAppPreferences</string>
<key>CustomBuildLocationType</key>
<string>RelativeToDerivedData</string>
<key>DerivedDataLocationStyle</key>
<string>Default</string>
<key>ShowSharedSchemesAutomaticallyEnabled</key>
<true/>
</dict>
</plist>

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
@@ -188,7 +189,7 @@ import UIKit
```swift ```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123) let recipeDetail = await mainViewModel.getRecipe(id: 123)
*/ */
func getRecipe(id: Int, fetchMode: FetchMode) async -> RecipeDetail? { func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
func getLocal() async -> RecipeDetail? { func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
return nil return nil
@@ -200,6 +201,10 @@ import UIKit
id: id id: id
) )
if let recipe = recipe { if let recipe = recipe {
if save {
self.recipeDetails[id] = recipe
await self.saveLocal(recipe, path: "recipe\(id).data")
}
return recipe return recipe
} else if let error = error { } else if let error = error {
print(error) print(error)
@@ -429,7 +434,7 @@ import UIKit
dataStore.delete(path: path) dataStore.delete(path: path)
if recipes[categoryName] != nil { if recipes[categoryName] != nil {
recipes[categoryName]!.removeAll(where: { recipe in recipes[categoryName]!.removeAll(where: { recipe in
recipe.recipe_id == id ? true : false recipe.recipe_id == id
}) })
recipeDetails.removeValue(forKey: id) recipeDetails.removeValue(forKey: id)
} }

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

@@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x29",
"green" : "0x1B",
"red" : "0x00"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xE2",
"red" : "0xAD"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x29",
"green" : "0x1B",
"red" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,56 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x52",
"green" : "0x35",
"red" : "0x00"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "light"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0xFF",
"green" : "0xF8",
"red" : "0xEB"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x52",
"green" : "0x35",
"red" : "0x00"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -8,6 +8,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
struct Category: Codable { struct Category: Codable {
let name: String let name: String
let recipe_count: Int let recipe_count: Int
@@ -21,166 +22,9 @@ extension Category: Identifiable, Hashable {
var id: String { name } var id: String { name }
} }
struct Recipe: Codable {
let name: String
let keywords: String?
let dateCreated: String
let dateModified: String
let imageUrl: String
let imagePlaceholderUrl: String
let recipe_id: Int
// Properties excluded from Codable
var storedLocally: Bool? = nil
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
}
}
extension Recipe: Identifiable, Hashable {
var id: String { name }
}
// MARK: - Login flow
struct RecipeDetail: Codable {
var name: String
var keywords: String
var dateCreated: String
var dateModified: String
var imageUrl: String
var id: String
var prepTime: String?
var cookTime: String?
var totalTime: String?
var description: String
var url: String
var recipeYield: Int
var recipeCategory: String
var tool: [String]
var recipeIngredient: [String]
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
}
init() {
name = ""
keywords = ""
dateCreated = ""
dateModified = ""
imageUrl = ""
id = ""
prepTime = ""
cookTime = ""
totalTime = ""
description = ""
url = ""
recipeYield = 0
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
}
}
extension RecipeDetail {
static var error: RecipeDetail {
return RecipeDetail(
name: "Error: Unable to load recipe.",
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
id: "",
prepTime: "",
cookTime: "",
totalTime: "",
description: "",
url: "",
recipeYield: 0,
recipeCategory: "",
tool: [],
recipeIngredient: [],
recipeInstructions: [],
nutrition: [:]
)
}
func getKeywordsArray() -> [String] {
if keywords == "" { return [] }
return keywords.components(separatedBy: ",")
}
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
if !keywordsArray.isEmpty {
self.keywords = keywordsArray.joined(separator: ",")
}
}
func getNutritionList() -> [String]? {
var stringList: [String] = []
if let value = nutrition["calories"] { stringList.append("Calories: \(value)") }
if let value = nutrition["carbohydrateContent"] { stringList.append("Carbohydrates: \(value)") }
if let value = nutrition["cholesterolContent"] { stringList.append("Cholesterol: \(value)") }
if let value = nutrition["fatContent"] { stringList.append("Fat: \(value)") }
if let value = nutrition["saturatedFatContent"] { stringList.append("Saturated fat: \(value)") }
if let value = nutrition["unsaturatedFatContent"] { stringList.append("Unsaturated fat: \(value)") }
if let value = nutrition["transFatContent"] { stringList.append("Trans fat: \(value)") }
if let value = nutrition["fiberContent"] { stringList.append("Fibers: \(value)") }
if let value = nutrition["proteinContent"] { stringList.append("Protein: \(value)") }
if let value = nutrition["sodiumContent"] { stringList.append("Sodium: \(value)") }
if let value = nutrition["sugarContent"] { stringList.append("Sugar: \(value)") }
return stringList.isEmpty ? nil : stringList
}
}
struct RecipeImage {
enum RecipeImageSize: String {
case THUMB="thumb", FULL="full"
}
var imageExists: Bool = true
var thumb: UIImage?
var full: UIImage?
}
struct RecipeKeyword: Codable {
let name: String
let recipe_count: Int
}
struct RecipeImportRequest: Codable {
let url: String
}
// Login flow
struct LoginV2Request: Codable { struct LoginV2Request: Codable {
let poll: LoginV2Poll let poll: LoginV2Poll

View File

@@ -89,26 +89,5 @@ class DataStore {
} }
} }
// SimilarityIndex loading and saving
import SimilaritySearchKit
extension DataStore {
func loadIndex() async -> [IndexItem]? {
do {
let indexItems = try await SimilarityIndex().loadIndex(fromDirectory: Self.fileURL(appending: "similarity_index"))
return indexItems
} catch {
print("Unable to load SimilarityIndex")
return nil
}
}
func saveIndex(_ index: SimilarityIndex) {
do {
try index.saveIndex(toDirectory: Self.fileURL(appending: "similarity_index"))
} catch {
print("Unable to save SimilarityIndex")
}
}
}

View File

@@ -1,155 +0,0 @@
//
// Duration.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.11.23.
//
import Foundation
import SwiftUI
class DurationComponents: ObservableObject {
@Published var secondComponent: String = "00" {
didSet {
if secondComponent.count > 2 {
secondComponent = oldValue
} else if secondComponent.count == 1 {
secondComponent = "0\(secondComponent)"
} else if secondComponent.count == 0 {
secondComponent = "00"
}
let filtered = secondComponent.filter { $0.isNumber }
if secondComponent != filtered {
secondComponent = filtered
}
}
}
@Published var minuteComponent: String = "00" {
didSet {
if minuteComponent.count > 2 {
minuteComponent = oldValue
} else if minuteComponent.count == 1 {
minuteComponent = "0\(minuteComponent)"
} else if minuteComponent.count == 0 {
minuteComponent = "00"
}
let filtered = minuteComponent.filter { $0.isNumber }
if minuteComponent != filtered {
minuteComponent = filtered
}
}
}
@Published var hourComponent: String = "00" {
didSet {
if hourComponent.count > 2 {
hourComponent = oldValue
} else if hourComponent.count == 1 {
hourComponent = "0\(hourComponent)"
} else if hourComponent.count == 0 {
hourComponent = "00"
}
let filtered = hourComponent.filter { $0.isNumber }
if hourComponent != filtered {
hourComponent = filtered
}
}
}
static func fromPTString(_ PTRepresentation: String) -> DurationComponents {
let duration = DurationComponents()
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = PTRepresentation.firstMatch(of: hourRegex) {
duration.hourComponent = String(match.1)
}
if let match = PTRepresentation.firstMatch(of: minuteRegex) {
duration.minuteComponent = String(match.1)
}
return duration
}
func fromPTString(_ PTRepresentation: String) {
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = PTRepresentation.firstMatch(of: hourRegex) {
self.hourComponent = String(match.1)
}
if let match = PTRepresentation.firstMatch(of: minuteRegex) {
self.minuteComponent = String(match.1)
}
}
func toPTString() -> String {
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" {
timeString.append("\(hourComponent):")
}
timeString.append("\(minuteComponent):")
timeString.append("\(secondComponent)")
return timeString
}
func toSeconds() -> Double {
guard let hours = Double(hourComponent) else { return 0 }
guard let minutes = Double(minuteComponent) else { return 0 }
guard let seconds = Double(secondComponent) else { return 0 }
return hours * 3600 + minutes * 60 + seconds
}
func fromSeconds(_ totalSeconds: Int) {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
self.hourComponent = String(hours)
self.minuteComponent = String(minutes)
self.secondComponent = String(seconds)
}
static func ptToText(_ ptString: String) -> String? {
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
var intHour = 0
var intMinute = 0
if let match = ptString.firstMatch(of: hourRegex) {
let hourComponent = String(match.1)
intHour = Int(hourComponent) ?? 0
}
if let match = ptString.firstMatch(of: minuteRegex) {
let minuteComponent = String(match.1)
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 nil
}
}
}

View File

@@ -0,0 +1,95 @@
//
// 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: [String]
@Published var recipeIngredient: [String]
@Published var recipeInstructions: [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.isEmpty ? [] : 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 = recipeDetail.tool
recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = 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: self.tool,
recipeIngredient: self.recipeIngredient,
recipeInstructions: self.recipeInstructions,
nutrition: self.nutrition
)
}
func ingredients(for servings: Int) -> [String] {
for ingredient in recipeIngredient {
// TODO: Parse ingredient strings, adjust them for yield
}
return []
}
}

View File

@@ -0,0 +1,219 @@
//
// RecipeModels.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 17.02.24.
//
import Foundation
import SwiftUI
struct Recipe: Codable {
let name: String
let keywords: String?
let dateCreated: String
let dateModified: String
let imageUrl: String
let imagePlaceholderUrl: String
let recipe_id: Int
// Properties excluded from Codable
var storedLocally: Bool? = nil
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
}
}
extension Recipe: Identifiable, Hashable {
var id: String { name }
}
struct RecipeDetail: Codable {
var name: String
var keywords: String
var dateCreated: String
var dateModified: String
var imageUrl: String
var id: String
var prepTime: String?
var cookTime: String?
var totalTime: String?
var description: String
var url: String
var recipeYield: Int
var recipeCategory: String
var tool: [String]
var recipeIngredient: [String]
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
}
init() {
name = ""
keywords = ""
dateCreated = ""
dateModified = ""
imageUrl = ""
id = ""
prepTime = ""
cookTime = ""
totalTime = ""
description = ""
url = ""
recipeYield = 0
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
}
}
extension RecipeDetail {
static var error: RecipeDetail {
return RecipeDetail(
name: "Error: Unable to load recipe.",
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
id: "",
prepTime: "",
cookTime: "",
totalTime: "",
description: "",
url: "",
recipeYield: 0,
recipeCategory: "",
tool: [],
recipeIngredient: [],
recipeInstructions: [],
nutrition: [:]
)
}
func getKeywordsArray() -> [String] {
if keywords == "" { return [] }
return keywords.components(separatedBy: ",")
}
mutating func setKeywordsFromArray(_ keywordsArray: [String]) {
if !keywordsArray.isEmpty {
self.keywords = keywordsArray.joined(separator: ",")
}
}
}
struct RecipeImage {
enum RecipeImageSize: String {
case THUMB="thumb", FULL="full"
}
var imageExists: Bool = true
var thumb: UIImage?
var full: UIImage?
}
struct RecipeKeyword: Codable {
let name: String
let recipe_count: Int
}
enum Nutrition: CaseIterable {
case servingSize,
calories,
carbohydrateContent,
cholesterolContent,
fatContent,
saturatedFatContent,
unsaturatedFatContent,
transFatContent,
fiberContent,
proteinContent,
sodiumContent,
sugarContent
var localizedDescription: String {
switch self {
case .servingSize:
return NSLocalizedString("Serving size", comment: "Serving size")
case .calories:
return NSLocalizedString("Calories", comment: "Calories")
case .carbohydrateContent:
return NSLocalizedString("Carbohydrate content", comment: "Carbohydrate content")
case .cholesterolContent:
return NSLocalizedString("Cholesterol content", comment: "Cholesterol content")
case .fatContent:
return NSLocalizedString("Fat content", comment: "Fat content")
case .saturatedFatContent:
return NSLocalizedString("Saturated fat content", comment: "Saturated fat content")
case .unsaturatedFatContent:
return NSLocalizedString("Unsaturated fat content", comment: "Unsaturated fat content")
case .transFatContent:
return NSLocalizedString("Trans fat content", comment: "Trans fat content")
case .fiberContent:
return NSLocalizedString("Fiber content", comment: "Fiber content")
case .proteinContent:
return NSLocalizedString("Protein content", comment: "Protein content")
case .sodiumContent:
return NSLocalizedString("Sodium content", comment: "Sodium content")
case .sugarContent:
return NSLocalizedString("Sugar content", comment: "Sugar content")
}
}
var dictKey: String {
switch self {
case .servingSize:
"servingSize"
case .calories:
"calories"
case .carbohydrateContent:
"carbohydrateContent"
case .cholesterolContent:
"cholesterolContent"
case .fatContent:
"fatContent"
case .saturatedFatContent:
"saturatedFatContent"
case .unsaturatedFatContent:
"unsaturatedFatContent"
case .transFatContent:
"transFatContent"
case .fiberContent:
"fiberContent"
case .proteinContent:
"proteinContent"
case .sodiumContent:
"sodiumContent"
case .sugarContent:
"sugarContent"
}
}
}

View File

@@ -12,10 +12,19 @@ extension Color {
public static var nextcloudBlue: Color { public static var nextcloudBlue: Color {
return Color("ncblue") return Color("ncblue")
} }
public static var nextcloudDarkBlue: Color {
return Color("ncdarkblue")
}
public static var backgroundHighlight: Color { public static var backgroundHighlight: Color {
return Color("backgroundHighlight") return Color("backgroundHighlight")
} }
public static var background: Color { public static var background: Color {
return Color(UIColor.systemBackground) return Color(UIColor.systemBackground)
} }
public static var ncGradientDark: Color {
return Color("ncgradientdarkblue")
}
public static var ncGradientLight: Color {
return Color("ncgradientlightblue")
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,7 @@ struct ApiRequest {
let authString: String? let authString: String?
let headerFields: [HeaderField] let headerFields: [HeaderField]
let body: Data? let body: Data?
/// The path to the Cookbook application on the nextcloud server.
init( init(
path: String, path: String,
method: RequestMethod, method: RequestMethod,

View File

@@ -49,6 +49,7 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send() let (data, error) = await request.send()
guard let data = data else { return (nil, error) } guard let data = data else { return (nil, error) }
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
return (JSONDecoder.safeDecode(data), nil) return (JSONDecoder.safeDecode(data), nil)
} }

View File

@@ -1,70 +0,0 @@
//
// CustomError.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
import SwiftUI
public enum NotImplementedError: Error, CustomStringConvertible {
case notImplemented
public var description: String {
return "Function not implemented."
}
}
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case decodingFailed = "Data decoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}
public enum ServerError: Error {
case unknownError, missingRequestBody, duplicateRecipe, noImage, missingRecipeName, recipeNotFound, deleteFailed, requestUnsuccessful
static func decodeFromURLResponse(response: HTTPURLResponse?) -> ServerError? {
guard let response = response else {
return ServerError.unknownError
}
print("Status code: ", response.statusCode)
switch response.statusCode {
case 200...299: return nil
case 400: return .missingRequestBody
case 404: return .recipeNotFound
case 409: return .duplicateRecipe
case 406: return .noImage
case 422: return .missingRecipeName
case 500: return .requestUnsuccessful
case 502: return .deleteFailed
default: return ServerError.unknownError
}
}
var localizedDescription: LocalizedStringKey {
switch self {
case .noImage: return "The recipe has no image whose MIME type matches the Accept header"
case .missingRecipeName: return "There was no name in the request given for the recipe. Cannot save the recipe."
default: return "An unknown server error occured."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .missingRequestBody: return "Missing Request Body"
case .duplicateRecipe: return "Duplicate Recipe"
case .noImage: return "Image MIME Error"
case .missingRecipeName: return "Missing Name"
default: return "Error"
}
}
}

View File

@@ -0,0 +1,23 @@
//
// CustomError.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
public enum NetworkError: String, Error {
case missingUrl = "Missing URL."
case parametersNil = "Parameters are nil."
case encodingFailed = "Parameter encoding failed."
case decodingFailed = "Data decoding failed."
case redirectionError = "Redirection error"
case clientError = "Client error"
case serverError = "Server error"
case invalidRequest = "Invalid request"
case unknownError = "Unknown error"
case dataError = "Invalid data error."
}

View File

@@ -1,80 +0,0 @@
//
// NetworkHandler.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
struct NetworkHandler {
static func sendHTTPRequest(
_ requestWrapper: RequestWrapper,
hostPath: String,
authString: String?
) async throws -> (Data?, NetworkError?) {
print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...")
// Prepare URL
let urlString = hostPath + requestWrapper.getPath()
let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
let url = URL(string: urlStringSanitized!)!
// Create URL request
var request = URLRequest(url: url)
// Set URL method
request.httpMethod = requestWrapper.getMethod()
// Set authentication string, if needed
if let authString = authString {
request.setValue(
"Basic \(authString)",
forHTTPHeaderField: "Authorization"
)
}
// Set other header fields
for headerField in requestWrapper.getHeaderFields() {
request.setValue(
headerField.getValue(),
forHTTPHeaderField: headerField.getField()
)
}
// Set http body
if let body = requestWrapper.getBody() {
request.httpBody = body
}
print("Request:\nMethod: \(request.httpMethod)\nPath: \(request.url?.absoluteString)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
// Wait for and return data and (decoded) response
var data: Data? = nil
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
print("Response: ", response)
print("Data: ", data?.description, data, String(data: data ?? Data(), encoding: .utf8))
return (data, nil)
} catch {
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
}
}
private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
guard let response = response else {
return NetworkError.unknownError
}
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}
}

View File

@@ -1,169 +0,0 @@
//
// NetworkRequests.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
enum RequestMethod: String {
case GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
enum ContentType: String {
case JSON = "application/json",
IMAGE = "image/jpeg",
FORM = "application/x-www-form-urlencoded"
}
struct HeaderField {
private let _field: String
private let _value: String
func getField() -> String {
return _field
}
func getValue() -> String {
return _value
}
static func accept(value: ContentType) -> HeaderField {
return HeaderField(_field: "accept", _value: value.rawValue)
}
static func ocsRequest(value: Bool) -> HeaderField {
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
}
static func contentType(value: ContentType) -> HeaderField {
return HeaderField(_field: "Content-Type", _value: value.rawValue)
}
}
enum RequestPath {
case CATEGORIES,
RECIPE_LIST(categoryName: String),
RECIPE_DETAIL(recipeId: Int),
NEW_RECIPE,
IMAGE(recipeId: Int, thumb: Bool),
CONFIG,
KEYWORDS
case LOGINV2REQ,
CUSTOM(path: String),
NONE
var stringValue: String {
switch self {
case .CATEGORIES: return "categories"
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
case .NEW_RECIPE: return "recipes"
case .CONFIG: return "config"
case .KEYWORDS: return "keywords"
case .LOGINV2REQ: return "/index.php/login/v2"
case .CUSTOM(path: let path): return path
case .NONE: return ""
}
}
}
struct RequestWrapper {
private let _method: RequestMethod
private let _path: RequestPath
private let _headerFields: [HeaderField]
private let _body: Data?
private let _authenticate: Bool = true
private init(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) {
self._method = method
self._path = path
self._headerFields = headerFields
self._body = body
}
func getMethod() -> String {
return self._method.rawValue
}
func getPath() -> String {
return self._path.stringValue
}
func getHeaderFields() -> [HeaderField] {
return self._headerFields
}
func getBody() -> Data? {
return _body
}
func needsAuth() -> Bool {
return _authenticate
}
}
extension RequestWrapper {
static func customRequest(
method: RequestMethod,
path: RequestPath,
headerFields: [HeaderField] = [],
body: Data? = nil,
authenticate: Bool = true
) -> RequestWrapper {
let request = RequestWrapper(
method: method,
path: path,
headerFields: headerFields,
body: body,
authenticate: authenticate
)
return request
}
static func jsonGetRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
static func imageRequest(path: RequestPath) -> RequestWrapper {
let headerFields = [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .IMAGE)
]
let request = RequestWrapper(
method: .GET,
path: path,
headerFields: headerFields,
authenticate: true
)
return request
}
}

View File

@@ -0,0 +1,50 @@
//
// NetworkRequests.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
enum RequestMethod: String {
case GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE"
}
enum ContentType: String {
case JSON = "application/json",
IMAGE = "image/jpeg",
FORM = "application/x-www-form-urlencoded"
}
struct HeaderField {
private let _field: String
private let _value: String
func getField() -> String {
return _field
}
func getValue() -> String {
return _value
}
static func accept(value: ContentType) -> HeaderField {
return HeaderField(_field: "accept", _value: value.rawValue)
}
static func ocsRequest(value: Bool) -> HeaderField {
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
}
static func contentType(value: ContentType) -> HeaderField {
return HeaderField(_field: "Content-Type", _value: value.rawValue)
}
}
struct RecipeImportRequest: Codable {
let url: String
}

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

@@ -30,7 +30,9 @@ enum RecipeAlert: UserAlert {
case NO_TITLE, case NO_TITLE,
DUPLICATE, DUPLICATE,
UPLOAD_ERROR, UPLOAD_ERROR,
UPLOAD_SUCCESS,
CONFIRM_DELETE, CONFIRM_DELETE,
DELETE_SUCCESS,
LOGIN_FAILED, LOGIN_FAILED,
GENERIC, GENERIC,
CUSTOM(title: LocalizedStringKey, description: LocalizedStringKey) CUSTOM(title: LocalizedStringKey, description: LocalizedStringKey)
@@ -43,8 +45,12 @@ enum RecipeAlert: UserAlert {
return "A recipe with that name already exists." return "A recipe with that name already exists."
case .UPLOAD_ERROR: case .UPLOAD_ERROR:
return "Unable to upload your recipe. Please check your internet connection." return "Unable to upload your recipe. Please check your internet connection."
case .UPLOAD_SUCCESS:
return "Recipe upload successful."
case .CONFIRM_DELETE: case .CONFIRM_DELETE:
return "This action is not reversible!" return "This action is not reversible!"
case .DELETE_SUCCESS:
return "Deletion successful."
case .LOGIN_FAILED: case .LOGIN_FAILED:
return "Please check your credentials and internet connection." return "Please check your credentials and internet connection."
case .CUSTOM(title: _, description: let description): case .CUSTOM(title: _, description: let description):
@@ -62,8 +68,12 @@ enum RecipeAlert: UserAlert {
return "Duplicate recipe." return "Duplicate recipe."
case .UPLOAD_ERROR: case .UPLOAD_ERROR:
return "Network error." return "Network error."
case .UPLOAD_SUCCESS:
return "Success!"
case .CONFIRM_DELETE: case .CONFIRM_DELETE:
return "Delete recipe?" return "Delete recipe?"
case .DELETE_SUCCESS:
return "Success!"
case .LOGIN_FAILED: case .LOGIN_FAILED:
return "Login failed." return "Login failed."
case .CUSTOM(title: let title, description: _): case .CUSTOM(title: let title, description: _):
@@ -113,12 +123,14 @@ enum RecipeImportAlert: UserAlert {
enum RequestAlert: UserAlert { enum RequestAlert: UserAlert {
case REQUEST_DELAYED, case REQUEST_DELAYED,
REQUEST_DROPPED REQUEST_DROPPED,
REQUEST_SUCCESS
var localizedDescription: LocalizedStringKey { var localizedDescription: LocalizedStringKey {
switch self { switch self {
case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection." case .REQUEST_DELAYED: return "Could not establish a connection to the server. The action will be retried upon reconnection."
case .REQUEST_DROPPED: return "Unable to complete action." case .REQUEST_DROPPED: return "Unable to complete action."
case .REQUEST_SUCCESS: return ""
} }
} }
@@ -126,6 +138,7 @@ enum RequestAlert: UserAlert {
switch self { switch self {
case .REQUEST_DELAYED: return "Action delayed" case .REQUEST_DELAYED: return "Action delayed"
case .REQUEST_DROPPED: return "Error" case .REQUEST_DROPPED: return "Error"
case .REQUEST_SUCCESS: return "Success!"
} }
} }

View File

@@ -0,0 +1,146 @@
//
// Duration.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.11.23.
//
import Foundation
import SwiftUI
class DurationComponents: ObservableObject {
@Published var secondComponent: Int = 0 {
didSet {
if secondComponent > 59 {
secondComponent = 59
} else if secondComponent < 0 {
secondComponent = 0
}
}
}
@Published var minuteComponent: Int = 0 {
didSet {
if minuteComponent > 59 {
minuteComponent = 59
} else if minuteComponent < 0 {
minuteComponent = 0
}
}
}
@Published var hourComponent: Int = 0 {
didSet {
if hourComponent < 0 {
hourComponent = 0
}
}
}
var displayString: String {
if hourComponent != 0 && minuteComponent != 0 {
return "\(hourComponent) h \(minuteComponent) min"
} else if hourComponent == 0 && minuteComponent != 0 {
return "\(minuteComponent) min"
} else if hourComponent != 0 && minuteComponent == 0 {
return "\(hourComponent) h"
} else {
return "-"
}
}
static func fromPTString(_ PTRepresentation: String) -> DurationComponents {
let duration = DurationComponents()
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = PTRepresentation.firstMatch(of: hourRegex) {
duration.hourComponent = Int(match.1) ?? 0
}
if let match = PTRepresentation.firstMatch(of: minuteRegex) {
duration.minuteComponent = Int(match.1) ?? 0
}
return duration
}
func fromPTString(_ PTRepresentation: String) {
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = PTRepresentation.firstMatch(of: hourRegex) {
self.hourComponent = Int(match.1) ?? 0
}
if let match = PTRepresentation.firstMatch(of: minuteRegex) {
self.minuteComponent = Int(match.1) ?? 0
}
}
private func stringFormatComponents() -> (String, String, String) {
let sec = secondComponent < 10 ? "0\(secondComponent)" : "\(secondComponent)"
let min = minuteComponent < 10 ? "0\(minuteComponent)" : "\(minuteComponent)"
let hr = hourComponent < 10 ? "0\(hourComponent)" : "\(hourComponent)"
return (hr, min, sec)
}
func toPTString() -> String {
let (hr, min, sec) = stringFormatComponents()
return "PT\(hr)H\(min)M\(sec)S"
}
func toTimerText() -> String {
var timeString = ""
let (hr, min, sec) = stringFormatComponents()
if hourComponent != 0 {
timeString.append("\(hr):")
}
timeString.append("\(min):")
timeString.append(sec)
return timeString
}
func toSeconds() -> Double {
return Double(hourComponent) * 3600 + Double(minuteComponent) * 60 + Double(secondComponent)
}
func fromSeconds(_ totalSeconds: Int) {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
self.hourComponent = Int(hours)
self.minuteComponent = Int(minutes)
self.secondComponent = Int(seconds)
}
static func ptToText(_ ptString: String) -> String? {
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
var intHour = 0
var intMinute = 0
if let match = ptString.firstMatch(of: hourRegex) {
let hourComponent = String(match.1)
intHour = Int(hourComponent) ?? 0
}
if let match = ptString.firstMatch(of: minuteRegex) {
let minuteComponent = String(match.1)
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 nil
}
}
static func + (lhs: DurationComponents, rhs: DurationComponents) -> DurationComponents {
let totalSeconds = lhs.toSeconds() + rhs.toSeconds()
let result = DurationComponents()
result.fromSeconds(Int(totalSeconds))
return result
}
}

View File

@@ -6,10 +6,9 @@
// //
import SwiftUI import SwiftUI
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 +23,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 +32,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 +52,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

@@ -1,63 +0,0 @@
//
// CategoryPickerView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 03.10.23.
//
import Foundation
import SwiftUI
struct CategoryPickerView: View {
@State var title: String
@State var searchSuggestions: [String]
@Binding var selection: String
@State var searchText: String = ""
var body: some View {
VStack {
TextField(title, text: $searchText)
.textFieldStyle(.roundedBorder)
.padding()
List {
if searchText != "" {
HStack {
if selection.contains(searchText) {
Image(systemName: "checkmark.circle.fill")
}
Text(searchText)
Spacer()
}
.padding()
.onTapGesture {
selection = searchText
}
}
ForEach(suggestionsFiltered(), id: \.self) { suggestion in
HStack {
if selection.contains(suggestion) {
Image(systemName: "checkmark.circle.fill")
}
Text(suggestion)
}
.padding()
.onTapGesture {
selection = suggestion
}
}
}
Spacer()
}
.navigationTitle(title)
}
func suggestionsFiltered() -> [String] {
guard searchText != "" else { return searchSuggestions }
return searchSuggestions.filter { suggestion in
suggestion.lowercased().contains(searchText.lowercased())
}
}
}

View File

@@ -1,292 +0,0 @@
//
// RecipeEditView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.09.23.
//
import Foundation
import SwiftUI
import PhotosUI
struct RecipeEditView: View {
@ObservedObject var viewModel: RecipeEditViewModel
@Binding var isPresented: Bool
@State var presentAlert = false
@State var alertType: UserAlert = RecipeAlert.GENERIC
@State var alertAction: @MainActor () async -> () = { }
var body: some View {
NavigationStack {
VStack {
HStack {
Button() {
isPresented = false
} label: {
Text("Cancel")
.bold()
}
if !viewModel.uploadNew {
Menu {
Button {
print("Delete recipe.")
alertType = RecipeAlert.CONFIRM_DELETE
alertAction = {
if let res = await viewModel.deleteRecipe() {
alertType = res
alertAction = { }
presentAlert = true
} else {
self.dismissEditView()
}
}
presentAlert = true
} label: {
Image(systemName: "trash")
.foregroundStyle(.red)
Text("Delete recipe")
.foregroundStyle(.red)
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.title3)
.padding()
}
}
Spacer()
Button() {
Task {
if viewModel.uploadNew {
if let res = await viewModel.uploadNewRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
} else {
if let res = await viewModel.uploadEditedRecipe() {
alertType = res
presentAlert = true
} else {
dismissEditView()
}
}
}
} label: {
Text("Upload")
.bold()
}
}.padding()
HStack {
Text(viewModel.recipe.name == "" ? String(localized: "New recipe") : viewModel.recipe.name)
.font(.title)
.bold()
.padding()
Spacer()
}
Form {
if viewModel.showImportSection {
Section {
TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importURL)
Button {
Task {
if let res = await viewModel.importRecipe() {
alertType = RecipeAlert.CUSTOM(
title: res.localizedTitle,
description: res.localizedDescription
)
alertAction = { }
presentAlert = true
}
}
} label: {
Text(LocalizedStringKey("Import"))
}
} header: {
Text(LocalizedStringKey("Import Recipe"))
} footer: {
Text(LocalizedStringKey("Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings."))
}
} else {
Section {
Button() {
withAnimation{
viewModel.showImportSection = true
}
} label: {
Text("Import recipe from a website")
}
}
}
TextField("Title", text: $viewModel.recipe.name)
Section {
TextEditor(text: $viewModel.recipe.description)
} header: {
Text("Description")
}
Section() {
NavigationLink(viewModel.recipe.recipeCategory == "" ? "Category" : "Category: \(viewModel.recipe.recipeCategory)") {
CategoryPickerView(
title: "Category",
searchSuggestions: viewModel.mainViewModel.categories.map({ category in
category.name == "*" ? "Other" : category.name
}),
selection: $viewModel.recipe.recipeCategory)
}
NavigationLink("Keywords") {
KeywordPickerView(
title: "Keywords",
searchSuggestions: viewModel.keywordSuggestions,
selection: $viewModel.keywords
)
}
} footer: {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(viewModel.keywords, id: \.self) { keyword in
Text(keyword)
}
}
}
}
Section() {
Picker("Servings:", selection: $viewModel.recipe.recipeYield) {
ForEach(0..<99, id: \.self) { i in
Text("\(i)").tag(i)
}
}
.pickerStyle(.menu)
DurationPicker(title: LocalizedStringKey("Preparation duration:"), duration: viewModel.prepDuration)
DurationPicker(title: LocalizedStringKey("Cooking duration:"), duration: viewModel.cookDuration)
DurationPicker(title: LocalizedStringKey("Total duration:"), duration: viewModel.totalDuration)
}
EditableListSection(title: LocalizedStringKey("Ingredients"), items: $viewModel.recipe.recipeIngredient)
EditableListSection(title: LocalizedStringKey("Tools"), items: $viewModel.recipe.tool)
EditableListSection(title: LocalizedStringKey("Instructions"), items: $viewModel.recipe.recipeInstructions)
}
}
}
.task {
viewModel.keywordSuggestions = await viewModel.mainViewModel.getKeywords(fetchMode: .preferServer)
}
.onAppear {
viewModel.prepareView()
}
.alert(alertType.localizedTitle, isPresented: $presentAlert) {
ForEach(alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await alertAction()
}
}
}
}
} message: {
Text(alertType.localizedDescription)
}
}
func dismissEditView() {
Task {
await viewModel.mainViewModel.getCategories()
await viewModel.mainViewModel.getCategory(named: viewModel.recipe.recipeCategory, fetchMode: .preferServer)
await viewModel.mainViewModel.updateRecipeDetails(in: viewModel.recipe.recipeCategory)
}
self.isPresented = false
}
}
fileprivate struct EditableListSection: View {
@State var title: LocalizedStringKey
@Binding var items: [String]
var body: some View {
Section() {
List {
ForEach(items.indices, id: \.self) { ix in
HStack(alignment: .top) {
Text("\(ix+1).")
.padding(.vertical, 10)
TextEditor(text: $items[ix])
.multilineTextAlignment(.leading)
.textFieldStyle(.plain)
.padding(.vertical, 1)
}
}
.onMove { indexSet, offset in
items.move(fromOffsets: indexSet, toOffset: offset)
}
.onDelete { indexSet in
items.remove(atOffsets: indexSet)
}
}
HStack {
Spacer()
Text("Add")
Button() {
items.append("")
} label: {
Image(systemName: "plus.circle.fill")
}
}
} header: {
HStack {
Text(title)
Spacer()
EditButton()
}
}
}
}
fileprivate struct DurationPicker: View {
@State var title: LocalizedStringKey
@ObservedObject var duration: DurationComponents
var body: some View {
HStack {
Text(title)
Spacer()
TextField("00", text: $duration.hourComponent)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 40)
Text(":")
TextField("00", text: $duration.minuteComponent)
.keyboardType(.decimalPad)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 40)
}
.frame(maxHeight: 40)
.clipped()
}
}

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
@@ -48,24 +48,24 @@ struct RecipeCardView: View {
} }
.background(Color.backgroundHighlight) .background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17)) .clipShape(RoundedRectangle(cornerRadius: 17))
.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
) )
} }
.frame(height: 80)
} }
} }

View File

@@ -1,553 +0,0 @@
//
// RecipeDetailView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct RecipeDetailView: View {
@ObservedObject var viewModel: AppState
@State var recipe: Recipe
@State var recipeDetail: RecipeDetail?
@State var recipeImage: UIImage?
@State var showTitle: Bool = false
@State var isDownloaded: Bool? = nil
@State private var presentEditView: Bool = false
@State private var presentNutritionPopover: Bool = false
@State private var presentKeywordPopover: Bool = false
@State private var presentShareSheet: Bool = false
@State private var sharedURL: URL? = nil
var body: some View {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading) {
ZStack {
if let recipeImage = recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: 300)
.clipped()
}
}.animation(.easeInOut, value: recipeImage)
if let recipeDetail = recipeDetail {
LazyVStack (alignment: .leading) {
HStack {
Text(recipeDetail.name)
.font(.title)
.bold()
.padding()
.onDisappear {
showTitle = true
}
.onAppear {
showTitle = false
}
if let isDownloaded = isDownloaded {
Spacer()
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
}
}
if recipeDetail.description != "" {
Text(recipeDetail.description)
.padding([.bottom, .horizontal])
}
Divider()
RecipeDurationSection(viewModel: viewModel, recipeDetail: recipeDetail)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!recipeDetail.recipeIngredient.isEmpty) {
RecipeIngredientSection(recipeDetail: recipeDetail)
}
if(!recipeDetail.recipeInstructions.isEmpty) {
RecipeInstructionSection(recipeDetail: recipeDetail)
}
if(!recipeDetail.tool.isEmpty) {
RecipeToolSection(recipeDetail: recipeDetail)
}
RecipeNutritionSection(recipeDetail: recipeDetail)
RecipeKeywordSection(recipeDetail: recipeDetail)
MoreInformationSection(recipeDetail: recipeDetail)
}
}.padding(.horizontal, 5)
}
}
}
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(showTitle ? recipe.name : "")
.toolbar {
if recipeDetail != nil {
Menu {
Button {
presentEditView = true
} label: {
HStack {
Text("Edit")
Image(systemName: "pencil")
}
}
Button {
print("Sharing recipe ...")
self.presentShareSheet = true
} label: {
Text("Share recipe")
Image(systemName: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.sheet(isPresented: $presentEditView) {
if let recipeDetail = recipeDetail {
RecipeEditView(
viewModel:
RecipeEditViewModel(
mainViewModel: viewModel,
recipeDetail: recipeDetail,
uploadNew: false
),
isPresented: $presentEditView
)
}
}
.sheet(isPresented: $presentShareSheet) {
if let recipeDetail = recipeDetail {
ShareView(recipeDetail: recipeDetail,
recipeImage: recipeImage,
presentShareSheet: $presentShareSheet)
}
}
.task {
recipeDetail = await viewModel.getRecipe(
id: recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
)
recipeImage = await viewModel.getImage(
id: recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
)
if recipe.storedLocally == nil {
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
}
self.isDownloaded = recipe.storedLocally
}
.refreshable {
recipeDetail = await viewModel.getRecipe(
id: recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
)
recipeImage = await viewModel.getImage(
id: recipe.recipe_id,
size: .FULL,
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
}
}
fileprivate struct ShareView: View {
@State var recipeDetail: RecipeDetail
@State var recipeImage: UIImage?
@Binding var presentShareSheet: Bool
@State var exporter = RecipeExporter()
@State var sharedURL: URL? = nil
var body: some View {
VStack(alignment: .leading) {
if let url = sharedURL {
ShareLink(item: url, subject: Text("PDF Document")) {
Image(systemName: "doc")
Text("Share as PDF")
}
.foregroundStyle(.primary)
.bold()
.padding()
}
ShareLink(item: exporter.createText(recipe: recipeDetail), subject: Text("Recipe")) {
Image(systemName: "ellipsis.message")
Text("Share as text")
}
.foregroundStyle(.primary)
.bold()
.padding()
/*ShareLink(item: exporter.createJson(recipe: recipeDetail), subject: Text("Recipe")) {
Image(systemName: "doc.badge.gearshape")
Text("Share as JSON")
}
.foregroundStyle(.primary)
.bold()
.padding()
*/
}
.task {
self.sharedURL = exporter.createPDF(recipe: recipeDetail, image: recipeImage)
}
}
}
fileprivate struct RecipeDurationSection: View {
@ObservedObject var viewModel: AppState
@State var recipeDetail: RecipeDetail
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"))
Spacer()
}
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()
}
}
}
}
fileprivate struct RecipeNutritionSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
HStack() {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
Group {
if let nutritionList = recipeDetail.getNutritionList() {
RecipeListSection(list: nutritionList)
} else {
Text(LocalizedStringKey("No nutritional information."))
}
}
} title: {
HStack {
if let servingSize = recipeDetail.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
}
Spacer()
}
}
.padding()
}
}
}
fileprivate struct RecipeKeywordSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if let keywords = getKeywords() {
RecipeListSection(list: keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
}
} title: {
HStack {
SecondaryLabel(text: LocalizedStringKey("Keywords"))
Spacer()
}
}
.padding()
}
func getKeywords() -> [String]? {
let keywords = recipeDetail.keywords.components(separatedBy: ",")
return keywords.isEmpty ? nil : keywords
}
}
fileprivate struct MoreInformationSection: View {
let recipeDetail: RecipeDetail
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) {
HStack() {
Text("URL:")
Link(destination: url) {
Text(recipeDetail.url)
}
}
}
}
.font(.caption)
.foregroundStyle(Color.secondary)
} title: {
HStack {
SecondaryLabel(text: "More information")
Spacer()
}
}
.padding()
}
}
fileprivate struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@State var recipeDetail: RecipeDetail
var body: some View {
VStack(alignment: .leading) {
HStack {
if recipeDetail.recipeYield == 0 {
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
} else if recipeDetail.recipeYield == 1 {
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
} else {
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
}
Spacer()
Button {
withAnimation {
if groceryList.containsRecipe(recipeDetail.id) {
groceryList.deleteGroceryRecipe(recipeDetail.id)
} else {
groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
}
}
} label: {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
} else {
Image(systemName: "heart.text.square")
}
}
}
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
IngredientListItem(ingredient: ingredient, recipeId: recipeDetail.id) {
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
}
.padding(4)
}
}.padding()
}
}
fileprivate struct RecipeToolSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: "Tools")
Spacer()
}
RecipeListSection(list: recipeDetail.tool)
}.padding()
}
}
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@State var ingredient: String
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false
// Drag animation
@State private var dragOffset: CGFloat = 0
@State private var animationStartOffset: CGFloat = 0
let maxDragDistance = 50.0
var body: some View {
HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
} else {
Image(systemName: "heart.text.square")
.foregroundStyle(Color.green)
}
} else if isSelected {
Image(systemName: "checkmark.circle")
} else {
Image(systemName: "circle")
}
Text("\(ingredient)")
.multilineTextAlignment(.leading)
.lineLimit(5)
Spacer()
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.offset(x: dragOffset, y: 0)
.animation(.easeInOut, value: isSelected)
.gesture(
DragGesture()
.onChanged { gesture in
// Update drag offset as the user drags
if animationStartOffset == 0 {
animationStartOffset = gesture.translation.width
}
let dragAmount = gesture.translation.width
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
self.dragOffset = max(0, offset)
}
.onEnded { gesture in
withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if groceryList.containsItem(at: recipeId, item: ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
}
}
// Animate back to original position
self.dragOffset = 0
self.animationStartOffset = 0
}
}
)
}
}
fileprivate struct RecipeListSection: View {
@State var list: [String]
var body: some View {
VStack(alignment: .leading) {
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
Text("\(item)")
.multilineTextAlignment(.leading)
}
.padding(4)
}
}
}
}
fileprivate struct RecipeInstructionSection: View {
@State var recipeDetail: RecipeDetail
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer()
}
ForEach(0..<recipeDetail.recipeInstructions.count) { ix in
RecipeInstructionListItem(instruction: recipeDetail.recipeInstructions[ix], index: ix+1)
}
}.padding()
}
}
fileprivate struct RecipeInstructionListItem: View {
@State var instruction: String
@State var index: Int
@State var isSelected: Bool = false
var body: some View {
HStack(alignment: .top) {
Text("\(index)")
.monospaced()
Text(instruction)
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.animation(.easeInOut, value: isSelected)
}
}
fileprivate struct SecondaryLabel: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.foregroundColor(.secondary)
.font(.headline)
.padding(.vertical, 5)
}
}

View File

@@ -10,35 +10,38 @@ import SwiftUI
struct CategoryDetailView: View { struct RecipeListView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@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
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { List(recipesFiltered(), id: \.recipe_id) { recipe in
LazyVStack { RecipeCardView(recipe: recipe)
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in .shadow(radius: 2)
.background(
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: viewModel, recipe: recipe) EmptyView()
.shadow(radius: 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.onTapGesture { .opacity(0)
selectedRecipe = recipe )
presentRecipeView = true .frame(height: 85)
} .listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
} .listRowSeparatorTint(.clear)
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: viewModel, recipe: recipe)
} }
.listStyle(.plain)
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName) .navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState)
.environmentObject(groceryList)
}
.toolbar { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
@@ -49,15 +52,14 @@ struct CategoryDetailView: View {
} }
} }
} }
.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 +67,7 @@ struct CategoryDetailView: 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

@@ -0,0 +1,461 @@
//
// RecipeDetailView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
struct RecipeView: View {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
}
private enum CoordinateSpaces {
case scrollView
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
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)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.observableRecipeDetail.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.observableRecipeDetail.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.observableRecipeDetail.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// 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
)
} else {
// Prepare view for a new recipe
viewModel.setupView(recipeDetail: RecipeDetail())
viewModel.editMode = true
viewModel.isDownloaded = false
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.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 showTitle: Bool = false
@Published var isDownloaded: Bool? = nil
@Published var importUrl: String = ""
@Published var presentShareSheet: Bool = false
@Published var presentInstructionEditView: Bool = false
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
@Published var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(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)
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await handleUpload()
}
} label: {
if viewModel.newRecipe {
Text("Upload Recipe")
} else {
Text("Upload Changes")
}
}
}
} else {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.editMode = true
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
print("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: true) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
guard let _ = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return
}
if let alert = await appState.uploadRecipe(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(), createNew: false) {
viewModel.presentAlert(alert)
return
}
}
await appState.getCategories()
await appState.getCategory(named: viewModel.observableRecipeDetail.recipeCategory, fetchMode: .preferServer)
if let id = Int(viewModel.observableRecipeDetail.id) {
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
}
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.observableRecipeDetail.recipeCategory
guard let id = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
return
}
if let alert = await appState.deleteRecipe(withId: id, categoryName: viewModel.observableRecipeDetail.recipeCategory) {
viewModel.presentAlert(alert)
return
}
await appState.getCategories()
await appState.getCategory(named: category, fetchMode: .preferServer)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in appState.recipes.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.observableRecipeDetail.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}

View File

@@ -0,0 +1,144 @@
//
// RecipeDurationSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Duration Section
struct RecipeDurationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
@State var presentPopover: Bool = false
var body: some View {
VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
}
if viewModel.editMode {
Button {
presentPopover.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
.padding(.top, 5)
}
}
.padding()
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
}
}
}
fileprivate struct DurationView: View {
@ObservedObject var time: DurationComponents
@State var title: LocalizedStringKey
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: title)
Spacer()
}
HStack {
Image(systemName: "clock")
.bold()
.foregroundStyle(.secondary)
Text(time.displayString)
.lineLimit(1)
}
}
}
}
fileprivate struct EditableDurationView: View {
@Environment(\.presentationMode) var presentationMode
@ObservedObject var prepTime: DurationComponents
@ObservedObject var cookTime: DurationComponents
@ObservedObject var totalTime: DurationComponents
var body: some View {
ScrollView {
VStack(alignment: .center) {
HStack {
SecondaryLabel(text: "Preparation")
Spacer()
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
TimePickerView(selectedHour: $prepTime.hourComponent, selectedMinute: $prepTime.minuteComponent)
HStack {
SecondaryLabel(text: "Cooking")
Spacer()
}
TimePickerView(selectedHour: $cookTime.hourComponent, selectedMinute: $cookTime.minuteComponent)
HStack {
SecondaryLabel(text: "Total time")
Spacer()
}
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
}
.padding()
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
}
}
private func updateTotalTime() {
var hourComponent = prepTime.hourComponent + cookTime.hourComponent
var minuteComponent = prepTime.minuteComponent + cookTime.minuteComponent
// Handle potential overflow from minutes to hours
if minuteComponent >= 60 {
hourComponent += minuteComponent / 60
minuteComponent %= 60
}
totalTime.hourComponent = hourComponent
totalTime.minuteComponent = minuteComponent
}
}
fileprivate struct TimePickerView: View {
@Binding var selectedHour: Int
@Binding var selectedMinute: Int
var body: some View {
HStack {
Picker(selection: $selectedHour, label: Text("Hours")) {
ForEach(0..<99, id: \.self) { hour in
Text("\(hour) h").tag(hour)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: 100, height: 150)
.clipped()
Picker(selection: $selectedMinute, label: Text("Minutes")) {
ForEach(0..<60, id: \.self) { minute in
Text("\(minute) min").tag(minute)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: 100, height: 150)
.clipped()
}
.padding()
}
}

View File

@@ -0,0 +1,154 @@
//
// RecipeSectionStructureViews.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Generic Editable View Elements
struct RecipeListSection: View {
@Binding var list: [String]
var body: some View {
VStack(alignment: .leading) {
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
Text("\(item)")
.multilineTextAlignment(.leading)
}
.padding(4)
}
}
}
}
struct SecondaryLabel: View {
let text: LocalizedStringKey
var body: some View {
Text(text)
.foregroundColor(.secondary)
.font(.headline)
.padding(.vertical, 5)
}
}
struct EditableText: View {
@Binding var text: String
@Binding var editMode: Bool
@State var titleKey: LocalizedStringKey = ""
@State var lineLimit: ClosedRange<Int> = 0...1
@State var axis: Axis = .horizontal
var body: some View {
if editMode {
TextField(titleKey, text: $text, axis: axis)
.textFieldStyle(.roundedBorder)
.lineLimit(lineLimit)
} else {
Text(text)
}
}
}
struct EditableListView: View {
@Binding var isPresented: Bool
@Binding var items: [String]
@State var title: LocalizedStringKey
@State var emptyListText: LocalizedStringKey
@State var titleKey: LocalizedStringKey = ""
@State var lineLimit: ClosedRange<Int> = 0...50
@State var axis: Axis = .vertical
var body: some View {
NavigationView {
ZStack {
List {
if items.isEmpty {
Text(emptyListText)
} else {
ForEach(items.indices, id: \.self) { ix in
TextField(titleKey, text: $items[ix], axis: axis)
.lineLimit(lineLimit)
.padding(5)
}
.onDelete(perform: deleteItem)
.onMove(perform: moveItem)
.scrollDismissesKeyboard(.immediately)
}
}
VStack {
Spacer()
Button {
addItem()
} label: {
Image(systemName: "plus")
.foregroundStyle(.white)
.bold()
.padding()
.background(Circle().fill(Color.nextcloudBlue))
}
.padding()
}
}
.navigationBarTitle(title, displayMode: .inline)
.navigationBarItems(
trailing: Button(action: { isPresented = false }) {
Text("Done")
}
)
.environment(\.editMode, .constant(.active))
}
}
private func addItem() {
withAnimation {
items.append("")
}
}
private func deleteItem(at offsets: IndexSet) {
withAnimation {
items.remove(atOffsets: offsets)
}
}
private func moveItem(from source: IndexSet, to destination: Int) {
withAnimation {
items.move(fromOffsets: source, toOffset: destination)
}
}
}
// MARK: - Previews
struct EditableListView_Previews: PreviewProvider {
// Sample keywords for preview
@State static var sampleList: [String] = [
/*"3 Eggs",
"1 kg Potatos",
"3 g Sugar",
"1 ml Milk",
"Salt, Pepper"*/
]
static var previews: some View {
Color.white
.sheet(isPresented: .constant(true), content: {
EditableListView(isPresented: .constant(true), items: $sampleList, title: "Ingredient", emptyListText: "Add cooking steps for fellow chefs to follow.")
})
}
}

View File

@@ -0,0 +1,52 @@
//
// RecipeImportSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 07.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Import Section
struct RecipeImportSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
var importRecipe: (String) async -> UserAlert?
var body: some View {
VStack(alignment: .leading) {
SecondaryLabel(text: "Import Recipe")
Text(LocalizedStringKey("Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings."))
.font(.caption)
.foregroundStyle(.secondary)
TextField(LocalizedStringKey("URL (e.g. example.com/recipe)"), text: $viewModel.importUrl)
.textFieldStyle(.roundedBorder)
.padding(.top, 5)
Button {
Task {
if let res = await importRecipe(viewModel.importUrl) {
viewModel.presentAlert(
RecipeAlert.CUSTOM(
title: res.localizedTitle,
description: res.localizedDescription
)
)
}
}
} label: {
Text(LocalizedStringKey("Import"))
}
.buttonStyle(.bordered)
}
.padding()
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
.padding(5)
.padding(.top, 5)
}
}

View File

@@ -0,0 +1,141 @@
//
// RecipeIngredientSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Ingredients Section
struct RecipeIngredientSection: View {
@EnvironmentObject var groceryList: GroceryList
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
if viewModel.observableRecipeDetail.recipeYield == 0 {
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
} else if viewModel.observableRecipeDetail.recipeYield == 1 {
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
} else {
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.observableRecipeDetail.recipeYield) servings"))
}
Spacer()
Button {
withAnimation {
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
} else {
groceryList.addItems(
viewModel.observableRecipeDetail.recipeIngredient,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
}
} label: {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
} else {
Image(systemName: "heart.text.square")
}
}.disabled(viewModel.editMode)
}
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix], recipeId: viewModel.observableRecipeDetail.id) {
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4)
}
if viewModel.editMode {
Button {
viewModel.presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding()
}
}
// MARK: - RecipeIngredientSection List Item
fileprivate struct IngredientListItem: View {
@EnvironmentObject var groceryList: GroceryList
@Binding var ingredient: String
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var isSelected: Bool = false
// Drag animation
@State private var dragOffset: CGFloat = 0
@State private var animationStartOffset: CGFloat = 0
let maxDragDistance = 50.0
var body: some View {
HStack(alignment: .top) {
if groceryList.containsItem(at: recipeId, item: ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
} else {
Image(systemName: "heart.text.square")
.foregroundStyle(Color.green)
}
} else if isSelected {
Image(systemName: "checkmark.circle")
} else {
Image(systemName: "circle")
}
Text("\(ingredient)")
.multilineTextAlignment(.leading)
.lineLimit(5)
Spacer()
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.offset(x: dragOffset, y: 0)
.animation(.easeInOut, value: isSelected)
.gesture(
DragGesture()
.onChanged { gesture in
// Update drag offset as the user drags
if animationStartOffset == 0 {
animationStartOffset = gesture.translation.width
}
let dragAmount = gesture.translation.width
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
self.dragOffset = max(0, offset)
}
.onEnded { gesture in
withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if groceryList.containsItem(at: recipeId, item: ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
}
}
// Animate back to original position
self.dragOffset = 0
self.animationStartOffset = 0
}
}
)
}
}

View File

@@ -0,0 +1,59 @@
//
// RecipeInstructionSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Instructions Section
struct RecipeInstructionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer()
}
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
if viewModel.editMode {
Button {
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}
.padding()
}
}
fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String
@State var index: Int
@State var isSelected: Bool = false
var body: some View {
HStack(alignment: .top) {
Text("\(index)")
.monospaced()
Text(instruction)
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
isSelected.toggle()
}
.animation(.easeInOut, value: isSelected)
}
}

View File

@@ -1,5 +1,5 @@
// //
// KeywordPickerView.swift // RecipeKeywordSection.swift
// Nextcloud Cookbook iOS Client // Nextcloud Cookbook iOS Client
// //
// Created by Vincent Meilinger on 03.10.23. // Created by Vincent Meilinger on 03.10.23.
@@ -8,9 +8,35 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
// MARK: - RecipeView Keyword Section
struct RecipeKeywordSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.observableRecipeDetail.keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
}
} title: {
HStack {
SecondaryLabel(text: LocalizedStringKey("Keywords"))
Spacer()
}
}
.padding()
}
}
// MARK: - RecipeView Keyword Sheet View
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,9 +46,17 @@ 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()
ScrollView { ScrollView {
LazyVGrid(columns: columns, spacing: 5) { LazyVGrid(columns: columns, spacing: 5) {
if searchText != "" { if searchText != "" {
@@ -85,7 +119,7 @@ struct KeywordPickerView: View {
} }
} }
.navigationTitle(title) .navigationTitle(title)
.padding(5) .padding()
} }
@@ -122,10 +156,36 @@ struct KeywordItemView: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 15) RoundedRectangle(cornerRadius: 15)
.foregroundStyle(Color("backgroundHighlight")) .foregroundStyle(.tertiary)
) )
.onTapGesture { .onTapGesture {
tapped(keyword) tapped(keyword)
} }
} }
} }
// MARK: - Previews
struct KeywordPickerView_Previews: PreviewProvider {
// Sample keywords for preview
static var sampleKeywords = [
RecipeKeyword(name: "Vegan", recipe_count: 10),
RecipeKeyword(name: "Meat", recipe_count: 5),
RecipeKeyword(name: "Gluten-Free", recipe_count: 8),
RecipeKeyword(name: "Difficult", recipe_count: 7),
RecipeKeyword(name: "Chinese", recipe_count: 3),
RecipeKeyword(name: "European", recipe_count: 5),
RecipeKeyword(name: "Easy", recipe_count: 1)
]
// Sample selection for preview
@State static var selection: [String] = ["Vegan"]
static var previews: some View {
KeywordPickerView(title: "Select Keywords", searchSuggestions: sampleKeywords, selection: $selection)
}
}

View File

@@ -0,0 +1,155 @@
//
// RecipeMetadataSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - Recipe Metadata Section
struct RecipeMetadataSection: View {
@EnvironmentObject var appState: AppState
@ObservedObject var viewModel: RecipeView.ViewModel
@State var keywords: [RecipeKeyword] = []
var categories: [String] {
appState.categories.map({ category in category.name })
}
@State var presentKeywordSheet: Bool = false
@State var presentServingsPopover: Bool = false
@State var presentCategoryPopover: Bool = false
var body: some View {
VStack(alignment: .leading) {
// Category
SecondaryLabel(text: "Category")
HStack {
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
.lineLimit(1)
.textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("").tag("")
ForEach(categories, id: \.self) { item in
Text(item)
}
}
.pickerStyle(.menu)
}
.padding(.bottom)
// Keywords
SecondaryLabel(text: "Keywords")
if !viewModel.observableRecipeDetail.keywords.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
Text(keyword)
.padding(5)
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
}
}
}
}
Button {
presentKeywordSheet.toggle()
} label: {
Text("Select Keywords")
Image(systemName: "chevron.right")
}
.padding(.bottom)
// Servings / Yield
VStack(alignment: .leading) {
SecondaryLabel(text: "Servings")
Button {
presentServingsPopover.toggle()
} label: {
Text("\(viewModel.observableRecipeDetail.recipeYield) Serving(s)")
.lineLimit(1)
}
.popover(isPresented: $presentServingsPopover) {
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 0..<99, title: "Servings", titleKey: "Servings")
}
}
}
.padding()
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
.padding([.horizontal, .bottom], 5)
.sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
}
}
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
@Binding var isPresented: Bool
@Binding var value: Item
@State var items: Collection
var title: LocalizedStringKey
var titleKey: LocalizedStringKey = ""
var body: some View {
VStack {
HStack {
SecondaryLabel(text: title)
Spacer()
Button {
isPresented = false
} label: {
Text("Done")
}
}
Spacer()
HStack {
Picker(selection: $value, label: Text(titleKey)) {
ForEach(Array(items), id: \.self) { item in
Text(item.description).tag(item)
}
}
.pickerStyle(WheelPickerStyle())
.frame(width: 150, height: 150)
.clipped()
}
Spacer()
}
.padding()
}
}
// MARK: - RecipeView More Information Section
struct MoreInformationSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) {
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(alignment: .top) {
Text("URL:")
Link(destination: url) {
Text(viewModel.observableRecipeDetail.url)
}
}
}
}
.font(.caption)
.foregroundStyle(Color.secondary)
} title: {
HStack {
SecondaryLabel(text: "More information")
Spacer()
}
}
.padding()
}
}

View File

@@ -0,0 +1,72 @@
//
// RecipeNutritionSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Nutrition Section
struct RecipeNutritionSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
VStack(alignment: .leading) {
if viewModel.editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack {
Text(nutrition.localizedDescription)
TextField("", text: binding(for: nutrition.dictKey), axis: .horizontal)
.textFieldStyle(.roundedBorder)
.lineLimit(1)
}
}
} else if !nutritionEmpty() {
VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading)
}
.padding(4)
}
}
}
} else {
Text(LocalizedStringKey("No nutritional information."))
}
}
} title: {
HStack {
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
}
Spacer()
}
}
.padding()
}
func binding(for key: String) -> Binding<String> {
Binding(
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
}
}

View File

@@ -0,0 +1,37 @@
//
// RecipeToolSection.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
// MARK: - RecipeView Tool Section
struct RecipeToolSection: View {
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: "Tools")
Spacer()
}
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
if viewModel.editMode {
Button {
viewModel.presentToolEditView.toggle()
} label: {
Text("Edit")
}
.buttonStyle(.borderedProminent)
}
}.padding()
}
}

View File

@@ -0,0 +1,64 @@
//
// ShareView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 17.02.24.
//
import Foundation
import SwiftUI
struct ShareView: View {
@State var recipeDetail: RecipeDetail
@State var recipeImage: UIImage?
@Binding var presentShareSheet: Bool
@State var exporter = RecipeExporter()
@State var sharedURL: URL? = nil
var body: some View {
NavigationStack {
VStack(alignment: .leading) {
if let url = sharedURL {
ShareLink(item: url, subject: Text("PDF Document")) {
Image(systemName: "doc")
Text("Share as PDF")
}
.foregroundStyle(.primary)
.bold()
.padding()
}
ShareLink(item: exporter.createText(recipe: recipeDetail), subject: Text("Recipe")) {
Image(systemName: "ellipsis.message")
Text("Share as text")
}
.foregroundStyle(.primary)
.bold()
.padding()
/*ShareLink(item: exporter.createJson(recipe: recipeDetail), subject: Text("Recipe")) {
Image(systemName: "doc.badge.gearshape")
Text("Share as JSON")
}
.foregroundStyle(.primary)
.bold()
.padding()
*/
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") {
presentShareSheet = false
}
}
}
}
.task {
self.sharedURL = exporter.createPDF(recipe: recipeDetail, image: recipeImage)
}
}
}

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

@@ -227,10 +227,12 @@ extension SettingsView {
func getUserData() async { func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar() let (data, _) = await NextcloudApi.getAvatar()
avatarImage = data
let (userData, _) = await NextcloudApi.getHoverCard() let (userData, _) = await NextcloudApi.getHoverCard()
self.userData = userData
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
} }
} }
} }

View File

@@ -10,14 +10,15 @@ import SwiftUI
struct RecipeTabView: View { struct RecipeTabView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@EnvironmentObject var viewModel: RecipeTabView.ViewModel @EnvironmentObject var viewModel: RecipeTabView.ViewModel
@EnvironmentObject var mainViewModel: AppState
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $viewModel.selectedCategory) { List(selection: $viewModel.selectedCategory) {
// Categories // Categories
ForEach(mainViewModel.categories) { category in ForEach(appState.categories) { category in
if category.recipe_count != 0 { if category.recipe_count != 0 {
NavigationLink(value: category) { NavigationLink(value: category) {
HStack(alignment: .center) { HStack(alignment: .center) {
@@ -49,36 +50,38 @@ struct RecipeTabView: View {
} }
.navigationDestination(isPresented: $viewModel.presentSettingsView) { .navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView() SettingsView()
.environmentObject(appState)
}
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
.environmentObject(appState)
.environmentObject(groceryList)
} }
} detail: { } detail: {
NavigationStack { NavigationStack {
if let category = viewModel.selectedCategory { if let category = viewModel.selectedCategory {
CategoryDetailView( 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
} }
} }
} }
.tint(.nextcloudBlue) .tint(.nextcloudBlue)
.sheet(isPresented: $viewModel.presentEditView) {
RecipeEditView(
viewModel:
RecipeEditViewModel(
mainViewModel: mainViewModel,
uploadNew: true
),
isPresented: $viewModel.presentEditView
)
}
.task { .task {
viewModel.serverConnection = await mainViewModel.checkServerConnection() let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
} }
.refreshable { .refreshable {
viewModel.serverConnection = await mainViewModel.checkServerConnection() let connection = await appState.checkServerConnection()
await mainViewModel.getCategories() DispatchQueue.main.async {
viewModel.serverConnection = connection
}
await appState.getCategories()
} }
} }

View File

@@ -7,35 +7,31 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
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 {
VStack { VStack {
ScrollView(showsIndicators: false) { List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
/* RecipeCardView(recipe: recipe)
Picker("Topping", selection: $viewModel.searchMode) { .shadow(radius: 2)
ForEach(ViewModel.SearchMode.allCases, id: \.self) { mode in .background(
Text(mode.rawValue)
}
}.pickerStyle(.segmented)
*/
LazyVStack {
ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
NavigationLink(value: recipe) { NavigationLink(value: recipe) {
RecipeCardView(viewModel: mainViewModel, recipe: recipe) EmptyView()
.shadow(radius: 2)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} .opacity(0)
} )
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
} }
.listStyle(.plain)
.navigationDestination(for: Recipe.self) { recipe in .navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(viewModel: mainViewModel, 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 +39,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()
} }
} }
@@ -56,8 +52,7 @@ struct SearchTabView: View {
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var searchMode: SearchMode = .name @Published var searchMode: SearchMode = .name
var similarityIndex: SimilarityIndex? = nil
var similaritySearchResults: [SearchResult] = []
enum SearchMode: String, CaseIterable { enum SearchMode: String, CaseIterable {
case name = "Name & Keywords", ingredient = "Ingredients" case name = "Name & Keywords", ingredient = "Ingredients"

View File

@@ -0,0 +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/>
</plist>

View File

@@ -7,9 +7,8 @@ A Nextcloud Cookbook native iOS/iPadOS/MacOS client, built using Swift and Swift
See [here](https://github.com/nextcloud/cookbook) for the corresponding Nextcloud server application. See [here](https://github.com/nextcloud/cookbook) for the corresponding Nextcloud server application.
You can download the app from the AppStore: You can download the app from the AppStore:
[<img src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us" alt="Download on the App Store" height="80" width="160">](https://apps.apple.com/de/app/cookbook-client/id6467141985)
[<img src="https://tools.applemediaservices.com/api/badges/download-on-the-app-store/black/en-us" alt="Download on the App Store" height="80" width="160">](https://apps.apple.com/de/app/cookbook-client/id6467141985)
## Features ## Features
@@ -27,16 +26,30 @@ You can download the app from the AppStore:
- [x] Share recipes (by name and keyword) - [x] Share recipes (by name and keyword)
- [x] Import recipes - [x] Import recipes
- [x] Keep display awake when viewing recipes - [x] Keep display awake when viewing recipes
- [ ] Cooking timer for recipes
- [x] Ingredient shopping list - [x] Ingredient shopping list
- [ ] Add code documentation
**Planned Features** ## Roadmap
- Calculate the required amount of a recipe ingredient depending on the number of servings
- Fuzzy search for recipe titles/keywords - [ ] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
- Search for recipes based on left-over ingredients
- [ ] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images.
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
- Fuzzy search for recipe names and keywords.
- In-app timer for the cook time specified in a recipe.
- Search for recipes based on left-over ingredients.
- An option to use the app without a Nextcloud account.
- An option to specify the recipe folder in the Files app, to enable the app to work on the recipe files directly.
**If you would like to suggest new features/improvements or report bugs, please open an Issue!**
## Screenshots ## Screenshots
The following screenshots might not be up to date, since there can always be minor user interface changes. The following screenshots might not be up to date, since there can always be minor user interface changes.