@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-20@2x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-20@3x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-29@2x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-29@3x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-40@2x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-40@3x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-60@2x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-60@3x.png",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"scale" : "3x",
|
|
||||||
"size" : "60x60"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-20.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-20@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "20x20"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-29.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-29@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "29x29"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-40.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-40@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "40x40"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-76.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-76@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "76x76"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-83.5@2x.png",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "83.5x83.5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "cookbook-icon-1024.png",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "1024x1024"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "cookbook-icon.png",
|
"filename" : "Hintergrund-1024.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
|||||||
BIN
Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Hintergrund-1024.png
vendored
Normal file
|
After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 58 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
219
Nextcloud Cookbook iOS Client/Data/RecipeModels.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ struct ApiRequest {
|
|||||||
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
Nextcloud Cookbook iOS Client/Network/NetworkError.swift
Normal 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."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
50
Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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!"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
146
Nextcloud Cookbook iOS Client/Util/DurationComponents.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
461
Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
64
Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// BottomClipper.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 27.02.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BottomClipper: Shape {
|
||||||
|
let bottom: CGFloat
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
Rectangle().path(in: CGRect(x: 0, y: rect.size.height - bottom, width: rect.size.width, height: bottom))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// ParallaxHeaderView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 26.02.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct ParallaxHeader<Content: View, Space: Hashable>: View {
|
||||||
|
let content: () -> Content
|
||||||
|
let coordinateSpace: Space
|
||||||
|
let defaultHeight: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
coordinateSpace: Space,
|
||||||
|
defaultHeight: CGFloat,
|
||||||
|
@ViewBuilder _ content: @escaping () -> Content
|
||||||
|
) {
|
||||||
|
self.content = content
|
||||||
|
self.coordinateSpace = coordinateSpace
|
||||||
|
self.defaultHeight = defaultHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
let offset = offset(for: proxy)
|
||||||
|
let heightModifier = heightModifier(for: proxy)
|
||||||
|
let blurRadius = min(
|
||||||
|
heightModifier / 20,
|
||||||
|
max(10, heightModifier / 20)
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
.edgesIgnoringSafeArea(.horizontal)
|
||||||
|
.frame(
|
||||||
|
width: proxy.size.width,
|
||||||
|
height: proxy.size.height + heightModifier
|
||||||
|
)
|
||||||
|
.offset(y: offset)
|
||||||
|
.blur(radius: blurRadius)
|
||||||
|
}.frame(height: defaultHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func offset(for proxy: GeometryProxy) -> CGFloat {
|
||||||
|
let frame = proxy.frame(in: .named(coordinateSpace))
|
||||||
|
if frame.minY < 0 {
|
||||||
|
return -frame.minY * 0.8
|
||||||
|
}
|
||||||
|
return -frame.minY
|
||||||
|
}
|
||||||
|
|
||||||
|
private func heightModifier(for proxy: GeometryProxy) -> CGFloat {
|
||||||
|
let frame = proxy.frame(in: .named(coordinateSpace))
|
||||||
|
return max(0, frame.minY)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
5
Nextcloud-Cookbook-iOS-Client-Info.plist
Normal 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>
|
||||||
27
README.md
@@ -10,7 +10,6 @@ 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
|
||||||
|
|
||||||
- [x] Load recipes from nextcloud instance
|
- [x] Load recipes from nextcloud instance
|
||||||
@@ -27,15 +26,29 @@ 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
|
|
||||||
- Search for recipes based on left-over ingredients
|
|
||||||
|
|
||||||
|
- [ ] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
|
||||||
|
|||||||