New Recipe Edit View

This commit is contained in:
VincentMeilinger
2024-02-18 12:25:18 +01:00
parent e88f1d329d
commit d3e0366ce6
23 changed files with 1261 additions and 1086 deletions

View File

@@ -15,11 +15,10 @@
A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; }; A701719E2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719D2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITests.swift */; };
A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; }; A70171A02AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A701719F2AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientUITestsLaunchTests.swift */; };
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; }; A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; };
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; }; A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* NetworkError.swift */; };
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; }; A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkUtils.swift */; };
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; }; A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
A70171BE2AB4987900064C43 /* CategoryDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* CategoryDetailView.swift */; }; A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
A70171C02AB498A900064C43 /* RecipeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeDetailView.swift */; };
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; }; A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; }; A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; }; A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
@@ -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;

View File

@@ -8,6 +8,7 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
struct Category: Codable { struct Category: Codable {
let name: String let name: String
let recipe_count: Int let recipe_count: Int
@@ -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

View 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"
}
}
}

View File

@@ -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" : {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,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 {

View 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()
}
}
}

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

View File

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

View File

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

View File

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

View 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>