@@ -14,7 +14,7 @@
|
|||||||
A70171942AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171932AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift */; };
|
A70171942AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171932AA8E72000064C43 /* Nextcloud_Cookbook_iOS_ClientTests.swift */; };
|
||||||
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 /* MainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* MainViewModel.swift */; };
|
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AC2AA8EF4700064C43 /* AppState.swift */; };
|
||||||
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; };
|
A70171AF2AB2116B00064C43 /* NetworkHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171AE2AB2116B00064C43 /* NetworkHandler.swift */; };
|
||||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; };
|
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B02AB211DF00064C43 /* CustomError.swift */; };
|
||||||
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; };
|
A70171B42AB2122900064C43 /* NetworkRequests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171B32AB2122900064C43 /* NetworkRequests.swift */; };
|
||||||
@@ -47,8 +47,13 @@
|
|||||||
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 */; };
|
||||||
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DD2B600300009783A9 /* SearchTabView.swift */; };
|
||||||
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0DF2B600318009783A9 /* RecipeTabView.swift */; };
|
||||||
|
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A977D0E12B60034E009783A9 /* GroceryListTabView.swift */; };
|
||||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||||
|
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
||||||
|
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -80,7 +85,7 @@
|
|||||||
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nextcloud Cookbook iOS ClientUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Nextcloud Cookbook iOS ClientUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
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 /* MainViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewModel.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>"; };
|
A70171AE2AB2116B00064C43 /* NetworkHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkHandler.swift; sourceTree = "<group>"; };
|
||||||
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; };
|
A70171B02AB211DF00064C43 /* CustomError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomError.swift; sourceTree = "<group>"; };
|
||||||
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; };
|
A70171B32AB2122900064C43 /* NetworkRequests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkRequests.swift; sourceTree = "<group>"; };
|
||||||
@@ -112,7 +117,12 @@
|
|||||||
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>"; };
|
||||||
|
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>"; };
|
||||||
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListTabView.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>"; };
|
||||||
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -145,6 +155,7 @@
|
|||||||
A70171752AA8E71900064C43 = {
|
A70171752AA8E71900064C43 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
A9FA2AB42B50798800A43702 /* Resources */,
|
||||||
A781E75E2AE9133B00452F6F /* Screenshots */,
|
A781E75E2AE9133B00452F6F /* Screenshots */,
|
||||||
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
||||||
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
||||||
@@ -174,6 +185,7 @@
|
|||||||
A781E75F2AF8228100452F6F /* RecipeImport */,
|
A781E75F2AF8228100452F6F /* RecipeImport */,
|
||||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
|
||||||
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
A703226B2ABAF60D00D7C4ED /* Extensions */,
|
||||||
|
A76B8A702AE002AE00096CEC /* Alerts.swift */,
|
||||||
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
|
A76B8A6E2ADFFA8800096CEC /* SupportedLanguage.swift */,
|
||||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
|
A7AEAE632AD5521400135378 /* Localizable.xcstrings */,
|
||||||
A70171852AA8E71F00064C43 /* Assets.xcassets */,
|
A70171852AA8E71F00064C43 /* Assets.xcassets */,
|
||||||
@@ -223,7 +235,7 @@
|
|||||||
A70171B72AB2445700064C43 /* ViewModels */ = {
|
A70171B72AB2445700064C43 /* ViewModels */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A70171AC2AA8EF4700064C43 /* MainViewModel.swift */,
|
A70171AC2AA8EF4700064C43 /* AppState.swift */,
|
||||||
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
A79AA8E12AFF8C14007D25F2 /* RecipeEditViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
@@ -232,17 +244,13 @@
|
|||||||
A70171BA2AB4980100064C43 /* Views */ = {
|
A70171BA2AB4980100064C43 /* Views */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A7FB0D782B25C65200A3469E /* Onboarding */,
|
|
||||||
A70171832AA8E71900064C43 /* MainView.swift */,
|
A70171832AA8E71900064C43 /* MainView.swift */,
|
||||||
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
|
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
|
||||||
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
|
|
||||||
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
|
|
||||||
A70171CC2AB501B100064C43 /* SettingsView.swift */,
|
A70171CC2AB501B100064C43 /* SettingsView.swift */,
|
||||||
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
|
A977D0DC2B6002DA009783A9 /* Tabs */,
|
||||||
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
|
A7FB0D782B25C65200A3469E /* Onboarding */,
|
||||||
A76B8A702AE002AE00096CEC /* Alerts.swift */,
|
A9C3BE502B630E3900562C79 /* Recipes */,
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
A9C3BE512B630E8300562C79 /* RecipeEditing */,
|
||||||
|
A9C3BE522B630F1300562C79 /* ReusableViews */,
|
||||||
);
|
);
|
||||||
path = Views;
|
path = Views;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -312,6 +320,45 @@
|
|||||||
path = Onboarding;
|
path = Onboarding;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A977D0DC2B6002DA009783A9 /* Tabs */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
|
);
|
||||||
|
path = Tabs;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A9C3BE502B630E3900562C79 /* Recipes */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A70171BD2AB4987900064C43 /* CategoryDetailView.swift */,
|
||||||
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
|
A70171BF2AB498A900064C43 /* RecipeDetailView.swift */,
|
||||||
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
|
);
|
||||||
|
path = Recipes;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A9C3BE512B630E8300562C79 /* RecipeEditing */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A70D7CA02AC73CA700D53DBF /* RecipeEditView.swift */,
|
||||||
|
A7F3F8E72ACBFC760076C227 /* KeywordPickerView.swift */,
|
||||||
|
A7F3F8E92ACC221C0076C227 /* CategoryPickerView.swift */,
|
||||||
|
);
|
||||||
|
path = RecipeEditing;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
|
||||||
|
);
|
||||||
|
path = ReusableViews;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = {
|
A9CA6CED2B4C084100F78AB5 /* RecipeExport */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -320,6 +367,14 @@
|
|||||||
path = RecipeExport;
|
path = RecipeExport;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A9FA2AB42B50798800A43702 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -435,6 +490,7 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */,
|
||||||
A701718A2AA8E71F00064C43 /* Preview Assets.xcassets in Resources */,
|
A701718A2AA8E71F00064C43 /* Preview Assets.xcassets in Resources */,
|
||||||
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */,
|
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */,
|
||||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */,
|
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */,
|
||||||
@@ -462,9 +518,11 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
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 */,
|
||||||
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
A70171B12AB211DF00064C43 /* CustomError.swift in Sources */,
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */,
|
||||||
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
A76B8A712AE002AE00096CEC /* Alerts.swift in Sources */,
|
||||||
@@ -489,12 +547,14 @@
|
|||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||||
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||||
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
|
||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */,
|
||||||
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
A74D33C32AFCD1C300D06555 /* RecipeScraper.swift in Sources */,
|
||||||
A70171AD2AA8EF4700064C43 /* MainViewModel.swift in Sources */,
|
A70171AD2AA8EF4700064C43 /* AppState.swift in Sources */,
|
||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||||
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -678,7 +738,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.7.1;
|
MARKETING_VERSION = 1.8.0;
|
||||||
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;
|
||||||
@@ -721,7 +781,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.7.1;
|
MARKETING_VERSION = 1.8.0;
|
||||||
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.
@@ -10,6 +10,22 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
class DurationComponents: ObservableObject {
|
class DurationComponents: ObservableObject {
|
||||||
|
@Published var secondComponent: String = "00" {
|
||||||
|
didSet {
|
||||||
|
if secondComponent.count > 2 {
|
||||||
|
secondComponent = oldValue
|
||||||
|
} else if secondComponent.count == 1 {
|
||||||
|
secondComponent = "0\(secondComponent)"
|
||||||
|
} else if secondComponent.count == 0 {
|
||||||
|
secondComponent = "00"
|
||||||
|
}
|
||||||
|
let filtered = secondComponent.filter { $0.isNumber }
|
||||||
|
if secondComponent != filtered {
|
||||||
|
secondComponent = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Published var minuteComponent: String = "00" {
|
@Published var minuteComponent: String = "00" {
|
||||||
didSet {
|
didSet {
|
||||||
if minuteComponent.count > 2 {
|
if minuteComponent.count > 2 {
|
||||||
@@ -42,6 +58,19 @@ class DurationComponents: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func fromPTString(_ PTRepresentation: String) -> DurationComponents {
|
||||||
|
let duration = DurationComponents()
|
||||||
|
let hourRegex = /([0-9]{1,2})H/
|
||||||
|
let minuteRegex = /([0-9]{1,2})M/
|
||||||
|
if let match = PTRepresentation.firstMatch(of: hourRegex) {
|
||||||
|
duration.hourComponent = String(match.1)
|
||||||
|
}
|
||||||
|
if let match = PTRepresentation.firstMatch(of: minuteRegex) {
|
||||||
|
duration.minuteComponent = String(match.1)
|
||||||
|
}
|
||||||
|
return duration
|
||||||
|
}
|
||||||
|
|
||||||
func fromPTString(_ PTRepresentation: String) {
|
func fromPTString(_ PTRepresentation: String) {
|
||||||
let hourRegex = /([0-9]{1,2})H/
|
let hourRegex = /([0-9]{1,2})H/
|
||||||
let minuteRegex = /([0-9]{1,2})M/
|
let minuteRegex = /([0-9]{1,2})M/
|
||||||
@@ -60,6 +89,7 @@ class DurationComponents: ObservableObject {
|
|||||||
func toText() -> LocalizedStringKey {
|
func toText() -> LocalizedStringKey {
|
||||||
let intHour = Int(hourComponent) ?? 0
|
let intHour = Int(hourComponent) ?? 0
|
||||||
let intMinute = Int(minuteComponent) ?? 0
|
let intMinute = Int(minuteComponent) ?? 0
|
||||||
|
|
||||||
if intHour != 0 && intMinute != 0 {
|
if intHour != 0 && intMinute != 0 {
|
||||||
return "\(intHour) h, \(intMinute) min"
|
return "\(intHour) h, \(intMinute) min"
|
||||||
} else if intHour == 0 && intMinute != 0 {
|
} else if intHour == 0 && intMinute != 0 {
|
||||||
@@ -71,6 +101,32 @@ class DurationComponents: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toTimerText() -> String {
|
||||||
|
var timeString = ""
|
||||||
|
if hourComponent != "00" {
|
||||||
|
timeString.append("\(hourComponent):")
|
||||||
|
}
|
||||||
|
timeString.append("\(minuteComponent):")
|
||||||
|
timeString.append("\(secondComponent)")
|
||||||
|
return timeString
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSeconds() -> Double {
|
||||||
|
guard let hours = Double(hourComponent) else { return 0 }
|
||||||
|
guard let minutes = Double(minuteComponent) else { return 0 }
|
||||||
|
guard let seconds = Double(secondComponent) else { return 0 }
|
||||||
|
return hours * 3600 + minutes * 60 + seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromSeconds(_ totalSeconds: Int) {
|
||||||
|
let hours = totalSeconds / 3600
|
||||||
|
let minutes = (totalSeconds % 3600) / 60
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
self.hourComponent = String(hours)
|
||||||
|
self.minuteComponent = String(minutes)
|
||||||
|
self.secondComponent = String(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
static func ptToText(_ ptString: String) -> String? {
|
static func ptToText(_ ptString: String) -> String? {
|
||||||
let hourRegex = /([0-9]{1,2})H/
|
let hourRegex = /([0-9]{1,2})H/
|
||||||
let minuteRegex = /([0-9]{1,2})M/
|
let minuteRegex = /([0-9]{1,2})M/
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ class UserSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var keepScreenAwake: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(keepScreenAwake, forKey: "keepScreenAwake")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||||
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
||||||
@@ -119,6 +125,7 @@ class UserSettings: ObservableObject {
|
|||||||
self.expandNutritionSection = UserDefaults.standard.object(forKey: "expandNutritionSection") as? Bool ?? false
|
self.expandNutritionSection = UserDefaults.standard.object(forKey: "expandNutritionSection") as? Bool ?? false
|
||||||
self.expandKeywordSection = UserDefaults.standard.object(forKey: "expandKeywordSection") as? Bool ?? false
|
self.expandKeywordSection = UserDefaults.standard.object(forKey: "expandKeywordSection") as? Bool ?? false
|
||||||
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
||||||
|
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
||||||
|
|
||||||
if authString == "" {
|
if authString == "" {
|
||||||
if token != "" && username != "" {
|
if token != "" && username != "" {
|
||||||
|
|||||||
@@ -293,6 +293,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents." : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -315,6 +337,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Acknowledgements" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Verwendete Bibliotheken"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Reconocimientos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Remerciements"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Action delayed" : {
|
"Action delayed" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -359,6 +403,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe." : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Fügen Sie dieser Liste Rezeptzutaten hinzu, indem Sie entweder den Button neben einer Zutatenliste in einem Rezept verwenden, um alle Zutaten hinzuzufügen, oder indem Sie einzelne Zutaten eines Rezepts nach rechts wischen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Agrega comestibles a esta lista usando el botón junto a una lista de ingredientes en una receta, o deslizando hacia la derecha en ingredientes individuales de una receta."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ajoutez des articles à cette liste soit en utilisant le bouton à côté d'une liste d'ingrédients dans une recette, soit en balayant vers la droite sur des ingrédients individuels d'une recette."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Add new recipe" : {
|
"Add new recipe" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -381,6 +447,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"An unknown error occured." : {
|
"An unknown error occured." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -711,6 +799,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Cooking time" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Kochen:"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Duración de cocción:"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Temps de cuisson:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Copy Link" : {
|
"Copy Link" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -1173,6 +1283,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Grocery List" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Einkaufsliste"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Lista de la compra"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Liste de courses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
"If 'Same as Device' is selected and your device language is not supported yet, this option will default to english." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -1443,6 +1575,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Keep screen awake when viewing recipes" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Automatische Bildschirmsperre beim Ansehen von Rezepten deaktivieren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Mantener la pantalla encendida al ver recetas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Garder l'écran allumé lors de la consultation des recettes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Keywords" : {
|
"Keywords" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2565,6 +2719,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"SwiftSoup" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Thank you for downloading" : {
|
"Thank you for downloading" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2829,6 +3005,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"TPPDF" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Unable to complete action." : {
|
"Unable to complete action." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3026,6 +3224,50 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"You're all set for cooking 🍓" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Sie sind bereit zum Kochen 🍓"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Estás listo(a) para cocinar 🍓"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Vous êtes prêt(e) pour cuisiner 🍓"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Your grocery list is stored locally and therefore not synchronized across your devices." : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Ihre Einkaufsliste wird lokal gespeichert und daher nicht auf andere Geräte übertragen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Tu lista de la compra se almacena localmente y, por lo tanto, no se sincroniza en tus dispositivos."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Votre liste de courses est stockée localement et n'est donc pas synchronisée sur vos appareils."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
||||||
@StateObject var mainViewModel = MainViewModel()
|
@StateObject var mainViewModel = AppState()
|
||||||
@AppStorage("onboarding") var onboarding = true
|
@AppStorage("onboarding") var onboarding = true
|
||||||
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ import SwiftUI
|
|||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
|
||||||
@MainActor class MainViewModel: ObservableObject {
|
@MainActor class AppState: ObservableObject {
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
|
||||||
|
|
||||||
@Published var categories: [Category] = []
|
@Published var categories: [Category] = []
|
||||||
@Published var recipes: [String: [Recipe]] = [:]
|
@Published var recipes: [String: [Recipe]] = [:]
|
||||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||||
|
@Published var timers: [String: RecipeTimer] = [:]
|
||||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||||
var lastUpdates: [String: Date] = [:]
|
var lastUpdates: [String: Date] = [:]
|
||||||
|
|
||||||
|
|
||||||
private let api: CookbookApi.Type
|
private let api: CookbookApi.Type
|
||||||
private let dataStore: DataStore
|
private let dataStore: DataStore
|
||||||
|
|
||||||
@@ -28,10 +28,10 @@ import UIKit
|
|||||||
self.api = api
|
self.api = api
|
||||||
self.dataStore = DataStore()
|
self.dataStore = DataStore()
|
||||||
|
|
||||||
if userSettings.authString == "" {
|
if UserSettings.shared.authString == "" {
|
||||||
let loginString = "\(userSettings.username):\(userSettings.token)"
|
let loginString = "\(UserSettings.shared.username):\(UserSettings.shared.token)"
|
||||||
let loginData = loginString.data(using: String.Encoding.utf8)!
|
let loginData = loginString.data(using: String.Encoding.utf8)!
|
||||||
userSettings.authString = loginData.base64EncodedString()
|
UserSettings.shared.authString = loginData.base64EncodedString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func getCategories() async {
|
func getCategories() async {
|
||||||
let (categories, _) = await api.getCategories(
|
let (categories, _) = await api.getCategories(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let categories = categories {
|
if let categories = categories {
|
||||||
print("Successfully loaded categories")
|
print("Successfully loaded categories")
|
||||||
@@ -97,7 +97,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer(store: Bool = false) async -> Bool {
|
func getServer(store: Bool = false) async -> Bool {
|
||||||
let (recipes, _) = await api.getCategory(
|
let (recipes, _) = await api.getCategory(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
named: categoryString
|
named: categoryString
|
||||||
)
|
)
|
||||||
if let recipes = recipes {
|
if let recipes = recipes {
|
||||||
@@ -130,16 +130,16 @@ import UIKit
|
|||||||
for category in self.categories {
|
for category in self.categories {
|
||||||
await updateRecipeDetails(in: category.name)
|
await updateRecipeDetails(in: category.name)
|
||||||
}
|
}
|
||||||
userSettings.lastUpdate = Date()
|
UserSettings.shared.lastUpdate = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateRecipeDetails(in category: String) async {
|
func updateRecipeDetails(in category: String) async {
|
||||||
guard userSettings.storeRecipes else { return }
|
guard UserSettings.shared.storeRecipes else { return }
|
||||||
guard let recipes = self.recipes[category] else { return }
|
guard let recipes = self.recipes[category] else { return }
|
||||||
for recipe in recipes {
|
for recipe in recipes {
|
||||||
if needsUpdate(category: category, lastModified: recipe.dateModified) {
|
if needsUpdate(category: category, lastModified: recipe.dateModified) {
|
||||||
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
|
print("\(recipe.name) needs an update. (last modified: \(recipe.dateModified)")
|
||||||
await updateRecipeDetail(id: recipe.recipe_id, withThumb: userSettings.storeThumb, withImage: userSettings.storeImages)
|
await updateRecipeDetail(id: recipe.recipe_id, withThumb: UserSettings.shared.storeThumb, withImage: UserSettings.shared.storeImages)
|
||||||
} else {
|
} else {
|
||||||
print("\(recipe.name) is up to date.")
|
print("\(recipe.name) is up to date.")
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func getRecipes() async -> [Recipe] {
|
func getRecipes() async -> [Recipe] {
|
||||||
let (recipes, error) = await api.getRecipes(
|
let (recipes, error) = await api.getRecipes(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let recipes = recipes {
|
if let recipes = recipes {
|
||||||
return recipes
|
return recipes
|
||||||
@@ -199,7 +199,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> RecipeDetail? {
|
func getServer() async -> RecipeDetail? {
|
||||||
let (recipe, error) = await api.getRecipe(
|
let (recipe, error) = await api.getRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id
|
id: id
|
||||||
)
|
)
|
||||||
if let recipe = recipe {
|
if let recipe = recipe {
|
||||||
@@ -292,7 +292,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> UIImage? {
|
func getServer() async -> UIImage? {
|
||||||
let (image, _) = await api.getImage(
|
let (image, _) = await api.getImage(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id,
|
id: id,
|
||||||
size: size
|
size: size
|
||||||
)
|
)
|
||||||
@@ -368,7 +368,7 @@ import UIKit
|
|||||||
|
|
||||||
func getServer() async -> [RecipeKeyword]? {
|
func getServer() async -> [RecipeKeyword]? {
|
||||||
let (tags, _) = await api.getTags(
|
let (tags, _) = await api.getTags(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
@@ -421,7 +421,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
func deleteRecipe(withId id: Int, categoryName: String) async -> RequestAlert? {
|
||||||
let (error) = await api.deleteRecipe(
|
let (error) = await api.deleteRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
id: id
|
id: id
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -452,7 +452,7 @@ import UIKit
|
|||||||
*/
|
*/
|
||||||
func checkServerConnection() async -> Bool {
|
func checkServerConnection() async -> Bool {
|
||||||
let (categories, _) = await api.getCategories(
|
let (categories, _) = await api.getCategories(
|
||||||
auth: userSettings.authString
|
auth: UserSettings.shared.authString
|
||||||
)
|
)
|
||||||
if let categories = categories {
|
if let categories = categories {
|
||||||
self.categories = categories
|
self.categories = categories
|
||||||
@@ -481,12 +481,12 @@ import UIKit
|
|||||||
var error: NetworkError? = nil
|
var error: NetworkError? = nil
|
||||||
if createNew {
|
if createNew {
|
||||||
error = await api.createRecipe(
|
error = await api.createRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
recipe: recipeDetail
|
recipe: recipeDetail
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
error = await api.updateRecipe(
|
error = await api.updateRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
recipe: recipeDetail
|
recipe: recipeDetail
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -499,7 +499,7 @@ import UIKit
|
|||||||
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
|
||||||
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
|
||||||
let (recipeDetail, error) = await api.importRecipe(
|
let (recipeDetail, error) = await api.importRecipe(
|
||||||
auth: userSettings.authString,
|
auth: UserSettings.shared.authString,
|
||||||
data: data
|
data: data
|
||||||
)
|
)
|
||||||
if error != nil {
|
if error != nil {
|
||||||
@@ -507,12 +507,13 @@ import UIKit
|
|||||||
}
|
}
|
||||||
return (recipeDetail, nil)
|
return (recipeDetail, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
extension MainViewModel {
|
extension AppState {
|
||||||
func loadLocal<T: Codable>(path: String) async -> T? {
|
func loadLocal<T: Codable>(path: String) async -> T? {
|
||||||
do {
|
do {
|
||||||
return try await dataStore.load(fromPath: path)
|
return try await dataStore.load(fromPath: path)
|
||||||
@@ -608,3 +609,21 @@ extension DateFormatter {
|
|||||||
return dateFormatter.string(from: date)
|
return dateFormatter.string(from: date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Timer logic
|
||||||
|
extension AppState {
|
||||||
|
func createTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
|
||||||
|
let timer = RecipeTimer(duration: duration)
|
||||||
|
timers[recipeId] = timer
|
||||||
|
return timer
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTimer(forRecipe recipeId: String, duration: DurationComponents) -> RecipeTimer {
|
||||||
|
return timers[recipeId] ?? createTimer(forRecipe: recipeId, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteTimer(forRecipe recipeId: String) {
|
||||||
|
timers.removeValue(forKey: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor class RecipeEditViewModel: ObservableObject {
|
@MainActor class RecipeEditViewModel: ObservableObject {
|
||||||
@ObservedObject var mainViewModel: MainViewModel
|
@ObservedObject var mainViewModel: AppState
|
||||||
@Published var recipe: RecipeDetail = RecipeDetail()
|
@Published var recipe: RecipeDetail = RecipeDetail()
|
||||||
|
|
||||||
@Published var prepDuration: DurationComponents = DurationComponents()
|
@Published var prepDuration: DurationComponents = DurationComponents()
|
||||||
@@ -29,12 +29,12 @@ import SwiftUI
|
|||||||
var waitingForUpload: Bool = false
|
var waitingForUpload: Bool = false
|
||||||
|
|
||||||
|
|
||||||
init(mainViewModel: MainViewModel, uploadNew: Bool) {
|
init(mainViewModel: AppState, uploadNew: Bool) {
|
||||||
self.mainViewModel = mainViewModel
|
self.mainViewModel = mainViewModel
|
||||||
self.uploadNew = uploadNew
|
self.uploadNew = uploadNew
|
||||||
}
|
}
|
||||||
|
|
||||||
init(mainViewModel: MainViewModel, recipeDetail: RecipeDetail, uploadNew: Bool) {
|
init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) {
|
||||||
self.mainViewModel = mainViewModel
|
self.mainViewModel = mainViewModel
|
||||||
self.recipe = recipeDetail
|
self.recipe = recipeDetail
|
||||||
self.uploadNew = uploadNew
|
self.uploadNew = uploadNew
|
||||||
|
|||||||
@@ -7,8 +7,65 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
|
@StateObject var viewModel = AppState()
|
||||||
|
@StateObject var groceryList = GroceryList()
|
||||||
|
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||||
|
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
||||||
|
|
||||||
|
enum Tab {
|
||||||
|
case recipes, search, groceryList
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
RecipeTabView()
|
||||||
|
.environmentObject(recipeViewModel)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.tabItem {
|
||||||
|
Label("Recipes", systemImage: "book.closed.fill")
|
||||||
|
}
|
||||||
|
.tag(Tab.recipes)
|
||||||
|
|
||||||
|
SearchTabView()
|
||||||
|
.environmentObject(searchViewModel)
|
||||||
|
.environmentObject(viewModel)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.tabItem {
|
||||||
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
|
}
|
||||||
|
.tag(Tab.search)
|
||||||
|
|
||||||
|
GroceryListTabView()
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.tabItem {
|
||||||
|
Label("Grocery List", systemImage: "storefront")
|
||||||
|
}
|
||||||
|
.tag(Tab.groceryList)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
recipeViewModel.presentLoadingIndicator = true
|
||||||
|
await viewModel.getCategories()
|
||||||
|
await viewModel.updateAllRecipeDetails()
|
||||||
|
|
||||||
|
// Open detail view for default category
|
||||||
|
if UserSettings.shared.defaultCategory != "" {
|
||||||
|
if let cat = viewModel.categories.first(where: { c in
|
||||||
|
if c.name == UserSettings.shared.defaultCategory {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}) {
|
||||||
|
recipeViewModel.selectedCategory = cat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await groceryList.load()
|
||||||
|
recipeViewModel.presentLoadingIndicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*struct MainView: View {
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@ObservedObject var viewModel: MainViewModel
|
||||||
@StateObject var userSettings: UserSettings = UserSettings.shared
|
@StateObject var userSettings: UserSettings = UserSettings.shared
|
||||||
|
|
||||||
@@ -214,43 +271,5 @@ struct MainView: View {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct RecipeSearchView: View {
|
|
||||||
@ObservedObject var viewModel: MainViewModel
|
|
||||||
@State var searchText: String = ""
|
|
||||||
@State var allRecipes: [Recipe] = []
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
NavigationStack {
|
|
||||||
VStack {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
LazyVStack {
|
|
||||||
ForEach(recipesFiltered(), id: \.recipe_id) { recipe in
|
|
||||||
NavigationLink(value: recipe) {
|
|
||||||
RecipeCardView(viewModel: viewModel, recipe: recipe)
|
|
||||||
.shadow(radius: 2)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
|
||||||
RecipeDetailView(viewModel: viewModel, recipe: recipe)
|
|
||||||
}
|
|
||||||
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
|
||||||
}
|
|
||||||
.navigationTitle("Search recipe")
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
allRecipes = await viewModel.getRecipes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recipesFiltered() -> [Recipe] {
|
|
||||||
guard searchText != "" else { return allRecipes }
|
|
||||||
return allRecipes.filter { recipe in
|
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import SwiftUI
|
|||||||
struct CategoryDetailView: View {
|
struct CategoryDetailView: View {
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@ObservedObject var viewModel: AppState
|
||||||
@Binding var showEditView: Bool
|
@Binding var showEditView: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -9,7 +9,7 @@ import Foundation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct RecipeCardView: View {
|
struct RecipeCardView: View {
|
||||||
@State var viewModel: MainViewModel
|
@State var viewModel: AppState
|
||||||
@State var recipe: Recipe
|
@State var recipe: Recipe
|
||||||
@State var recipeThumb: UIImage?
|
@State var recipeThumb: UIImage?
|
||||||
@State var isDownloaded: Bool? = nil
|
@State var isDownloaded: Bool? = nil
|
||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
struct RecipeDetailView: View {
|
struct RecipeDetailView: View {
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@ObservedObject var viewModel: AppState
|
||||||
@State var recipe: Recipe
|
@State var recipe: Recipe
|
||||||
@State var recipeDetail: RecipeDetail?
|
@State var recipeDetail: RecipeDetail?
|
||||||
@State var recipeImage: UIImage?
|
@State var recipeImage: UIImage?
|
||||||
@@ -25,13 +25,15 @@ struct RecipeDetailView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
if let recipeImage = recipeImage {
|
ZStack {
|
||||||
Image(uiImage: recipeImage)
|
if let recipeImage = recipeImage {
|
||||||
.resizable()
|
Image(uiImage: recipeImage)
|
||||||
.scaledToFill()
|
.resizable()
|
||||||
.frame(maxHeight: 300)
|
.scaledToFill()
|
||||||
.clipped()
|
.frame(maxHeight: 300)
|
||||||
}
|
.clipped()
|
||||||
|
}
|
||||||
|
}.animation(.easeInOut, value: recipeImage)
|
||||||
|
|
||||||
if let recipeDetail = recipeDetail {
|
if let recipeDetail = recipeDetail {
|
||||||
LazyVStack (alignment: .leading) {
|
LazyVStack (alignment: .leading) {
|
||||||
@@ -62,7 +64,7 @@ struct RecipeDetailView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
RecipeDurationSection(recipeDetail: recipeDetail)
|
RecipeDurationSection(viewModel: viewModel, recipeDetail: recipeDetail)
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
if(!recipeDetail.recipeIngredient.isEmpty) {
|
if(!recipeDetail.recipeIngredient.isEmpty) {
|
||||||
@@ -82,7 +84,7 @@ struct RecipeDetailView: View {
|
|||||||
}.padding(.horizontal, 5)
|
}.padding(.horizontal, 5)
|
||||||
|
|
||||||
}
|
}
|
||||||
}.animation(.easeInOut, value: recipeImage)
|
}
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.navigationTitle(showTitle ? recipe.name : "")
|
.navigationTitle(showTitle ? recipe.name : "")
|
||||||
@@ -157,6 +159,14 @@ struct RecipeDetailView: View {
|
|||||||
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
fetchMode: UserSettings.shared.storeImages ? .preferServer : .onlyServer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
if UserSettings.shared.keepScreenAwake {
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +216,11 @@ fileprivate struct ShareView: View {
|
|||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeDurationSection: View {
|
fileprivate struct RecipeDurationSection: View {
|
||||||
|
@ObservedObject var viewModel: AppState
|
||||||
@State var recipeDetail: RecipeDetail
|
@State var recipeDetail: RecipeDetail
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), alignment: .leading)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 250), alignment: .leading)]) {
|
||||||
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
|
if let prepTime = recipeDetail.prepTime, let time = DurationComponents.ptToText(prepTime) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -220,6 +231,12 @@ fileprivate struct RecipeDurationSection: View {
|
|||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
}.padding()
|
}.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) {
|
if let cookTime = recipeDetail.cookTime, let time = DurationComponents.ptToText(cookTime) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
@@ -337,7 +354,9 @@ fileprivate struct MoreInformationSection: View {
|
|||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeIngredientSection: View {
|
fileprivate struct RecipeIngredientSection: View {
|
||||||
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@State var recipeDetail: RecipeDetail
|
@State var recipeDetail: RecipeDetail
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -349,10 +368,25 @@ fileprivate struct RecipeIngredientSection: View {
|
|||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients for \(recipeDetail.recipeYield) servings"))
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button {
|
||||||
|
withAnimation {
|
||||||
|
if groceryList.containsRecipe(recipeDetail.id) {
|
||||||
|
groceryList.deleteGroceryRecipe(recipeDetail.id)
|
||||||
|
} else {
|
||||||
|
groceryList.addItems(recipeDetail.recipeIngredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "storefront")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
ForEach(recipeDetail.recipeIngredient, id: \.self) { ingredient in
|
||||||
IngredientListItem(ingredient: ingredient)
|
IngredientListItem(ingredient: ingredient, recipeId: recipeDetail.id) {
|
||||||
|
groceryList.addItem(ingredient, toRecipe: recipeDetail.id, recipeName: recipeDetail.name)
|
||||||
|
}
|
||||||
.padding(4)
|
.padding(4)
|
||||||
|
|
||||||
}
|
}
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
@@ -375,12 +409,23 @@ fileprivate struct RecipeToolSection: View {
|
|||||||
|
|
||||||
|
|
||||||
fileprivate struct IngredientListItem: View {
|
fileprivate struct IngredientListItem: View {
|
||||||
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
@State var ingredient: String
|
@State var ingredient: String
|
||||||
|
@State var recipeId: String
|
||||||
|
let addToGroceryListAction: () -> Void
|
||||||
@State var isSelected: Bool = false
|
@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 {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
if isSelected {
|
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
||||||
|
Image(systemName: "storefront")
|
||||||
|
.foregroundStyle(Color.green)
|
||||||
|
} else if isSelected {
|
||||||
Image(systemName: "checkmark.circle")
|
Image(systemName: "checkmark.circle")
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "circle")
|
Image(systemName: "circle")
|
||||||
@@ -389,12 +434,43 @@ fileprivate struct IngredientListItem: View {
|
|||||||
Text("\(ingredient)")
|
Text("\(ingredient)")
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
isSelected.toggle()
|
isSelected.toggle()
|
||||||
}
|
}
|
||||||
|
.offset(x: dragOffset, y: 0)
|
||||||
.animation(.easeInOut, value: isSelected)
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
206
Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift
Normal file
206
Nextcloud Cookbook iOS Client/Views/Recipes/TimerView.swift
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
//
|
||||||
|
// TimerView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 11.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Combine
|
||||||
|
import AVFoundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
|
||||||
|
struct TimerView: View {
|
||||||
|
@ObservedObject var timer: RecipeTimer
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Gauge(value: timer.timeTotal - timer.timeElapsed, in: 0...timer.timeTotal) {
|
||||||
|
Text("Cooking time")
|
||||||
|
} currentValueLabel: {
|
||||||
|
Button {
|
||||||
|
if timer.isRunning {
|
||||||
|
timer.pause()
|
||||||
|
} else {
|
||||||
|
timer.start()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if timer.isRunning {
|
||||||
|
Image(systemName: "pause.fill")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "play.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gaugeStyle(.accessoryCircularCapacity)
|
||||||
|
.animation(.easeInOut, value: timer.timeElapsed)
|
||||||
|
.tint(timer.isRunning ? .green : .nextcloudBlue)
|
||||||
|
.foregroundStyle(timer.isRunning ? Color.green : Color.nextcloudBlue)
|
||||||
|
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text("Cooking")
|
||||||
|
Text(timer.duration.toTimerText())
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
timer.cancel()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(timer.isRunning ? Color.nextcloudBlue : Color.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bold()
|
||||||
|
.padding()
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 20)
|
||||||
|
.foregroundStyle(.ultraThickMaterial)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeTimer: ObservableObject {
|
||||||
|
var timeTotal: Double
|
||||||
|
@Published var duration: DurationComponents
|
||||||
|
private var startDate: Date?
|
||||||
|
private var pauseDate: Date?
|
||||||
|
@Published var timeElapsed: Double = 0
|
||||||
|
@Published var isRunning: Bool = false
|
||||||
|
@Published var timerExpired: Bool = false
|
||||||
|
private var timer: Timer.TimerPublisher?
|
||||||
|
private var timerCancellable: Cancellable?
|
||||||
|
var audioPlayer: AVAudioPlayer?
|
||||||
|
|
||||||
|
init(duration: DurationComponents) {
|
||||||
|
self.duration = duration
|
||||||
|
self.timeTotal = duration.toSeconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
self.isRunning = true
|
||||||
|
if startDate == nil {
|
||||||
|
startDate = Date()
|
||||||
|
} else if let pauseDate = pauseDate {
|
||||||
|
// Adjust start date based on the pause duration
|
||||||
|
let pauseDuration = Date().timeIntervalSince(pauseDate)
|
||||||
|
startDate = startDate?.addingTimeInterval(pauseDuration)
|
||||||
|
}
|
||||||
|
requestNotificationPermissions()
|
||||||
|
scheduleTimerNotification(timeInterval: timeTotal)
|
||||||
|
// Prepare audio session
|
||||||
|
setupAudioSession()
|
||||||
|
prepareAudioPlayer(with: "alarm_sound_0")
|
||||||
|
|
||||||
|
self.timer = Timer.publish(every: 1, on: .main, in: .common)
|
||||||
|
self.timerCancellable = self.timer?.autoconnect().sink { [weak self] _ in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let self = self, let startTime = self.startDate {
|
||||||
|
let elapsed = Date().timeIntervalSince(startTime)
|
||||||
|
if elapsed < self.timeTotal {
|
||||||
|
self.timeElapsed = elapsed
|
||||||
|
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
||||||
|
} else {
|
||||||
|
self.timerExpired = true
|
||||||
|
self.timeElapsed = self.timeTotal
|
||||||
|
self.duration.fromSeconds(Int(self.timeTotal - self.timeElapsed))
|
||||||
|
self.pause()
|
||||||
|
|
||||||
|
self.startAlarm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
self.isRunning = false
|
||||||
|
pauseDate = Date()
|
||||||
|
self.timerCancellable?.cancel()
|
||||||
|
self.timerCancellable = nil
|
||||||
|
self.timer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
self.isRunning = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
self.isRunning = false
|
||||||
|
self.timerCancellable?.cancel()
|
||||||
|
self.timerCancellable = nil
|
||||||
|
self.timer = nil
|
||||||
|
self.timeElapsed = 0
|
||||||
|
self.startDate = nil
|
||||||
|
self.pauseDate = nil
|
||||||
|
self.duration.fromSeconds(Int(timeTotal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RecipeTimer {
|
||||||
|
func setupAudioSession() {
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
} catch {
|
||||||
|
print("Failed to set audio session category. Error: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareAudioPlayer(with soundName: String) {
|
||||||
|
if let soundURL = Bundle.main.url(forResource: "alarm_sound_0", withExtension: "mp3") {
|
||||||
|
do {
|
||||||
|
audioPlayer = try AVAudioPlayer(contentsOf: soundURL)
|
||||||
|
audioPlayer?.prepareToPlay()
|
||||||
|
audioPlayer?.numberOfLoops = -1 // Loop indefinitely
|
||||||
|
} catch {
|
||||||
|
print("Error loading sound file: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postNotification() {
|
||||||
|
NotificationCenter.default.post(name: Notification.Name("AlarmNotification"), object: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAlarm() {
|
||||||
|
audioPlayer?.play()
|
||||||
|
postNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAlarm() {
|
||||||
|
audioPlayer?.stop()
|
||||||
|
try? AVAudioSession.sharedInstance().setActive(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestNotificationPermissions() {
|
||||||
|
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
|
if granted {
|
||||||
|
print("Notification permission granted.")
|
||||||
|
} else if let error = error {
|
||||||
|
print("Notification permission denied because: \(error.localizedDescription).")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scheduleTimerNotification(timeInterval: TimeInterval) {
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = "Timer Finished"
|
||||||
|
content.body = "Your timer is up!"
|
||||||
|
content.sound = UNNotificationSound.default
|
||||||
|
|
||||||
|
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: timeInterval, repeats: false)
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(identifier: "timerNotification", content: content, trigger: trigger)
|
||||||
|
|
||||||
|
UNUserNotificationCenter.current().add(request) { error in
|
||||||
|
if let error = error {
|
||||||
|
print("Error scheduling notification: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@ObservedObject var viewModel: MainViewModel
|
@EnvironmentObject var viewModel: AppState
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
|
|
||||||
@State fileprivate var alertType: SettingsAlert = .NONE
|
@State fileprivate var alertType: SettingsAlert = .NONE
|
||||||
@@ -48,6 +48,12 @@ struct SettingsView: View {
|
|||||||
Text("Configure which sections in your recipes are expanded by default.")
|
Text("Configure which sections in your recipes are expanded by default.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: $userSettings.keepScreenAwake) {
|
||||||
|
Text("Keep screen awake when viewing recipes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $userSettings.storeRecipes) {
|
Toggle(isOn: $userSettings.storeRecipes) {
|
||||||
Text("Offline recipes")
|
Text("Offline recipes")
|
||||||
@@ -112,6 +118,23 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Deleting local data will not affect the recipe data stored on your server.")
|
Text("Deleting local data will not affect the recipe data stored on your server.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section(header: Text("Acknowledgements")) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
|
||||||
|
Link("SwiftSoup", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
||||||
|
Link("TPPDF", destination: url)
|
||||||
|
.font(.headline)
|
||||||
|
Text("A simple-to-use PDF builder for Swift. Used for generating recipe PDF documents.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Settings")
|
.navigationTitle("Settings")
|
||||||
.alert(alertType.getTitle(), isPresented: $showAlert) {
|
.alert(alertType.getTitle(), isPresented: $showAlert) {
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
//
|
||||||
|
// GroceryListTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct GroceryListTabView: View {
|
||||||
|
@EnvironmentObject var groceryList: GroceryList
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
if groceryList.groceryDict.isEmpty {
|
||||||
|
EmptyGroceryListView()
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
|
||||||
|
Section {
|
||||||
|
ForEach(groceryList.groceryDict[key]!.items) { item in
|
||||||
|
GroceryListItemView(item: item, toggleAction: {
|
||||||
|
groceryList.toggleItemChecked(item)
|
||||||
|
groceryList.objectWillChange.send()
|
||||||
|
}, deleteAction: {
|
||||||
|
groceryList.deleteItem(item.name, fromRecipe: key)
|
||||||
|
withAnimation {
|
||||||
|
groceryList.objectWillChange.send()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text(groceryList.groceryDict[key]!.name)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
groceryList.deleteGroceryRecipe(key)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Grocery List")
|
||||||
|
.toolbar {
|
||||||
|
Button {
|
||||||
|
groceryList.deleteAll()
|
||||||
|
} label: {
|
||||||
|
Text("Delete")
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct GroceryListItemView: View {
|
||||||
|
let item: GroceryRecipeItem
|
||||||
|
let toggleAction: () -> Void
|
||||||
|
let deleteAction: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
if item.isChecked {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "circle")
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\(item.name)")
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(5)
|
||||||
|
}
|
||||||
|
.padding(5)
|
||||||
|
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
|
||||||
|
.onTapGesture(perform: toggleAction)
|
||||||
|
.animation(.easeInOut, value: item.isChecked)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(action: deleteAction) {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct EmptyGroceryListView: View {
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
Text("You're all set for cooking 🍓")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Add groceries to this list by either using the button next to an ingredient list in a recipe, or by swiping right on individual ingredients of a recipe.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.navigationTitle("Grocery List")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class GroceryRecipe: Identifiable, Codable {
|
||||||
|
let name: String
|
||||||
|
var items: [GroceryRecipeItem]
|
||||||
|
|
||||||
|
init(name: String, items: [GroceryRecipeItem]) {
|
||||||
|
self.name = name
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
|
||||||
|
init(name: String, item: GroceryRecipeItem) {
|
||||||
|
self.name = name
|
||||||
|
self.items = [item]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class GroceryRecipeItem: Identifiable, Codable {
|
||||||
|
let name: String
|
||||||
|
var isChecked: Bool
|
||||||
|
|
||||||
|
init(_ name: String, isChecked: Bool = false) {
|
||||||
|
self.name = name
|
||||||
|
self.isChecked = isChecked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@MainActor class GroceryList: ObservableObject {
|
||||||
|
let dataStore: DataStore = DataStore()
|
||||||
|
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
||||||
|
@Published var sortBySimilarity: Bool = false
|
||||||
|
|
||||||
|
|
||||||
|
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil, saveGroceryDict: Bool = true) {
|
||||||
|
print("Adding item of recipe \(String(describing: recipeName))")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.groceryDict[recipeId] != nil {
|
||||||
|
self.groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
||||||
|
} else {
|
||||||
|
let newRecipe = GroceryRecipe(name: recipeName ?? "-", items: [GroceryRecipeItem(itemName)])
|
||||||
|
self.groceryDict[recipeId] = newRecipe
|
||||||
|
}
|
||||||
|
if saveGroceryDict {
|
||||||
|
self.save()
|
||||||
|
self.objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
||||||
|
for item in items {
|
||||||
|
addItem(item, toRecipe: recipeId, recipeName: recipeName, saveGroceryDict: false)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||||
|
print("Deleting item \(itemName)")
|
||||||
|
guard let recipe = groceryDict[recipeId] else { return }
|
||||||
|
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
||||||
|
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
||||||
|
if groceryDict[recipeId]!.items.isEmpty {
|
||||||
|
groceryDict.removeValue(forKey: recipeId)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroceryRecipe(_ recipeId: String) {
|
||||||
|
print("Deleting grocery recipe with id \(recipeId)")
|
||||||
|
groceryDict.removeValue(forKey: recipeId)
|
||||||
|
save()
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAll() {
|
||||||
|
print("Deleting all grocery items")
|
||||||
|
groceryDict = [:]
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||||
|
print("Item checked: \(groceryItem.name)")
|
||||||
|
groceryItem.isChecked.toggle()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItem(at recipeId: String, item: String) -> Bool {
|
||||||
|
guard let recipe = groceryDict[recipeId] else { return false }
|
||||||
|
if recipe.items.contains(where: { $0.name == item }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRecipe(_ recipeId: String) -> Bool {
|
||||||
|
return groceryDict[recipeId] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: groceryDict, toPath: "grocery_list.data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
do {
|
||||||
|
guard let groceryDict: [String: GroceryRecipe] = try await dataStore.load(
|
||||||
|
fromPath: "grocery_list.data"
|
||||||
|
) else { return }
|
||||||
|
self.groceryDict = groceryDict
|
||||||
|
} catch {
|
||||||
|
print("Unable to load grocery list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
171
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal file
171
Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
//
|
||||||
|
// RecipeTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct RecipeTabView: View {
|
||||||
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
@EnvironmentObject var mainViewModel: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List(selection: $viewModel.selectedCategory) {
|
||||||
|
// Categories
|
||||||
|
ForEach(mainViewModel.categories) { category in
|
||||||
|
if category.recipe_count != 0 {
|
||||||
|
NavigationLink(value: category) {
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
if viewModel.selectedCategory != nil && category.name == viewModel.selectedCategory!.name {
|
||||||
|
Image(systemName: "book")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "book.closed.fill")
|
||||||
|
}
|
||||||
|
Text(category.name == "*" ? String(localized: "Other") : category.name)
|
||||||
|
.font(.system(size: 20, weight: .medium, design: .default))
|
||||||
|
Spacer()
|
||||||
|
Text("\(category.recipe_count)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .default))
|
||||||
|
.foregroundStyle(Color.background)
|
||||||
|
.frame(width: 25, height: 25, alignment: .center)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
.background {
|
||||||
|
Circle()
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
}.padding(7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Cookbooks")
|
||||||
|
.toolbar {
|
||||||
|
RecipeTabViewToolBar()
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
NavigationStack {
|
||||||
|
if let category = viewModel.selectedCategory {
|
||||||
|
CategoryDetailView(
|
||||||
|
categoryName: category.name,
|
||||||
|
viewModel: mainViewModel,
|
||||||
|
showEditView: $viewModel.presentEditView
|
||||||
|
)
|
||||||
|
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(.nextcloudBlue)
|
||||||
|
.sheet(isPresented: $viewModel.presentEditView) {
|
||||||
|
RecipeEditView(
|
||||||
|
viewModel:
|
||||||
|
RecipeEditViewModel(
|
||||||
|
mainViewModel: mainViewModel,
|
||||||
|
uploadNew: true
|
||||||
|
),
|
||||||
|
isPresented: $viewModel.presentEditView
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
viewModel.serverConnection = await mainViewModel.checkServerConnection()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.serverConnection = await mainViewModel.checkServerConnection()
|
||||||
|
await mainViewModel.getCategories()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var presentEditView: Bool = false
|
||||||
|
@Published var presentSettingsView: Bool = false
|
||||||
|
|
||||||
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
|
@Published var presentConnectionPopover: Bool = false
|
||||||
|
@Published var serverConnection: Bool = false
|
||||||
|
|
||||||
|
@Published var selectedCategory: Category? = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
|
@EnvironmentObject var mainViewModel: AppState
|
||||||
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
|
||||||
|
var body: some ToolbarContent {
|
||||||
|
// Top left menu toolbar item
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
viewModel.presentLoadingIndicator = true
|
||||||
|
UserSettings.shared.lastUpdate = Date.distantPast
|
||||||
|
await mainViewModel.getCategories()
|
||||||
|
for category in mainViewModel.categories {
|
||||||
|
await mainViewModel.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
|
}
|
||||||
|
await mainViewModel.updateAllRecipeDetails()
|
||||||
|
viewModel.presentLoadingIndicator = false
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("Refresh all")
|
||||||
|
Image(systemName: "icloud.and.arrow.down")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.presentSettingsView = true
|
||||||
|
} label: {
|
||||||
|
Text("Settings")
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server connection indicator
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
print("Check server connection")
|
||||||
|
viewModel.presentConnectionPopover = true
|
||||||
|
} label: {
|
||||||
|
if viewModel.presentLoadingIndicator {
|
||||||
|
ProgressView()
|
||||||
|
} else if viewModel.serverConnection {
|
||||||
|
Image(systemName: "checkmark.icloud")
|
||||||
|
} else {
|
||||||
|
Image(systemName: "xmark.icloud")
|
||||||
|
}
|
||||||
|
}.popover(isPresented: $viewModel.presentConnectionPopover) {
|
||||||
|
VStack(alignment: .leading) {
|
||||||
|
Text(viewModel.serverConnection ? LocalizedStringKey("Connected to server.") : LocalizedStringKey("Unable to connect to server."))
|
||||||
|
.bold()
|
||||||
|
|
||||||
|
Text("Last updated: \(DateFormatter.utcToString(date: UserSettings.shared.lastUpdate))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.presentationCompactAdaptation(.popover)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new recipes
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button {
|
||||||
|
print("Add new recipe")
|
||||||
|
viewModel.presentEditView = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
59
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal file
59
Nextcloud Cookbook iOS Client/Views/Tabs/SearchTabView.swift
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//
|
||||||
|
// SearchTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
// Created by Vincent Meilinger on 23.01.24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
struct SearchTabView: View {
|
||||||
|
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||||
|
@EnvironmentObject var mainViewModel: AppState
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack {
|
||||||
|
ScrollView(showsIndicators: false) {
|
||||||
|
LazyVStack {
|
||||||
|
ForEach(viewModel.recipesFiltered(), id: \.recipe_id) { recipe in
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
RecipeCardView(viewModel: mainViewModel, recipe: recipe)
|
||||||
|
.shadow(radius: 2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
|
RecipeDetailView(viewModel: mainViewModel, recipe: recipe)
|
||||||
|
}
|
||||||
|
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||||
|
}
|
||||||
|
.navigationTitle("Search recipe")
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if viewModel.allRecipes.isEmpty {
|
||||||
|
viewModel.allRecipes = await mainViewModel.getRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.allRecipes = await mainViewModel.getRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewModel: ObservableObject {
|
||||||
|
@Published var allRecipes: [Recipe] = []
|
||||||
|
@Published var searchText: String = ""
|
||||||
|
|
||||||
|
func recipesFiltered() -> [Recipe] {
|
||||||
|
guard searchText != "" else { return allRecipes }
|
||||||
|
return allRecipes.filter { recipe in
|
||||||
|
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
||||||
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Resources/alarm_sound_0.mp3
Normal file
BIN
Resources/alarm_sound_0.mp3
Normal file
Binary file not shown.
Reference in New Issue
Block a user