diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj index 48d3d9b..6ed9157 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj @@ -15,11 +15,10 @@ 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 */; }; A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; }; - A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; }; - A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; - A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; - A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; }; - A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; }; + A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* NetworkError.swift */; }; + A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkUtils.swift */; }; + A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; }; + A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; }; A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; }; A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.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 */; }; A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.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 */; }; A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A74D33C22AFCD1C300D06555 /* RecipeScraper.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 */; }; A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; }; A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; }; - A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */; }; - A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */; }; + A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; }; A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; }; A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; }; A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; }; + 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 */; }; A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.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 */; }; A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; }; 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 */; }; /* 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 = ""; }; A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = ""; }; A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = ""; }; - A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = ""; }; - A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = ""; }; - A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = ""; }; - A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = ""; }; + A70171B02AB211DF00064C43 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; + A70171B32AB2122900064C43 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = ""; }; + A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = ""; }; + A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = ""; }; A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = ""; }; A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = ""; }; A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = ""; }; @@ -99,7 +108,6 @@ A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = ""; }; A703226E2ABB1DD700D7C4ED /* ColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorExtension.swift; sourceTree = ""; }; - A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeEditView.swift; sourceTree = ""; }; A74D33C22AFCD1C300D06555 /* RecipeScraper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeScraper.swift; sourceTree = ""; }; A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportedLanguage.swift; sourceTree = ""; }; A76B8A702AE002AE00096CEC /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = ""; }; @@ -113,16 +121,29 @@ A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = ""; }; A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = ""; }; - A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordPickerView.swift; sourceTree = ""; }; - A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPickerView.swift; sourceTree = ""; }; + A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = ""; }; A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = ""; }; A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = ""; }; + A97506122B920D9F00E86029 /* RecipeDurationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDurationSection.swift; sourceTree = ""; }; + A97506142B920DF200E86029 /* RecipeGenericViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeGenericViews.swift; sourceTree = ""; }; + A97506182B920EC200E86029 /* RecipeIngredientSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeIngredientSection.swift; sourceTree = ""; }; + A975061A2B920F9F00E86029 /* RecipeNutritionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeNutritionSection.swift; sourceTree = ""; }; + A975061C2B920FCC00E86029 /* RecipeInstructionSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeInstructionSection.swift; sourceTree = ""; }; + A975061E2B920FFC00E86029 /* RecipeToolSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeToolSection.swift; sourceTree = ""; }; + A97506202B92104700E86029 /* RecipeMetadataSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeMetadataSection.swift; sourceTree = ""; }; A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = ""; }; A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = ""; }; A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = ""; }; + A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = ""; }; + A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; + A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = ""; }; + A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = ""; }; + A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.swift; sourceTree = ""; }; A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = ""; }; A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = ""; }; + A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ @@ -132,7 +153,7 @@ buildActionMask = 2147483647; files = ( A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */, - A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */, + A9A43AE12B963150003D95CA /* SwipeActions in Frameworks */, A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -179,16 +200,17 @@ A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = { isa = PBXGroup; children = ( + A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */, A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */, + A70171AC2AA8EF4700064C43 /* AppState.swift */, A70171C72AB4C4A100064C43 /* Data */, A70171BA2AB4980100064C43 /* Views */, A70171B72AB2445700064C43 /* Models */, + A97B4D332B80B51700EC1A88 /* Util */, A70171B22AB211F000064C43 /* Network */, A781E75F2AF8228100452F6F /* RecipeImport */, A9CA6CED2B4C084100F78AB5 /* RecipeExport */, A703226B2ABAF60D00D7C4ED /* Extensions */, - A76B8A702AE002AE00096CEC /* Alerts.swift */, - A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */, A7AEAE632AD5521400135378 /* Localizable.xcstrings */, A70171852AA8E71F00064C43 /* Assets.xcassets */, A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */, @@ -226,11 +248,10 @@ isa = PBXGroup; children = ( A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */, - A79AA8EE2B063B33007D25F2 /* NextcloudApi */, A79AA8E72B062DB6007D25F2 /* CookbookApi */, - A70171B32AB2122900064C43 /* NetworkRequests.swift */, - A70171AE2AB2116B00064C43 /* NetworkHandler.swift */, - A70171B02AB211DF00064C43 /* CustomError.swift */, + A79AA8EE2B063B33007D25F2 /* NextcloudApi */, + A70171B32AB2122900064C43 /* NetworkUtils.swift */, + A70171B02AB211DF00064C43 /* NetworkError.swift */, ); path = Network; sourceTree = ""; @@ -238,7 +259,6 @@ A70171B72AB2445700064C43 /* Models */ = { isa = PBXGroup; children = ( - A70171AC2AA8EF4700064C43 /* AppState.swift */, A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */, ); path = Models; @@ -252,7 +272,6 @@ A977D0DC2B6002DA009783A9 /* Tabs */, A7FB0D782B25C65200A3469E /* Onboarding */, A9C3BE502B630E3900562C79 /* Recipes */, - A9C3BE512B630E8300562C79 /* RecipeEditing */, A9C3BE522B630F1300562C79 /* ReusableViews */, ); path = Views; @@ -262,9 +281,10 @@ isa = PBXGroup; children = ( A70171C32AB4A31200064C43 /* DataStore.swift */, - A70171C52AB4C43A00064C43 /* DataModels.swift */, A70171CA2AB4CD1700064C43 /* UserSettings.swift */, - A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */, + A70171C52AB4C43A00064C43 /* DataModels.swift */, + A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */, + A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */, ); path = Data; sourceTree = ""; @@ -322,6 +342,22 @@ path = Onboarding; sourceTree = ""; }; + 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 = ""; + }; A977D0DC2B6002DA009783A9 /* Tabs */ = { isa = PBXGroup; children = ( @@ -332,31 +368,35 @@ path = Tabs; sourceTree = ""; }; + A97B4D332B80B51700EC1A88 /* Util */ = { + isa = PBXGroup; + children = ( + A76B8A702AE002AE00096CEC /* Alerts.swift */, + A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */, + A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */, + ); + path = Util; + sourceTree = ""; + }; A9C3BE502B630E3900562C79 /* Recipes */ = { isa = PBXGroup; children = ( - A70171BD2AB4987900064C43 /* CategoryDetailView.swift */, + A70171BD2AB4987900064C43 /* RecipeListView.swift */, A70171C12AB498C600064C43 /* RecipeCardView.swift */, - A70171BF2AB498A900064C43 /* RecipeDetailView.swift */, + A70171BF2AB498A900064C43 /* RecipeView.swift */, + A97506112B920D8100E86029 /* RecipeViewSections */, A9D89AAF2B4FE97800F49D92 /* TimerView.swift */, + A97B4D342B80B82A00EC1A88 /* ShareView.swift */, ); path = Recipes; sourceTree = ""; }; - A9C3BE512B630E8300562C79 /* RecipeEditing */ = { - isa = PBXGroup; - children = ( - A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */, - A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */, - A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */, - ); - path = RecipeEditing; - sourceTree = ""; - }; A9C3BE522B630F1300562C79 /* ReusableViews */ = { isa = PBXGroup; children = ( + A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */, A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */, + A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */, ); path = ReusableViews; sourceTree = ""; @@ -396,7 +436,7 @@ packageProductDependencies = ( A74D33BD2AF82AAE00D06555 /* SwiftSoup */, A9CA6CF52B4C63F200F78AB5 /* TPPDF */, - A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */, + A9A43AE02B963150003D95CA /* SwipeActions */, ); productName = "Nextcloud Cookbook iOS Client"; productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */; @@ -476,7 +516,7 @@ packageReferences = ( A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */, A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */, - A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */, + A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */, ); productRefGroup = A701717F2AA8E71900064C43 /* Products */; projectDirPath = ""; @@ -522,30 +562,39 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; 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 */, + A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */, A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */, + A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */, A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */, - A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */, A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */, - A70171B12AB211DF00064C43 /* CustomError.swift in Sources */, + A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */, A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */, A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */, A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */, A70171C42AB4A31200064C43 /* DataStore.swift in Sources */, A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */, + A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */, A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */, A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */, - A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */, A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */, - A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */, - A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */, + A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.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 */, A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */, - A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */, + A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */, A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */, - A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */, - A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */, + A70171C02AB498A900064C43 /* RecipeView.swift in Sources */, A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */, + A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */, A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */, A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */, A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */, @@ -554,6 +603,7 @@ A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */, A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */, A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */, + A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */, A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */, A74D33C32AFCD1C300D06555 /* RecipeScraper.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_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_TEAM = EF2ABA36D9; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Cookbook; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -742,7 +793,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.8.2; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -762,13 +813,14 @@ CODE_SIGN_ENTITLEMENTS = "Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_Client.entitlements"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\""; DEVELOPMENT_TEAM = EF2ABA36D9; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Cookbook; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink"; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; @@ -785,7 +837,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.8.2; + MARKETING_VERSION = 1.9.1; PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -941,12 +993,12 @@ minimumVersion = 2.6.1; }; }; - A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */ = { + A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ZachNagengast/similarity-search-kit"; + repositoryURL = "https://github.com/aheze/SwipeActions"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 0.0.13; + minimumVersion = 1.1.0; }; }; A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */ = { @@ -965,10 +1017,10 @@ package = A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */ = { + A9A43AE02B963150003D95CA /* SwipeActions */ = { isa = XCSwiftPackageProductDependency; - package = A99DC7BA2B6411A7000118AA /* XCRemoteSwiftPackageReference "similarity-search-kit" */; - productName = SimilaritySearchKit; + package = A9A43ADF2B963150003D95CA /* XCRemoteSwiftPackageReference "SwipeActions" */; + productName = SwipeActions; }; A9CA6CF52B4C63F200F78AB5 /* TPPDF */ = { isa = XCSwiftPackageProductDependency; diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,5 @@ + + + + + diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7f6ada7..98ab635 100644 --- a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,14 +1,5 @@ { "pins" : [ - { - "identity" : "similarity-search-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ZachNagengast/similarity-search-kit", - "state" : { - "revision" : "ddc8e458d0e826b2fe5dbce6f6eac96a8935e8eb", - "version" : "0.0.13" - } - }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", @@ -18,6 +9,15 @@ "version" : "2.6.1" } }, + { + "identity" : "swipeactions", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aheze/SwipeActions", + "state" : { + "revision" : "41e6f6dce02d8cfa164f8c5461a41340850ca3ab", + "version" : "1.1.0" + } + }, { "identity" : "tppdf", "kind" : "remoteSourceControl", diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate index 689b432..d4ca057 100644 Binary files a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate and b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/WorkspaceSettings.xcsettings b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..bbfef02 --- /dev/null +++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/vincie.xcuserdatad/WorkspaceSettings.xcsettings @@ -0,0 +1,14 @@ + + + + + BuildLocationStyle + UseAppPreferences + CustomBuildLocationType + RelativeToDerivedData + DerivedDataLocationStyle + Default + ShowSharedSchemesAutomaticallyEnabled + + + diff --git a/Nextcloud Cookbook iOS Client/Models/AppState.swift b/Nextcloud Cookbook iOS Client/AppState.swift similarity index 98% rename from Nextcloud Cookbook iOS Client/Models/AppState.swift rename to Nextcloud Cookbook iOS Client/AppState.swift index 0333e13..8cbb8e8 100644 --- a/Nextcloud Cookbook iOS Client/Models/AppState.swift +++ b/Nextcloud Cookbook iOS Client/AppState.swift @@ -18,6 +18,7 @@ import UIKit var recipeImages: [Int: [String: UIImage]] = [:] var imagesNeedUpdate: [Int: [String: Bool]] = [:] var lastUpdates: [String: Date] = [:] + var allKeywords: [RecipeKeyword] = [] private let dataStore: DataStore @@ -188,7 +189,7 @@ import UIKit ```swift 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? { if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe } return nil @@ -200,6 +201,10 @@ import UIKit id: id ) if let recipe = recipe { + if save { + self.recipeDetails[id] = recipe + await self.saveLocal(recipe, path: "recipe\(id).data") + } return recipe } else if let error = error { print(error) @@ -429,7 +434,7 @@ import UIKit dataStore.delete(path: path) if recipes[categoryName] != nil { recipes[categoryName]!.removeAll(where: { recipe in - recipe.recipe_id == id ? true : false + recipe.recipe_id == id }) recipeDetails.removeValue(forKey: id) } diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/Contents.json deleted file mode 100644 index 3f7f444..0000000 --- a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/Contents.json +++ /dev/null @@ -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 - } -} diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-1024.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-1024.png deleted file mode 100644 index c78e0c2..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-1024.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20.png deleted file mode 100644 index 5d4e6a2..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@2x.png deleted file mode 100644 index cf17cef..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@3x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@3x.png deleted file mode 100644 index d84d18f..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-20@3x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29.png deleted file mode 100644 index fd2e3a8..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@2x.png deleted file mode 100644 index d40d5fe..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@3x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@3x.png deleted file mode 100644 index a2d472f..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-29@3x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40.png deleted file mode 100644 index cf17cef..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@2x.png deleted file mode 100644 index c0d9db9..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@3x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@3x.png deleted file mode 100644 index 3b61b69..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-40@3x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@2x.png deleted file mode 100644 index 3b61b69..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@3x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@3x.png deleted file mode 100644 index 7357fda..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-60@3x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76.png deleted file mode 100644 index dd4d389..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76@2x.png deleted file mode 100644 index b879aae..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-76@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-83.5@2x.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-83.5@2x.png deleted file mode 100644 index b5f070c..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/AppIcon_old.appiconset/cookbook-icon-83.5@2x.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Contents.json index e6140ae..720f745 100644 --- a/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Contents.json +++ b/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "cookbook-icon.png", + "filename" : "Hintergrund-1024.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Hintergrund-1024.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Hintergrund-1024.png new file mode 100644 index 0000000..9dd3e8d Binary files /dev/null and b/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/Hintergrund-1024.png differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/cookbook-icon.png b/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/cookbook-icon.png deleted file mode 100644 index 69fb117..0000000 Binary files a/Nextcloud Cookbook iOS Client/Assets.xcassets/cookbook-icon.imageset/cookbook-icon.png and /dev/null differ diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientdarkblue.colorset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientdarkblue.colorset/Contents.json new file mode 100644 index 0000000..f31fd0e --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientdarkblue.colorset/Contents.json @@ -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 + } +} diff --git a/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientlightblue.colorset/Contents.json b/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientlightblue.colorset/Contents.json new file mode 100644 index 0000000..416f3ca --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Assets.xcassets/ncgradientlightblue.colorset/Contents.json @@ -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 + } +} diff --git a/Nextcloud Cookbook iOS Client/Data/DataModels.swift b/Nextcloud Cookbook iOS Client/Data/DataModels.swift index 5a388d1..2a20a1f 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataModels.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataModels.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI + struct Category: Codable { let name: String let recipe_count: Int @@ -21,166 +22,9 @@ extension Category: Identifiable, Hashable { 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 } -} - -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 +// MARK: - Login flow struct LoginV2Request: Codable { let poll: LoginV2Poll diff --git a/Nextcloud Cookbook iOS Client/Data/DataStore.swift b/Nextcloud Cookbook iOS Client/Data/DataStore.swift index 26472cb..4ef9bd0 100644 --- a/Nextcloud Cookbook iOS Client/Data/DataStore.swift +++ b/Nextcloud Cookbook iOS Client/Data/DataStore.swift @@ -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") - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift b/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift deleted file mode 100644 index edd017a..0000000 --- a/Nextcloud Cookbook iOS Client/Data/DurationComponents.swift +++ /dev/null @@ -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 - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift new file mode 100644 index 0000000..999d904 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/ObservableRecipeDetail.swift @@ -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 [] + } +} + + + diff --git a/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift new file mode 100644 index 0000000..de51043 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Data/RecipeModels.swift @@ -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" + } + } +} diff --git a/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift b/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift index 7f55d8d..439e045 100644 --- a/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift +++ b/Nextcloud Cookbook iOS Client/Extensions/ColorExtension.swift @@ -12,10 +12,19 @@ extension Color { public static var nextcloudBlue: Color { return Color("ncblue") } + public static var nextcloudDarkBlue: Color { + return Color("ncdarkblue") + } public static var backgroundHighlight: Color { return Color("backgroundHighlight") } public static var background: Color { return Color(UIColor.systemBackground) } + public static var ncGradientDark: Color { + return Color("ncgradientdarkblue") + } + public static var ncGradientLight: Color { + return Color("ncgradientlightblue") + } } diff --git a/Nextcloud Cookbook iOS Client/Localizable.xcstrings b/Nextcloud Cookbook iOS Client/Localizable.xcstrings index 4ff1deb..90f8b34 100644 --- a/Nextcloud Cookbook iOS Client/Localizable.xcstrings +++ b/Nextcloud Cookbook iOS Client/Localizable.xcstrings @@ -46,6 +46,7 @@ } }, ":" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -111,6 +112,34 @@ } } }, + "%@: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@: %2$@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@: %2$@" + } + } + } + }, "%lld" : { "localizations" : { "de" : { @@ -155,6 +184,34 @@ } } }, + "%lld h %lld min" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld h %2$lld min" + } + }, + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld h %2$lld min" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld h %2$lld min" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld h %2$lld min" + } + } + } + }, "%lld h, %lld min" : { "localizations" : { "de" : { @@ -205,7 +262,30 @@ } } }, + "%lld Serving(s)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Portion(en)" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Porción(es)" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Portion(s)" + } + } + } + }, "%lld." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -250,6 +330,7 @@ } }, "00" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -382,6 +463,7 @@ } }, "Add" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -403,12 +485,34 @@ } } }, + "Add cooking steps for fellow chefs to follow." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hier ist Platz für eine Rezeptanleitung." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todavía no hay pasos de cocina." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il n'y a pas encore d'étapes de cuisson." + } + } + } + }, "Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Fügen Sie dieser Liste Rezeptzutaten hinzu, indem Sie entweder den Button neben einer Zutatenliste in einem Rezept verwenden, um alle Zutaten hinzuzufügen, oder indem Sie einzelne Zutaten eines Rezepts nach rechts wischen." + "value" : "Für das Hinzufügen aller Zutaten eines Rezepts kann der „Einkaufsliste“-Button neben der Zutatenliste eines Rezepts benutzt werden. Sollen nur einzelne Zutaten hinzugefügt werden, kann die gewünschte Zutat in der Liste des Rezepts nach rechts gewischt werden." } }, "es" : { @@ -492,6 +596,7 @@ } }, "An unknown server error occured." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -557,6 +662,29 @@ } } }, + "Calories" : { + "comment" : "Calories", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kalorien" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calorías" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Calories" + } + } + } + }, "Cancel" : { "localizations" : { "de" : { @@ -579,6 +707,29 @@ } } }, + "Carbohydrate content" : { + "comment" : "Carbohydrate content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohlenhydratgehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carbohidratos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Glucides" + } + } + } + }, "Category" : { "localizations" : { "de" : { @@ -602,6 +753,7 @@ } }, "Category: %@" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -623,12 +775,57 @@ } } }, + "Cholesterol content" : { + "comment" : "Cholesterol content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cholesteringehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Colesterol" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cholestérol" + } + } + } + }, + "Choose" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auswählen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elija" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez" + } + } + } + }, "Configure what is stored on your device." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Legen Sie fest, was lokal auf diesem Gerät gespeichert werden soll." + "value" : "Legt fest, was lokal auf diesem Gerät gespeichert werden soll." } }, "es" : { @@ -650,7 +847,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Legen Sie fest, welche Rezept-Abschnitte standardmäßig gezeigt werden." + "value" : "Legt fest, welche Rezept-Abschnitte standardmäßig gezeigt werden." } }, "es" : { @@ -777,28 +974,6 @@ } } }, - "Cooking duration:" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kochen:" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Duración de cocción:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Temps de cuisson:" - } - } - } - }, "Cooking time" : { "localizations" : { "de" : { @@ -932,11 +1107,34 @@ } }, "Delete recipe" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Rezept Löschen" + "value" : "Rezept löschen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eliminar receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Supprimer la recette" + } + } + } + }, + "Delete Recipe" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezept löschen" } }, "es" : { @@ -980,7 +1178,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Das Löschen lokaler Daten hat keine Auswirkungen auf die Rezeptdaten, die auf Ihrem Server gespeichert sind." + "value" : "Das Löschen lokaler Daten hat keine Auswirkungen auf die Rezeptdaten, die auf dem Server gespeichert sind." } }, "es" : { @@ -997,6 +1195,28 @@ } } }, + "Deletion successful." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Rezept wurde erfolgreich gelöscht." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La receta se ha eliminado con éxito." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La recette a été supprimée avec succès." + } + } + } + }, "Description" : { "localizations" : { "de" : { @@ -1020,7 +1240,26 @@ } }, "Done" : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hecho" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminer" + } + } + } }, "Downloads" : { "localizations" : { @@ -1045,6 +1284,7 @@ } }, "Duplicate Recipe" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1242,6 +1482,52 @@ } } }, + "Fat content" : { + "comment" : "Fat content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fett" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grasas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lipides" + } + } + } + }, + "Fiber content" : { + "comment" : "Fiber content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ballaststoffgehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fibra dietética" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fibres alimentaires" + } + } + } + }, "General" : { "localizations" : { "de" : { @@ -1308,12 +1594,34 @@ } } }, + "Hours" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stunden" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Horas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heures" + } + } + } + }, "If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wenn \"Systemsprache\" ausgewählt ist und Ihre Systemsprache noch nicht unterstützt wird, wird standardmäßig Englisch verwendet." + "value" : "Wenn \"Systemsprache\" ausgewählt ist und die Systemsprache noch nicht unterstützt wird, wird standardmäßig Englisch verwendet." } }, "es" : { @@ -1335,7 +1643,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wenn der Anmeldebutton Ihren Browser nicht öffnet, verwenden Sie den 'Link kopieren' Button und fügen Sie den Link manuell in Ihren Browser ein." + "value" : "Falls sich der Browser beim Klicken auf den Anmeldebutton nicht öffnet, klicke einfach auf den Button „Link kopieren“. Anschließend kann der kopierte Link manuell in den Browser eingefügt werden." } }, "es" : { @@ -1357,7 +1665,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wenn Sie Interesse daran haben, zu diesem Projekt beizutragen oder einfach den Quellcode überprüfen möchten, ermutigen wir Sie, das GitHub-Repository für diese Anwendung zu besuchen." + "value" : "Wir freuen uns über jedes Interesse und laden Nutzer ein, das GitHub-Repository dieser Anwendung zu besuchen, um einen Beitrag zu diesem Projekt zu leisten oder einfach nur einen Blick in den Quellcode zu werfen. " } }, "es" : { @@ -1379,7 +1687,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wenn Sie Anfragen oder Rückmeldungen haben, oder Unterstützung benötigen, finden Sie unter diesem Link die Kontaktinformationen." + "value" : "Bei Fragen, Feedback oder benötigter Unterstützung finden sich alle Kontaktinformationen unter diesem Link." } }, "es" : { @@ -1396,28 +1704,6 @@ } } }, - "Image MIME Error" : { - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "MIME fehler" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "" - } - } - } - }, "Import" : { "localizations" : { "de" : { @@ -1462,30 +1748,24 @@ } } }, - "Import recipe from a website" : { + "Ingredient" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Rezept von einer Website importieren (Experimentell)" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Import recipe from a website (Experimental)" + "value" : "Zutat" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Importar receta desde un sitio web (Experimental)" + "value" : "Ingrediente" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Importer une recette depuis un site web (Expérimental)" + "value" : "Ingrédient" } } } @@ -1556,6 +1836,28 @@ } } }, + "Instruction" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anleitung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instrucción" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Instruction" + } + } + } + }, "Instructions" : { "localizations" : { "de" : { @@ -1688,6 +1990,28 @@ } } }, + "List your tools here. 🍴" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Küchenutensilien können hier notiert werden. 🍴" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enumera tus utensilios de cocina aquí. 🍴" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listez vos ustensiles de cuisine ici. 🍴" + } + } + } + }, "Log out" : { "localizations" : { "de" : { @@ -1781,7 +2105,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Stellen Sie sicher, dass Sie die Serveradresse in der Form 'beispiel.com' eingeben, oder ':', wenn ein nicht standardmäßiger Port verwendet wird." + "value" : "Stelle sicher, dass die Serveradresse im Format 'beispiel.com' eingegeben wurde oder als ':', falls ein nicht standardmäßiger Port genutzt wird." } }, "es" : { @@ -1798,7 +2122,30 @@ } } }, + "Minutes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minuten" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minutos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minutes" + } + } + } + }, "Missing Name" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1843,6 +2190,7 @@ } }, "Missing Request Body" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -1909,6 +2257,29 @@ } }, "New recipe" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Neues Rezept" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nueva receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nouvelle recette" + } + } + } + }, + "New Recipe" : { "localizations" : { "de" : { "stringUnit" : { @@ -2155,7 +2526,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Fügen Sie hier den Link einer Website ein, von der Sie ein Rezept importieren möchten. Dies funktioniert nicht mit allen Seiten. Falls eine Seite nicht unterstützt wird, kontaktieren Sie uns gerne um uns darauf Aufmerksam zu machen." + "value" : "Den Link zum Importieren eines Rezepts hier einfügen. Dies funktioniert nicht mit allen Webseiten. Sollte eine Seite nicht unterstützt werden, kontaktiere uns gerne um uns darauf aufmerksam zu machen. Jedes Feedback hilft dabei, den Service zu verbessern." } }, "es" : { @@ -2199,7 +2570,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bitte überprüfen Sie die eingegebenen Link." + "value" : "Bitte überprüfe den eingegebenen Link." } }, "es" : { @@ -2221,7 +2592,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bitte überprüfen Sie Ihre Anmeldedaten oder Ihre Internetverbindung." + "value" : "Bitte die Anmeldedaten und die Internetverbindung überprüfen." } }, "es" : { @@ -2243,7 +2614,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Bitte tragen Sie einen Rezeptnamen ein." + "value" : "Bitte einen Rezeptnamen eintragen." } }, "es" : { @@ -2283,6 +2654,7 @@ } }, "Preparation duration:" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2304,6 +2676,29 @@ } } }, + "Protein content" : { + "comment" : "Protein content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proteingehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Proteínas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Protéines" + } + } + } + }, "Recipe" : { "localizations" : { "de" : { @@ -2326,6 +2721,50 @@ } } }, + "Recipe Name" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rezept-Titel" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Título de la receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titre de la recette" + } + } + } + }, + "Recipe upload successful." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Rezept wurde erfolgreich hochgeladen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "La receta se ha subido con éxito." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "La recette a été téléchargée avec succès." + } + } + } + }, "Recipes" : { "localizations" : { "de" : { @@ -2392,6 +2831,29 @@ } } }, + "Saturated fat content" : { + "comment" : "Saturated fat content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesättigte Fettsäuren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grasas saturadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graisses saturées" + } + } + } + }, "Search" : { "localizations" : { "de" : { @@ -2463,7 +2925,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Wählen Sie ein Standard-Kochbuch" + "value" : "Standard-Kochbuch" } }, "es" : { @@ -2480,6 +2942,28 @@ } } }, + "Select Keywords" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schlagwörter auswählen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleccione palabras clave" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez les mots-clés" + } + } + } + }, "Selected keywords:" : { "localizations" : { "de" : { @@ -2502,7 +2986,53 @@ } } }, + "Serving size" : { + "comment" : "Serving size", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portionen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Porciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portions" + } + } + } + }, + "Servings" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portionen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Porciones" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Portions" + } + } + } + }, "Servings:" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2591,6 +3121,29 @@ } }, "Share recipe" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compartir receta" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partager la recette" + } + } + } + }, + "Share Recipe" : { "localizations" : { "de" : { "stringUnit" : { @@ -2634,6 +3187,51 @@ } } }, + "Sodium content" : { + "comment" : "Sodium content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Natriumgehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sodio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sodium" + } + } + } + }, + "Start by adding your first ingredient! 🥬" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hier fehlen Zutaten! 🥬" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Empieza por añadir tu primer ingrediente! 🥬" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Commencez par ajouter votre premier ingrédient ! 🥬" + } + } + } + }, "Store recipe images locally" : { "localizations" : { "de" : { @@ -2700,6 +3298,51 @@ } } }, + "Success!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erfolg!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Éxito!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Succès!" + } + } + } + }, + "Sugar content" : { + "comment" : "Sugar content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuckergehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azúcares" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sucres" + } + } + } + }, "Support" : { "localizations" : { "de" : { @@ -2771,7 +3414,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Der 'Anmelden'-Button wird einen Webbrowser öffnen. Bitte folgen Sie den dort angegebenen Anmeldeanweisungen.\nNach einer erfolgreichen Anmeldung kehren Sie zu dieser Anwendung zurück und drücken Sie 'Validieren'." + "value" : "Durch Klicken auf den 'Anmelden'-Button wird ein Webbrowser geöffnet. Bitte den dort angegebenen Anmeldeanweisungen folgen. Nach erfolgreicher Anmeldung zur Anwendung zurückkehren und 'Validieren' drücken." } }, "es" : { @@ -2789,6 +3432,7 @@ } }, "The recipe has no image whose MIME type matches the Accept header" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2833,6 +3477,7 @@ } }, "There was no name in the request given for the recipe. Cannot save the recipe." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2881,7 +3526,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Anwendung ist ein Open-Source-Projekt. Wenn Sie daran interessiert sind, neue Funktionen vorzuschlagen oder beizutragen, oder wenn Sie auf Probleme stoßen, nutzen Sie bitte den Kontakt-Link oder besuchen Sie das GitHub-Repository in den App-Einstellungen." + "value" : "Diese Anwendung ist ein Open-Source-Projekt. Bei bestehendem Interesse neue Funktionen vorzuschlagen oder beizutragen, oder wenn Probleme auftreten, den Kontakt-Link nutzen oder das GitHub-Repository in den App-Einstellungen besuchen." } }, "es" : { @@ -2903,7 +3548,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Diese Website wird möglicherweise noch nicht unterstützt. Falls Sie das ändern möchten, können Sie uns gerne darauf aufmerksam machen." + "value" : "Diese Website wird möglicherweise derzeit nicht unterstützt. Wenn sich das ändern soll, kann die Support-Option in den App-Einstellungen genutzt werden, um auf dieses Problem aufmerksam zu machen." } }, "es" : { @@ -2921,6 +3566,7 @@ } }, "Title" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -2942,6 +3588,28 @@ } } }, + "Tool" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Küchenutensilie" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utensilio de cocina" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ustensile de cuisine" + } + } + } + }, "Tools" : { "localizations" : { "de" : { @@ -2965,6 +3633,7 @@ } }, "Total duration:" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -3030,6 +3699,29 @@ } } }, + "Trans fat content" : { + "comment" : "Trans fat content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transfettgehalt" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grasas trans" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graisses trans" + } + } + } + }, "Unable to complete action." : { "localizations" : { "de" : { @@ -3079,7 +3771,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Der Inhalt der Website konnte nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung." + "value" : "Der Inhalt der Website konnte nicht geladen werden. Bitte die Internetverbindung überprüfen." } }, "es" : { @@ -3101,7 +3793,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Es ist nicht möglich, Ihr Rezept hochzuladen. Bitte überprüfen Sie Ihre Internetverbindung." + "value" : "Es ist nicht möglich, das Rezept hochzuladen. Bitte die Internetverbindung überprüfen." } }, "es" : { @@ -3118,7 +3810,75 @@ } } }, + "Unsaturated fat content" : { + "comment" : "Unsaturated fat content", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ungesättigte Fettsäuren" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grasas insaturadas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graisses insaturées" + } + } + } + }, "Upload" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger" + } + } + } + }, + "Upload Changes" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speichern" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cargar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Télécharger" + } + } + } + }, + "Upload Recipe" : { "localizations" : { "de" : { "stringUnit" : { @@ -3185,7 +3945,26 @@ } }, "Username: %@" : { - + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nutzername: %@" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nombre de usuario: %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nom d'utilisateur: %@" + } + } + } }, "Validate" : { "localizations" : { @@ -3236,7 +4015,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Sie sind bereit zum Kochen 🍓" + "value" : "Bereit zum Kochen 🍓" } }, "es" : { @@ -3258,7 +4037,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ihre Einkaufsliste wird lokal gespeichert und daher nicht auf andere Geräte übertragen." + "value" : "Die Einkaufsliste wird lokal gespeichert und daher nicht auf andere Geräte übertragen." } }, "es" : { diff --git a/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift index fd95965..4971767 100644 --- a/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift +++ b/Nextcloud Cookbook iOS Client/Network/ApiRequest.swift @@ -14,9 +14,7 @@ struct ApiRequest { let authString: String? let headerFields: [HeaderField] let body: Data? - - /// The path to the Cookbook application on the nextcloud server. - + init( path: String, method: RequestMethod, diff --git a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift index cbe6b59..facab74 100644 --- a/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift +++ b/Nextcloud Cookbook iOS Client/Network/CookbookApi/CookbookApiV1.swift @@ -49,6 +49,7 @@ class CookbookApiV1: CookbookApi { let (data, error) = await request.send() guard let data = data else { return (nil, error) } + print("\n\nRECIPE: ", String(data: data, encoding: .utf8)) return (JSONDecoder.safeDecode(data), nil) } diff --git a/Nextcloud Cookbook iOS Client/Network/CustomError.swift b/Nextcloud Cookbook iOS Client/Network/CustomError.swift deleted file mode 100644 index c823741..0000000 --- a/Nextcloud Cookbook iOS Client/Network/CustomError.swift +++ /dev/null @@ -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" - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkError.swift b/Nextcloud Cookbook iOS Client/Network/NetworkError.swift new file mode 100644 index 0000000..cf6b6e1 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/NetworkError.swift @@ -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." +} + + diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift b/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift deleted file mode 100644 index 67cd3a9..0000000 --- a/Nextcloud Cookbook iOS Client/Network/NetworkHandler.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift b/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift deleted file mode 100644 index f782a7f..0000000 --- a/Nextcloud Cookbook iOS Client/Network/NetworkRequests.swift +++ /dev/null @@ -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 - } -} - - diff --git a/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift b/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift new file mode 100644 index 0000000..4d0258f --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift @@ -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 +} diff --git a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift index f831488..430b325 100644 --- a/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift +++ b/Nextcloud Cookbook iOS Client/Nextcloud_Cookbook_iOS_ClientApp.swift @@ -11,7 +11,6 @@ import SwiftUI @main struct Nextcloud_Cookbook_iOS_ClientApp: App { - @StateObject var mainViewModel = AppState() @AppStorage("onboarding") var onboarding = true @AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en" @@ -21,7 +20,7 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App { if onboarding { OnboardingView() } else { - MainView(viewModel: mainViewModel) + MainView() } } .transition(.slide) diff --git a/Nextcloud Cookbook iOS Client/Alerts.swift b/Nextcloud Cookbook iOS Client/Util/Alerts.swift similarity index 88% rename from Nextcloud Cookbook iOS Client/Alerts.swift rename to Nextcloud Cookbook iOS Client/Util/Alerts.swift index 6608936..f435240 100644 --- a/Nextcloud Cookbook iOS Client/Alerts.swift +++ b/Nextcloud Cookbook iOS Client/Util/Alerts.swift @@ -30,7 +30,9 @@ enum RecipeAlert: UserAlert { case NO_TITLE, DUPLICATE, UPLOAD_ERROR, + UPLOAD_SUCCESS, CONFIRM_DELETE, + DELETE_SUCCESS, LOGIN_FAILED, GENERIC, CUSTOM(title: LocalizedStringKey, description: LocalizedStringKey) @@ -43,8 +45,12 @@ enum RecipeAlert: UserAlert { return "A recipe with that name already exists." case .UPLOAD_ERROR: return "Unable to upload your recipe. Please check your internet connection." + case .UPLOAD_SUCCESS: + return "Recipe upload successful." case .CONFIRM_DELETE: return "This action is not reversible!" + case .DELETE_SUCCESS: + return "Deletion successful." case .LOGIN_FAILED: return "Please check your credentials and internet connection." case .CUSTOM(title: _, description: let description): @@ -62,8 +68,12 @@ enum RecipeAlert: UserAlert { return "Duplicate recipe." case .UPLOAD_ERROR: return "Network error." + case .UPLOAD_SUCCESS: + return "Success!" case .CONFIRM_DELETE: return "Delete recipe?" + case .DELETE_SUCCESS: + return "Success!" case .LOGIN_FAILED: return "Login failed." case .CUSTOM(title: let title, description: _): @@ -113,12 +123,14 @@ enum RecipeImportAlert: UserAlert { enum RequestAlert: UserAlert { case REQUEST_DELAYED, - REQUEST_DROPPED + REQUEST_DROPPED, + REQUEST_SUCCESS var localizedDescription: LocalizedStringKey { switch self { 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_SUCCESS: return "" } } @@ -126,6 +138,7 @@ enum RequestAlert: UserAlert { switch self { case .REQUEST_DELAYED: return "Action delayed" case .REQUEST_DROPPED: return "Error" + case .REQUEST_SUCCESS: return "Success!" } } diff --git a/Nextcloud Cookbook iOS Client/Util/DurationComponents.swift b/Nextcloud Cookbook iOS Client/Util/DurationComponents.swift new file mode 100644 index 0000000..b4416ba --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Util/DurationComponents.swift @@ -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 + } +} diff --git a/Nextcloud Cookbook iOS Client/SupportedLanguage.swift b/Nextcloud Cookbook iOS Client/Util/SupportedLanguage.swift similarity index 100% rename from Nextcloud Cookbook iOS Client/SupportedLanguage.swift rename to Nextcloud Cookbook iOS Client/Util/SupportedLanguage.swift diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift index 039c37b..81ad244 100644 --- a/Nextcloud Cookbook iOS Client/Views/MainView.swift +++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift @@ -6,10 +6,9 @@ // import SwiftUI -import SimilaritySearchKit struct MainView: View { - @StateObject var viewModel = AppState() + @StateObject var appState = AppState() @StateObject var groceryList = GroceryList() // Tab ViewModels @@ -24,7 +23,7 @@ struct MainView: View { TabView { RecipeTabView() .environmentObject(recipeViewModel) - .environmentObject(viewModel) + .environmentObject(appState) .environmentObject(groceryList) .tabItem { Label("Recipes", systemImage: "book.closed.fill") @@ -33,7 +32,7 @@ struct MainView: View { SearchTabView() .environmentObject(searchViewModel) - .environmentObject(viewModel) + .environmentObject(appState) .environmentObject(groceryList) .tabItem { Label("Search", systemImage: "magnifyingglass") @@ -53,12 +52,12 @@ struct MainView: View { } .task { recipeViewModel.presentLoadingIndicator = true - await viewModel.getCategories() - await viewModel.updateAllRecipeDetails() + await appState.getCategories() + await appState.updateAllRecipeDetails() // Open detail view for default category if UserSettings.shared.defaultCategory != "" { - if let cat = viewModel.categories.first(where: { c in + if let cat = appState.categories.first(where: { c in if c.name == UserSettings.shared.defaultCategory { return true } diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/CategoryPickerView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditing/CategoryPickerView.swift deleted file mode 100644 index 551600d..0000000 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/CategoryPickerView.swift +++ /dev/null @@ -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()) - } - } -} - diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/RecipeEditView.swift b/Nextcloud Cookbook iOS Client/Views/RecipeEditing/RecipeEditView.swift deleted file mode 100644 index a90455e..0000000 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/RecipeEditView.swift +++ /dev/null @@ -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() - } -} - - - - - - - diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift index abefdd1..8a6a880 100644 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeCardView.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI struct RecipeCardView: View { - @State var viewModel: AppState + @EnvironmentObject var appState: AppState @State var recipe: Recipe @State var recipeThumb: UIImage? @State var isDownloaded: Bool? = nil @@ -48,24 +48,24 @@ struct RecipeCardView: View { } .background(Color.backgroundHighlight) .clipShape(RoundedRectangle(cornerRadius: 17)) - .padding(.horizontal) .task { - recipeThumb = await viewModel.getImage( + recipeThumb = await appState.getImage( id: recipe.recipe_id, size: .THUMB, fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer ) if recipe.storedLocally == nil { - recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id) + recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id) } isDownloaded = recipe.storedLocally } .refreshable { - recipeThumb = await viewModel.getImage( + recipeThumb = await appState.getImage( id: recipe.recipe_id, size: .THUMB, fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer ) } + .frame(height: 80) } } diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift deleted file mode 100644 index b99601f..0000000 --- a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeDetailView.swift +++ /dev/null @@ -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.. [Recipe] { - guard let recipes = viewModel.recipes[categoryName] else { return [] } + guard let recipes = appState.recipes[categoryName] else { return [] } guard searchText != "" else { return recipes } return recipes.filter { recipe in recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift new file mode 100644 index 0000000..99a2edc --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift @@ -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 + } +} + + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift new file mode 100644 index 0000000..19749d6 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeDurationSection.swift @@ -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() + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift new file mode 100644 index 0000000..e56a61e --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeGenericViews.swift @@ -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 = 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 = 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.") + }) + + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeImportSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeImportSection.swift new file mode 100644 index 0000000..e39dea9 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeImportSection.swift @@ -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) + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift new file mode 100644 index 0000000..8364395 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeIngredientSection.swift @@ -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.. 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 + } + } + ) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift new file mode 100644 index 0000000..4409f4b --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeInstructionSection.swift @@ -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) + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/KeywordPickerView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift similarity index 66% rename from Nextcloud Cookbook iOS Client/Views/RecipeEditing/KeywordPickerView.swift rename to Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift index 2a7b6e7..9dc72e1 100644 --- a/Nextcloud Cookbook iOS Client/Views/RecipeEditing/KeywordPickerView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeKeywordSection.swift @@ -1,5 +1,5 @@ // -// KeywordPickerView.swift +// RecipeKeywordSection.swift // Nextcloud Cookbook iOS Client // // Created by Vincent Meilinger on 03.10.23. @@ -8,9 +8,35 @@ import Foundation 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 { + @Environment(\.presentationMode) var presentationMode @State var title: String @State var searchSuggestions: [RecipeKeyword] @Binding var selection: [String] @@ -20,9 +46,17 @@ struct KeywordPickerView: View { var body: some View { VStack(alignment: .leading) { + HStack { + Spacer() + Button { + presentationMode.wrappedValue.dismiss() + } label: { + Text("Done") + }.padding() + } TextField(title, text: $searchText) .textFieldStyle(.roundedBorder) - .padding() + ScrollView { LazyVGrid(columns: columns, spacing: 5) { if searchText != "" { @@ -85,7 +119,7 @@ struct KeywordPickerView: View { } } .navigationTitle(title) - .padding(5) + .padding() } @@ -122,10 +156,36 @@ struct KeywordItemView: View { .padding() .background( RoundedRectangle(cornerRadius: 15) - .foregroundStyle(Color("backgroundHighlight")) + .foregroundStyle(.tertiary) ) .onTapGesture { 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) + + } +} + diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift new file mode 100644 index 0000000..69139e3 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeMetadataSection.swift @@ -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: 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() + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift new file mode 100644 index 0000000..03e02d0 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeNutritionSection.swift @@ -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 { + 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 + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift new file mode 100644 index 0000000..04dfb6d --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/RecipeViewSections/RecipeToolSection.swift @@ -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() + } + + +} diff --git a/Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift b/Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift new file mode 100644 index 0000000..86cecec --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift @@ -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) + } + + + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/ReusableViews/BottomClipper.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/BottomClipper.swift new file mode 100644 index 0000000..16cd14b --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/ReusableViews/BottomClipper.swift @@ -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)) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/ReusableViews/ParallaxHeaderView.swift b/Nextcloud Cookbook iOS Client/Views/ReusableViews/ParallaxHeaderView.swift new file mode 100644 index 0000000..ccd14b1 --- /dev/null +++ b/Nextcloud Cookbook iOS Client/Views/ReusableViews/ParallaxHeaderView.swift @@ -0,0 +1,59 @@ +// +// ParallaxHeaderView.swift +// Nextcloud Cookbook iOS Client +// +// Created by Vincent Meilinger on 26.02.24. +// + +import Foundation +import SwiftUI + + +struct ParallaxHeader: 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) + } +} diff --git a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift index 17387ae..cc49369 100644 --- a/Nextcloud Cookbook iOS Client/Views/SettingsView.swift +++ b/Nextcloud Cookbook iOS Client/Views/SettingsView.swift @@ -227,10 +227,12 @@ extension SettingsView { func getUserData() async { let (data, _) = await NextcloudApi.getAvatar() - avatarImage = data - let (userData, _) = await NextcloudApi.getHoverCard() - self.userData = userData + + DispatchQueue.main.async { + self.avatarImage = data + self.userData = userData + } } } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift index 76bebfa..d9c6199 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift @@ -10,14 +10,15 @@ import SwiftUI struct RecipeTabView: View { + @EnvironmentObject var appState: AppState + @EnvironmentObject var groceryList: GroceryList @EnvironmentObject var viewModel: RecipeTabView.ViewModel - @EnvironmentObject var mainViewModel: AppState var body: some View { NavigationSplitView { List(selection: $viewModel.selectedCategory) { // Categories - ForEach(mainViewModel.categories) { category in + ForEach(appState.categories) { category in if category.recipe_count != 0 { NavigationLink(value: category) { HStack(alignment: .center) { @@ -49,36 +50,38 @@ struct RecipeTabView: View { } .navigationDestination(isPresented: $viewModel.presentSettingsView) { SettingsView() + .environmentObject(appState) + } + .navigationDestination(isPresented: $viewModel.presentEditView) { + RecipeView(viewModel: RecipeView.ViewModel()) + .environmentObject(appState) + .environmentObject(groceryList) } } detail: { NavigationStack { if let category = viewModel.selectedCategory { - CategoryDetailView( + RecipeListView( categoryName: category.name, - viewModel: mainViewModel, showEditView: $viewModel.presentEditView ) .id(category.id) // Workaround: This is needed to update the detail view when the selection changes } + } } .tint(.nextcloudBlue) - .sheet(isPresented: $viewModel.presentEditView) { - RecipeEditView( - viewModel: - RecipeEditViewModel( - mainViewModel: mainViewModel, - uploadNew: true - ), - isPresented: $viewModel.presentEditView - ) - } .task { - viewModel.serverConnection = await mainViewModel.checkServerConnection() + let connection = await appState.checkServerConnection() + DispatchQueue.main.async { + viewModel.serverConnection = connection + } } .refreshable { - viewModel.serverConnection = await mainViewModel.checkServerConnection() - await mainViewModel.getCategories() + let connection = await appState.checkServerConnection() + DispatchQueue.main.async { + viewModel.serverConnection = connection + } + await appState.getCategories() } } diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift index 2f26d9a..0a127a9 100644 --- a/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift +++ b/Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift @@ -7,35 +7,31 @@ import Foundation import SwiftUI -import SimilaritySearchKit struct SearchTabView: View { @EnvironmentObject var viewModel: SearchTabView.ViewModel - @EnvironmentObject var mainViewModel: AppState + @EnvironmentObject var appState: AppState var body: some View { NavigationStack { VStack { - ScrollView(showsIndicators: false) { - /* - Picker("Topping", selection: $viewModel.searchMode) { - ForEach(ViewModel.SearchMode.allCases, id: \.self) { mode in - Text(mode.rawValue) - } - }.pickerStyle(.segmented) - */ - LazyVStack { - ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in + List(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in + RecipeCardView(recipe: recipe) + .shadow(radius: 2) + .background( NavigationLink(value: recipe) { - RecipeCardView(viewModel: mainViewModel, recipe: recipe) - .shadow(radius: 2) + EmptyView() } .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 - RecipeDetailView(viewModel: mainViewModel, recipe: recipe) + RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe)) } .searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords") } @@ -43,11 +39,11 @@ struct SearchTabView: View { } .task { if viewModel.allRecipes.isEmpty { - viewModel.allRecipes = await mainViewModel.getRecipes() + viewModel.allRecipes = await appState.getRecipes() } } .refreshable { - viewModel.allRecipes = await mainViewModel.getRecipes() + viewModel.allRecipes = await appState.getRecipes() } } @@ -56,8 +52,7 @@ struct SearchTabView: View { @Published var searchText: String = "" @Published var searchMode: SearchMode = .name - var similarityIndex: SimilarityIndex? = nil - var similaritySearchResults: [SearchResult] = [] + enum SearchMode: String, CaseIterable { case name = "Name & Keywords", ingredient = "Ingredients" diff --git a/Nextcloud-Cookbook-iOS-Client-Info.plist b/Nextcloud-Cookbook-iOS-Client-Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Nextcloud-Cookbook-iOS-Client-Info.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/README.md b/README.md index 5f55605..7f501e6 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,8 @@ A Nextcloud Cookbook native iOS/iPadOS/MacOS client, built using Swift and Swift See [here](https://github.com/nextcloud/cookbook) for the corresponding Nextcloud server application. You can download the app from the AppStore: - -[Download on the App Store](https://apps.apple.com/de/app/cookbook-client/id6467141985) +[Download on the App Store](https://apps.apple.com/de/app/cookbook-client/id6467141985) ## Features @@ -27,16 +26,30 @@ You can download the app from the AppStore: - [x] Share recipes (by name and keyword) - [x] Import recipes - [x] Keep display awake when viewing recipes -- [ ] Cooking timer for recipes - [x] Ingredient shopping list -- [ ] Add code documentation -**Planned Features** -- 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 +## Roadmap + +- [ ] **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 The following screenshots might not be up to date, since there can always be minor user interface changes.