Merge pull request #14 from VincentMeilinger/1.8.0

1.8.0
This commit is contained in:
VincentM
2024-01-25 23:31:15 +01:00
committed by GitHub
23 changed files with 1269 additions and 96 deletions

View File

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

View File

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

View File

@@ -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 != "" {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
}
}
*/

View File

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

View File

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

View File

@@ -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,6 +25,7 @@ struct RecipeDetailView: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack {
if let recipeImage = recipeImage { if let recipeImage = recipeImage {
Image(uiImage: recipeImage) Image(uiImage: recipeImage)
.resizable() .resizable()
@@ -32,6 +33,7 @@ struct RecipeDetailView: View {
.frame(maxHeight: 300) .frame(maxHeight: 300)
.clipped() .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
}
}
)
} }
} }

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

View File

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

View File

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

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

View 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

Binary file not shown.