New Recipe Edit View
This commit is contained in:
@@ -15,11 +15,10 @@
|
|||||||
A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; };
|
A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; };
|
||||||
A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; };
|
A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; };
|
||||||
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; };
|
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; };
|
||||||
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; };
|
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* NetworkError.swift */; };
|
||||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; };
|
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkUtils.swift */; };
|
||||||
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; };
|
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
||||||
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; };
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||||
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; };
|
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
||||||
@@ -47,9 +46,12 @@
|
|||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; };
|
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */; };
|
||||||
|
A95364672B7E89F1001018B0 /* ReorderableForEach.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95364662B7E89F1001018B0 /* ReorderableForEach.swift */; };
|
||||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
|
||||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
|
||||||
|
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
|
||||||
|
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
|
||||||
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; };
|
A99DC7BC2B6411A7000118AA /* SimilaritySearchKit in Frameworks */ = {isa = PBXBuildFile; productRef = A99DC7BB2B6411A7000118AA /* SimilaritySearchKit */; };
|
||||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||||
@@ -87,11 +89,10 @@
|
|||||||
A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = "<group>"; };
|
A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITests.swift; sourceTree = "<group>"; };
|
||||||
A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = "<group>"; };
|
A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift; sourceTree = "<group>"; };
|
||||||
A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
A70171AC2AA8EF4700064C43 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||||
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = "<group>"; };
|
A70171B02AB211DF00064C43 /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = "<group>"; };
|
||||||
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; };
|
A70171B32AB2122900064C43 /* NetworkUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkUtils.swift; sourceTree = "<group>"; };
|
||||||
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; };
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
||||||
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailView.swift; sourceTree = "<group>"; };
|
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
||||||
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeDetailView.swift; sourceTree = "<group>"; };
|
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
||||||
@@ -118,11 +119,15 @@
|
|||||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7D2B25C6A200A3469E /* V2LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = V2LoginView.swift; sourceTree = "<group>"; };
|
||||||
|
A95364662B7E89F1001018B0 /* ReorderableForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableForEach.swift; sourceTree = "<group>"; };
|
||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTabView.swift; sourceTree = "<group>"; };
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeTabView.swift; sourceTree = "<group>"; };
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; };
|
||||||
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||||
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeExporter.swift; sourceTree = "<group>"; };
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerView.swift; sourceTree = "<group>"; };
|
||||||
|
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
|
||||||
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@@ -179,16 +184,17 @@
|
|||||||
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
|
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
|
||||||
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
|
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
|
||||||
|
A70171AC2AA8EF4700064C43 /* AppState.swift */,
|
||||||
A70171C72AB4C4A100064C43 /* Data */,
|
A70171C72AB4C4A100064C43 /* Data */,
|
||||||
A70171BA2AB4980100064C43 /* Views */,
|
A70171BA2AB4980100064C43 /* Views */,
|
||||||
A70171B72AB2445700064C43 /* Models */,
|
A70171B72AB2445700064C43 /* Models */,
|
||||||
|
A97B4D332B80B51700EC1A88 /* Util */,
|
||||||
A70171B22AB211F000064C43 /* Network */,
|
A70171B22AB211F000064C43 /* Network */,
|
||||||
A781E75F2AF8228100452F6F /* RecipeImport */,
|
A781E75F2AF8228100452F6F /* RecipeImport */,
|
||||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
||||||
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
||||||
A76B8A702AE002AE00096CEC /* Alerts.swift */,
|
|
||||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
|
|
||||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
|
A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
|
||||||
A70171852AA8E71F00064C43 /* Assets.xcassets */,
|
A70171852AA8E71F00064C43 /* Assets.xcassets */,
|
||||||
A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */,
|
A70171872AA8E71F00064C43 /* Nextcloud_Cookbook_iOS_Client.entitlements */,
|
||||||
@@ -226,11 +232,10 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
|
||||||
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
|
|
||||||
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
|
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
|
||||||
A70171B32AB2122900064C43 /* NetworkRequests.swift */,
|
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
|
||||||
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */,
|
A70171B32AB2122900064C43 /* NetworkUtils.swift */,
|
||||||
A70171B02AB211DF00064C43 /* CustomError.swift */,
|
A70171B02AB211DF00064C43 /* NetworkError.swift */,
|
||||||
);
|
);
|
||||||
path = Network;
|
path = Network;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -238,7 +243,6 @@
|
|||||||
A70171B72AB2445700064C43 /* Models */ = {
|
A70171B72AB2445700064C43 /* Models */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A70171AC2AA8EF4700064C43 /* AppState.swift */,
|
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = Models;
|
path = Models;
|
||||||
@@ -262,9 +266,9 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
A70171C32AB4A31200064C43 /* DataStore.swift */,
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
|
||||||
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */,
|
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
||||||
|
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -332,13 +336,24 @@
|
|||||||
path = Tabs;
|
path = Tabs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A97B4D332B80B51700EC1A88 /* Util */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A76B8A702AE002AE00096CEC /* Alerts.swift */,
|
||||||
|
A79AA8DF2AFF80E3007D25F2 /* DurationComponents.swift */,
|
||||||
|
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
|
||||||
|
);
|
||||||
|
path = Util;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A9C3BE502B630E3900562C79 /* Recipes */ = {
|
A9C3BE502B630E3900562C79 /* Recipes */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||||
);
|
);
|
||||||
path = Recipes;
|
path = Recipes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -357,6 +372,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||||
|
A95364662B7E89F1001018B0 /* ReorderableForEach.swift */,
|
||||||
);
|
);
|
||||||
path = ReusableViews;
|
path = ReusableViews;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -522,28 +538,30 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
|
||||||
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
|
||||||
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
A70D7CA12AC73CA800D53DBF /* RecipeEditView.swift in Sources */,
|
||||||
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
|
||||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
||||||
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
|
||||||
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
|
||||||
|
A95364672B7E89F1001018B0 /* ReorderableForEach.swift in Sources */,
|
||||||
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
|
||||||
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
|
||||||
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */,
|
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
|
||||||
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */,
|
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
|
||||||
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */,
|
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
|
||||||
|
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
|
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
|
||||||
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
|
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
|
||||||
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
|
A7F3F8E82ACBFC760076C227 /* KeywordPickerView.swift in Sources */,
|
||||||
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
|
||||||
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */,
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
|
||||||
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
|
A7F3F8EA2ACC221C0076C227 /* CategoryPickerView.swift in Sources */,
|
||||||
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
|
||||||
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */,
|
||||||
@@ -726,6 +744,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
@@ -742,7 +761,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.8.2;
|
MARKETING_VERSION = 1.8.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -769,6 +788,7 @@
|
|||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
@@ -785,7 +805,7 @@
|
|||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.8.2;
|
MARKETING_VERSION = 1.8.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
|
|||||||
Binary file not shown.
@@ -8,6 +8,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
struct Category: Codable {
|
struct Category: Codable {
|
||||||
let name: String
|
let name: String
|
||||||
let recipe_count: Int
|
let recipe_count: Int
|
||||||
@@ -17,170 +18,17 @@ struct Category: Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
extension Category: Identifiable, Hashable {
|
extension Category: Identifiable, Hashable {
|
||||||
var id: String { name }
|
var id: String { name }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Recipe: Codable {
|
|
||||||
let name: String
|
|
||||||
let keywords: String?
|
|
||||||
let dateCreated: String
|
|
||||||
let dateModified: String
|
|
||||||
let imageUrl: String
|
|
||||||
let imagePlaceholderUrl: String
|
|
||||||
let recipe_id: Int
|
|
||||||
|
|
||||||
// Properties excluded from Codable
|
|
||||||
var storedLocally: Bool? = nil
|
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Recipe: Identifiable, Hashable {
|
|
||||||
var id: String { name }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
struct LoginV2Request: Codable {
|
||||||
let poll: LoginV2Poll
|
let poll: LoginV2Poll
|
||||||
|
|||||||
214
Nextcloud Cookbook iOS Client/Data/RecipeModels.swift
Normal file
214
Nextcloud Cookbook iOS Client/Data/RecipeModels.swift
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
//
|
||||||
|
// 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 calories,
|
||||||
|
carbohydrateContent,
|
||||||
|
cholesterolContent,
|
||||||
|
fatContent,
|
||||||
|
saturatedFatContent,
|
||||||
|
unsaturatedFatContent,
|
||||||
|
transFatContent,
|
||||||
|
fiberContent,
|
||||||
|
proteinContent,
|
||||||
|
sodiumContent,
|
||||||
|
sugarContent
|
||||||
|
|
||||||
|
var localizedDescription: LocalizedStringKey {
|
||||||
|
switch self {
|
||||||
|
case .calories:
|
||||||
|
"Calories"
|
||||||
|
case .carbohydrateContent:
|
||||||
|
"Carbohydrate content"
|
||||||
|
case .cholesterolContent:
|
||||||
|
"Cholesterol content"
|
||||||
|
case .fatContent:
|
||||||
|
"Fat content"
|
||||||
|
case .saturatedFatContent:
|
||||||
|
"Saturated fat content"
|
||||||
|
case .unsaturatedFatContent:
|
||||||
|
"Unsaturated fat content"
|
||||||
|
case .transFatContent:
|
||||||
|
"Trans fat content"
|
||||||
|
case .fiberContent:
|
||||||
|
"Fiber content"
|
||||||
|
case .proteinContent:
|
||||||
|
"Protein content"
|
||||||
|
case .sodiumContent:
|
||||||
|
"Sodium content"
|
||||||
|
case .sugarContent:
|
||||||
|
"Sugar content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dictKey: String {
|
||||||
|
switch self {
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : "Wenn du alle Zutaten eines Rezepts auf einmal hinzufügen möchtest, klicke einfach auf den „Einkaufsliste“-Button, den du neben der Zutatenliste des Rezepts findest. Möchtest du nur einzelne Zutaten hinzufügen, wische die gewünschte Zutat in der Liste des Rezepts einfach nach rechts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -492,6 +492,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"An unknown server error occured." : {
|
"An unknown server error occured." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -556,6 +557,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Calories" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -578,6 +582,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Carbohydrate content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Category" : {
|
"Category" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -622,13 +629,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Cholesterol content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Configure what is stored on your device." : {
|
"Configure what is stored on your device." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -650,7 +660,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Legen Sie fest, welche Rezept-Abschnitte standardmäßig gezeigt werden."
|
"value" : "Legt fest, welche Rezept-Abschnitte standardmäßig gezeigt werden."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -980,7 +990,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -1019,8 +1029,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Done" : {
|
"Disable deletion" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Done" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Fertig"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Downloads" : {
|
"Downloads" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1045,6 +1065,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Duplicate Recipe" : {
|
"Duplicate Recipe" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1131,6 +1152,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Enable deletion" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Error" : {
|
"Error" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1241,6 +1265,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Fat content" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Fiber content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"General" : {
|
"General" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1313,7 +1343,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -1335,7 +1365,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -1357,7 +1387,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : "Möchtest du einen Beitrag zu diesem Projekt leisten oder einfach nur einen Blick in den Quellcode werfen? Wir freuen uns über jedes Interesse und laden dich ein, das GitHub-Repository unserer Anwendung zu besuchen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -1379,7 +1409,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -1397,6 +1427,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Image MIME Error" : {
|
"Image MIME Error" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1489,6 +1520,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Ingredient" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Ingredients" : {
|
"Ingredients" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1555,6 +1589,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Instruction" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Instructions" : {
|
"Instructions" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1599,6 +1636,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Keyword" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Keywords" : {
|
"Keywords" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -1781,7 +1821,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Stellen Sie sicher, dass Sie die Serveradresse in der Form 'beispiel.com' eingeben, oder '<Serveradresse>:<Port>', wenn ein nicht standardmäßiger Port verwendet wird."
|
"value" : "Stelle sicher, dass die Serveradresse im Format 'beispiel.com' eingegeben wurde oder als '<Serveradresse>:<Port>', falls ein nicht standardmäßiger Port genutzt wird."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -1799,6 +1839,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Missing Name" : {
|
"Missing Name" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -1843,6 +1884,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Missing Request Body" : {
|
"Missing Request Body" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2155,7 +2197,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -2199,7 +2241,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Bitte überprüfen Sie die eingegebenen Link."
|
"value" : "Bitte überprüfe den eingegebenen Link."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -2221,7 +2263,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Bitte überprüfen Sie Ihre Anmeldedaten oder Ihre Internetverbindung."
|
"value" : "Bitte überprüfe die Anmeldedaten oder die Internetverbindung."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -2243,7 +2285,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Bitte tragen Sie einen Rezeptnamen ein."
|
"value" : "Bitte einen Rezeptnamen eintragen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -2303,6 +2345,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Protein content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Recipe" : {
|
"Recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2391,6 +2436,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Saturated fat content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Search" : {
|
"Search" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2463,7 +2511,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Wählen Sie ein Standard-Kochbuch"
|
"value" : "Standard-Kochbuch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -2633,6 +2681,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Sodium content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Store recipe images locally" : {
|
"Store recipe images locally" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2699,6 +2750,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Sugar content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Support" : {
|
"Support" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2771,7 +2825,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -2789,6 +2843,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"The recipe has no image whose MIME type matches the Accept header" : {
|
"The recipe has no image whose MIME type matches the Accept header" : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2833,6 +2888,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"There was no name in the request given for the recipe. Cannot save the recipe." : {
|
"There was no name in the request given for the recipe. Cannot save the recipe." : {
|
||||||
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
@@ -2881,7 +2937,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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, nutze den Kontakt-Link oder besuche das GitHub-Repository in den App-Einstellungen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -2903,7 +2959,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
@@ -2941,6 +2997,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Tool" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Tools" : {
|
"Tools" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3029,6 +3088,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Trans fat content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Unable to complete action." : {
|
"Unable to complete action." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3079,7 +3141,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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 überprüfe die Internetverbindung."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -3101,7 +3163,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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 überprüfe die Internetverbindung."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -3117,6 +3179,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Unsaturated fat content" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Upload" : {
|
"Upload" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3185,7 +3250,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Username: %@" : {
|
"Username: %@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nutzername: %@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Validate" : {
|
"Validate" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3236,7 +3308,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "Sie sind bereit zum Kochen 🍓"
|
"value" : "Bereit zum Kochen 🍓"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
@@ -3258,7 +3330,7 @@
|
|||||||
"de" : {
|
"de" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"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" : {
|
"es" : {
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ struct ApiRequest {
|
|||||||
let authString: String?
|
let authString: String?
|
||||||
let headerFields: [HeaderField]
|
let headerFields: [HeaderField]
|
||||||
let body: Data?
|
let body: Data?
|
||||||
|
|
||||||
/// The path to the Cookbook application on the nextcloud server.
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
path: String,
|
path: String,
|
||||||
method: RequestMethod,
|
method: RequestMethod,
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
//
|
|
||||||
// CustomError.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 13.09.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public enum NotImplementedError: Error, CustomStringConvertible {
|
|
||||||
case notImplemented
|
|
||||||
public var description: String {
|
|
||||||
return "Function not implemented."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum NetworkError: String, Error {
|
|
||||||
case missingUrl = "Missing URL."
|
|
||||||
case parametersNil = "Parameters are nil."
|
|
||||||
case encodingFailed = "Parameter encoding failed."
|
|
||||||
case decodingFailed = "Data decoding failed."
|
|
||||||
case redirectionError = "Redirection error"
|
|
||||||
case clientError = "Client error"
|
|
||||||
case serverError = "Server error"
|
|
||||||
case invalidRequest = "Invalid request"
|
|
||||||
case unknownError = "Unknown error"
|
|
||||||
case dataError = "Invalid data error."
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum ServerError: Error {
|
|
||||||
case unknownError, missingRequestBody, duplicateRecipe, noImage, missingRecipeName, recipeNotFound, deleteFailed, requestUnsuccessful
|
|
||||||
|
|
||||||
|
|
||||||
static func decodeFromURLResponse(response: HTTPURLResponse?) -> ServerError? {
|
|
||||||
guard let response = response else {
|
|
||||||
return ServerError.unknownError
|
|
||||||
}
|
|
||||||
print("Status code: ", response.statusCode)
|
|
||||||
switch response.statusCode {
|
|
||||||
case 200...299: return nil
|
|
||||||
case 400: return .missingRequestBody
|
|
||||||
case 404: return .recipeNotFound
|
|
||||||
case 409: return .duplicateRecipe
|
|
||||||
case 406: return .noImage
|
|
||||||
case 422: return .missingRecipeName
|
|
||||||
case 500: return .requestUnsuccessful
|
|
||||||
case 502: return .deleteFailed
|
|
||||||
default: return ServerError.unknownError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var localizedDescription: LocalizedStringKey {
|
|
||||||
switch self {
|
|
||||||
case .noImage: return "The recipe has no image whose MIME type matches the Accept header"
|
|
||||||
case .missingRecipeName: return "There was no name in the request given for the recipe. Cannot save the recipe."
|
|
||||||
default: return "An unknown server error occured."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var localizedTitle: LocalizedStringKey {
|
|
||||||
switch self {
|
|
||||||
case .missingRequestBody: return "Missing Request Body"
|
|
||||||
case .duplicateRecipe: return "Duplicate Recipe"
|
|
||||||
case .noImage: return "Image MIME Error"
|
|
||||||
case .missingRecipeName: return "Missing Name"
|
|
||||||
default: return "Error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
23
Nextcloud Cookbook iOS Client/Network/NetworkError.swift
Normal file
23
Nextcloud Cookbook iOS Client/Network/NetworkError.swift
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// CustomError.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 13.09.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum NetworkError: String, Error {
|
||||||
|
case missingUrl = "Missing URL."
|
||||||
|
case parametersNil = "Parameters are nil."
|
||||||
|
case encodingFailed = "Parameter encoding failed."
|
||||||
|
case decodingFailed = "Data decoding failed."
|
||||||
|
case redirectionError = "Redirection error"
|
||||||
|
case clientError = "Client error"
|
||||||
|
case serverError = "Server error"
|
||||||
|
case invalidRequest = "Invalid request"
|
||||||
|
case unknownError = "Unknown error"
|
||||||
|
case dataError = "Invalid data error."
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
//
|
|
||||||
// NetworkHandler.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 13.09.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct NetworkHandler {
|
|
||||||
static func sendHTTPRequest(
|
|
||||||
_ requestWrapper: RequestWrapper,
|
|
||||||
hostPath: String,
|
|
||||||
authString: String?
|
|
||||||
) async throws -> (Data?, NetworkError?) {
|
|
||||||
print("Sending \(requestWrapper.getMethod()) request (path: \(requestWrapper.getPath())) ...")
|
|
||||||
|
|
||||||
// Prepare URL
|
|
||||||
let urlString = hostPath + requestWrapper.getPath()
|
|
||||||
let urlStringSanitized = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
|
|
||||||
let url = URL(string: urlStringSanitized!)!
|
|
||||||
|
|
||||||
// Create URL request
|
|
||||||
var request = URLRequest(url: url)
|
|
||||||
|
|
||||||
// Set URL method
|
|
||||||
request.httpMethod = requestWrapper.getMethod()
|
|
||||||
|
|
||||||
// Set authentication string, if needed
|
|
||||||
if let authString = authString {
|
|
||||||
request.setValue(
|
|
||||||
"Basic \(authString)",
|
|
||||||
forHTTPHeaderField: "Authorization"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set other header fields
|
|
||||||
for headerField in requestWrapper.getHeaderFields() {
|
|
||||||
request.setValue(
|
|
||||||
headerField.getValue(),
|
|
||||||
forHTTPHeaderField: headerField.getField()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set http body
|
|
||||||
if let body = requestWrapper.getBody() {
|
|
||||||
request.httpBody = body
|
|
||||||
}
|
|
||||||
|
|
||||||
print("Request:\nMethod: \(request.httpMethod)\nPath: \(request.url?.absoluteString)\nHeaders: \(request.allHTTPHeaderFields)\nBody: \(request.httpBody)")
|
|
||||||
|
|
||||||
// Wait for and return data and (decoded) response
|
|
||||||
var data: Data? = nil
|
|
||||||
var response: URLResponse? = nil
|
|
||||||
do {
|
|
||||||
(data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
print("Response: ", response)
|
|
||||||
print("Data: ", data?.description, data, String(data: data ?? Data(), encoding: .utf8))
|
|
||||||
return (data, nil)
|
|
||||||
} catch {
|
|
||||||
return (nil, decodeURLResponse(response: response as? HTTPURLResponse))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func decodeURLResponse(response: HTTPURLResponse?) -> NetworkError? {
|
|
||||||
guard let response = response else {
|
|
||||||
return NetworkError.unknownError
|
|
||||||
}
|
|
||||||
switch response.statusCode {
|
|
||||||
case 200...299: return (nil)
|
|
||||||
case 300...399: return (NetworkError.redirectionError)
|
|
||||||
case 400...499: return (NetworkError.clientError)
|
|
||||||
case 500...599: return (NetworkError.serverError)
|
|
||||||
case 600: return (NetworkError.invalidRequest)
|
|
||||||
default: return (NetworkError.unknownError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
//
|
|
||||||
// NetworkRequests.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 13.09.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum RequestMethod: String {
|
|
||||||
case GET = "GET",
|
|
||||||
POST = "POST",
|
|
||||||
PUT = "PUT",
|
|
||||||
DELETE = "DELETE"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum ContentType: String {
|
|
||||||
case JSON = "application/json",
|
|
||||||
IMAGE = "image/jpeg",
|
|
||||||
FORM = "application/x-www-form-urlencoded"
|
|
||||||
}
|
|
||||||
|
|
||||||
struct HeaderField {
|
|
||||||
private let _field: String
|
|
||||||
private let _value: String
|
|
||||||
|
|
||||||
func getField() -> String {
|
|
||||||
return _field
|
|
||||||
}
|
|
||||||
|
|
||||||
func getValue() -> String {
|
|
||||||
return _value
|
|
||||||
}
|
|
||||||
|
|
||||||
static func accept(value: ContentType) -> HeaderField {
|
|
||||||
return HeaderField(_field: "accept", _value: value.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func ocsRequest(value: Bool) -> HeaderField {
|
|
||||||
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func contentType(value: ContentType) -> HeaderField {
|
|
||||||
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
enum RequestPath {
|
|
||||||
case CATEGORIES,
|
|
||||||
RECIPE_LIST(categoryName: String),
|
|
||||||
RECIPE_DETAIL(recipeId: Int),
|
|
||||||
NEW_RECIPE,
|
|
||||||
IMAGE(recipeId: Int, thumb: Bool),
|
|
||||||
CONFIG,
|
|
||||||
KEYWORDS
|
|
||||||
|
|
||||||
case LOGINV2REQ,
|
|
||||||
CUSTOM(path: String),
|
|
||||||
NONE
|
|
||||||
|
|
||||||
var stringValue: String {
|
|
||||||
switch self {
|
|
||||||
case .CATEGORIES: return "categories"
|
|
||||||
case .RECIPE_LIST(categoryName: let name): return "category/\(name)"
|
|
||||||
case .RECIPE_DETAIL(recipeId: let recipeId): return "recipes/\(recipeId)"
|
|
||||||
case .IMAGE(recipeId: let recipeId, thumb: let thumb): return "recipes/\(recipeId)/image?size=\(thumb ? "thumb" : "full")"
|
|
||||||
case .NEW_RECIPE: return "recipes"
|
|
||||||
case .CONFIG: return "config"
|
|
||||||
case .KEYWORDS: return "keywords"
|
|
||||||
|
|
||||||
case .LOGINV2REQ: return "/index.php/login/v2"
|
|
||||||
case .CUSTOM(path: let path): return path
|
|
||||||
case .NONE: return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RequestWrapper {
|
|
||||||
private let _method: RequestMethod
|
|
||||||
private let _path: RequestPath
|
|
||||||
private let _headerFields: [HeaderField]
|
|
||||||
private let _body: Data?
|
|
||||||
private let _authenticate: Bool = true
|
|
||||||
|
|
||||||
private init(
|
|
||||||
method: RequestMethod,
|
|
||||||
path: RequestPath,
|
|
||||||
headerFields: [HeaderField] = [],
|
|
||||||
body: Data? = nil,
|
|
||||||
authenticate: Bool = true
|
|
||||||
) {
|
|
||||||
self._method = method
|
|
||||||
self._path = path
|
|
||||||
self._headerFields = headerFields
|
|
||||||
self._body = body
|
|
||||||
}
|
|
||||||
|
|
||||||
func getMethod() -> String {
|
|
||||||
return self._method.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPath() -> String {
|
|
||||||
return self._path.stringValue
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHeaderFields() -> [HeaderField] {
|
|
||||||
return self._headerFields
|
|
||||||
}
|
|
||||||
|
|
||||||
func getBody() -> Data? {
|
|
||||||
return _body
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsAuth() -> Bool {
|
|
||||||
return _authenticate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension RequestWrapper {
|
|
||||||
static func customRequest(
|
|
||||||
method: RequestMethod,
|
|
||||||
path: RequestPath,
|
|
||||||
headerFields: [HeaderField] = [],
|
|
||||||
body: Data? = nil,
|
|
||||||
authenticate: Bool = true
|
|
||||||
) -> RequestWrapper {
|
|
||||||
let request = RequestWrapper(
|
|
||||||
method: method,
|
|
||||||
path: path,
|
|
||||||
headerFields: headerFields,
|
|
||||||
body: body,
|
|
||||||
authenticate: authenticate
|
|
||||||
)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
static func jsonGetRequest(path: RequestPath) -> RequestWrapper {
|
|
||||||
let headerFields = [
|
|
||||||
HeaderField.ocsRequest(value: true),
|
|
||||||
HeaderField.accept(value: .JSON)
|
|
||||||
]
|
|
||||||
let request = RequestWrapper(
|
|
||||||
method: .GET,
|
|
||||||
path: path,
|
|
||||||
headerFields: headerFields,
|
|
||||||
authenticate: true
|
|
||||||
)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
|
|
||||||
static func imageRequest(path: RequestPath) -> RequestWrapper {
|
|
||||||
let headerFields = [
|
|
||||||
HeaderField.ocsRequest(value: true),
|
|
||||||
HeaderField.accept(value: .IMAGE)
|
|
||||||
]
|
|
||||||
let request = RequestWrapper(
|
|
||||||
method: .GET,
|
|
||||||
path: path,
|
|
||||||
headerFields: headerFields,
|
|
||||||
authenticate: true
|
|
||||||
)
|
|
||||||
return request
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
50
Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift
Normal file
50
Nextcloud Cookbook iOS Client/Network/NetworkUtils.swift
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//
|
||||||
|
// NetworkRequests.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 13.09.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum RequestMethod: String {
|
||||||
|
case GET = "GET",
|
||||||
|
POST = "POST",
|
||||||
|
PUT = "PUT",
|
||||||
|
DELETE = "DELETE"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ContentType: String {
|
||||||
|
case JSON = "application/json",
|
||||||
|
IMAGE = "image/jpeg",
|
||||||
|
FORM = "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HeaderField {
|
||||||
|
private let _field: String
|
||||||
|
private let _value: String
|
||||||
|
|
||||||
|
func getField() -> String {
|
||||||
|
return _field
|
||||||
|
}
|
||||||
|
|
||||||
|
func getValue() -> String {
|
||||||
|
return _value
|
||||||
|
}
|
||||||
|
|
||||||
|
static func accept(value: ContentType) -> HeaderField {
|
||||||
|
return HeaderField(_field: "accept", _value: value.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ocsRequest(value: Bool) -> HeaderField {
|
||||||
|
return HeaderField(_field: "OCS-APIRequest", _value: value ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func contentType(value: ContentType) -> HeaderField {
|
||||||
|
return HeaderField(_field: "Content-Type", _value: value.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecipeImportRequest: Codable {
|
||||||
|
let url: String
|
||||||
|
}
|
||||||
@@ -1,553 +0,0 @@
|
|||||||
//
|
|
||||||
// RecipeDetailView.swift
|
|
||||||
// Nextcloud Cookbook iOS Client
|
|
||||||
//
|
|
||||||
// Created by Vincent Meilinger on 15.09.23.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
|
|
||||||
struct RecipeDetailView: View {
|
|
||||||
@ObservedObject var viewModel: AppState
|
|
||||||
@State var recipe: Recipe
|
|
||||||
@State var recipeDetail: RecipeDetail?
|
|
||||||
@State var recipeImage: UIImage?
|
|
||||||
@State var showTitle: Bool = false
|
|
||||||
@State var isDownloaded: Bool? = nil
|
|
||||||
@State private var presentEditView: Bool = false
|
|
||||||
@State private var presentNutritionPopover: Bool = false
|
|
||||||
@State private var presentKeywordPopover: Bool = false
|
|
||||||
@State private var presentShareSheet: Bool = false
|
|
||||||
@State private var sharedURL: URL? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
ZStack {
|
|
||||||
if let recipeImage = recipeImage {
|
|
||||||
Image(uiImage: recipeImage)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(maxHeight: 300)
|
|
||||||
.clipped()
|
|
||||||
}
|
|
||||||
}.animation(.easeInOut, value: recipeImage)
|
|
||||||
|
|
||||||
if let recipeDetail = recipeDetail {
|
|
||||||
LazyVStack (alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
Text(recipeDetail.name)
|
|
||||||
.font(.title)
|
|
||||||
.bold()
|
|
||||||
.padding()
|
|
||||||
.onDisappear {
|
|
||||||
showTitle = true
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
showTitle = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if let isDownloaded = isDownloaded {
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if recipeDetail.description != "" {
|
|
||||||
Text(recipeDetail.description)
|
|
||||||
.padding([.bottom, .horizontal])
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
RecipeDurationSection(viewModel: viewModel, recipeDetail: recipeDetail)
|
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
|
||||||
if(!recipeDetail.recipeIngredient.isEmpty) {
|
|
||||||
RecipeIngredientSection(recipeDetail: recipeDetail)
|
|
||||||
}
|
|
||||||
if(!recipeDetail.recipeInstructions.isEmpty) {
|
|
||||||
RecipeInstructionSection(recipeDetail: recipeDetail)
|
|
||||||
}
|
|
||||||
if(!recipeDetail.tool.isEmpty) {
|
|
||||||
RecipeToolSection(recipeDetail: recipeDetail)
|
|
||||||
}
|
|
||||||
RecipeNutritionSection(recipeDetail: recipeDetail)
|
|
||||||
RecipeKeywordSection(recipeDetail: recipeDetail)
|
|
||||||
MoreInformationSection(recipeDetail: recipeDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
}.padding(.horizontal, 5)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
|
||||||
.navigationTitle(showTitle ? recipe.name : "")
|
|
||||||
.toolbar {
|
|
||||||
if recipeDetail != nil {
|
|
||||||
Menu {
|
|
||||||
Button {
|
|
||||||
presentEditView = true
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text("Edit")
|
|
||||||
Image(systemName: "pencil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button {
|
|
||||||
print("Sharing recipe ...")
|
|
||||||
self.presentShareSheet = true
|
|
||||||
} label: {
|
|
||||||
Text("Share recipe")
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "ellipsis.circle")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $presentEditView) {
|
|
||||||
if let recipeDetail = recipeDetail {
|
|
||||||
RecipeEditView(
|
|
||||||
viewModel:
|
|
||||||
RecipeEditViewModel(
|
|
||||||
mainViewModel: viewModel,
|
|
||||||
recipeDetail: recipeDetail,
|
|
||||||
uploadNew: false
|
|
||||||
),
|
|
||||||
isPresented: $presentEditView
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $presentShareSheet) {
|
|
||||||
if let recipeDetail = recipeDetail {
|
|
||||||
ShareView(recipeDetail: recipeDetail,
|
|
||||||
recipeImage: recipeImage,
|
|
||||||
presentShareSheet: $presentShareSheet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task {
|
|
||||||
recipeDetail = await viewModel.getRecipe(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
|
||||||
)
|
|
||||||
recipeImage = await viewModel.getImage(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
size: .FULL,
|
|
||||||
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
|
||||||
)
|
|
||||||
if recipe.storedLocally == nil {
|
|
||||||
recipe.storedLocally = viewModel.recipeDetailExists(recipeId: recipe.recipe_id)
|
|
||||||
}
|
|
||||||
self.isDownloaded = recipe.storedLocally
|
|
||||||
}
|
|
||||||
.refreshable {
|
|
||||||
recipeDetail = await viewModel.getRecipe(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
|
||||||
)
|
|
||||||
recipeImage = await viewModel.getImage(
|
|
||||||
id: recipe.recipe_id,
|
|
||||||
size: .FULL,
|
|
||||||
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
if UserSettings.shared.keepScreenAwake {
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ShareView: View {
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
@State var recipeImage: UIImage?
|
|
||||||
@Binding var presentShareSheet: Bool
|
|
||||||
|
|
||||||
@State var exporter = RecipeExporter()
|
|
||||||
@State var sharedURL: URL? = nil
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
if let url = sharedURL {
|
|
||||||
ShareLink(item: url, subject: Text("PDF Document")) {
|
|
||||||
Image(systemName: "doc")
|
|
||||||
Text("Share as PDF")
|
|
||||||
}
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.bold()
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
ShareLink(item: exporter.createText(recipe: recipeDetail), subject: Text("Recipe")) {
|
|
||||||
Image(systemName: "ellipsis.message")
|
|
||||||
Text("Share as text")
|
|
||||||
}
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.bold()
|
|
||||||
.padding()
|
|
||||||
|
|
||||||
/*ShareLink(item: exporter.createJson(recipe: recipeDetail), subject: Text("Recipe")) {
|
|
||||||
Image(systemName: "doc.badge.gearshape")
|
|
||||||
Text("Share as JSON")
|
|
||||||
}
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.bold()
|
|
||||||
.padding()
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
self.sharedURL = exporter.createPDF(recipe: recipeDetail, image: recipeImage)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeDurationSection: View {
|
|
||||||
@ObservedObject var viewModel: AppState
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 250), alignment: .leading)]) {
|
|
||||||
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Preparation"))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Text(time)
|
|
||||||
.lineLimit(1)
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
|
||||||
TimerView(timer: viewModel.getTimer(forRecipe: recipeDetail.id, duration: DurationComponents.fromPTString(cookTime)))
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Cooking"))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Text(time)
|
|
||||||
.lineLimit(1)
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let totalTime = recipeDetail.totalTime, let time = DurationComponents.ptToText(totalTime) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Total time"))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
Text(time)
|
|
||||||
.lineLimit(1)
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeNutritionSection: View {
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack() {
|
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
|
||||||
Group {
|
|
||||||
if let nutritionList = recipeDetail.getNutritionList() {
|
|
||||||
RecipeListSection(list: nutritionList)
|
|
||||||
} else {
|
|
||||||
Text(LocalizedStringKey("No nutritional information."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} title: {
|
|
||||||
HStack {
|
|
||||||
if let servingSize = recipeDetail.nutrition["servingSize"] {
|
|
||||||
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
|
||||||
} else {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeKeywordSection: View {
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
|
||||||
Group {
|
|
||||||
if let keywords = getKeywords() {
|
|
||||||
RecipeListSection(list: keywords)
|
|
||||||
} else {
|
|
||||||
Text(LocalizedStringKey("No keywords."))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} title: {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Keywords"))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getKeywords() -> [String]? {
|
|
||||||
let keywords = recipeDetail.keywords.components(separatedBy: ",")
|
|
||||||
return keywords.isEmpty ? nil : keywords
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct MoreInformationSection: View {
|
|
||||||
let recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateCreated) ?? "")")
|
|
||||||
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: recipeDetail.dateModified) ?? "")")
|
|
||||||
if recipeDetail.url != "", let url = URL(string: recipeDetail.url) {
|
|
||||||
HStack() {
|
|
||||||
Text("URL:")
|
|
||||||
Link(destination: url) {
|
|
||||||
Text(recipeDetail.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(Color.secondary)
|
|
||||||
} title: {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: "More information")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeIngredientSection: View {
|
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
if recipeDetail.recipeYield == 0 {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
|
||||||
} else if recipeDetail.recipeYield == 1 {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
|
|
||||||
} else {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
if groceryList.containsRecipe(recipeDetail.id) {
|
|
||||||
groceryList.deleteGroceryRecipe(recipeDetail.id)
|
|
||||||
} else {
|
|
||||||
groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
|
||||||
IngredientListItem(ingredient: ingredient, recipeId: recipeDetail.id) {
|
|
||||||
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
|
|
||||||
}
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeToolSection: View {
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: "Tools")
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
RecipeListSection(list: recipeDetail.tool)
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct IngredientListItem: View {
|
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
|
||||||
@State var ingredient: String
|
|
||||||
@State var recipeId: String
|
|
||||||
let addToGroceryListAction: () -> Void
|
|
||||||
@State var isSelected: Bool = false
|
|
||||||
|
|
||||||
// Drag animation
|
|
||||||
@State private var dragOffset: CGFloat = 0
|
|
||||||
@State private var animationStartOffset: CGFloat = 0
|
|
||||||
let maxDragDistance = 50.0
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
|
||||||
if #available(iOS 17.0, *) {
|
|
||||||
Image(systemName: "storefront")
|
|
||||||
.foregroundStyle(Color.green)
|
|
||||||
} else {
|
|
||||||
Image(systemName: "heart.text.square")
|
|
||||||
.foregroundStyle(Color.green)
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if isSelected {
|
|
||||||
Image(systemName: "checkmark.circle")
|
|
||||||
} else {
|
|
||||||
Image(systemName: "circle")
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("\(ingredient)")
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
.lineLimit(5)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
|
||||||
.onTapGesture {
|
|
||||||
isSelected.toggle()
|
|
||||||
}
|
|
||||||
.offset(x: dragOffset, y: 0)
|
|
||||||
.animation(.easeInOut, value: isSelected)
|
|
||||||
|
|
||||||
.gesture(
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { gesture in
|
|
||||||
// Update drag offset as the user drags
|
|
||||||
if animationStartOffset == 0 {
|
|
||||||
animationStartOffset = gesture.translation.width
|
|
||||||
}
|
|
||||||
let dragAmount = gesture.translation.width
|
|
||||||
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
|
|
||||||
self.dragOffset = max(0, offset)
|
|
||||||
}
|
|
||||||
.onEnded { gesture in
|
|
||||||
withAnimation {
|
|
||||||
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
|
||||||
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
|
||||||
} else {
|
|
||||||
addToGroceryListAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
// Animate back to original position
|
|
||||||
|
|
||||||
self.dragOffset = 0
|
|
||||||
self.animationStartOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeListSection: View {
|
|
||||||
@State var list: [String]
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
ForEach(list, id: \.self) { item in
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
Text("\u{2022}")
|
|
||||||
Text("\(item)")
|
|
||||||
.multilineTextAlignment(.leading)
|
|
||||||
}
|
|
||||||
.padding(4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeInstructionSection: View {
|
|
||||||
@State var recipeDetail: RecipeDetail
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
HStack {
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
ForEach(0..<recipeDetail.recipeInstructions.count) { ix in
|
|
||||||
RecipeInstructionListItem(instruction: recipeDetail.recipeInstructions[ix], index: ix+1)
|
|
||||||
}
|
|
||||||
}.padding()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeInstructionListItem: View {
|
|
||||||
@State var instruction: String
|
|
||||||
@State var index: Int
|
|
||||||
@State var isSelected: Bool = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top) {
|
|
||||||
Text("\(index)")
|
|
||||||
.monospaced()
|
|
||||||
Text(instruction)
|
|
||||||
}.padding(4)
|
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
|
||||||
.onTapGesture {
|
|
||||||
isSelected.toggle()
|
|
||||||
}
|
|
||||||
.animation(.easeInOut, value: isSelected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct SecondaryLabel: View {
|
|
||||||
let text: LocalizedStringKey
|
|
||||||
var body: some View {
|
|
||||||
Text(text)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.font(.headline)
|
|
||||||
.padding(.vertical, 5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct CategoryDetailView: View {
|
struct RecipeListView: View {
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
@ObservedObject var viewModel: AppState
|
@ObservedObject var viewModel: AppState
|
||||||
@@ -36,7 +36,7 @@ struct CategoryDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeDetailView(viewModel: viewModel, recipe: recipe)
|
RecipeView(appState: viewModel, viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
}
|
}
|
||||||
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
633
Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
Normal file
633
Nextcloud Cookbook iOS Client/Views/Recipes/RecipeView.swift
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
//
|
||||||
|
// RecipeDetailView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 15.09.23.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct RecipeView: View {
|
||||||
|
@ObservedObject var appState: AppState
|
||||||
|
@StateObject var viewModel: ViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ZStack {
|
||||||
|
if let recipeImage = viewModel.recipeImage {
|
||||||
|
Image(uiImage: recipeImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxHeight: 300)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
}.animation(.easeInOut, value: viewModel.recipeImage)
|
||||||
|
|
||||||
|
|
||||||
|
LazyVStack (alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
EditableText(text: $viewModel.recipeDetail.name, editMode: $viewModel.editMode)
|
||||||
|
.font(.title)
|
||||||
|
.bold()
|
||||||
|
.padding()
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.showTitle = true
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
viewModel.showTitle = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if let isDownloaded = viewModel.isDownloaded {
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.recipeDetail.description != "" || viewModel.editMode {
|
||||||
|
EditableText(text: $viewModel.recipeDetail.description, editMode: $viewModel.editMode, lineLimit: 0...10, axis: .vertical)
|
||||||
|
.padding([.bottom, .horizontal])
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
RecipeDurationSection(viewModel: appState, recipeDetail: viewModel.recipeDetail)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
|
if(!viewModel.recipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
|
||||||
|
RecipeIngredientSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
if(!viewModel.recipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
|
||||||
|
RecipeInstructionSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
if(!viewModel.recipeDetail.tool.isEmpty || viewModel.editMode) {
|
||||||
|
RecipeToolSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
RecipeNutritionSection(viewModel: viewModel)
|
||||||
|
RecipeKeywordSection(viewModel: viewModel)
|
||||||
|
MoreInformationSection(recipeDetail: viewModel.recipeDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
}.padding(.horizontal, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
|
||||||
|
.toolbar {
|
||||||
|
if viewModel.editMode {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.editMode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
// TODO: POST edited recipe
|
||||||
|
viewModel.editMode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
viewModel.editMode = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Edit")
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
print("Sharing recipe ...")
|
||||||
|
viewModel.presentShareSheet = true
|
||||||
|
} label: {
|
||||||
|
Text("Share recipe")
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $viewModel.presentShareSheet) {
|
||||||
|
ShareView(recipeDetail: viewModel.recipeDetail,
|
||||||
|
recipeImage: viewModel.recipeImage,
|
||||||
|
presentShareSheet: $viewModel.presentShareSheet)
|
||||||
|
}
|
||||||
|
|
||||||
|
.task {
|
||||||
|
viewModel.recipeDetail = await appState.getRecipe(
|
||||||
|
id: viewModel.recipe.recipe_id,
|
||||||
|
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||||
|
) ?? RecipeDetail.error
|
||||||
|
viewModel.recipeImage = await appState.getImage(
|
||||||
|
id: viewModel.recipe.recipe_id,
|
||||||
|
size: .FULL,
|
||||||
|
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
if viewModel.recipe.storedLocally == nil {
|
||||||
|
viewModel.recipe.storedLocally = appState.recipeDetailExists(recipeId: viewModel.recipe.recipe_id)
|
||||||
|
}
|
||||||
|
viewModel.isDownloaded = viewModel.recipe.storedLocally
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.recipeDetail = await appState.getRecipe(
|
||||||
|
id: viewModel.recipe.recipe_id,
|
||||||
|
fetchMode: UserSettings.shared.storeRecipes ? .preferServer : .onlyServer
|
||||||
|
) ?? RecipeDetail.error
|
||||||
|
viewModel.recipeImage = await appState.getImage(
|
||||||
|
id: viewModel.recipe.recipe_id,
|
||||||
|
size: .FULL,
|
||||||
|
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if UserSettings.shared.keepScreenAwake {
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - RecipeView ViewModel
|
||||||
|
|
||||||
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
|
||||||
|
@Published var recipeImage: UIImage? = nil
|
||||||
|
@Published var editMode: Bool = false
|
||||||
|
@Published var presentShareSheet: Bool = false
|
||||||
|
@Published var showTitle: Bool = false
|
||||||
|
@Published var isDownloaded: Bool? = nil
|
||||||
|
|
||||||
|
@Published var keywords: [String] = []
|
||||||
|
@Published var nutrition: [String] = []
|
||||||
|
|
||||||
|
var recipe: Recipe
|
||||||
|
var sharedURL: URL? = nil
|
||||||
|
|
||||||
|
|
||||||
|
init(recipe: Recipe) {
|
||||||
|
self.recipe = recipe
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupView(recipeDetail: RecipeDetail) {
|
||||||
|
self.keywords = recipeDetail.keywords.components(separatedBy: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Duration Section
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Nutrition Section
|
||||||
|
|
||||||
|
fileprivate struct RecipeNutritionSection: View {
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
|
||||||
|
Group {
|
||||||
|
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 !viewModel.recipeDetail.nutrition.isEmpty {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
ForEach(Nutrition.allCases, id: \.self) { nutrition in
|
||||||
|
if let value = viewModel.recipeDetail.nutrition[nutrition.dictKey] {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(nutrition.localizedDescription)
|
||||||
|
Text(":")
|
||||||
|
Text(value)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey("No nutritional information."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} title: {
|
||||||
|
HStack {
|
||||||
|
if let servingSize = viewModel.recipeDetail.nutrition["servingSize"] {
|
||||||
|
SecondaryLabel(text: "Nutrition (\(servingSize))")
|
||||||
|
} else {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
func binding(for key: String) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { viewModel.recipeDetail.nutrition[key, default: ""] },
|
||||||
|
set: { viewModel.recipeDetail.nutrition[key] = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Keyword Section
|
||||||
|
|
||||||
|
fileprivate struct RecipeKeywordSection: View {
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
@State var keywords: [String] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
|
||||||
|
Group {
|
||||||
|
if !keywords.isEmpty || viewModel.editMode {
|
||||||
|
//RecipeListSection(list: keywords)
|
||||||
|
EditableStringList(items: $keywords, editMode: $viewModel.editMode, titleKey: "Keyword", lineLimit: 0...1, axis: .horizontal) {
|
||||||
|
RecipeListSection(list: keywords)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(LocalizedStringKey("No keywords."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
self.keywords = viewModel.recipeDetail.keywords.components(separatedBy: ",")
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
viewModel.recipeDetail.keywords = keywords.joined(separator: ",")
|
||||||
|
}
|
||||||
|
} title: {
|
||||||
|
HStack {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Keywords"))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - More Information Section
|
||||||
|
|
||||||
|
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 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 SecondaryLabel: View {
|
||||||
|
let text: LocalizedStringKey
|
||||||
|
var body: some View {
|
||||||
|
Text(text)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Ingredients Section
|
||||||
|
|
||||||
|
fileprivate struct RecipeIngredientSection: View {
|
||||||
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
if viewModel.recipeDetail.recipeYield == 0 {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
|
} else if viewModel.recipeDetail.recipeYield == 1 {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Ingredients per serving"))
|
||||||
|
} else {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(viewModel.recipeDetail.recipeYield) servings"))
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
if groceryList.containsRecipe(viewModel.recipeDetail.id) {
|
||||||
|
groceryList.deleteGroceryRecipe(viewModel.recipeDetail.id)
|
||||||
|
} else {
|
||||||
|
groceryList.addItems(
|
||||||
|
viewModel.recipeDetail.recipeIngredient,
|
||||||
|
toRecipe: viewModel.recipeDetail.id,
|
||||||
|
recipeName: viewModel.recipeDetail.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
|
Image(systemName: "storefront")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "heart.text.square")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditableStringList(items: $viewModel.recipeDetail.recipeIngredient, editMode: $viewModel.editMode, titleKey: "Ingredient", lineLimit: 0...1, axis: .horizontal) {
|
||||||
|
ForEach(0..<viewModel.recipeDetail.recipeIngredient.count, id: \.self) { ix in
|
||||||
|
IngredientListItem(ingredient: viewModel.recipeDetail.recipeIngredient[ix], recipeId: viewModel.recipeDetail.id) {
|
||||||
|
groceryList.addItem(
|
||||||
|
viewModel.recipeDetail.recipeIngredient[ix],
|
||||||
|
toRecipe: viewModel.recipeDetail.id,
|
||||||
|
recipeName: viewModel.recipeDetail.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Instructions Section
|
||||||
|
|
||||||
|
fileprivate struct RecipeInstructionSection: View {
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
SecondaryLabel(text: LocalizedStringKey("Instructions"))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
EditableStringList(items: $viewModel.recipeDetail.recipeInstructions, editMode: $viewModel.editMode, titleKey: "Instruction", lineLimit: 0...15, axis: .vertical) {
|
||||||
|
ForEach(0..<viewModel.recipeDetail.recipeInstructions.count, id: \.self) { ix in
|
||||||
|
RecipeInstructionListItem(instruction: viewModel.recipeDetail.recipeInstructions[ix], index: ix+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct RecipeInstructionListItem: View {
|
||||||
|
@State var instruction: String
|
||||||
|
@State var index: Int
|
||||||
|
@State var isSelected: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text("\(index)")
|
||||||
|
.monospaced()
|
||||||
|
Text(instruction)
|
||||||
|
}.padding(4)
|
||||||
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
|
.onTapGesture {
|
||||||
|
isSelected.toggle()
|
||||||
|
}
|
||||||
|
.animation(.easeInOut, value: isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Tool Section
|
||||||
|
|
||||||
|
fileprivate struct RecipeToolSection: View {
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
HStack {
|
||||||
|
SecondaryLabel(text: "Tools")
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
EditableStringList(items: $viewModel.recipeDetail.tool, editMode: $viewModel.editMode, titleKey: "Tool", lineLimit: 0...1, axis: .horizontal) {
|
||||||
|
RecipeListSection(list: viewModel.recipeDetail.tool)
|
||||||
|
}
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Generic Editable View Elements
|
||||||
|
|
||||||
|
fileprivate struct EditableText: View {
|
||||||
|
@Binding var text: String
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@State var titleKey: LocalizedStringKey = ""
|
||||||
|
@State var lineLimit: ClosedRange<Int> = 0...1
|
||||||
|
@State var axis: Axis = .horizontal
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if editMode {
|
||||||
|
TextField(titleKey, text: $text, axis: axis)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(lineLimit)
|
||||||
|
} else {
|
||||||
|
Text(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct EditableStringList<Content: View>: View {
|
||||||
|
@Binding var items: [String]
|
||||||
|
@Binding var editMode: Bool
|
||||||
|
@State var titleKey: LocalizedStringKey = ""
|
||||||
|
@State var lineLimit: ClosedRange<Int> = 0...50
|
||||||
|
@State var axis: Axis = .vertical
|
||||||
|
|
||||||
|
@State var editableItems: [ReorderableItem<String>] = []
|
||||||
|
|
||||||
|
var content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if editMode {
|
||||||
|
VStack {
|
||||||
|
ReorderableForEach(items: $editableItems, defaultItem: ReorderableItem(item: "")) { ix, item in
|
||||||
|
TextField("", text: $editableItems[ix].item, axis: axis)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(lineLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
editableItems = ReorderableItem.list(items: items)
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
items = ReorderableItem.items(editableItems)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift
Normal file
54
Nextcloud Cookbook iOS Client/Views/Recipes/ShareView.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//
|
||||||
|
// ReorderableForEach.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 15.02.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
|
||||||
|
struct ReorderableForEach<Item: Any, Content: View>: View {
|
||||||
|
@Binding var items: [ReorderableItem<Item>]
|
||||||
|
var defaultItem: ReorderableItem<Item>
|
||||||
|
var content: (Int, Item) -> Content
|
||||||
|
|
||||||
|
@State var draggedItemId: UUID? = nil
|
||||||
|
@State var allowDeletion: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack {
|
||||||
|
ForEach(Array(zip(items.indices, items)), id: \.1.id) { ix, item in
|
||||||
|
HStack {
|
||||||
|
if allowDeletion {
|
||||||
|
Button {
|
||||||
|
items.remove(at: ix)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle.fill")
|
||||||
|
.foregroundColor(.red)
|
||||||
|
.padding(5)
|
||||||
|
.bold()
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
content(ix, item.item)
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.padding(5)
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.foregroundStyle(.background)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.onDrag {
|
||||||
|
self.draggedItemId = item.id
|
||||||
|
return NSItemProvider(item: nil, typeIdentifier: item.id.uuidString)
|
||||||
|
} preview: {
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
.onDrop(of: [.plainText], delegate: DropViewDelegate(targetId: item.id, sourceId: $draggedItemId, items: $items))
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
allowDeletion.toggle()
|
||||||
|
} label: {
|
||||||
|
Text(allowDeletion ? "Disable deletion" : "Enable deletion")
|
||||||
|
.bold()
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.tint(Color.red)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
items.append(defaultItem)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.bold()
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}.animation(.default, value: allowDeletion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct ReorderableItem<Item: Any>: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var item: Item
|
||||||
|
|
||||||
|
static func list(items: [Item]) -> [ReorderableItem] {
|
||||||
|
items.map({ item in ReorderableItem(item: item) })
|
||||||
|
}
|
||||||
|
|
||||||
|
static func items(_ reorderableItems: [ReorderableItem]) -> [Item] {
|
||||||
|
reorderableItems.map { $0.item }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
struct DropViewDelegate<Item: Any>: DropDelegate {
|
||||||
|
let targetId: UUID
|
||||||
|
@Binding var sourceId : UUID?
|
||||||
|
@Binding var items: [ReorderableItem<Item>]
|
||||||
|
|
||||||
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropEntered(info: DropInfo) {
|
||||||
|
guard let sourceId = self.sourceId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if sourceId != targetId {
|
||||||
|
guard let sourceIndex = items.firstIndex(where: { $0.id == sourceId }),
|
||||||
|
let targetIndex = items.firstIndex(where: { $0.id == targetId })
|
||||||
|
else { return }
|
||||||
|
withAnimation(.default) {
|
||||||
|
self.items.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: targetIndex > sourceIndex ? targetIndex + 1 : targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,7 @@ struct RecipeTabView: View {
|
|||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let category = viewModel.selectedCategory {
|
if let category = viewModel.selectedCategory {
|
||||||
CategoryDetailView(
|
RecipeListView(
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
viewModel: mainViewModel,
|
viewModel: mainViewModel,
|
||||||
showEditView: $viewModel.presentEditView
|
showEditView: $viewModel.presentEditView
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct SearchTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeDetailView(viewModel: mainViewModel, recipe: recipe)
|
RecipeView(appState: mainViewModel, viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
}
|
}
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||||
}
|
}
|
||||||
|
|||||||
17
Nextcloud-Cookbook-iOS-Client-Info.plist
Normal file
17
Nextcloud-Cookbook-iOS-Client-Info.plist
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UTImportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIcons</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>com.cookbook-client.uuid</string>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict/>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in New Issue
Block a user