4 Commits
1.11 ... 1.10.1

Author SHA1 Message Date
VincentMeilinger
078c01808d 1.10.2 2025-05-28 06:29:37 +02:00
VincentMeilinger
31dd6c6926 Fixed disappearing images when updating recipes 2025-05-26 23:56:52 +02:00
VincentMeilinger
d7272026bb Bugfixes, manual grocery list entries 2025-05-26 23:04:14 +02:00
VincentMeilinger
6cecdcf1fd Markdown support in recipe ingredients, instructions and tools 2025-05-26 17:30:36 +02:00
58 changed files with 1091 additions and 3636 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 70;
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
@@ -21,6 +21,7 @@
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
A70171CD2AB501B100064C43 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CC2AB501B100064C43 /* SettingsView.swift */; };
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */; };
@@ -53,27 +54,17 @@
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 */; };
A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */; };
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */; };
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97B4D342B80B82A00EC1A88 /* ShareView.swift */; };
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */; };
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98F931D2C07B07400E34359 /* CookbookState.swift */; };
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */; };
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */; };
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */; };
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */; };
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AAB0512DE911C300A4C74B /* AuthManager.swift */; };
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */; };
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */; };
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* Recipe.swift */; };
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */; };
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */; };
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */; };
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */; };
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A332BEA773900206866 /* LocalDataInterface.swift */; };
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = A9E78A362BEA839100206866 /* KeychainSwift */; };
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
/* End PBXBuildFile section */
@@ -113,6 +104,7 @@
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
A70171CC2AB501B100064C43 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
A70322692ABAF49800D7C4ED /* JSONCoderExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONCoderExtension.swift; sourceTree = "<group>"; };
@@ -144,39 +136,25 @@
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>"; };
A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookModelsV1.swift; sourceTree = "<group>"; };
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeModels.swift; sourceTree = "<group>"; };
A97B4D342B80B82A00EC1A88 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
A9805BEC2BAAC70E003B7231 /* NumberFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberFormatter.swift; sourceTree = "<group>"; };
A98F931D2C07B07400E34359 /* CookbookState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookState.swift; sourceTree = "<group>"; };
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookLoginModels.swift; sourceTree = "<group>"; };
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CookbookProtocols.swift; sourceTree = "<group>"; };
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListVStack.swift; sourceTree = "<group>"; };
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTabView.swift; sourceTree = "<group>"; };
A9AAB0512DE911C300A4C74B /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParallaxHeaderView.swift; sourceTree = "<group>"; };
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomClipper.swift; sourceTree = "<group>"; };
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Recipe.swift; sourceTree = "<group>"; };
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableRecipeDetail.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>"; };
A9D8F9042B99F3E4009BACAE /* RecipeImportSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeImportSection.swift; sourceTree = "<group>"; };
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataInterface.swift; sourceTree = "<group>"; };
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudDataInterface.swift; sourceTree = "<group>"; };
A9E78A332BEA773900206866 /* LocalDataInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalDataInterface.swift; sourceTree = "<group>"; };
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
A9C34A722D390E69006EEB66 /* Account */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Account; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
A701717B2AA8E71900064C43 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A9E78A372BEA839100206866 /* KeychainSwift in Frameworks */,
A74D33BE2AF82AAE00D06555 /* SwiftSoup in Frameworks */,
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */,
);
@@ -224,8 +202,6 @@
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */ = {
isa = PBXGroup;
children = (
A9C34A722D390E69006EEB66 /* Account */,
A9E78A2E2BEA726A00206866 /* Persistence */,
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */,
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */,
A70171AC2AA8EF4700064C43 /* AppState.swift */,
@@ -233,7 +209,7 @@
A70171BA2AB4980100064C43 /* Views */,
A70171B72AB2445700064C43 /* Models */,
A97B4D332B80B51700EC1A88 /* Util */,
A70171B22AB211F000064C43 /* Networking */,
A70171B22AB211F000064C43 /* Network */,
A781E75F2AF8228100452F6F /* RecipeImport */,
A9CA6CED2B4C084100F78AB5 /* RecipeExport */,
A703226B2ABAF60D00D7C4ED /* Extensions */,
@@ -270,16 +246,16 @@
path = "Nextcloud Cookbook iOS ClientUITests";
sourceTree = "<group>";
};
A70171B22AB211F000064C43 /* Networking */ = {
A70171B22AB211F000064C43 /* Network */ = {
isa = PBXGroup;
children = (
A79AA8EA2B062E15007D25F2 /* ApiRequest.swift */,
A70171B32AB2122900064C43 /* NetworkUtils.swift */,
A70171B02AB211DF00064C43 /* NetworkError.swift */,
A79AA8E72B062DB6007D25F2 /* CookbookApi */,
A79AA8EE2B063B33007D25F2 /* NextcloudApi */,
A70171B32AB2122900064C43 /* NetworkUtils.swift */,
A70171B02AB211DF00064C43 /* NetworkError.swift */,
);
path = Networking;
path = Network;
sourceTree = "<group>";
};
A70171B72AB2445700064C43 /* Models */ = {
@@ -306,10 +282,11 @@
A70171C72AB4C4A100064C43 /* Data */ = {
isa = PBXGroup;
children = (
A9AAB0512DE911C300A4C74B /* AuthManager.swift */,
A70171C32AB4A31200064C43 /* DataStore.swift */,
A70171CA2AB4CD1700064C43 /* UserSettings.swift */,
A9BBB38F2B91BE31002DA7FF /* Recipe.swift */,
A70171C52AB4C43A00064C43 /* DataModels.swift */,
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
);
path = Data;
sourceTree = "<group>";
@@ -344,10 +321,7 @@
isa = PBXGroup;
children = (
A79AA8E32B02A961007D25F2 /* CookbookApi.swift */,
A99A2D4F2BEFC44000402B36 /* CookbookProtocols.swift */,
A79AA8E82B062DD1007D25F2 /* CookbookApiV1.swift */,
A99A2D4D2BEFBC0900402B36 /* CookbookLoginModels.swift */,
A97B4D312B80B3E900EC1A88 /* CookbookModelsV1.swift */,
);
path = CookbookApi;
sourceTree = "<group>";
@@ -389,7 +363,6 @@
A977D0DC2B6002DA009783A9 /* Tabs */ = {
isa = PBXGroup;
children = (
A9AAB04F2DE881F600A4C74B /* SettingsTabView.swift */,
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
@@ -409,16 +382,6 @@
path = Util;
sourceTree = "<group>";
};
A98F931F2C07BA4F00E34359 /* Interfaces */ = {
isa = PBXGroup;
children = (
A9E78A2C2BE8E3AF00206866 /* DataInterface.swift */,
A9E78A332BEA773900206866 /* LocalDataInterface.swift */,
A9E78A312BEA770600206866 /* NextcloudDataInterface.swift */,
);
path = Interfaces;
sourceTree = "<group>";
};
A9C3BE502B630E3900562C79 /* Recipes */ = {
isa = PBXGroup;
children = (
@@ -435,7 +398,6 @@
A9C3BE522B630F1300562C79 /* ReusableViews */ = {
isa = PBXGroup;
children = (
A9AAB04D2DE861FA00A4C74B /* ListVStack.swift */,
A9BBB38B2B8D3B0C002DA7FF /* ParallaxHeaderView.swift */,
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */,
A9BBB38D2B8E44B3002DA7FF /* BottomClipper.swift */,
@@ -451,15 +413,6 @@
path = RecipeExport;
sourceTree = "<group>";
};
A9E78A2E2BEA726A00206866 /* Persistence */ = {
isa = PBXGroup;
children = (
A98F931D2C07B07400E34359 /* CookbookState.swift */,
A98F931F2C07BA4F00E34359 /* Interfaces */,
);
path = Persistence;
sourceTree = "<group>";
};
A9FA2AB42B50798800A43702 /* Resources */ = {
isa = PBXGroup;
children = (
@@ -483,14 +436,10 @@
);
dependencies = (
);
fileSystemSynchronizedGroups = (
A9C34A722D390E69006EEB66 /* Account */,
);
name = "Nextcloud Cookbook iOS Client";
packageProductDependencies = (
A74D33BD2AF82AAE00D06555 /* SwiftSoup */,
A9CA6CF52B4C63F200F78AB5 /* TPPDF */,
A9E78A362BEA839100206866 /* KeychainSwift */,
);
productName = "Nextcloud Cookbook iOS Client";
productReference = A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */;
@@ -570,7 +519,6 @@
packageReferences = (
A74D33BC2AF82AAE00D06555 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */,
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */,
);
productRefGroup = A701717F2AA8E71900064C43 /* Products */;
projectDirPath = "";
@@ -621,13 +569,11 @@
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
A975061F2B920FFC00E86029 /* RecipeToolSection.swift in Sources */,
A9E78A322BEA770600206866 /* NextcloudDataInterface.swift in Sources */,
A9805BED2BAAC70E003B7231 /* NumberFormatter.swift in Sources */,
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */,
A9BBB38C2B8D3B0C002DA7FF /* ParallaxHeaderView.swift in Sources */,
A79AA8E22AFF8C14007D25F2 /* RecipeEditViewModel.swift in Sources */,
A97506152B920DF200E86029 /* RecipeGenericViews.swift in Sources */,
A9AAB0522DE911C600A4C74B /* AuthManager.swift in Sources */,
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */,
A977D0E22B60034E009783A9 /* GroceryListTabView.swift in Sources */,
A70171B12AB211DF00064C43 /* NetworkError.swift in Sources */,
@@ -636,25 +582,20 @@
A79AA8E62B02C3CB007D25F2 /* LoggerExtension.swift in Sources */,
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */,
A7FB0D7E2B25C6A200A3469E /* V2LoginView.swift in Sources */,
A9AAB04E2DE8620000A4C74B /* ListVStack.swift in Sources */,
A975061D2B920FCC00E86029 /* RecipeInstructionSection.swift in Sources */,
A787B0782B2B1E6400C2DF1B /* DateExtension.swift in Sources */,
A79AA8E92B062DD1007D25F2 /* CookbookApiV1.swift in Sources */,
A9E78A2D2BE8E3AF00206866 /* DataInterface.swift in Sources */,
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */,
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */,
A9AAB0502DE881FC00A4C74B /* SettingsTabView.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* Recipe.swift in Sources */,
A98F931E2C07B07400E34359 /* CookbookState.swift in Sources */,
A99A2D4E2BEFBC0900402B36 /* CookbookLoginModels.swift in Sources */,
A9BBB3902B91BE31002DA7FF /* ObservableRecipeDetail.swift in Sources */,
A97506212B92104700E86029 /* RecipeMetadataSection.swift in Sources */,
A70171B42AB2122900064C43 /* NetworkUtils.swift in Sources */,
A97B4D322B80B3E900EC1A88 /* CookbookModelsV1.swift in Sources */,
A97B4D322B80B3E900EC1A88 /* RecipeModels.swift in Sources */,
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */,
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */,
A79AA8EB2B062E15007D25F2 /* ApiRequest.swift in Sources */,
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */,
A79AA8E02AFF80E3007D25F2 /* DurationComponents.swift in Sources */,
A9E78A342BEA773900206866 /* LocalDataInterface.swift in Sources */,
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */,
A79AA8E42B02A962007D25F2 /* CookbookApi.swift in Sources */,
A975061B2B920F9F00E86029 /* RecipeNutritionSection.swift in Sources */,
@@ -664,7 +605,6 @@
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
A99A2D502BEFC44000402B36 /* CookbookProtocols.swift in Sources */,
A703226A2ABAF49800D7C4ED /* JSONCoderExtension.swift in Sources */,
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */,
A97506132B920D9F00E86029 /* RecipeDurationSection.swift in Sources */,
@@ -853,11 +793,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
MARKETING_VERSION = 1.10.2;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -897,11 +837,11 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.1;
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
MARKETING_VERSION = 1.10.2;
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
@@ -1065,14 +1005,6 @@
minimumVersion = 2.4.1;
};
};
A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/evgenyneu/keychain-swift.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 24.0.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@@ -1086,11 +1018,6 @@
package = A9CA6CF42B4C63F200F78AB5 /* XCRemoteSwiftPackageReference "TPPDF" */;
productName = TPPDF;
};
A9E78A362BEA839100206866 /* KeychainSwift */ = {
isa = XCSwiftPackageProductDependency;
package = A9E78A352BEA839100206866 /* XCRemoteSwiftPackageReference "keychain-swift" */;
productName = KeychainSwift;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A70171762AA8E71900064C43 /* Project object */;

View File

@@ -1,15 +1,5 @@
{
"originHash" : "4b59f87688d89ebd5be92449f747ea79123f0d90515aea6b92e218b4860a3ef3",
"pins" : [
{
"identity" : "keychain-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/evgenyneu/keychain-swift.git",
"state" : {
"revision" : "5e1b02b6a9dac2a759a1d5dbc175c86bd192a608",
"version" : "24.0.0"
}
},
{
"identity" : "swiftsoup",
"kind" : "remoteSourceControl",
@@ -29,5 +19,5 @@
}
}
],
"version" : 3
"version" : 2
}

View File

@@ -1,45 +0,0 @@
//
// AccountManager.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.01.25.
//
import Foundation
@Observable
class AccountManager {
var accounts: [any Account] = []
var authTokens: [String: String] = [:]
/// Save account as JSON.
func save(account: any Account, authToken: String?) async throws {
account.saveTokenToKeychain(authToken!)
let data = try JSONEncoder().encode(account)
let accountDir = account.accountType.rawValue + "/" + account.id.uuidString + "/account.json"
await DataStore.shared.save(data: data, toPath: accountDir)
}
/// Load accounts from JSON files.
func loadAccounts() async throws {
// Read data from file or user defaults
for accountType in AccountType.allCases {
// List all account UUIDs under the /accountType directory
let accountUUIDs = DataStore.shared.listAllFolders(dir: accountType.rawValue + "/")
// Decode each account and fetch the authToken
for accountUUID in accountUUIDs {
do {
guard let account = try await DataStore.shared.loadDynamic(fromPath: accountType.rawValue + "/" + accountUUID + "/account.json", type: accountType.accountType) else {
continue
}
authTokens[accountUUID] = (account as! any Account).getTokenFromKeychain() ?? ""
self.accounts.append(account as! (any Account))
} catch {
continue
}
}
}
}
}

View File

@@ -1,49 +0,0 @@
//
// AccountProtocol.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.01.25.
//
import Foundation
import SwiftUI
import KeychainSwift
enum AccountType: String, Codable, CaseIterable {
case cookbook = "cookbook"
case local = "local"
var accountType: any Decodable.Type {
switch self {
case .cookbook: return CookbookAccount.self
case .local: return LocalAccount.self
}
}
}
protocol Account: Codable, Identifiable {
/// A unique identifier for this account
var id: UUID { get }
/// A name for the account that can be displayed in the UI
var displayName: String { get }
/// For differentiating account types when decoding
var accountType: AccountType { get }
/// Base endpoint URL
var baseURL: URL { get }
/// Account username
var username: String { get }
/// For storing/retrieving tokens from Keychain
func saveTokenToKeychain(_ token: String)
func getTokenFromKeychain() -> String?
}

View File

@@ -1,31 +0,0 @@
//
// CookbookAccount.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 24.01.25.
//
import Foundation
import KeychainSwift
struct CookbookAccount: Account {
let accountType: AccountType = .cookbook
let id: UUID
var displayName: String = "Nextcloud Cookbook Account"
let baseURL: URL
let username: String
/// Keychain convenience
func saveTokenToKeychain(_ token: String) {
let keychain = KeychainSwift()
keychain.set(token, forKey: "token-\(id.uuidString)")
}
func getTokenFromKeychain() -> String? {
let keychain = KeychainSwift()
return keychain.get("token-\(id.uuidString)")
}
}

View File

@@ -1,28 +0,0 @@
//
// LocalAccount.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 16.04.25.
//
import Foundation
struct LocalAccount: Account {
let id: UUID
var displayName: String = "Local Account"
var accountType: AccountType = .local
let baseURL: URL = URL(filePath: "")!
let username: String = ""
/// Keychain convenience
func saveTokenToKeychain(_ token: String) {
return
}
func getTokenFromKeychain() -> String? {
return nil
}
}

View File

@@ -9,15 +9,11 @@ import Foundation
import SwiftUI
import UIKit
@Observable class AppState {
}
/*
@MainActor class AppState: ObservableObject {
@Published var categories: [Category] = []
@Published var recipes: [String: [CookbookApiRecipeV1]] = [:]
@Published var recipeDetails: [Int: CookbookApiRecipeDetailV1] = [:]
@Published var recipes: [String: [Recipe]] = [:]
@Published var recipeDetails: [Int: RecipeDetail] = [:]
@Published var timers: [String: RecipeTimer] = [:]
var recipeImages: [Int: [String: UIImage]] = [:]
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
@@ -90,7 +86,7 @@ import UIKit
func getCategory(named name: String, fetchMode: FetchMode) async {
print("getCategory(\(name), fetchMode: \(fetchMode))")
func getLocal() async -> Bool {
if let recipes: [CookbookApiRecipeV1] = await loadLocal(path: "category_\(categoryString).data") {
if let recipes: [Recipe] = await loadLocal(path: "category_\(categoryString).data") {
self.recipes[name] = recipes
return true
}
@@ -163,7 +159,7 @@ import UIKit
```swift
let recipes = await mainViewModel.getRecipes()
*/
func getRecipes() async -> [CookbookApiRecipeV1] {
func getRecipes() async -> [Recipe] {
let (recipes, error) = await cookbookApi.getRecipes(
auth: UserSettings.shared.authString
)
@@ -172,8 +168,11 @@ import UIKit
} else if let error = error {
print(error)
}
var allRecipes: [CookbookApiRecipeV1] = []
var allRecipes: [Recipe] = []
for category in categories {
if self.recipes[category.name] == nil {
await getCategory(named: category.name, fetchMode: .preferLocal)
}
if let recipeArray = self.recipes[category.name] {
allRecipes.append(contentsOf: recipeArray)
}
@@ -197,13 +196,13 @@ import UIKit
```swift
let recipeDetail = await mainViewModel.getRecipe(id: 123)
*/
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> CookbookApiRecipeDetailV1? {
func getLocal() async -> CookbookApiRecipeDetailV1? {
if let recipe: CookbookApiRecipeDetailV1 = await loadLocal(path: "recipe\(id).data") { return recipe }
func getRecipe(id: Int, fetchMode: FetchMode, save: Bool = false) async -> RecipeDetail? {
func getLocal() async -> RecipeDetail? {
if let recipe: RecipeDetail = await loadLocal(path: "recipe\(id).data") { return recipe }
return nil
}
func getServer() async -> CookbookApiRecipeDetailV1? {
func getServer() async -> RecipeDetail? {
let (recipe, error) = await cookbookApi.getRecipe(
auth: UserSettings.shared.authString,
id: id
@@ -487,7 +486,7 @@ import UIKit
```swift
let uploadResult = await mainViewModel.uploadRecipe(recipeDetail: myRecipeDetail, createNew: true)
*/
func uploadRecipe(recipeDetail: CookbookApiRecipeDetailV1, createNew: Bool) async -> RequestAlert? {
func uploadRecipe(recipeDetail: RecipeDetail, createNew: Bool) async -> RequestAlert? {
var error: NetworkError? = nil
if createNew {
error = await cookbookApi.createRecipe(
@@ -506,7 +505,7 @@ import UIKit
return nil
}
func importRecipe(url: String) async -> (CookbookApiRecipeDetailV1?, RequestAlert?) {
func importRecipe(url: String) async -> (RecipeDetail?, RequestAlert?) {
guard let data = JSONEncoder.safeEncode(RecipeImportRequest(url: url)) else { return (nil, .REQUEST_DROPPED) }
let (recipeDetail, error) = await cookbookApi.importRecipe(
auth: UserSettings.shared.authString,
@@ -637,4 +636,3 @@ extension AppState {
timers.removeValue(forKey: recipeId)
}
}
*/

View File

@@ -1,44 +0,0 @@
//
// AuthManager.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 30.05.25.
//
import Foundation
import KeychainSwift
class AuthManager {
static let shared = AuthManager()
let keychain = KeychainSwift()
var authString: String? = nil
private let nextcloudUsernameKey = "nextcloud_username"
private let nextcloudAuthStringKey = "nextcloud_auth_string" // Stored as base64
func saveNextcloudCredentials(username: String, appPassword: String) {
keychain.set(username, forKey: nextcloudUsernameKey)
let loginString = "\(username):\(appPassword)"
if let loginData = loginString.data(using: .utf8) {
keychain.set(loginData.base64EncodedString(), forKey: nextcloudAuthStringKey)
}
}
func getNextcloudCredentials() -> (username: String?, authString: String?) {
let username = keychain.get(nextcloudUsernameKey)
let authString = keychain.get(nextcloudAuthStringKey)
return (username, authString)
}
func loadAuthString() {
authString = keychain.get(nextcloudAuthStringKey)
}
func deleteNextcloudCredentials() {
keychain.delete(nextcloudUsernameKey)
keychain.delete(nextcloudAuthStringKey)
}
}

View File

@@ -1,15 +1,30 @@
//
// CookbookLoginModels.swift
// DataModels.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
// Created by Vincent Meilinger on 15.09.23.
//
import Foundation
import SwiftUI
// MARK: - Login Models
struct Category: Codable {
let name: String
let recipe_count: Int
private enum CodingKeys: String, CodingKey {
case name, recipe_count
}
}
extension Category: Identifiable, Hashable {
var id: String { name }
}
// MARK: - Login flow
struct LoginV2Request: Codable {
let poll: LoginV2Poll
@@ -39,3 +54,6 @@ struct MetaData: Codable {
let status: String
let statuscode: Int
}

View File

@@ -37,21 +37,12 @@ class DataStore {
guard let data = try? Data(contentsOf: fileURL) else {
return nil
}
let decodedData = try JSONDecoder().decode(D.self, from: data)
return decodedData
let storedRecipes = try JSONDecoder().decode(D.self, from: data)
return storedRecipes
}
return try await task.value
}
func loadDynamic(fromPath path: String, type: Decodable.Type) async throws -> Any? {
let fileURL = try Self.fileURL(appending: path)
guard let data = try? Data(contentsOf: fileURL) else {
return nil
}
let decoded = try JSONDecoder().decode(type, from: data)
return decoded
}
func save<D: Encodable>(data: D, toPath path: String) async {
let task = Task {
let data = try JSONEncoder().encode(data)
@@ -78,27 +69,6 @@ class DataStore {
return fileManager.fileExists(atPath: folderPath + filePath)
}
func listAllFolders(dir: String) -> [String] {
guard let baseURL = try? Self.fileURL() else {
print("Failed to retrieve documents directory.")
return []
}
let targetURL = baseURL.appendingPathComponent(dir)
do {
let contents = try fileManager.contentsOfDirectory(at: targetURL, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
let folders = contents.filter { url in
(try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
}
return folders.map { $0.lastPathComponent }
} catch {
print("Error listing folders in \(dir): \(error)")
return []
}
}
func clearAll() -> Bool {
print("Attempting to delete all data ...")
guard let folderPath = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first?.path() else { return false }

View File

@@ -0,0 +1,294 @@
//
// ObservableRecipeDetail.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
class ObservableRecipeDetail: ObservableObject {
// Cookbook recipe detail fields
var id: String
@Published var name: String
@Published var keywords: [String]
@Published var image: String
@Published var imageUrl: String
@Published var prepTime: DurationComponents
@Published var cookTime: DurationComponents
@Published var totalTime: DurationComponents
@Published var description: String
@Published var url: String
@Published var recipeYield: Int
@Published var recipeCategory: String
@Published var tool: [String]
@Published var recipeIngredient: [String]
@Published var recipeInstructions: [String]
@Published var nutrition: [String:String]
// Additional functionality
@Published var ingredientMultiplier: Double
init() {
id = ""
name = String(localized: "New Recipe")
keywords = []
image = ""
imageUrl = ""
prepTime = DurationComponents()
cookTime = DurationComponents()
totalTime = DurationComponents()
description = ""
url = ""
recipeYield = 1
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
ingredientMultiplier = 1
}
init(_ recipeDetail: RecipeDetail) {
id = recipeDetail.id
name = recipeDetail.name
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
image = recipeDetail.image ?? ""
imageUrl = recipeDetail.imageUrl ?? ""
prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "")
cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "")
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
description = recipeDetail.description
url = recipeDetail.url ?? ""
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
recipeCategory = recipeDetail.recipeCategory
tool = recipeDetail.tool
recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = recipeDetail.recipeInstructions
nutrition = recipeDetail.nutrition
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
}
func toRecipeDetail() -> RecipeDetail {
return RecipeDetail(
name: self.name,
keywords: self.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
image: self.image,
imageUrl: self.imageUrl,
id: self.id,
prepTime: self.prepTime.toPTString(),
cookTime: self.cookTime.toPTString(),
totalTime: self.totalTime.toPTString(),
description: self.description,
url: self.url,
recipeYield: self.recipeYield,
recipeCategory: self.recipeCategory,
tool: self.tool,
recipeIngredient: self.recipeIngredient,
recipeInstructions: self.recipeInstructions,
nutrition: self.nutrition
)
}
static func applyMarkdownStyling(_ text: String) -> AttributedString {
if var markdownString = try? AttributedString(
markdown: text,
options: .init(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible
)
) {
for (intentBlock, intentRange) in markdownString.runs[AttributeScopes.FoundationAttributes.PresentationIntentAttribute.self].reversed() {
guard let intentBlock = intentBlock else { continue }
for intent in intentBlock.components {
switch intent.kind {
case .header(level: let level):
switch level {
case 1:
markdownString[intentRange].font = .system(.title).bold()
case 2:
markdownString[intentRange].font = .system(.title2).bold()
case 3:
markdownString[intentRange].font = .system(.title3).bold()
default:
break
}
default:
break
}
}
if intentRange.lowerBound != markdownString.startIndex {
markdownString.characters.insert(contentsOf: "\n", at: intentRange.lowerBound)
}
}
return markdownString
}
return AttributedString(text)
}
static func adjustIngredient(_ ingredient: AttributedString, by factor: Double) -> AttributedString {
if factor == 0 {
return ingredient
}
// Match mixed fractions first
var matches = ObservableRecipeDetail.matchPatternAndMultiply(
.mixedFraction,
in: String(ingredient.characters),
multFactor: factor
)
// Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.fraction,
in: String(ingredient.characters),
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
)
// Match numbers at last, exclude all prior matches
matches.append(contentsOf:
ObservableRecipeDetail.matchPatternAndMultiply(
.number,
in: String(ingredient.characters),
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
)
// Sort matches by match range lower bound, descending.
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
var attributedString = ingredient
for (newSubstring, matchRange) in matches {
guard let range = Range(matchRange, in: attributedString) else { continue }
var attributedSubString = AttributedString(newSubstring)
//attributedSubString.foregroundColor = .ncTextHighlight
attributedSubString.font = .system(.body, weight: .bold)
attributedString.replaceSubrange(range, with: attributedSubString)
}
return attributedString
}
static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range<String.Index>]? = nil) -> [(String, Range<String.Index>)] {
var foundMatches: [(String, Range<String.Index>)] = []
do {
let regex = try NSRegularExpression(pattern: expr.pattern)
let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str))
for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
if let excludedRanges = excludedRanges,
excludedRanges.contains(where: { $0.overlaps(matchRange) }) {
// If there's an overlap, skip this match.
continue
}
let matchedString = String(str[matchRange])
// Process each match based on its type
var adjustedValue: Double = 0
switch expr {
case .number:
guard let number = numberFormatter.number(from: matchedString) else { continue }
adjustedValue = number.doubleValue
case .fraction:
let fracComponents = matchedString.split(separator: "/")
guard fracComponents.count == 2 else { continue }
guard let nominator = Double(fracComponents[0]) else { continue }
guard let denominator = Double(fracComponents[1]), denominator > 0 else { continue }
adjustedValue = nominator/denominator
case .mixedFraction:
guard match.numberOfRanges == 4 else { continue }
guard let intRange = Range(match.range(at: 1), in: str) else { continue }
guard let nomRange = Range(match.range(at: 2), in: str) else { continue }
guard let denomRange = Range(match.range(at: 3), in: str) else { continue }
guard let number = Double(str[intRange]),
let nominator = Double(str[nomRange]),
let denominator = Double(str[denomRange]), denominator > 0
else { continue }
adjustedValue = number + nominator/denominator
}
let formattedAdjustedValue = formatNumber(adjustedValue * multFactor)
foundMatches.append((formattedAdjustedValue, matchRange))
}
return foundMatches
} catch {
print("Regex error: \(error.localizedDescription)")
}
return []
}
static func formatNumber(_ value: Double) -> String {
if value <= 0.0001 {
return "0"
}
let integerPart = value >= 1 ? Int(value) : 0
let decimalPart = value - Double(integerPart)
if integerPart >= 1 && decimalPart < 0.0001 {
return String(format: "%.0f", value)
}
// Define known fractions and their decimal equivalents
let knownFractions: [(fraction: String, value: Double)] = [
("1/8", 0.125), ("1/6", 0.167), ("1/4", 0.25), ("1/3", 0.33), ("1/2", 0.5), ("2/3", 0.66), ("3/4", 0.75)
]
// Find the known fraction closest to the given value
let closest = knownFractions.min(by: { abs($0.value - decimalPart) < abs($1.value - decimalPart) })!
// Check if the value is close enough to a known fraction to be considered a match
let threshold = 0.05
if abs(closest.value - decimalPart) <= threshold && integerPart == 0 {
return closest.fraction
} else if abs(closest.value - decimalPart) <= threshold && integerPart > 0 {
return "\(String(integerPart)) \(closest.fraction)"
} else {
// If no close match is found, return the original value as a string
return numberFormatter.string(from: NSNumber(value: value)) ?? "0"//String(format: "%.2f", value)
}
}
func ingredientUnitsToMetric() {
// TODO: Convert imperial units in recipes to metric units
}
}
enum RegexPattern: String, CaseIterable, Identifiable {
case mixedFraction, fraction, number
var id: String { self.rawValue }
var pattern: String {
switch self {
case .mixedFraction:
#"(\d+)\s+(\d+)/(\d+)"#
case .fraction:
#"(?:[1-9][0-9]*|0)\/([1-9][0-9]*)"#
case .number:
#"(\d+([.,]\d+)?)"#
}
}
var localizedDescription: LocalizedStringKey {
switch self {
case .mixedFraction:
"Mixed fraction"
case .fraction:
"Fraction"
case .number:
"Number"
}
}
}

View File

@@ -1,491 +0,0 @@
//
// ObservableRecipeDetail.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 01.03.24.
//
import Foundation
import SwiftUI
import SwiftData
// MARK: - Recipe Model
@Model
class RecipeImage {
enum RecipeImageSize {
case THUMB, FULL
}
var imageData: Data?
@Transient
var image: UIImage? {
guard let imageData else { return nil }
return UIImage(data: imageData)
}
init(imageData: Data? = nil) {
self.imageData = imageData
}
}
@Model
class RecipeThumbnail {
var thumbnailData: Data?
@Transient
var thumbnail: UIImage? {
guard let thumbnailData else { return nil }
return UIImage(data: thumbnailData)
}
init(thumbnailData: Data? = nil) {
self.thumbnailData = thumbnailData
}
}
@Model
class Recipe {
var id: String
var name: String
var keywords: [String]
@Attribute(.externalStorage) var image: RecipeImage?
@Attribute(.externalStorage) var thumbnail: RecipeThumbnail?
var dateCreated: String? = nil
var dateModified: String? = nil
var prepTime: String
var cookTime: String
var totalTime: String
var recipeDescription: String
var url: String?
var yield: Int
var category: String
var tools: [String]
var ingredients: [String]
var instructions: [String]
var nutrition: [String:String]
// Additional functionality
@Transient
var ingredientMultiplier: Double = 1.0
var prepTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(prepTime)
}
var cookTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(cookTime)
}
var totalTimeDurationComponent: DurationComponents {
DurationComponents.fromPTString(totalTime)
}
init(
id: String,
name: String,
keywords: [String],
dateCreated: String? = nil,
dateModified: String? = nil,
prepTime: String,
cookTime: String,
totalTime: String,
recipeDescription: String,
url: String? = nil,
yield: Int,
category: String,
tools: [String],
ingredients: [String],
instructions: [String],
nutrition: [String : String],
ingredientMultiplier: Double
) {
self.id = id
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.prepTime = prepTime
self.cookTime = cookTime
self.totalTime = totalTime
self.recipeDescription = recipeDescription
self.url = url
self.yield = yield
self.category = category
self.tools = tools
self.ingredients = ingredients
self.instructions = instructions
self.nutrition = nutrition
self.ingredientMultiplier = ingredientMultiplier
}
init() {
self.id = UUID().uuidString
self.name = String(localized: "New Recipe")
self.keywords = []
self.dateCreated = nil
self.dateModified = nil
self.prepTime = "0"
self.cookTime = "0"
self.totalTime = "0"
self.recipeDescription = ""
self.url = ""
self.yield = 1
self.category = ""
self.tools = []
self.ingredients = []
self.instructions = []
self.nutrition = [:]
self.ingredientMultiplier = 1
}
}
// MARK: - Recipe Stub
struct RecipeStub: Codable, Hashable, Identifiable {
let id: String
let name: String
let keywords: String?
let dateCreated: String?
let dateModified: String?
let thumbnailPath: String?
var storedLocally: Bool = false
var lastUpdated: String?
}
// MARK: - Recipe
/*
class Recipe: ObservableObject {
// Cookbook recipe detail fields
var id: String
@Published var name: String
@Published var keywords: [String]
@Published var imageUrl: String?
var dateCreated: String? = nil
var dateModified: String? = nil
@Published var prepTime: DurationComponents
@Published var cookTime: DurationComponents
@Published var totalTime: DurationComponents
@Published var description: String
@Published var url: String?
@Published var recipeYield: Int
@Published var recipeCategory: String
@Published var tool: [String]
@Published var recipeIngredient: [String]
@Published var recipeInstructions: [String]
@Published var nutrition: [String:String]
// Additional functionality
@Published var ingredientMultiplier: Double
init() {
id = ""
name = String(localized: "New Recipe")
keywords = []
imageUrl = ""
prepTime = DurationComponents()
cookTime = DurationComponents()
totalTime = DurationComponents()
description = ""
url = ""
recipeYield = 1
recipeCategory = ""
tool = []
recipeIngredient = []
recipeInstructions = []
nutrition = [:]
ingredientMultiplier = 1
}
init(_ recipeDetail: CookbookApiRecipeDetailV1) {
id = recipeDetail.id
name = recipeDetail.name
keywords = recipeDetail.keywords.isEmpty ? [] : recipeDetail.keywords.components(separatedBy: ",")
imageUrl = recipeDetail.imageUrl ?? ""
prepTime = DurationComponents.fromPTString(recipeDetail.prepTime ?? "")
cookTime = DurationComponents.fromPTString(recipeDetail.cookTime ?? "")
totalTime = DurationComponents.fromPTString(recipeDetail.totalTime ?? "")
description = recipeDetail.description
url = recipeDetail.url ?? ""
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
recipeCategory = recipeDetail.recipeCategory
tool = recipeDetail.tool
recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = recipeDetail.recipeInstructions
nutrition = recipeDetail.nutrition
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
}
init(
name: String,
keywords: [String],
dateCreated: String?,
dateModified: String?,
imageUrl: String?,
id: String,
prepTime: DurationComponents? = nil,
cookTime: DurationComponents? = nil,
totalTime: DurationComponents? = nil,
description: String,
url: String?,
recipeYield: Int,
recipeCategory: String,
tool: [String],
recipeIngredient: [String],
recipeInstructions: [String],
nutrition: [String:String]
) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime ?? DurationComponents()
self.cookTime = cookTime ?? DurationComponents()
self.totalTime = totalTime ?? DurationComponents()
self.description = description
self.url = url
self.recipeYield = recipeYield
self.recipeCategory = recipeCategory
self.tool = tool
self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions
self.nutrition = nutrition
ingredientMultiplier = Double(recipeYield == 0 ? 1 : recipeYield)
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
_name = Published(initialValue: try container.decode(String.self, forKey: .name))
_keywords = Published(initialValue: try container.decode([String].self, forKey: .keywords))
_imageUrl = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .imageUrl))
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
_prepTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .prepTime))
_cookTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .cookTime))
_totalTime = Published(initialValue: try container.decode(DurationComponents.self, forKey: .totalTime))
_description = Published(initialValue: try container.decode(String.self, forKey: .description))
_url = Published(initialValue: try container.decodeIfPresent(String.self, forKey: .url))
_recipeYield = Published(initialValue: try container.decode(Int.self, forKey: .recipeYield))
_recipeCategory = Published(initialValue: try container.decode(String.self, forKey: .recipeCategory))
_tool = Published(initialValue: try container.decode([String].self, forKey: .tool))
_recipeIngredient = Published(initialValue: try container.decode([String].self, forKey: .recipeIngredient))
_recipeInstructions = Published(initialValue: try container.decode([String].self, forKey: .recipeInstructions))
_nutrition = Published(initialValue: try container.decode([String: String].self, forKey: .nutrition))
_ingredientMultiplier = Published(initialValue: try container.decode(Double.self, forKey: .ingredientMultiplier))
}
func toRecipeDetail() -> CookbookApiRecipeDetailV1 {
return CookbookApiRecipeDetailV1(
name: self.name,
keywords: self.keywords.joined(separator: ","),
dateCreated: "",
dateModified: "",
imageUrl: self.imageUrl,
id: self.id,
prepTime: self.prepTime.toPTString(),
cookTime: self.cookTime.toPTString(),
totalTime: self.totalTime.toPTString(),
description: self.description,
url: self.url,
recipeYield: self.recipeYield,
recipeCategory: self.recipeCategory,
tool: self.tool,
recipeIngredient: self.recipeIngredient,
recipeInstructions: self.recipeInstructions,
nutrition: self.nutrition
)
}
static func adjustIngredient(_ ingredient: String, by factor: Double) -> AttributedString {
if factor == 0 {
return AttributedString(ingredient)
}
// Match mixed fractions first
var matches = Recipe.matchPatternAndMultiply(
.mixedFraction,
in: ingredient,
multFactor: factor
)
// Then match fractions, exclude mixed fraction ranges
matches.append(contentsOf:
Recipe.matchPatternAndMultiply(
.fraction,
in: ingredient,
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
)
// Match numbers at last, exclude all prior matches
matches.append(contentsOf:
Recipe.matchPatternAndMultiply(
.number,
in: ingredient,
multFactor: factor,
excludedRanges: matches.map({ tuple in tuple.1 })
)
)
// Sort matches by match range lower bound, descending.
matches.sort(by: { a, b in a.1.lowerBound > b.1.lowerBound})
var attributedString = AttributedString(ingredient)
for (newSubstring, matchRange) in matches {
guard let range = Range(matchRange, in: attributedString) else { continue }
var attributedSubString = AttributedString(newSubstring)
//attributedSubString.foregroundColor = .ncTextHighlight
attributedSubString.font = .system(.body, weight: .bold)
attributedString.replaceSubrange(range, with: attributedSubString)
}
return attributedString
}
static func matchPatternAndMultiply(_ expr: RegexPattern, in str: String, multFactor: Double, excludedRanges: [Range<String.Index>]? = nil) -> [(String, Range<String.Index>)] {
var foundMatches: [(String, Range<String.Index>)] = []
do {
let regex = try NSRegularExpression(pattern: expr.pattern)
let matches = regex.matches(in: str, range: NSRange(str.startIndex..., in: str))
for match in matches {
guard let matchRange = Range(match.range, in: str) else { continue }
if let excludedRanges = excludedRanges,
excludedRanges.contains(where: { $0.overlaps(matchRange) }) {
// If there's an overlap, skip this match.
continue
}
let matchedString = String(str[matchRange])
// Process each match based on its type
var adjustedValue: Double = 0
switch expr {
case .number:
guard let number = numberFormatter.number(from: matchedString) else { continue }
adjustedValue = number.doubleValue
case .fraction:
let fracComponents = matchedString.split(separator: "/")
guard fracComponents.count == 2 else { continue }
guard let nominator = Double(fracComponents[0]) else { continue }
guard let denominator = Double(fracComponents[1]), denominator > 0 else { continue }
adjustedValue = nominator/denominator
case .mixedFraction:
guard match.numberOfRanges == 4 else { continue }
guard let intRange = Range(match.range(at: 1), in: str) else { continue }
guard let nomRange = Range(match.range(at: 2), in: str) else { continue }
guard let denomRange = Range(match.range(at: 3), in: str) else { continue }
guard let number = Double(str[intRange]),
let nominator = Double(str[nomRange]),
let denominator = Double(str[denomRange]), denominator > 0
else { continue }
adjustedValue = number + nominator/denominator
}
let formattedAdjustedValue = formatNumber(adjustedValue * multFactor)
foundMatches.append((formattedAdjustedValue, matchRange))
}
return foundMatches
} catch {
print("Regex error: \(error.localizedDescription)")
}
return []
}
static func formatNumber(_ value: Double) -> String {
if value <= 0.0001 {
return "0"
}
let integerPart = value >= 1 ? Int(value) : 0
let decimalPart = value - Double(integerPart)
if integerPart >= 1 && decimalPart < 0.0001 {
return String(format: "%.0f", value)
}
// Define known fractions and their decimal equivalents
let knownFractions: [(fraction: String, value: Double)] = [
("1/8", 0.125), ("1/6", 0.167), ("1/4", 0.25), ("1/3", 0.33), ("1/2", 0.5), ("2/3", 0.66), ("3/4", 0.75)
]
// Find the known fraction closest to the given value
let closest = knownFractions.min(by: { abs($0.value - decimalPart) < abs($1.value - decimalPart) })!
// Check if the value is close enough to a known fraction to be considered a match
let threshold = 0.05
if abs(closest.value - decimalPart) <= threshold && integerPart == 0 {
return closest.fraction
} else if abs(closest.value - decimalPart) <= threshold && integerPart > 0 {
return "\(String(integerPart)) \(closest.fraction)"
} else {
// If no close match is found, return the original value as a string
return numberFormatter.string(from: NSNumber(value: value)) ?? "0"//String(format: "%.2f", value)
}
}
func ingredientUnitsToMetric() {
// TODO: Convert imperial units in recipes to metric units
}
}
extension Recipe: Codable {
enum CodingKeys: String, CodingKey {
case id, name, keywords, imageUrl, dateCreated, dateModified, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition, ingredientMultiplier
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(keywords, forKey: .keywords)
try container.encode(imageUrl, forKey: .imageUrl)
try container.encode(dateCreated, forKey: .dateCreated)
try container.encode(dateModified, forKey: .dateModified)
try container.encode(prepTime, forKey: .prepTime)
try container.encode(cookTime, forKey: .cookTime)
try container.encode(totalTime, forKey: .totalTime)
try container.encode(description, forKey: .description)
try container.encode(url, forKey: .url)
try container.encode(recipeYield, forKey: .recipeYield)
try container.encode(recipeCategory, forKey: .recipeCategory)
try container.encode(tool, forKey: .tool)
try container.encode(recipeIngredient, forKey: .recipeIngredient)
try container.encode(recipeInstructions, forKey: .recipeInstructions)
try container.encode(nutrition, forKey: .nutrition)
try container.encode(ingredientMultiplier, forKey: .ingredientMultiplier)
}
}
enum RegexPattern: String, CaseIterable, Identifiable {
case mixedFraction, fraction, number
var id: String { self.rawValue }
var pattern: String {
switch self {
case .mixedFraction:
#"(\d+)\s+(\d+)/(\d+)"#
case .fraction:
#"(?:[1-9][0-9]*|0)\/([1-9][0-9]*)"#
case .number:
#"(\d+([.,]\d+)?)"#
}
}
var localizedDescription: LocalizedStringKey {
switch self {
case .mixedFraction:
"Mixed fraction"
case .fraction:
"Fraction"
case .number:
"Number"
}
}
}
*/

View File

@@ -8,18 +8,8 @@
import Foundation
import SwiftUI
struct CookbookApiCategory: Codable, Identifiable, Hashable {
var id: String { name }
var name: String
var recipe_count: Int
private enum CodingKeys: String, CodingKey {
case name, recipe_count
}
}
struct CookbookApiRecipeV1: CookbookApiRecipe, Codable, Identifiable, Hashable {
var id: String { name + String(recipe_id) }
struct Recipe: Codable {
let name: String
let keywords: String?
let dateCreated: String?
@@ -34,27 +24,21 @@ struct CookbookApiRecipeV1: CookbookApiRecipe, Codable, Identifiable, Hashable {
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, imagePlaceholderUrl, recipe_id
}
func toRecipeStub() -> RecipeStub {
return RecipeStub(
id: String(recipe_id),
name: name,
keywords: keywords,
dateCreated: dateCreated,
dateModified: dateModified,
thumbnailPath: nil
)
}
}
extension Recipe: Identifiable, Hashable {
var id: String { name }
}
struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
struct RecipeDetail: Codable {
var name: String
var keywords: String
var dateCreated: String?
var dateModified: String?
var imageUrl: String?
var image: String?
var id: String
var prepTime: String?
var cookTime: String?
@@ -68,11 +52,12 @@ struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
var recipeInstructions: [String]
var nutrition: [String:String]
init(name: String, keywords: String, dateCreated: String?, dateModified: String?, imageUrl: String?, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String?, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
init(name: String, keywords: String, dateCreated: String, dateModified: String, image: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
self.name = name
self.keywords = keywords
self.dateCreated = dateCreated
self.dateModified = dateModified
self.image = image
self.imageUrl = imageUrl
self.id = id
self.prepTime = prepTime
@@ -93,6 +78,7 @@ struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
keywords = ""
dateCreated = ""
dateModified = ""
image = ""
imageUrl = ""
id = ""
prepTime = ""
@@ -110,7 +96,7 @@ struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
// Custom decoder to handle value type ambiguity
private enum CodingKeys: String, CodingKey {
case name, keywords, dateCreated, dateModified, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
case name, keywords, dateCreated, dateModified, image, imageUrl, id, prepTime, cookTime, totalTime, description, url, recipeYield, recipeCategory, tool, recipeIngredient, recipeInstructions, nutrition
}
init(from decoder: Decoder) throws {
@@ -119,6 +105,7 @@ struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
keywords = try container.decode(String.self, forKey: .keywords)
dateCreated = try container.decodeIfPresent(String.self, forKey: .dateCreated)
dateModified = try container.decodeIfPresent(String.self, forKey: .dateModified)
image = try container.decodeIfPresent(String.self, forKey: .image)
imageUrl = try container.decodeIfPresent(String.self, forKey: .imageUrl)
id = try container.decode(String.self, forKey: .id)
prepTime = try container.decodeIfPresent(String.self, forKey: .prepTime)
@@ -126,65 +113,26 @@ struct CookbookApiRecipeDetailV1: CookbookApiRecipeDetail {
totalTime = try container.decodeIfPresent(String.self, forKey: .totalTime)
description = try container.decode(String.self, forKey: .description)
url = try container.decode(String.self, forKey: .url)
recipeYield = try container.decode(Int.self, forKey: .recipeYield)
recipeYield = try container.decode(Int?.self, forKey: .recipeYield) ?? 1
recipeCategory = try container.decode(String.self, forKey: .recipeCategory)
tool = try container.decode([String].self, forKey: .tool)
recipeIngredient = try container.decode([String].self, forKey: .recipeIngredient)
recipeInstructions = try container.decode([String].self, forKey: .recipeInstructions)
nutrition = try container.decode(Dictionary<String, JSONAny>.self, forKey: .nutrition).mapValues { String(describing: $0.value) }
}
func toRecipe() -> Recipe {
return Recipe(
id: self.id,
name: self.name,
keywords: keywords.components(separatedBy: ","),
dateCreated: self.dateCreated,
dateModified: self.dateModified,
prepTime: self.prepTime ?? "",
cookTime: self.cookTime ?? "",
totalTime: self.totalTime ?? "",
recipeDescription: self.description,
url: self.url,
yield: self.recipeYield,
category: self.recipeCategory,
tools: self.tool,
ingredients: self.recipeIngredient,
instructions: self.recipeInstructions,
nutrition: self.nutrition,
ingredientMultiplier: 1.0
)
}
static func fromRecipe(_ recipe: Recipe) -> any CookbookApiRecipeDetail {
return CookbookApiRecipeDetailV1(
name: recipe.name,
keywords: recipe.keywords.joined(separator: ","),
dateCreated: recipe.dateCreated,
dateModified: recipe.dateModified,
imageUrl: "",
id: recipe.id,
description: recipe.recipeDescription,
url: recipe.url,
recipeYield: recipe.yield,
recipeCategory: recipe.category,
tool: recipe.tools,
recipeIngredient: recipe.ingredients,
recipeInstructions: recipe.instructions,
nutrition: recipe.nutrition
)
}
}
extension CookbookApiRecipeDetailV1 {
static var error: CookbookApiRecipeDetailV1 {
return CookbookApiRecipeDetailV1(
extension RecipeDetail {
static var error: RecipeDetail {
return RecipeDetail(
name: "Error: Unable to load recipe.",
keywords: "",
dateCreated: "",
dateModified: "",
image: "",
imageUrl: "",
id: "",
prepTime: "",
@@ -213,7 +161,7 @@ extension CookbookApiRecipeDetailV1 {
}
}
/*
struct RecipeImage {
enum RecipeImageSize: String {
case THUMB="thumb", FULL="full"
@@ -222,7 +170,7 @@ struct RecipeImage {
var thumb: UIImage?
var full: UIImage?
}
*/
struct RecipeKeyword: Codable {
let name: String
@@ -302,4 +250,3 @@ enum Nutrition: CaseIterable {
}
}
}

View File

@@ -69,7 +69,6 @@
}
},
"(%lld)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -286,7 +285,6 @@
}
},
"%lld Serving(s)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -553,7 +551,6 @@
}
},
"Add new recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -574,9 +571,6 @@
}
}
}
},
"All Recipes" : {
},
"An HTML parsing and web scraping library for Swift. Used for importing schema.org recipes from websites." : {
"localizations" : {
@@ -646,7 +640,6 @@
}
},
"App Token Login" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -757,12 +750,8 @@
}
}
}
},
"Categories" : {
},
"Category" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -831,7 +820,6 @@
}
},
"Choose" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -852,9 +840,6 @@
}
}
}
},
"Client error: %lld" : {
},
"Comma (e.g. 1,42)" : {
"localizations" : {
@@ -923,7 +908,6 @@
}
},
"Connected to server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -968,7 +952,6 @@
}
},
"Cookbook Client" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -991,7 +974,6 @@
}
},
"Cookbooks" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1056,9 +1038,6 @@
}
}
}
},
"Copy Error" : {
},
"Copy Link" : {
"localizations" : {
@@ -1125,9 +1104,6 @@
}
}
}
},
"Data decoding failed." : {
},
"Decimal number format" : {
"localizations" : {
@@ -1150,9 +1126,6 @@
}
}
}
},
"Decoding Error" : {
},
"Delete" : {
"localizations" : {
@@ -1330,9 +1303,6 @@
}
}
}
},
"Dismiss" : {
},
"Done" : {
"localizations" : {
@@ -1424,7 +1394,6 @@
}
},
"e.g.: example.com" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1467,9 +1436,6 @@
}
}
}
},
"Encoding Error" : {
},
"Error" : {
"localizations" : {
@@ -1492,9 +1458,6 @@
}
}
}
},
"Error: Login URL not available." : {
},
"Error." : {
"localizations" : {
@@ -1517,9 +1480,6 @@
}
}
}
},
"example.com" : {
},
"Expand information section" : {
"localizations" : {
@@ -1634,7 +1594,6 @@
}
},
"Fraction" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1657,7 +1616,6 @@
}
},
"General" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1768,7 +1726,6 @@
}
},
"If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1833,12 +1790,8 @@
}
}
}
},
"If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application." : {
},
"Import" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -1861,7 +1814,6 @@
}
},
"Import Recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2016,12 +1968,6 @@
}
}
}
},
"Invalid data error." : {
},
"Invalid request" : {
},
"Keep screen awake when viewing recipes" : {
"localizations" : {
@@ -2046,7 +1992,6 @@
}
},
"Keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2113,7 +2058,6 @@
}
},
"Last updated: %@" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2156,12 +2100,6 @@
}
}
}
},
"Log in" : {
},
"Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed." : {
},
"Log out" : {
"localizations" : {
@@ -2230,7 +2168,6 @@
}
},
"Login Method" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2253,7 +2190,6 @@
}
},
"Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2386,12 +2322,8 @@
}
}
}
},
"Missing URL." : {
},
"Mixed fraction" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2501,9 +2433,6 @@
}
}
}
},
"Nextcloud" : {
},
"Nextcloud Login" : {
"localizations" : {
@@ -2528,7 +2457,6 @@
}
},
"No keywords." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2573,7 +2501,6 @@
}
},
"None" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2596,7 +2523,6 @@
}
},
"Number" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -2727,12 +2653,6 @@
}
}
}
},
"Parameter encoding failed." : {
},
"Parameters are nil." : {
},
"Parsing error" : {
"localizations" : {
@@ -2757,7 +2677,6 @@
}
},
"Paste the url of a recipe you would like to import in the above, and we will try to fill in the fields for you. This feature does not work with every website. If your favourite website is not supported, feel free to reach out for help. You can find the contact details in the app settings." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3044,12 +2963,8 @@
}
}
}
},
"Redirection error" : {
},
"Refresh" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3072,7 +2987,6 @@
}
},
"Refresh all" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3140,7 +3054,6 @@
}
},
"Search" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3163,7 +3076,6 @@
}
},
"Search recipe" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3186,7 +3098,6 @@
}
},
"Search recipes/keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3209,7 +3120,6 @@
}
},
"Select a default cookbook" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3230,12 +3140,8 @@
}
}
}
},
"Select a Recipe" : {
},
"Select Keywords" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3258,7 +3164,6 @@
}
},
"Selected keywords:" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3279,15 +3184,6 @@
}
}
}
},
"Server address:" : {
},
"Server error: %lld" : {
},
"Server Protocol:" : {
},
"Serving size" : {
"comment" : "Serving size",
@@ -3313,7 +3209,6 @@
}
},
"Servings" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3470,7 +3365,6 @@
}
},
"Show help" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3582,7 +3476,6 @@
}
},
"Submit" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3694,7 +3587,6 @@
}
},
"Thank you for downloading" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3717,7 +3609,6 @@
}
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3738,9 +3629,6 @@
}
}
}
},
"The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.\nIf the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually." : {
},
"The recipe has no image whose MIME type matches the Accept header" : {
"extractionState" : "stale",
@@ -3766,7 +3654,6 @@
}
},
"The selected cookbook will open on app launch by default." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3789,7 +3676,6 @@
}
},
"There are no recipes in this cookbook!" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3857,7 +3743,6 @@
}
},
"This application is an open source effort. If you're interested in suggesting or contributing new features, or you encounter any problems, please use the support link or visit the GitHub repository in the app settings." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -3947,7 +3832,26 @@
}
},
"To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Über das Textfeld können Einkäufe manuell hinzugefügt werden. Durch Zeilenumbrüche können mehrere Artikel auf einmal hinzugefügt werden."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Para añadir comestibles manualmente, escríbelos en el cuadro de abajo y pulsa el botón.\nPara añadir varios artículos a la vez, sepáralos con una nueva línea."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Pour ajouter des courses manuellement, tape-les dans la case ci-dessous et appuie sur le bouton.\nPour ajouter plusieurs articles à la fois, sépare-les par un saut de ligne."
}
}
}
},
"Tool" : {
"localizations" : {
@@ -4106,7 +4010,6 @@
}
},
"Unable to connect to server." : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4127,15 +4030,6 @@
}
}
}
},
"Unable to decode recipe data." : {
},
"Unable to encode recipe data." : {
},
"Unable to load recipe." : {
},
"Unable to load website content. Please check your internet connection." : {
"localizations" : {
@@ -4158,9 +4052,6 @@
}
}
}
},
"Unable to save recipe." : {
},
"Unable to upload your recipe. Please check your internet connection." : {
"localizations" : {
@@ -4183,9 +4074,6 @@
}
}
}
},
"Unknown error" : {
},
"Unsaturated fat content" : {
"comment" : "Unsaturated fat content",
@@ -4278,7 +4166,6 @@
}
},
"URL (e.g. example.com/recipe)" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
@@ -4345,7 +4232,6 @@
}
},
"Validate" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {

View File

@@ -7,10 +7,10 @@
import Foundation
import SwiftUI
/*
@MainActor class RecipeEditViewModel: ObservableObject {
@ObservedObject var mainViewModel: AppState
@Published var recipe: CookbookApiRecipeDetailV1 = CookbookApiRecipeDetailV1()
@Published var recipe: RecipeDetail = RecipeDetail()
@Published var prepDuration: DurationComponents = DurationComponents()
@Published var cookDuration: DurationComponents = DurationComponents()
@@ -34,7 +34,7 @@ import SwiftUI
self.uploadNew = uploadNew
}
init(mainViewModel: AppState, recipeDetail: CookbookApiRecipeDetailV1, uploadNew: Bool) {
init(mainViewModel: AppState, recipeDetail: RecipeDetail, uploadNew: Bool) {
self.mainViewModel = mainViewModel
self.recipe = recipeDetail
self.uploadNew = uploadNew
@@ -135,4 +135,3 @@ import SwiftUI
}
}
*/

View File

@@ -69,7 +69,7 @@ struct ApiRequest {
var response: URLResponse? = nil
do {
(data, response) = try await URLSession.shared.data(for: request)
Logger.network.debug("\(method.rawValue) \(path) received response ...")
Logger.network.debug("\(method.rawValue) \(path) SUCCESS!")
if let error = decodeURLResponse(response: response as? HTTPURLResponse) {
print("\(method.rawValue) \(path) FAILURE: \(error.localizedDescription)")
return (nil, error)
@@ -94,8 +94,9 @@ struct ApiRequest {
switch response.statusCode {
case 200...299: return (nil)
case 300...399: return (NetworkError.redirectionError)
case 400...499: return (NetworkError.clientError(statusCode: response.statusCode))
case 500...599: return (NetworkError.serverError(statusCode: response.statusCode))
case 400...499: return (NetworkError.clientError)
case 500...599: return (NetworkError.serverError)
case 600: return (NetworkError.invalidRequest)
default: return (NetworkError.unknownError)
}
}

View File

@@ -32,7 +32,7 @@ protocol CookbookApi {
static func importRecipe(
auth: String,
data: Data
) async -> (Recipe?, NetworkError?)
) async -> (RecipeDetail?, NetworkError?)
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
@@ -42,7 +42,7 @@ protocol CookbookApi {
/// - Returns: The image of the recipe with the specified id. A NetworkError if the request fails, otherwise nil.
static func getImage(
auth: String,
id: String,
id: Int,
size: RecipeImage.RecipeImageSize
) async -> (UIImage?, NetworkError?)
@@ -52,7 +52,7 @@ protocol CookbookApi {
/// - Returns: A list of all recipes.
static func getRecipes(
auth: String
) async -> ([RecipeStub]?, NetworkError?)
) async -> ([Recipe]?, NetworkError?)
/// Create a new recipe.
/// - Parameters:
@@ -60,7 +60,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func createRecipe(
auth: String,
recipe: Recipe
recipe: RecipeDetail
) async -> (NetworkError?)
/// Get the recipe with the specified id.
@@ -69,9 +69,8 @@ protocol CookbookApi {
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A NetworkError if the request fails.
static func getRecipe(
auth: String,
id: String
) async -> (Recipe?, NetworkError?)
auth: String, id: Int
) async -> (RecipeDetail?, NetworkError?)
/// Update an existing recipe with new entries.
/// - Parameters:
@@ -80,7 +79,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func updateRecipe(
auth: String,
recipe: Recipe
recipe: RecipeDetail
) async -> (NetworkError?)
/// Delete the recipe with the specified id.
@@ -90,7 +89,7 @@ protocol CookbookApi {
/// - Returns: A NetworkError if the request fails. Nil otherwise.
static func deleteRecipe(
auth: String,
id: String
id: Int
) async -> (NetworkError?)
/// Get all categories.
@@ -99,7 +98,7 @@ protocol CookbookApi {
/// - Returns: A list of categories. A NetworkError if the request fails.
static func getCategories(
auth: String
) async -> ([CookbookApiCategory]?, NetworkError?)
) async -> ([Category]?, NetworkError?)
/// Get all recipes of a specified category.
/// - Parameters:
@@ -109,7 +108,7 @@ protocol CookbookApi {
static func getCategory(
auth: String,
named categoryName: String
) async -> ([RecipeStub]?, NetworkError?)
) async -> ([Recipe]?, NetworkError?)
/// Rename an existing category.
/// - Parameters:
@@ -139,7 +138,7 @@ protocol CookbookApi {
static func getRecipesTagged(
auth: String,
keyword: String
) async -> ([RecipeStub]?, NetworkError?)
) async -> ([Recipe]?, NetworkError?)
/// Get the servers api version.
/// - Parameters:
@@ -177,4 +176,3 @@ protocol CookbookApi {

View File

@@ -12,7 +12,7 @@ import UIKit
class CookbookApiV1: CookbookApi {
static let basePath: String = "/index.php/apps/cookbook/api/v1"
static func importRecipe(auth: String, data: Data) async -> (Recipe?, NetworkError?) {
static func importRecipe(auth: String, data: Data) async -> (RecipeDetail?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/import",
method: .POST,
@@ -22,12 +22,10 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), error)
return (JSONDecoder.safeDecode(data), nil)
}
static func getImage(auth: String, id: String, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
static func getImage(auth: String, id: Int, size: RecipeImage.RecipeImageSize) async -> (UIImage?, NetworkError?) {
let imageSize = (size == .FULL ? "full" : "thumb")
let request = ApiRequest(
path: basePath + "/recipes/\(id)/image?size=\(imageSize)",
@@ -41,7 +39,7 @@ class CookbookApiV1: CookbookApi {
return (UIImage(data: data), error)
}
static func getRecipes(auth: String) async -> ([RecipeStub]?, NetworkError?) {
static func getRecipes(auth: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/recipes",
method: .GET,
@@ -52,12 +50,10 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
print("\n\nRECIPE: ", String(data: data, encoding: .utf8))
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
return (JSONDecoder.safeDecode(data), nil)
}
static func createRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let recipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
static func createRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
@@ -77,7 +73,7 @@ class CookbookApiV1: CookbookApi {
if let id = json as? Int {
return nil
} else if let dict = json as? [String: Any] {
return .unknownError
return .serverError
}
} catch {
return .decodingFailed
@@ -85,8 +81,7 @@ class CookbookApiV1: CookbookApi {
return nil
}
static func getRecipe(auth: String, id: String) async -> (Recipe?, NetworkError?) {
guard let id = Int(id) else {return (nil, .unknownError)}
static func getRecipe(auth: String, id: Int) async -> (RecipeDetail?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .GET,
@@ -96,14 +91,11 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipe: CookbookApiRecipeDetailV1? = JSONDecoder.safeDecode(data)
return (recipe?.toRecipe(), nil)
return (JSONDecoder.safeDecode(data), nil)
}
static func updateRecipe(auth: String, recipe: Recipe) async -> (NetworkError?) {
let cookbookRecipe = CookbookApiRecipeDetailV1.fromRecipe(recipe)
guard let recipeData = JSONEncoder.safeEncode(cookbookRecipe) else {
static func updateRecipe(auth: String, recipe: RecipeDetail) async -> (NetworkError?) {
guard let recipeData = JSONEncoder.safeEncode(recipe) else {
return .dataError
}
let request = ApiRequest(
@@ -121,7 +113,7 @@ class CookbookApiV1: CookbookApi {
if let id = json as? Int {
return nil
} else if let dict = json as? [String: Any] {
return .unknownError
return .serverError
}
} catch {
return .decodingFailed
@@ -129,8 +121,7 @@ class CookbookApiV1: CookbookApi {
return nil
}
static func deleteRecipe(auth: String, id: String) async -> (NetworkError?) {
guard let id = Int(id) else {return .unknownError}
static func deleteRecipe(auth: String, id: Int) async -> (NetworkError?) {
let request = ApiRequest(
path: basePath + "/recipes/\(id)",
method: .DELETE,
@@ -143,7 +134,7 @@ class CookbookApiV1: CookbookApi {
return nil
}
static func getCategories(auth: String) async -> ([CookbookApiCategory]?, NetworkError?) {
static func getCategories(auth: String) async -> ([Category]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/categories",
method: .GET,
@@ -156,7 +147,7 @@ class CookbookApiV1: CookbookApi {
return (JSONDecoder.safeDecode(data), nil)
}
static func getCategory(auth: String, named categoryName: String) async -> ([RecipeStub]?, NetworkError?) {
static func getCategory(auth: String, named categoryName: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/category/\(categoryName)",
method: .GET,
@@ -166,8 +157,7 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
return (JSONDecoder.safeDecode(data), nil)
}
static func renameCategory(auth: String, named categoryName: String, newName: String) async -> (NetworkError?) {
@@ -196,7 +186,7 @@ class CookbookApiV1: CookbookApi {
return (JSONDecoder.safeDecode(data), nil)
}
static func getRecipesTagged(auth: String, keyword: String) async -> ([RecipeStub]?, NetworkError?) {
static func getRecipesTagged(auth: String, keyword: String) async -> ([Recipe]?, NetworkError?) {
let request = ApiRequest(
path: basePath + "/tags/\(keyword)",
method: .GET,
@@ -206,8 +196,7 @@ class CookbookApiV1: CookbookApi {
let (data, error) = await request.send()
guard let data = data else { return (nil, error) }
let recipes: [CookbookApiRecipeV1]? = JSONDecoder.safeDecode(data)
return (recipes?.map({ recipe in recipe.toRecipeStub() }), nil)
return (JSONDecoder.safeDecode(data), nil)
}
static func getApiVersion(auth: String) async -> (NetworkError?) {
@@ -226,4 +215,3 @@ class CookbookApiV1: CookbookApi {
return .none
}
}

View File

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

View File

@@ -20,9 +20,10 @@ class NextcloudApi {
/// - `LoginV2Request?`: An object containing the necessary information for the second step of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Request(_ baseAddress: String) async -> (LoginV2Request?, NetworkError?) {
static func loginV2Request() async -> (LoginV2Request?, NetworkError?) {
let path = UserSettings.shared.serverProtocol + UserSettings.shared.serverAddress
let request = ApiRequest(
path: baseAddress + "/index.php/login/v2",
path: path + "/index.php/login/v2",
method: .POST
)
@@ -51,16 +52,16 @@ class NextcloudApi {
/// - `LoginV2Response?`: An object representing the response of the login process, if successful.
/// - `NetworkError?`: An error encountered during the network request, if any.
static func loginV2Poll(pollURL: String, pollToken: String) async -> (LoginV2Response?, NetworkError?) {
static func loginV2Response(req: LoginV2Request) async -> (LoginV2Response?, NetworkError?) {
let request = ApiRequest(
path: pollURL,
path: req.poll.endpoint,
method: .POST,
headerFields: [
HeaderField.ocsRequest(value: true),
HeaderField.accept(value: .JSON),
HeaderField.contentType(value: .FORM)
],
body: "token=\(pollToken)".data(using: .utf8)
body: "token=\(req.poll.token)".data(using: .utf8)
)
let (data, error) = await request.send(pathCompletion: false)

View File

@@ -1,18 +0,0 @@
//
// CookbookProtocols.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 11.05.24.
//
import Foundation
protocol CookbookApiRecipe {
func toRecipeStub() -> RecipeStub
}
protocol CookbookApiRecipeDetail: Codable {
func toRecipe() -> Recipe
static func fromRecipe(_ recipe: Recipe) -> CookbookApiRecipeDetail
}

View File

@@ -1,58 +0,0 @@
//
// CustomError.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 13.09.23.
//
import Foundation
import SwiftUI
public enum NetworkError: UserAlert {
case missingUrl
case parametersNil
case encodingFailed
case decodingFailed
case redirectionError
case clientError(statusCode: Int)
case serverError(statusCode: Int)
case invalidRequest
case unknownError
case dataError
var localizedTitle: LocalizedStringKey {
switch self {
case .missingUrl:
"Missing URL."
case .parametersNil:
"Parameters are nil."
case .encodingFailed:
"Parameter encoding failed."
case .decodingFailed:
"Data decoding failed."
case .redirectionError:
"Redirection error"
case .clientError(let code):
"Client error: \(code)"
case .serverError(let code):
"Server error: \(code)"
case .invalidRequest:
"Invalid request"
case .unknownError:
"Unknown error"
case .dataError:
"Invalid data error."
}
}
var localizedDescription: LocalizedStringKey {
return self.localizedTitle
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -6,7 +6,8 @@
//
import SwiftUI
import SwiftData
import SwiftUI
@main
struct Nextcloud_Cookbook_iOS_ClientApp: App {
@@ -17,11 +18,9 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
WindowGroup {
ZStack {
if onboarding {
//OnboardingView()
EmptyView()
OnboardingView()
} else {
MainView()
.modelContainer(for: [Recipe.self, GroceryItem.self, RecipeGroceries.self])
}
}
.transition(.slide)
@@ -30,10 +29,6 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
.init(identifier: language ==
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
)
.onAppear {
AuthManager.shared.loadAuthString() // Load the auth string as soon as possible
}
}
}
}

View File

@@ -1,222 +0,0 @@
//
// CookbookState.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.24.
//
import Foundation
import SwiftUI
/*
@Observable
class CookbookState {
/// Caches recipe categories.
var categories: [Category] = []
/// Caches RecipeStubs.
var recipeStubs: [String: [RecipeStub]] = [:]
/// Caches Recipes by recipe id.
var recipes: [String: Recipe] = [:]
/// Caches recipe thumbnails by recipe id.
var thumbnails: [String: UIImage] = [:]
/// Caches recipe images by recipe id.
var images: [String: UIImage] = [:]
/// Caches recipe keywords.
var keywords: [RecipeKeyword] = []
/// Read and write interfaces.
var localReadInterface: ReadInterface
var localWriteInterface: WriteInterface
var remoteReadInterface: ReadInterface?
var remoteWriteInterface: WriteInterface?
/// UI state variables
var selectedCategory: Category? = nil
var selectedRecipeStub: RecipeStub? = nil
var showSettings: Bool = false
var showGroceries: Bool = false
/// Grocery List
var groceryList = GroceryList()
init(
localReadInterface: ReadInterface,
localWriteInterface: WriteInterface,
remoteReadInterface: ReadInterface? = nil,
remoteWriteInterface: WriteInterface? = nil
) {
self.localReadInterface = localReadInterface
self.localWriteInterface = localWriteInterface
self.remoteReadInterface = remoteReadInterface
self.remoteWriteInterface = remoteWriteInterface
}
init() {
let accountLoader = AccountLoader()
rI, wI = accountLoader.load
}
}
extension CookbookState {
func loadCategories(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return
}
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
} else {
if let categories = await localReadInterface.getCategories() {
self.categories = categories
return
}
guard let remoteReadInterface else { return }
if let categories = await remoteReadInterface.getCategories() {
self.categories = categories
return
}
}
}
func loadRecipeStubs(category: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
} else {
if let stubs = await localReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
guard let remoteReadInterface else { return }
if let stubs = await remoteReadInterface.getRecipeStubs() {
self.recipeStubs[category] = stubs
return
}
}
}
func loadKeywords(remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
} else {
if let keywords = await localReadInterface.getTags() {
self.keywords = keywords
return
}
guard let remoteReadInterface else { return }
if let keywords = await remoteReadInterface.getTags() {
self.keywords = keywords
return
}
}
}
func loadRecipe(id: String, remoteFirst: Bool = false) async {
if remoteFirst {
if let remoteReadInterface, let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
} else {
if let recipe = await localReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
guard let remoteReadInterface else { return }
if let recipe = await remoteReadInterface.getRecipe(id: id) {
self.recipes[id] = recipe
return
}
}
}
}
class AccountLoader {
func initInterfaces() async -> [ReadInterface & WriteInterface] {
let accounts = await self.loadAccounts("accounts.data")
if accounts.isEmpty && UserSettings.shared.serverAddress != "" {
print("Creating new Account from legacy Cookbook client account.")
let auth = Authentication(
baseUrl: UserSettings.shared.serverAddress,
user: UserSettings.shared.username,
token: UserSettings.shared.authString
)
let authKey = "legacyNextcloud"
let legacyAccount = Account(
id: UUID(),
name: "Nextcloud",
type: .nextcloud,
apiVersion: "1.0",
authKey: authKey
)
await saveAccounts([legacyAccount], "accounts.data")
legacyAccount.storeAuth(auth)
let interface = NextcloudDataInterface(auth: auth, version: legacyAccount.apiVersion)
return [interface]
} else {
print("Recovering existing accounts.")
var interfaces: [ReadInterface & WriteInterface] = []
for account in accounts {
if let interface: CookbookInterface = account.getInterface() {
interfaces.append(interface)
}
}
return interfaces
}
}
func loadAccounts(_ path: String) async -> [Account] {
do {
return try await DataStore.shared.load(fromPath: path) ?? []
} catch (let error) {
print(error)
return []
}
}
func saveAccounts(_ accounts: [Account], _ path: String) async {
await DataStore.shared.save(data: accounts, toPath: path)
}
}
*/

View File

@@ -1,114 +0,0 @@
//
// PersistenceInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 06.05.24.
//
import Foundation
import SwiftUI
import KeychainSwift
/*
protocol CookbookInterface {
/// A unique id of the interface. Used to associate recipes to their respective accounts.
var id: String { get }
}
protocol ReadInterface {
/// Get either the full image or a thumbnail sized version.
/// - Parameters:
/// - id: The according recipe id.
/// - size: The size of the image.
/// - Returns: The image of the recipe with the specified id. A UserAlert if the request fails, otherwise nil.
func getImage(
id: String,
size: RecipeImage.RecipeImageSize
) async -> UIImage?
/// Get all recipe stubs.
/// - Returns: A list of all recipes.
func getRecipeStubs(
) async -> [RecipeStub]?
/// Get the recipe with the specified id.
/// - Parameters:
/// - id: The recipe id.
/// - Returns: The recipe if it exists. A UserAlert if the request fails.
func getRecipe(
id: String
) async -> Recipe?
/// Get all categories.
/// - Returns: A list of categories. A UserAlert if the request fails.
func getCategories(
) async -> [Category]?
/// Get all recipes of a specified category.
/// - Parameters:
/// - categoryName: The category name.
/// - Returns: A list of recipes. A UserAlert if the request fails.
func getRecipeStubsForCategory(
named categoryName: String
) async -> [RecipeStub]?
/// Get all keywords/tags.
/// - Returns: A list of tag strings. A UserAlert if the request fails.
func getTags(
) async -> [RecipeKeyword]?
/// Get all recipes tagged with the specified keyword.
/// - Parameters:
/// - keyword: The keyword.
/// - Returns: A list of recipes tagged with the specified keyword. A UserAlert if the request fails.
func getRecipesTagged(
keyword: String
) async -> [RecipeStub]?
}
protocol WriteInterface {
/// Post either the full image or a thumbnail sized version.
/// - Parameters:
/// - id: The according recipe id.
/// - size: The size of the image.
/// - Returns: A UserAlert if the request fails, otherwise nil.
func postImage(
id: String,
image: UIImage,
size: RecipeImage.RecipeImageSize
) async -> (UserAlert?)
/// Create a new recipe.
/// - Parameters:
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func postRecipe(
recipe: Recipe
) async -> (UserAlert?)
/// Update an existing recipe with new entries.
/// - Parameters:
/// - recipe: The recipe.
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func updateRecipe(
recipe: Recipe
) async -> (UserAlert?)
/// Delete the recipe with the specified id.
/// - Parameters:
/// - id: The recipe id.
/// - Returns: A UserAlert if the request fails. Nil otherwise.
func deleteRecipe(
id: String
) async -> (UserAlert?)
/// Rename an existing category.
/// - Parameters:
/// - categoryName: The name of the category to be renamed.
/// - newName: The new category name.
/// - Returns: A UserAlert if the request fails.
func renameCategory(
named categoryName: String,
newName: String
) async -> (UserAlert?)
}
*/

View File

@@ -1,151 +0,0 @@
//
// LocalDataInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 07.05.24.
//
import Foundation
import SwiftUI
/*
class LocalDataInterface: CookbookInterface {
var id: String
init(id: String) {
self.id = id
}
enum LocalDataPath {
case recipeStubs(category: String),
recipe(id: String),
image(id: String, size: RecipeImage.RecipeImageSize),
categories,
keywords
var path: String {
switch self {
case .recipe(let id):
"recipe_\(id).data"
case .recipeStubs(let category):
"recipes_\(category).data"
case .image(let id, let size):
if size == .FULL {
"image_\(id).data"
} else {
"thumb_\(id).data"
}
case .categories:
"categories.data"
case .keywords:
"keywords.data"
}
}
}
}
// MARK: - Local Read Interface
extension LocalDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
guard let data: String = await load(path: .image(id: id, size: size)) else {
return nil
}
guard let dataDecoded = Data(base64Encoded: data) else { return nil }
return UIImage(data: dataDecoded)
}
func getRecipeStubs() async -> [RecipeStub]? {
return nil
}
func getRecipe(id: String) async -> Recipe? {
return await load(path: LocalDataPath.recipe(id: id))
}
func getCategories() async -> [Category]? {
return await load(path: LocalDataPath.categories)
}
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await load(path: .recipeStubs(category: categoryName))
}
func getTags() async -> [RecipeKeyword]? {
return await load(path: .keywords)
}
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return nil
}
}
// MARK: - Local Write Interface
extension LocalDataInterface: WriteInterface {
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
if let data = image.pngData() {
await save(
data,
path: LocalDataPath.image(id: id, size: size)
)
}
return nil
}
func postRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
await save(recipe, path: LocalDataPath.recipe(id: recipe.id))
return nil
}
func updateRecipe(recipe: Recipe) async -> ((any UserAlert)?) {
return await postRecipe(recipe: recipe)
}
func deleteRecipe(id: String) async -> ((any UserAlert)?) {
await delete(path: .recipe(id: id))
return nil
}
func renameCategory(named categoryName: String, newName: String) async -> ((any UserAlert)?) {
guard let stubs: [RecipeStub] = await load(path: .recipeStubs(category: categoryName)) else {
return PersistenceAlert.LOAD_FAILED
}
await save(stubs, path: .recipeStubs(category: newName))
await delete(path: .recipeStubs(category: categoryName))
return nil
}
}
// MARK: - Local Data Interface Utils
extension LocalDataInterface {
func load<T: Codable>(path ldPath: LocalDataPath) async -> T? {
do {
return try await DataStore.shared.load(fromPath: ldPath.path)
} catch (let error) {
print(error)
return nil
}
}
func save<T: Codable>(_ object: T, path ldPath: LocalDataPath) async {
await DataStore.shared.save(data: object, toPath: ldPath.path)
}
func delete(path ldPath: LocalDataPath) async {
DataStore.shared.delete(path: ldPath.path)
}
}
*/

View File

@@ -1,92 +0,0 @@
//
// NextcloudDataInterface.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 07.05.24.
//
import Foundation
import SwiftUI
/*
class NextcloudDataInterface: CookbookInterface {
var id: String
var auth: Authentication
var api: CookbookApi.Type
init(auth: Authentication, version: String) {
self.id = UUID().uuidString
self.auth = auth
switch version {
case "1.0":
self.api = CookbookApiV1.self
default:
self.api = CookbookApiV1.self
}
}
}
// MARK: - Nextcloud Read Interface
extension NextcloudDataInterface: ReadInterface {
func getImage(id: String, size: RecipeImage.RecipeImageSize) async -> UIImage? {
return await api.getImage(auth: auth.token, id: id, size: size).0
}
func getRecipeStubs() async -> [RecipeStub]? {
return await api.getRecipes(auth: auth.token).0
}
func getRecipe(id: String) async -> Recipe?{
return await api.getRecipe(auth: auth.token, id: id).0
}
func getCategories() async -> [Category]? {
return await api.getCategories(auth: auth.token).0
}
func getRecipeStubsForCategory(named categoryName: String) async -> [RecipeStub]? {
return await api.getCategory(
auth: UserSettings.shared.authString,
named: categoryName
).0
}
func getTags() async -> [RecipeKeyword]? {
return await api.getTags(auth: auth.token).0
}
func getRecipesTagged(keyword: String) async -> [RecipeStub]? {
return await api.getRecipesTagged(auth: auth.token, keyword: keyword).0
}
}
// MARK: - Nextcloud Write Interface
extension NextcloudDataInterface: WriteInterface {
func postImage(id: String, image: UIImage, size: RecipeImage.RecipeImageSize) async -> ((any UserAlert)?) {
return nil
}
func postRecipe(recipe: Recipe) async -> (UserAlert?) {
return await api.createRecipe(auth: auth.token, recipe: recipe)
}
func updateRecipe(recipe: Recipe) async -> (UserAlert?) {
return await api.updateRecipe(auth: auth.token, recipe: recipe)
}
func deleteRecipe(id: String) async -> (UserAlert?) {
return await api.deleteRecipe(auth: auth.token, id: id)
}
func renameCategory(named categoryName: String, newName: String) async -> (UserAlert?) {
return await api.renameCategory(auth: auth.token, named: categoryName, newName: newName)
}
}
*/

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeExporter {
func createPDF(recipe: CookbookApiRecipeDetailV1, image: UIImage?) -> URL? {
func createPDF(recipe: RecipeDetail, image: UIImage?) -> URL? {
let document = PDFDocument(format: .a4)
let titleStyle = PDFTextStyle(name: "title", font: UIFont.boldSystemFont(ofSize: 18), color: .black)
@@ -82,7 +82,7 @@ class RecipeExporter {
}
}
func createText(recipe: CookbookApiRecipeDetailV1) -> String {
func createText(recipe: RecipeDetail) -> String {
var recipeString = ""
recipeString.append("" + recipe.name + "\n")
recipeString.append(recipe.description + "\n\n")
@@ -99,7 +99,7 @@ class RecipeExporter {
return recipeString
}
func createJson(recipe: CookbookApiRecipeDetailV1) -> Data? {
func createJson(recipe: RecipeDetail) -> Data? {
return JSONEncoder.safeEncode(recipe)
}
}

View File

@@ -11,7 +11,7 @@ import SwiftUI
class RecipeScraper {
func scrape(url: String) async throws -> (CookbookApiRecipeDetailV1?, RecipeImportAlert?) {
func scrape(url: String) async throws -> (RecipeDetail?, RecipeImportAlert?) {
var contents: String? = nil
if let url = URL(string: url) {
do {
@@ -77,9 +77,9 @@ class RecipeScraper {
}
}
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> CookbookApiRecipeDetailV1? {
private func getRecipe(fromDict recipe: Dictionary<String, Any>) -> RecipeDetail? {
var recipeDetail = CookbookApiRecipeDetailV1()
var recipeDetail = RecipeDetail()
recipeDetail.name = recipe["name"] as? String ?? "New Recipe"
recipeDetail.recipeCategory = recipe["recipeCategory"] as? String ?? ""
recipeDetail.keywords = joinedStringForKey("keywords", dict: recipe)

View File

@@ -146,33 +146,3 @@ enum RequestAlert: UserAlert {
return [.OK]
}
}
enum PersistenceAlert: UserAlert {
case DECODING_FAILED,
ENCODING_FAILED,
SAVE_FAILED,
LOAD_FAILED
var localizedDescription: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Unable to decode recipe data."
case .ENCODING_FAILED: return "Unable to encode recipe data."
case .SAVE_FAILED: return "Unable to save recipe."
case .LOAD_FAILED: return "Unable to load recipe."
}
}
var localizedTitle: LocalizedStringKey {
switch self {
case .DECODING_FAILED: return "Decoding Error"
case .ENCODING_FAILED: return "Encoding Error"
case .SAVE_FAILED: return "Error"
case .LOAD_FAILED: return "Error"
}
}
var alertButtons: [AlertButton] {
return [.OK]
}
}

View File

@@ -37,28 +37,7 @@ class DurationComponents: ObservableObject {
}
}
init() {
}
init(_ hours: Int, _ min: Int, _ sec: Int = 0) {
self.hourComponent = hours
self.minuteComponent = min
self.secondComponent = sec
}
required init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let durationString = try container.decode(String.self)
let hourRegex = /([0-9]{1,2})H/
let minuteRegex = /([0-9]{1,2})M/
if let match = durationString.firstMatch(of: hourRegex) {
self.hourComponent = Int(match.1) ?? 0
}
if let match = durationString.firstMatch(of: minuteRegex) {
self.minuteComponent = Int(match.1) ?? 0
}
}
var displayString: String {
if hourComponent != 0 && minuteComponent != 0 {
@@ -165,11 +144,3 @@ class DurationComponents: ObservableObject {
return result
}
}
extension DurationComponents: Codable {
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
let durationString = toPTString()
try container.encode(durationString)
}
}

View File

@@ -6,23 +6,41 @@
//
import SwiftUI
import SwiftData
struct MainView: View {
@StateObject var appState = AppState()
@StateObject var groceryList = GroceryList()
// Tab ViewModels
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
@StateObject var searchViewModel = SearchTabView.ViewModel()
enum Tab {
case recipes, settings, groceryList
case recipes, search, groceryList
}
var body: some View {
TabView {
RecipeTabView()
.environmentObject(recipeViewModel)
.environmentObject(appState)
.environmentObject(groceryList)
.tabItem {
Label("Recipes", systemImage: "book.closed.fill")
}
.tag(Tab.recipes)
SearchTabView()
.environmentObject(searchViewModel)
.environmentObject(appState)
.environmentObject(groceryList)
.tabItem {
Label("Search", systemImage: "magnifyingglass")
}
.tag(Tab.search)
GroceryListTabView()
.environmentObject(groceryList)
.tabItem {
if #available(iOS 17.0, *) {
Label("Grocery List", systemImage: "storefront")
@@ -31,16 +49,8 @@ struct MainView: View {
}
}
.tag(Tab.groceryList)
SettingsTabView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(Tab.settings)
}
.task {
/*
recipeViewModel.presentLoadingIndicator = true
await appState.getCategories()
await appState.updateAllRecipeDetails()
@@ -58,94 +68,6 @@ struct MainView: View {
}
await groceryList.load()
recipeViewModel.presentLoadingIndicator = false
*/
}
}
}
/*struct CategoryListView: View {
@Bindable var cookbookState: CookbookState
var body: some View {
List(cookbookState.selectedAccountState.categories) { category in
NavigationLink {
RecipeListView(
cookbookState: cookbookState,
selectedCategory: category.name,
showEditView: .constant(false)
)
} label: {
HStack(alignment: .center) {
if cookbookState.selectedAccountState.selectedCategory != nil &&
category.name == cookbookState.selectedAccountState.selectedCategory?.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(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)
}
}
}
}*/
/*struct CategoryListView: View {
@State var state: CookbookState
var body: some View {
List(selection: $state.categoryListSelection) {
ForEach(state.categories) { category in
NavigationLink(value: category) {
HStack(alignment: .center) {
if state.categoryListSelection != nil &&
category.name == state.categoryListSelection {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(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)
}
}
}
}
}*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct OnboardingView: View {
@State var selectedTab: Int = 0
@@ -244,4 +244,3 @@ struct ServerAddressField_Preview: PreviewProvider {
.background(Color.nextcloudBlue)
}
}
*/

View File

@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
/*
struct TokenLoginView: View {
@Binding var showAlert: Bool
@Binding var alertMessage: String
@@ -105,4 +105,3 @@ struct TokenLoginView: View {
return true
}
}
*/

View File

@@ -8,13 +8,6 @@
import Foundation
import SwiftUI
import WebKit
import AuthenticationServices
protocol LoginStage {
func next() -> Self
func previous() -> Self
}
enum V2LoginStage: LoginStage {
case login, validate
@@ -37,28 +30,13 @@ enum V2LoginStage: LoginStage {
struct V2LoginView: View {
@Environment(\.dismiss) var dismiss
@State var showAlert: Bool = false
@State var alertMessage: String = ""
@Binding var showAlert: Bool
@Binding var alertMessage: String
@State var loginStage: V2LoginStage = .login
@State var loginRequest: LoginV2Request? = nil
@State var presentBrowser = false
@State var serverAddress: String = ""
@State var serverProtocol: ServerProtocol = .https
@State var loginPressed: Bool = false
@State var isLoading: Bool = false
// Task reference for polling, to cancel if needed
@State private var pollTask: Task<Void, Never>? = nil
enum ServerProtocol: String {
case https="https://", http="http://"
static let all = [https, http]
}
// TextField handling
enum Field {
case server
@@ -67,210 +45,119 @@ struct V2LoginView: View {
}
var body: some View {
VStack {
HStack {
Button("Cancel") {
dismiss()
}
Spacer()
if isLoading {
ProgressView()
ScrollView {
VStack(alignment: .leading) {
ServerAddressField()
CollapsibleView {
VStack(alignment: .leading) {
Text("Make sure to enter the server address in the form 'example.com', or \n'<server address>:<port>'\n when a non-standard port is used.")
.padding(.bottom)
Text("The 'Login' button will open a web browser. Please follow the login instructions provided there.\nAfter a successful login, return to this application and press 'Validate'.")
.padding(.bottom)
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
}
} title: {
Text("Show help")
.foregroundColor(.white)
.font(.headline)
}.padding()
Form {
Section {
HStack {
Text("Server address:")
TextField("example.com", text: $serverAddress)
.multilineTextAlignment(.trailing)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
Picker("Server Protocol:", selection: $serverProtocol) {
ForEach(ServerProtocol.all, id: \.self) {
Text($0.rawValue)
}
}
HStack {
Button("Login") {
initiateLoginV2()
}
Spacer()
Text(serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines))
.foregroundStyle(Color.secondary)
}
} header: {
Text("Nextcloud Login")
} footer: {
Text(
"""
The 'Login' button will open a web browser. Please follow the login instructions provided there.
After a successful login, return to this application and press 'Validate'.
If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.
"""
)
}.disabled(loginPressed)
if let loginRequest = loginRequest {
Section {
Text(loginRequest.login)
.font(.caption)
.foregroundStyle(.secondary)
if loginRequest != nil {
Button("Copy Link") {
UIPasteboard.general.string = loginRequest.login
}
} footer: {
Text("If your browser does not open automatically, copy the link above and paste it manually. After a successful login, return to this application.")
}
}
}
}
.sheet(isPresented: $presentBrowser) {
if let loginReq = loginRequest {
LoginBrowserView(authURL: URL(string: loginReq.login) ?? URL(string: "")!, callbackURLScheme: "nc") { result in
switch result {
case .success(let url):
print("Login completed with URL: \(url)")
dismiss()
case .failure(let error):
print("Login failed: \(error.localizedDescription)")
self.alertMessage = error.localizedDescription
self.isLoading = false
self.loginPressed = false
self.showAlert = true
}
}
} else {
Text("Error: Login URL not available.")
}
}
.alert("Error", isPresented: $showAlert) {
Button("Copy Error") {
print("Error copied: \(alertMessage)")
UIPasteboard.general.string = alertMessage
isLoading = false
loginPressed = false
}
Button("Dismiss") {
print("Error dismissed.")
isLoading = false
loginPressed = false
}
} message: {
Text(alertMessage)
UIPasteboard.general.string = loginRequest!.login
}
.font(.headline)
.foregroundStyle(.white)
.padding()
}
func initiateLoginV2() {
isLoading = true
loginPressed = true
HStack {
Button {
if UserSettings.shared.serverAddress == "" {
alertMessage = "Please enter a valid server address."
showAlert = true
return
}
Task {
let baseAddress = serverProtocol.rawValue + serverAddress.trimmingCharacters(in: .whitespacesAndNewlines)
let (req, error) = await NextcloudApi.loginV2Request(baseAddress)
let error = await sendLoginV2Request()
if let error = error {
self.alertMessage = error.localizedDescription
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
alertMessage = "A network error occured (\(error.rawValue))."
showAlert = true
}
guard let req = req else {
self.alertMessage = "Failed to get login URL from server."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
return
}
self.loginRequest = req
// Present the browser session
if let loginRequest = loginRequest {
presentBrowser = true
// Start polling in a separate task
startPolling(pollURL: req.poll.endpoint, pollToken: req.poll.token)
}
}
func startPolling(pollURL: String, pollToken: String) {
// Cancel any existing poll task first
pollTask?.cancel()
var pollingFailed = true
pollTask = Task {
let maxRetries = 60 * 10 // Poll for up to 60 * 1 second = 1 minute
for _ in 0..<maxRetries {
if Task.isCancelled {
print("Task cancelled.")
break
}
let (response, error) = await NextcloudApi.loginV2Poll(pollURL: pollURL, pollToken: pollToken)
if Task.isCancelled {
print("Task cancelled.")
break
}
if let response = response {
// Success
print("Task succeeded.")
AuthManager.shared.saveNextcloudCredentials(username: response.loginName, appPassword: response.appPassword)
pollingFailed = false
await MainActor.run {
self.checkLogin(response: response, error: nil)
self.presentBrowser = false // Explicitly dismiss ASWebAuthenticationSession
self.isLoading = false
self.loginPressed = false
}
return
} else if let error = error {
if case .clientError(statusCode: 404) = error {
// Continue polling
print("Polling unsuccessful, continuing.")
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
} else {
// A more serious error occurred during polling
print("Polling error: \(error.localizedDescription)")
await MainActor.run {
self.alertMessage = "Polling error: \(error.localizedDescription)"
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
return
alertMessage = "Unable to reach server. Please check your server address and internet connection."
showAlert = true
}
}
loginStage = loginStage.next()
} label: {
Text("Login")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}.padding()
if loginStage == .validate {
Spacer()
Button {
// fetch login v2 response
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
} label: {
Text("Validate")
.foregroundColor(.white)
.font(.headline)
.padding()
.background(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.white, lineWidth: 2)
.foregroundColor(.clear)
)
}
.disabled(loginRequest == nil ? true : false)
.padding()
}
}
}
}
.sheet(isPresented: $presentBrowser, onDismiss: {
Task {
let (response, error) = await fetchLoginV2Response()
checkLogin(response: response, error: error)
}
}) {
if let loginRequest = loginRequest {
WebViewSheet(url: loginRequest.login)
}
}
isLoading = true
try? await Task.sleep(nanoseconds: 1_000_000_000) // Wait 1 sec before next poll
isLoading = false
}
// If polling finishes without success
if !Task.isCancelled && pollingFailed {
await MainActor.run {
self.alertMessage = "Login timed out. Please try again."
self.showAlert = true
self.isLoading = false
self.loginPressed = false
}
}
func sendLoginV2Request() async -> NetworkError? {
let (req, error) = await NextcloudApi.loginV2Request()
self.loginRequest = req
return error
}
func fetchLoginV2Response() async -> (LoginV2Response?, NetworkError?) {
guard let loginRequest = loginRequest else { return (nil, .parametersNil) }
return await NextcloudApi.loginV2Response(req: loginRequest)
}
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
if let error = error {
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
alertMessage = "Login failed. Please login via the browser and try again. (\(error.rawValue))"
showAlert = true
return
}
@@ -293,72 +180,32 @@ struct V2LoginView: View {
struct LoginBrowserView: UIViewControllerRepresentable {
let authURL: URL
let callbackURLScheme: String
var completion: (Result<URL, Error>) -> Void
// Login WebView logic
func makeUIViewController(context: Context) -> UIViewController {
UIViewController()
}
struct WebViewSheet: View {
@Environment(\.dismiss) var dismiss
@State var url: String
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
if !context.coordinator.sessionStarted {
context.coordinator.sessionStarted = true
let session = ASWebAuthenticationSession(url: authURL, callbackURLScheme: callbackURLScheme) { callbackURL, error in
context.coordinator.sessionStarted = false // Reset for potential retry
if let callbackURL = callbackURL {
completion(.success(callbackURL))
} else if let error = error {
completion(.failure(error))
} else {
// Handle unexpected nil URL and error
completion(.failure(LoginError.unknownError))
}
}
session.presentationContextProvider = context.coordinator
session.prefersEphemeralWebBrowserSession = false
session.start()
}
}
// MARK: - Coordinator for ASWebAuthenticationPresentationContextProviding
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, ASWebAuthenticationPresentationContextProviding {
var parent: LoginBrowserView
var sessionStarted: Bool = false // Prevent starting multiple sessions
init(_ parent: LoginBrowserView) {
self.parent = parent
}
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
if let windowScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
return windowScene.windows.first!
}
return ASPresentationAnchor()
}
}
enum LoginError: Error, LocalizedError {
case unknownError
var errorDescription: String? {
switch self {
case .unknownError: return "An unknown error occurred during login."
}
var body: some View {
NavigationView {
WebView(url: URL(string: url)!)
.navigationBarTitle(Text("Nextcloud Login"), displayMode: .inline)
.navigationBarItems(trailing: Button("Done") {
dismiss()
})
}
}
}
struct WebView: UIViewRepresentable {
let url: URL
#Preview {
V2LoginView()
func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let request = URLRequest(url: url)
uiView.load(request)
}
}

View File

@@ -8,9 +8,8 @@
import Foundation
import SwiftUI
struct RecipeCardView: View {
//@EnvironmentObject var appState: AppState
@EnvironmentObject var appState: AppState
@State var recipe: Recipe
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
@@ -50,7 +49,6 @@ struct RecipeCardView: View {
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.task {
/*
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
@@ -60,76 +58,14 @@ struct RecipeCardView: View {
recipe.storedLocally = appState.recipeDetailExists(recipeId: recipe.recipe_id)
}
isDownloaded = recipe.storedLocally
*/
}
.refreshable {
/*
recipeThumb = await appState.getImage(
id: recipe.recipe_id,
size: .THUMB,
fetchMode: UserSettings.shared.storeThumb ? .preferServer : .onlyServer
)*/
}
.frame(height: 80)
}
}
/*
struct RecipeCardView: View {
@State var state: AccountState
@State var recipe: RecipeStub
@State var recipeThumb: UIImage?
@State var isDownloaded: Bool? = nil
var body: some View {
HStack {
if let recipeThumb = recipeThumb {
Image(uiImage: recipeThumb)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
} else {
Image(systemName: "square.text.square")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(Color.white)
.padding(10)
.background(Color("ncblue"))
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 17))
}
Text(recipe.name)
.font(.headline)
.padding(.leading, 4)
Spacer()
if let isDownloaded = isDownloaded {
VStack {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
.padding()
Spacer()
}
}
}
.background(Color.backgroundHighlight)
.clipShape(RoundedRectangle(cornerRadius: 17))
.task {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
)
isDownloaded = recipe.storedLocally
}
.refreshable {
recipeThumb = await state.getImage(
id: recipe.id,
size: .THUMB
)
}
.frame(height: 80)
}
}
*/

View File

@@ -7,63 +7,16 @@
import Foundation
import SwiftUI
import SwiftData
struct RecipeListView: View {
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@Binding var selectedRecipe: Recipe?
@Binding var selectedCategory: String?
init(selectedCategory: Binding<String?>, selectedRecipe: Binding<Recipe?>) {
var predicate: Predicate<Recipe>? = nil
if let category = selectedCategory.wrappedValue, category != "*" {
predicate = #Predicate<Recipe> {
$0.category == category
}
}
_recipes = Query(filter: predicate, sort: \.name)
_selectedRecipe = selectedRecipe
_selectedCategory = selectedCategory
}
var body: some View {
List(selection: $selectedRecipe) {
ForEach(recipes) { recipe in
RecipeCardView(recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink(value: recipe) {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
}
.listStyle(.plain)
.navigationTitle("Recipes")
.toolbar {
}
}
}
/*
struct RecipeListView: View {
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@State var categoryName: String
@State var searchText: String = ""
@Binding var showEditView: Bool
@State var selectedRecipe: CookbookApiRecipeV1? = nil
@State var selectedRecipe: Recipe? = nil
var body: some View {
Group {
@@ -103,7 +56,7 @@ struct RecipeListView: View {
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(categoryName == "*" ? String(localized: "Other") : categoryName)
.navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
.environmentObject(appState)
.environmentObject(groceryList)
@@ -132,7 +85,7 @@ struct RecipeListView: View {
}
}
func recipesFiltered() -> [CookbookApiRecipeV1] {
func recipesFiltered() -> [Recipe] {
guard let recipes = appState.recipes[categoryName] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
@@ -141,86 +94,3 @@ struct RecipeListView: View {
}
}
}
*/
/*
struct RecipeListView: View {
@Bindable var cookbookState: CookbookState
@State var selectedCategory: String
@State var searchText: String = ""
@Binding var showEditView: Bool
var body: some View {
Group {
let recipes = recipesFiltered()
if !recipes.isEmpty {
List(recipesFiltered(), selection: $cookbookState.selectedAccountState.selectedRecipe) { recipe in
RecipeCardView(state: cookbookState.selectedAccountState, recipe: recipe)
.shadow(radius: 2)
.background(
NavigationLink {
RecipeView(viewModel: RecipeView.ViewModel(recipeStub: recipe))
.environment(cookbookState)
} label: {
EmptyView()
}
.buttonStyle(.plain)
.opacity(0)
)
.frame(height: 85)
.listRowInsets(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15))
.listRowSeparatorTint(.clear)
}
.listStyle(.plain)
} else {
VStack {
Text("There are no recipes in this cookbook!")
Button {
Task {
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: selectedCategory)
}
} label: {
Text("Refresh")
.bold()
}
.buttonStyle(.bordered)
}.padding()
}
}
.searchable(text: $searchText, prompt: "Search recipes/keywords")
.navigationTitle(selectedCategory == "*" ? String(localized: "Other") : selectedCategory)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
print("Add new recipe")
showEditView = true
} label: {
Image(systemName: "plus.circle.fill")
}
}
}
.task {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
.refreshable {
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(
named: selectedCategory
)
}
}
func recipesFiltered() -> [RecipeStub] {
guard let recipes = cookbookState.selectedAccountState.recipeStubs[selectedCategory] else { return [] }
guard searchText != "" else { return recipes }
return recipes.filter { recipe in
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
}
}
}
*/

View File

@@ -10,13 +10,13 @@ import SwiftUI
struct RecipeView: View {
@Bindable var recipe: Recipe
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@StateObject var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let recipeImage = recipe.image, let image = recipeImage.image {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
@@ -33,8 +33,8 @@ struct RecipeView: View {
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = recipe.image, let image = recipeImage.image {
Image(uiImage: image)
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
@@ -54,12 +54,15 @@ struct RecipeView: View {
VStack(alignment: .leading) {
if viewModel.editMode {
//RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
//RecipeMetadataSection(viewModel: viewModel)
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
EditableText(text: $viewModel.observableRecipeDetail.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
@@ -71,37 +74,36 @@ struct RecipeView: View {
}
}.padding([.top, .horizontal])
if recipe.recipeDescription != "" || viewModel.editMode {
EditableText(text: $recipe.recipeDescription, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
if viewModel.observableRecipeDetail.description != "" || viewModel.editMode {
EditableText(text: $viewModel.observableRecipeDetail.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(recipe: recipe, editMode: $viewModel.editMode)
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!recipe.ingredients.isEmpty || viewModel.editMode) {
RecipeIngredientSection(recipe: recipe, editMode: $viewModel.editMode, presentIngredientEditView: $viewModel.presentIngredientEditView)
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!recipe.instructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(recipe: recipe, editMode: $viewModel.editMode, presentInstructionEditView: $viewModel.presentInstructionEditView)
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!recipe.tools.isEmpty || viewModel.editMode) {
RecipeToolSection(recipe: recipe, editMode: $viewModel.editMode, presentToolEditView: $viewModel.presentToolEditView)
if(!viewModel.observableRecipeDetail.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(recipe: recipe, editMode: $viewModel.editMode)
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
//RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(recipe: recipe)
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
@@ -113,21 +115,21 @@ struct RecipeView: View {
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
/*ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
ShareView(recipeDetail: viewModel.observableRecipeDetail.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)*/
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $recipe.instructions,
items: $viewModel.observableRecipeDetail.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
@@ -137,7 +139,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $recipe.ingredients,
items: $viewModel.observableRecipeDetail.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
@@ -147,7 +149,7 @@ struct RecipeView: View {
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $recipe.tools,
items: $viewModel.observableRecipeDetail.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
@@ -156,14 +158,13 @@ struct RecipeView: View {
}
.task {
/*
// Load recipe detail
if !viewModel.newRecipe {
// For existing recipes, load the recipeDetail and image
let recipeDetail = await appState.getRecipe(
id: viewModel.recipe.recipe_id,
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
) ?? CookbookApiRecipeDetailV1.error
) ?? RecipeDetail.error
viewModel.setupView(recipeDetail: recipeDetail)
// Show download badge
@@ -181,10 +182,10 @@ struct RecipeView: View {
} else {
// Prepare view for a new recipe
viewModel.setupView(recipeDetail: CookbookApiRecipeDetailV1())
viewModel.setupView(recipeDetail: RecipeDetail())
viewModel.editMode = true
viewModel.isDownloaded = false
}*/
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
@@ -216,14 +217,13 @@ struct RecipeView: View {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: viewModel.editMode) { newValue in
/*
if newValue && appState.allKeywords.isEmpty {
Task {
appState.allKeywords = await appState.getKeywords(fetchMode: .preferServer).sorted(by: { a, b in
a.recipe_count > b.recipe_count
})
}
}*/
}
}
}
@@ -231,8 +231,9 @@ struct RecipeView: View {
// MARK: - RecipeView ViewModel
class ViewModel: ObservableObject {
@Published var recipe: Recipe
@Published var observableRecipeDetail: ObservableRecipeDetail = ObservableRecipeDetail()
@Published var recipeDetail: RecipeDetail = RecipeDetail.error
@Published var recipeImage: UIImage? = nil
@Published var editMode: Bool = false
@Published var showTitle: Bool = false
@Published var isDownloaded: Bool? = nil
@@ -243,6 +244,7 @@ struct RecipeView: View {
@Published var presentIngredientEditView: Bool = false
@Published var presentToolEditView: Bool = false
var recipe: Recipe
var sharedURL: URL? = nil
var newRecipe: Bool = false
@@ -258,7 +260,20 @@ struct RecipeView: View {
init() {
self.newRecipe = true
self.recipe = Recipe()
self.recipe = Recipe(
name: String(localized: "New Recipe"),
keywords: "",
dateCreated: "",
dateModified: "",
imageUrl: "",
imagePlaceholderUrl: "",
recipe_id: 0)
}
// View setup
func setupView(recipeDetail: RecipeDetail) {
self.recipeDetail = recipeDetail
self.observableRecipeDetail = ObservableRecipeDetail(recipeDetail)
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
@@ -270,7 +285,7 @@ struct RecipeView: View {
}
/*
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
let (scrapedRecipe, error) = await appState.importRecipe(url: url)
@@ -294,12 +309,13 @@ extension RecipeView {
return nil
}
}
*/
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: RecipeView.ViewModel
@@ -369,7 +385,6 @@ struct RecipeViewToolBar: ToolbarContent {
}
func handleUpload() async {
/*
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
@@ -401,11 +416,9 @@ struct RecipeViewToolBar: ToolbarContent {
}
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
*/
}
func handleDelete() async {
/*
let category = viewModel.observableRecipeDetail.recipeCategory
guard let id = Int(viewModel.observableRecipeDetail.id) else {
viewModel.presentAlert(RequestAlert.REQUEST_DROPPED)
@@ -419,13 +432,11 @@ struct RecipeViewToolBar: ToolbarContent {
await appState.getCategory(named: category, fetchMode: .preferServer)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
*/
}
func recipeValid() -> RecipeAlert? {
/*
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
if viewModel.observableRecipeDetail.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
@@ -443,437 +454,8 @@ struct RecipeViewToolBar: ToolbarContent {
}
}
}
*/
return nil
}
}
/*
struct RecipeView: View {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: ViewModel
@GestureState private var dragOffset = CGSize.zero
var imageHeight: CGFloat {
if let image = viewModel.recipeImage {
return image.size.height < 350 ? image.size.height : 350
}
return 200
}
private enum CoordinateSpaces {
case scrollView
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
ParallaxHeader(
coordinateSpace: CoordinateSpaces.scrollView,
defaultHeight: imageHeight
) {
if let recipeImage = viewModel.recipeImage {
Image(uiImage: recipeImage)
.resizable()
.scaledToFill()
.frame(maxHeight: imageHeight + 200)
.clipped()
} else {
Rectangle()
.frame(height: 400)
.foregroundStyle(
LinearGradient(
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
}
VStack(alignment: .leading) {
if viewModel.editMode {
RecipeImportSection(viewModel: viewModel, importRecipe: importRecipe)
}
if viewModel.editMode {
RecipeMetadataSection(viewModel: viewModel)
}
HStack {
EditableText(text: $viewModel.recipe.name, editMode: $viewModel.editMode, titleKey: "Recipe Name")
.font(.title)
.bold()
Spacer()
if let isDownloaded = viewModel.isDownloaded {
Image(systemName: isDownloaded ? "checkmark.circle" : "icloud.and.arrow.down")
.foregroundColor(.secondary)
}
}.padding([.top, .horizontal])
if viewModel.recipe.description != "" || viewModel.editMode {
EditableText(text: $viewModel.recipe.description, editMode: $viewModel.editMode, titleKey: "Description", lineLimit: 0...5, axis: .vertical)
.fontWeight(.medium)
.padding(.horizontal)
.padding(.top, 2)
}
// Recipe Body Section
RecipeDurationSection(viewModel: viewModel)
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
if(!viewModel.recipe.recipeIngredient.isEmpty || viewModel.editMode) {
RecipeIngredientSection(viewModel: viewModel)
}
if(!viewModel.recipe.recipeInstructions.isEmpty || viewModel.editMode) {
RecipeInstructionSection(viewModel: viewModel)
}
if(!viewModel.recipe.tool.isEmpty || viewModel.editMode) {
RecipeToolSection(viewModel: viewModel)
}
RecipeNutritionSection(viewModel: viewModel)
}
if !viewModel.editMode {
Divider()
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
RecipeKeywordSection(viewModel: viewModel)
MoreInformationSection(viewModel: viewModel)
}
}
}
.padding(.horizontal, 5)
.background(Rectangle().foregroundStyle(.background).shadow(radius: 5).mask(Rectangle().padding(.top, -20)))
}
}
.coordinateSpace(name: CoordinateSpaces.scrollView)
.ignoresSafeArea(.container, edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
//.toolbarTitleDisplayMode(.inline)
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
.toolbar {
RecipeViewToolBar(viewModel: viewModel)
}
.sheet(isPresented: $viewModel.presentShareSheet) {
ShareView(recipeDetail: viewModel.recipe.toRecipeDetail(),
recipeImage: viewModel.recipeImage,
presentShareSheet: $viewModel.presentShareSheet)
}
.sheet(isPresented: $viewModel.presentInstructionEditView) {
EditableListView(
isPresented: $viewModel.presentInstructionEditView,
items: $viewModel.recipe.recipeInstructions,
title: "Instructions",
emptyListText: "Add cooking steps for fellow chefs to follow.",
titleKey: "Instruction",
lineLimit: 0...10,
axis: .vertical)
}
.sheet(isPresented: $viewModel.presentIngredientEditView) {
EditableListView(
isPresented: $viewModel.presentIngredientEditView,
items: $viewModel.recipe.recipeIngredient,
title: "Ingredients",
emptyListText: "Start by adding your first ingredient! 🥬",
titleKey: "Ingredient",
lineLimit: 0...1,
axis: .horizontal)
}
.sheet(isPresented: $viewModel.presentToolEditView) {
EditableListView(
isPresented: $viewModel.presentToolEditView,
items: $viewModel.recipe.tool,
title: "Tools",
emptyListText: "List your tools here. 🍴",
titleKey: "Tool",
lineLimit: 0...1,
axis: .horizontal)
}
.task {
// Load recipe detail
if let recipeStub = viewModel.recipeStub {
// For existing recipes, load the recipeDetail and image
let recipe = await cookbookState.selectedAccountState.getRecipe(
id: recipeStub.id
) ?? Recipe()
viewModel.recipe = recipe
// Show download badge
/*if viewModel.recipeStub!.storedLocally == nil {
viewModel.recipeStub?.storedLocally = cookbookState.selectedAccountState.recipeDetailExists(
recipeId: viewModel.recipe.recipe_id
)
}
viewModel.isDownloaded = viewModel.recipeStub!.storedLocally
*/
// Load recipe image
viewModel.recipeImage = await cookbookState.selectedAccountState.getImage(
id: recipeStub.id,
size: .FULL
)
} else {
// Prepare view for a new recipe
viewModel.editMode = true
viewModel.isDownloaded = false
viewModel.recipe = Recipe()
}
}
.alert(viewModel.alertType.localizedTitle, isPresented: $viewModel.presentAlert) {
ForEach(viewModel.alertType.alertButtons) { buttonType in
if buttonType == .OK {
Button(AlertButton.OK.rawValue, role: .cancel) {
Task {
await viewModel.alertAction()
}
}
} else if buttonType == .CANCEL {
Button(AlertButton.CANCEL.rawValue, role: .cancel) { }
} else if buttonType == .DELETE {
Button(AlertButton.DELETE.rawValue, role: .destructive) {
Task {
await viewModel.alertAction()
}
}
}
}
} message: {
Text(viewModel.alertType.localizedDescription)
}
.onAppear {
if UserSettings.shared.keepScreenAwake {
UIApplication.shared.isIdleTimerDisabled = true
}
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
}
.onChange(of: viewModel.editMode) { newValue in
if newValue && cookbookState.selectedAccountState.keywords.isEmpty {
Task {
if let keywords = await cookbookState.selectedAccountState.getTags()?.sorted(by: { a, b in
a.recipe_count > b.recipe_count
}) {
cookbookState.selectedAccountState.keywords = keywords
}
}
}
}
}
// MARK: - RecipeView ViewModel
@Observable class ViewModel {
var recipeImage: UIImage? = nil
var editMode: Bool = false
var showTitle: Bool = false
var isDownloaded: Bool? = nil
var importUrl: String = ""
var presentShareSheet: Bool = false
var presentInstructionEditView: Bool = false
var presentIngredientEditView: Bool = false
var presentToolEditView: Bool = false
var recipeStub: RecipeStub? = nil
var recipe: Recipe = Recipe()
var sharedURL: URL? = nil
var newRecipe: Bool = false
// Alerts
var presentAlert = false
var alertType: UserAlert = RecipeAlert.GENERIC
var alertAction: () async -> () = { }
// Initializers
init(recipeStub: RecipeStub) {
self.recipeStub = recipeStub
}
init() {
self.newRecipe = true
}
func presentAlert(_ type: UserAlert, action: @escaping () async -> () = {}) {
alertType = type
alertAction = action
presentAlert = true
}
}
}
extension RecipeView {
func importRecipe(from url: String) async -> UserAlert? {
/*let (scrapedRecipe, error) = await appState.importRecipe(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.setupView(recipeDetail: scrapedRecipe)
return nil
}*/
do {
let (scrapedRecipe, error) = try await RecipeScraper().scrape(url: url)
if let scrapedRecipe = scrapedRecipe {
viewModel.recipe = scrapedRecipe.toRecipe()
}
if let error = error {
return error
}
} catch {
print("Error")
}
return nil
}
}
// MARK: - Tool Bar
struct RecipeViewToolBar: ToolbarContent {
@Environment(CookbookState.self) var cookbookState
@Environment(\.dismiss) private var dismiss
@State var viewModel: RecipeView.ViewModel
var body: some ToolbarContent {
if viewModel.editMode {
ToolbarItemGroup(placement: .topBarLeading) {
Button("Cancel") {
viewModel.editMode = false
if viewModel.newRecipe {
dismiss()
}
}
if !viewModel.newRecipe {
Menu {
Button(role: .destructive) {
viewModel.presentAlert(
RecipeAlert.CONFIRM_DELETE,
action: {
await handleDelete()
}
)
} label: {
Label("Delete Recipe", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
await handleUpload()
}
} label: {
if viewModel.newRecipe {
Text("Upload Recipe")
} else {
Text("Upload Changes")
}
}
}
} else {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button {
viewModel.editMode = true
} label: {
Label("Edit", systemImage: "pencil")
}
Button {
print("Sharing recipe ...")
viewModel.presentShareSheet = true
} label: {
Label("Share Recipe", systemImage: "square.and.arrow.up")
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}
func handleUpload() async {
if viewModel.newRecipe {
print("Uploading new recipe.")
if let recipeValidationError = recipeValid() {
viewModel.presentAlert(recipeValidationError)
return
}
if let alert = await cookbookState.selectedAccountState.postRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
} else {
print("Uploading changed recipe.")
if let alert = await cookbookState.selectedAccountState.updateRecipe(recipe: viewModel.recipe) {
viewModel.presentAlert(alert)
return
}
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: viewModel.recipe.recipeCategory)
let _ = await cookbookState.selectedAccountState.getRecipe(id: viewModel.recipe.id)
viewModel.editMode = false
viewModel.presentAlert(RecipeAlert.UPLOAD_SUCCESS)
}
func handleDelete() async {
let category = viewModel.recipe.recipeCategory
if let alert = await cookbookState.selectedAccountState.deleteRecipe(id: viewModel.recipe.id) {
viewModel.presentAlert(alert)
return
}
let _ = await cookbookState.selectedAccountState.getCategories()
let _ = await cookbookState.selectedAccountState.getRecipeStubsForCategory(named: category)
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
dismiss()
}
func recipeValid() -> RecipeAlert? {
// Check if the recipe has a name
if viewModel.recipe.name.replacingOccurrences(of: " ", with: "") == "" {
return RecipeAlert.NO_TITLE
}
// Check if the recipe has a unique name
for recipeList in cookbookState.selectedAccountState.recipeStubs.values {
for r in recipeList {
if r.name
.replacingOccurrences(of: " ", with: "")
.lowercased() ==
viewModel.recipe.name
.replacingOccurrences(of: " ", with: "")
.lowercased()
{
return RecipeAlert.DUPLICATE
}
}
}
return nil
}
}
*/

View File

@@ -11,18 +11,17 @@ import SwiftUI
// MARK: - RecipeView Duration Section
struct RecipeDurationSection: View {
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@ObservedObject var viewModel: RecipeView.ViewModel
@State var presentPopover: Bool = false
var body: some View {
VStack(alignment: .leading) {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 200, maximum: .infinity), alignment: .leading)]) {
DurationView(time: recipe.prepTimeDurationComponent, title: LocalizedStringKey("Preparation"))
DurationView(time: recipe.cookTimeDurationComponent, title: LocalizedStringKey("Cooking"))
DurationView(time: recipe.totalTimeDurationComponent, title: LocalizedStringKey("Total time"))
DurationView(time: viewModel.observableRecipeDetail.prepTime, title: LocalizedStringKey("Preparation"))
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
}
if editMode {
if viewModel.editMode {
Button {
presentPopover.toggle()
} label: {
@@ -35,9 +34,9 @@ struct RecipeDurationSection: View {
.padding()
.popover(isPresented: $presentPopover) {
EditableDurationView(
prepTime: recipe.prepTimeDurationComponent,
cookTime: recipe.cookTimeDurationComponent,
totalTime: recipe.totalTimeDurationComponent
prepTime: viewModel.observableRecipeDetail.prepTime,
cookTime: viewModel.observableRecipeDetail.cookTime,
totalTime: viewModel.observableRecipeDetail.totalTime
)
}
}
@@ -95,10 +94,10 @@ fileprivate struct EditableDurationView: View {
TimePickerView(selectedHour: $totalTime.hourComponent, selectedMinute: $totalTime.minuteComponent)
}
.padding()
.onChange(of: prepTime.hourComponent) { updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { updateTotalTime() }
.onChange(of: cookTime.hourComponent) { updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { updateTotalTime() }
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
}
}
@@ -143,5 +142,3 @@ fileprivate struct TimePickerView: View {
.padding()
}
}

View File

@@ -19,7 +19,7 @@ struct RecipeListSection: View {
ForEach(list, id: \.self) { item in
HStack(alignment: .top) {
Text("\u{2022}")
Text("\(item)")
Text(ObservableRecipeDetail.applyMarkdownStyling(item))
.multilineTextAlignment(.leading)
}
.padding(4)
@@ -53,7 +53,7 @@ struct EditableText: View {
.textFieldStyle(.roundedBorder)
.lineLimit(lineLimit)
} else {
Text(text)
Text(ObservableRecipeDetail.applyMarkdownStyling(text))
}
}
}

View File

@@ -10,9 +10,9 @@ import SwiftUI
// MARK: - RecipeView Import Section
/*
struct RecipeImportSection: View {
@State var viewModel: RecipeView.ViewModel
@ObservedObject var viewModel: RecipeView.ViewModel
var importRecipe: (String) async -> UserAlert?
var body: some View {
@@ -49,4 +49,4 @@ struct RecipeImportSection: View {
.padding(.top, 5)
}
}
*/

View File

@@ -7,34 +7,27 @@
import Foundation
import SwiftUI
import SwiftData
// MARK: - RecipeView Ingredients Section
struct RecipeIngredientSection: View {
@Environment(\.modelContext) var modelContext
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentIngredientEditView: Bool
@State var recipeGroceries: RecipeGroceries? = nil
@EnvironmentObject var groceryList: GroceryList
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
Button {
withAnimation {
/*
if cookbookState.groceryList.containsRecipe(viewModel.recipe.id) {
cookbookState.groceryList.deleteGroceryRecipe(viewModel.recipe.id)
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
} else {
cookbookState.groceryList.addItems(
viewModel.recipe.recipeIngredient,
toRecipe: viewModel.recipe.id,
recipeName: viewModel.recipe.name
groceryList.addItems(
viewModel.observableRecipeDetail.recipeIngredient,
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
*/
}
} label: {
if #available(iOS 17.0, *) {
@@ -42,7 +35,7 @@ struct RecipeIngredientSection: View {
} else {
Image(systemName: "heart.text.square")
}
}.disabled(editMode)
}.disabled(viewModel.editMode)
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
@@ -52,30 +45,26 @@ struct RecipeIngredientSection: View {
.foregroundStyle(.secondary)
.bold()
ServingPickerView(selectedServingSize: $recipe.ingredientMultiplier)
ServingPickerView(selectedServingSize: $viewModel.observableRecipeDetail.ingredientMultiplier)
}
ForEach(0..<recipe.ingredients.count, id: \.self) { ix in
/*IngredientListItem(
ingredient: $recipe.recipeIngredient[ix],
servings: $recipe.ingredientMultiplier,
recipeYield: Double(recipe.recipeYield),
recipeId: recipe.id
ForEach(0..<viewModel.observableRecipeDetail.recipeIngredient.count, id: \.self) { ix in
IngredientListItem(
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
recipeId: viewModel.observableRecipeDetail.id
) {
/*
cookbookState.groceryList.addItem(
recipe.recipeIngredient[ix],
toRecipe: recipe.id,
recipeName: recipe.name
)*/
groceryList.addItem(
viewModel.observableRecipeDetail.recipeIngredient[ix],
toRecipe: viewModel.observableRecipeDetail.id,
recipeName: viewModel.observableRecipeDetail.name
)
}
.padding(4)*/
Text(recipe.ingredients[ix])
.padding(4)
}
if recipe.ingredientMultiplier != Double(recipe.yield) {
if viewModel.observableRecipeDetail.ingredientMultiplier != Double(viewModel.observableRecipeDetail.recipeYield) {
HStack() {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.secondary)
@@ -84,9 +73,9 @@ struct RecipeIngredientSection: View {
}.padding(.top)
}
if editMode {
if viewModel.editMode {
Button {
presentIngredientEditView.toggle()
viewModel.presentIngredientEditView.toggle()
} label: {
Text("Edit")
}
@@ -94,80 +83,19 @@ struct RecipeIngredientSection: View {
}
}
.padding()
.animation(.easeInOut, value: recipe.ingredientMultiplier)
}
func toggleAllGroceryItems(_ itemNames: [String], inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete category if it exists
modelContext.delete(existingCategory)
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
newCategory.items.append(newItem)
}
try modelContext.save()
}
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
func toggleGroceryItem(_ itemName: String, inCategory categoryId: String, named name: String) {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
// Delete item if it exists
if existingCategory.items.contains(where: { $0.name == itemName }) {
existingCategory.items.removeAll { $0.name == itemName }
// Delete category if empty
if existingCategory.items.isEmpty {
modelContext.delete(existingCategory)
}
} else {
existingCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
} else {
// Add the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
// Add the item to the new category
newCategory.items.append(GroceryItem(name: itemName, isChecked: false))
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
}
}
// MARK: - RecipeIngredientSection List Item
/*
fileprivate struct IngredientListItem: View {
@Environment(\.modelContext) var modelContext
@Bindable var recipeGroceries: RecipeGroceries
@EnvironmentObject var groceryList: GroceryList
@Binding var ingredient: String
@Binding var servings: Double
@State var recipeYield: Double
@State var recipeId: String
let addToGroceryListAction: () -> Void
@State var modifiedIngredient: AttributedString = ""
@State var isSelected: Bool = false
@@ -182,7 +110,7 @@ fileprivate struct IngredientListItem: View {
var body: some View {
HStack(alignment: .top) {
if recipeGroceries.items.contains(ingredient) {
if groceryList.containsItem(at: recipeId, item: ingredient) {
if #available(iOS 17.0, *) {
Image(systemName: "storefront")
.foregroundStyle(Color.green)
@@ -201,7 +129,7 @@ fileprivate struct IngredientListItem: View {
.foregroundStyle(.red)
}
if unmodified {
Text(ingredient)
Text(ObservableRecipeDetail.applyMarkdownStyling(ingredient))
.multilineTextAlignment(.leading)
.lineLimit(5)
} else {
@@ -212,11 +140,11 @@ fileprivate struct IngredientListItem: View {
}
Spacer()
}
.onChange(of: servings) { _, newServings in
.onChange(of: servings) { newServings in
if recipeYield == 0 {
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings)
} else {
modifiedIngredient = Recipe.adjustIngredient(ingredient, by: newServings/recipeYield)
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ObservableRecipeDetail.applyMarkdownStyling(ingredient), by: newServings/recipeYield)
}
}
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
@@ -240,8 +168,8 @@ fileprivate struct IngredientListItem: View {
.onEnded { gesture in
withAnimation {
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
if recipeGroceries.items.contains(ingredient) {
cookbookState.groceryList.deleteItem(ingredient, fromRecipe: recipeId)
if groceryList.containsItem(at: recipeId, item: ingredient) {
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
} else {
addToGroceryListAction()
}
@@ -254,7 +182,7 @@ fileprivate struct IngredientListItem: View {
)
}
}
*/
struct ServingPickerView: View {
@@ -281,12 +209,9 @@ struct ServingPickerView: View {
.bold()
}
}
.onChange(of: selectedServingSize) { _, newValue in
.onChange(of: selectedServingSize) { newValue in
if newValue < 0 { selectedServingSize = 0 }
else if newValue > 100 { selectedServingSize = 100 }
}
}
}

View File

@@ -11,24 +11,20 @@ import SwiftUI
// MARK: - RecipeView Instructions Section
struct RecipeInstructionSection: View {
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentInstructionEditView: Bool
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
HStack {
SecondaryLabel(text: LocalizedStringKey("Instructions"))
Spacer()
}
ForEach(recipe.instructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $recipe.instructions[ix], index: ix+1)
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
}
if editMode {
if viewModel.editMode {
Button {
presentInstructionEditView.toggle()
viewModel.presentInstructionEditView.toggle()
} label: {
Text("Edit")
}
@@ -36,10 +32,11 @@ struct RecipeInstructionSection: View {
}
}
.padding()
}
}
// MARK: - Preview
fileprivate struct RecipeInstructionListItem: View {
@Binding var instruction: String
@@ -50,7 +47,7 @@ fileprivate struct RecipeInstructionListItem: View {
HStack(alignment: .top) {
Text("\(index)")
.monospaced()
Text(instruction)
Text(ObservableRecipeDetail.applyMarkdownStyling(instruction))
}.padding(4)
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
.onTapGesture {
@@ -60,44 +57,3 @@ fileprivate struct RecipeInstructionListItem: View {
}
}
struct RecipeInstructionSection_Previews: PreviewProvider {
static var previews: some View {
// Create a mock recipe
@State var mockRecipe = createRecipe()
// Create mock state variables for the @Binding properties
@State var mockEditMode = true
@State var mockPresentInstructionEditView = false
// Provide the mock data to the view
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Edit Mode")
RecipeInstructionSection(
recipe: mockRecipe,
editMode: $mockEditMode,
presentInstructionEditView: $mockPresentInstructionEditView
)
.previewDisplayName("Instructions - Read Only")
.environment(\.editMode, .constant(.inactive))
}
static func createRecipe() -> Recipe {
let recipe = Recipe()
recipe.name = "Mock Recipe"
recipe.instructions = [
"Step 1: Gather all ingredients and equipment.",
"Step 2: Preheat oven to 180°C (350°F) and prepare baking dish.",
"Step 3: Combine dry ingredients in a large bowl and mix thoroughly.",
"Step 4: In a separate bowl, whisk wet ingredients until smooth.",
"Step 5: Gradually add wet ingredients to dry ingredients, mixing until just combined. Do not overmix.",
"Step 6: Pour the mixture into the prepared baking dish and bake for 30-35 minutes, or until golden brown and a toothpick inserted into the center comes out clean.",
"Step 7: Let cool before serving. Enjoy!"
]
return recipe
}
}

View File

@@ -9,16 +9,16 @@ import Foundation
import SwiftUI
// MARK: - RecipeView Keyword Section
/*
struct RecipeKeywordSection: View {
@State var viewModel: RecipeView.ViewModel
@ObservedObject var viewModel: RecipeView.ViewModel
let columns: [GridItem] = [ GridItem(.flexible(minimum: 50, maximum: 200), spacing: 5) ]
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandKeywordSection) {
Group {
if !viewModel.recipe.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.recipe.keywords)
if !viewModel.observableRecipeDetail.keywords.isEmpty && !viewModel.editMode {
RecipeListSection(list: $viewModel.observableRecipeDetail.keywords)
} else {
Text(LocalizedStringKey("No keywords."))
}
@@ -189,4 +189,3 @@ struct KeywordPickerView_Previews: PreviewProvider {
}
}
*/

View File

@@ -9,14 +9,14 @@ import Foundation
import SwiftUI
// MARK: - Recipe Metadata Section
/*
struct RecipeMetadataSection: View {
@Environment(CookbookState.self) var cookbookState
@State var viewModel: RecipeView.ViewModel
@EnvironmentObject var appState: AppState
@ObservedObject var viewModel: RecipeView.ViewModel
@State var keywords: [RecipeKeyword] = []
var categories: [String] {
cookbookState.selectedAccountState.categories.map({ category in category.name })
appState.categories.map({ category in category.name })
}
@State var presentKeywordSheet: Bool = false
@@ -28,11 +28,11 @@ struct RecipeMetadataSection: View {
// Category
SecondaryLabel(text: "Category")
HStack {
TextField("Category", text: $viewModel.recipe.recipeCategory)
TextField("Category", text: $viewModel.observableRecipeDetail.recipeCategory)
.lineLimit(1)
.textFieldStyle(.roundedBorder)
Picker("Choose", selection: $viewModel.recipe.recipeCategory) {
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
Text("").tag("")
ForEach(categories, id: \.self) { item in
Text(item)
@@ -45,10 +45,10 @@ struct RecipeMetadataSection: View {
// Keywords
SecondaryLabel(text: "Keywords")
if !viewModel.recipe.keywords.isEmpty {
if !viewModel.observableRecipeDetail.keywords.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(viewModel.recipe.keywords, id: \.self) { keyword in
ForEach(viewModel.observableRecipeDetail.keywords, id: \.self) { keyword in
Text(keyword)
.padding(5)
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
@@ -70,11 +70,11 @@ struct RecipeMetadataSection: View {
Button {
presentServingsPopover.toggle()
} label: {
Text("\(viewModel.recipe.recipeYield) Serving(s)")
Text("\(viewModel.observableRecipeDetail.recipeYield) Serving(s)")
.lineLimit(1)
}
.popover(isPresented: $presentServingsPopover) {
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.recipe.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
PickerPopoverView(isPresented: $presentServingsPopover, value: $viewModel.observableRecipeDetail.recipeYield, items: 1..<99, title: "Servings", titleKey: "Servings")
}
}
}
@@ -82,7 +82,7 @@ struct RecipeMetadataSection: View {
.background(RoundedRectangle(cornerRadius: 20).foregroundStyle(Color.primary.opacity(0.1)))
.padding([.horizontal, .bottom], 5)
.sheet(isPresented: $presentKeywordSheet) {
KeywordPickerView(title: "Keywords", searchSuggestions: cookbookState.selectedAccountState.keywords, selection: $viewModel.recipe.keywords)
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
}
}
}
@@ -121,27 +121,27 @@ fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, C
.padding()
}
}
*/
// MARK: - RecipeView More Information Section
struct MoreInformationSection: View {
@Bindable var recipe: Recipe
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandInfoSection) {
VStack(alignment: .leading) {
if let dateCreated = recipe.dateCreated {
if let dateCreated = viewModel.recipeDetail.dateCreated {
Text("Created: \(Date.convertISOStringToLocalString(isoDateString: dateCreated) ?? "")")
}
if let dateModified = recipe.dateModified {
if let dateModified = viewModel.recipeDetail.dateModified {
Text("Last modified: \(Date.convertISOStringToLocalString(isoDateString: dateModified) ?? "")")
}
if recipe.url != "", let url = URL(string: recipe.url ?? "") {
if viewModel.observableRecipeDetail.url != "", let url = URL(string: viewModel.observableRecipeDetail.url) {
HStack(alignment: .top) {
Text("URL:")
Link(destination: url) {
Text(recipe.url ?? "")
Text(viewModel.observableRecipeDetail.url)
}
}
}

View File

@@ -11,13 +11,12 @@ import SwiftUI
// MARK: - RecipeView Nutrition Section
struct RecipeNutritionSection: View {
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
CollapsibleView(titleColor: .secondary, isCollapsed: !UserSettings.shared.expandNutritionSection) {
VStack(alignment: .leading) {
if editMode {
if viewModel.editMode {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
HStack {
Text(nutrition.localizedDescription)
@@ -29,7 +28,7 @@ struct RecipeNutritionSection: View {
} else if !nutritionEmpty() {
VStack(alignment: .leading) {
ForEach(Nutrition.allCases, id: \.self) { nutrition in
if let value = recipe.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey], nutrition.dictKey != Nutrition.servingSize.dictKey {
HStack(alignment: .top) {
Text("\(nutrition.localizedDescription): \(value)")
.multilineTextAlignment(.leading)
@@ -44,7 +43,7 @@ struct RecipeNutritionSection: View {
}
} title: {
HStack {
if let servingSize = recipe.nutrition["servingSize"] {
if let servingSize = viewModel.observableRecipeDetail.nutrition["servingSize"] {
SecondaryLabel(text: "Nutrition (\(servingSize))")
} else {
SecondaryLabel(text: LocalizedStringKey("Nutrition"))
@@ -57,18 +56,17 @@ struct RecipeNutritionSection: View {
func binding(for key: String) -> Binding<String> {
Binding(
get: { recipe.nutrition[key, default: ""] },
set: { recipe.nutrition[key] = $0 }
get: { viewModel.observableRecipeDetail.nutrition[key, default: ""] },
set: { viewModel.observableRecipeDetail.nutrition[key] = $0 }
)
}
func nutritionEmpty() -> Bool {
for nutrition in Nutrition.allCases {
if let value = recipe.nutrition[nutrition.dictKey] {
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
return false
}
}
return true
}
}

View File

@@ -11,9 +11,7 @@ import SwiftUI
// MARK: - RecipeView Tool Section
struct RecipeToolSection: View {
@Bindable var recipe: Recipe
@Binding var editMode: Bool
@Binding var presentToolEditView: Bool
@ObservedObject var viewModel: RecipeView.ViewModel
var body: some View {
VStack(alignment: .leading) {
@@ -22,11 +20,11 @@ struct RecipeToolSection: View {
Spacer()
}
RecipeListSection(list: $recipe.tools)
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
if editMode {
if viewModel.editMode {
Button {
presentToolEditView.toggle()
viewModel.presentToolEditView.toggle()
} label: {
Text("Edit")
}
@@ -37,5 +35,3 @@ struct RecipeToolSection: View {
}

View File

@@ -10,7 +10,7 @@ import SwiftUI
struct ShareView: View {
@State var recipeDetail: CookbookApiRecipeDetailV1
@State var recipeDetail: RecipeDetail
@State var recipeImage: UIImage?
@Binding var presentShareSheet: Bool

View File

@@ -48,4 +48,3 @@ struct CollapsibleView<C: View, T: View>: View {
}
}
}

View File

@@ -1,38 +0,0 @@
//
// ListVStack.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import SwiftUI
struct ListVStack<Element, HeaderContent: View, RowContent: View>: View {
@Binding var items: [Element]
let header: () -> HeaderContent
let rows: (Int, Binding<Element>) -> RowContent
init(_ items: Binding<[Element]>, header: @escaping () -> HeaderContent, rows: @escaping (Int, Binding<Element>) -> RowContent) {
self._items = items
self.header = header
self.rows = rows
}
var body: some View {
VStack(alignment: .leading) {
header()
.padding(.horizontal, 30)
VStack(alignment: .leading, spacing: 0) {
ForEach(items.indices, id: \.self) { index in
rows(index, $items[index])
.padding(10)
}
}
.padding(4)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 15))
.padding(.horizontal)
}
}
}

View File

@@ -9,12 +9,8 @@ import Foundation
import SwiftUI
struct SettingsView: View {
var body: some View {
Text("Settings")
}
}
/*struct SettingsView: View {
@EnvironmentObject var appState: AppState
@ObservedObject var userSettings = UserSettings.shared
@ObservedObject var viewModel = ViewModel()
@@ -252,4 +248,3 @@ extension SettingsView {
*/

View File

@@ -7,47 +7,18 @@
import Foundation
import SwiftUI
import SwiftData
@Model class GroceryItem {
var name: String
var isChecked: Bool
init(name: String, isChecked: Bool) {
self.name = name
self.isChecked = isChecked
}
}
@Model class RecipeGroceries: Identifiable {
var id: String
var name: String
@Relationship(deleteRule: .cascade) var items: [GroceryItem]
var multiplier: Double
init(id: String, name: String, items: [GroceryItem], multiplier: Double) {
self.id = id
self.name = name
self.items = items
self.multiplier = multiplier
}
init(id: String, name: String) {
self.id = id
self.name = name
self.items = []
self.multiplier = 1
}
}
struct GroceryListTabView: View {
@Environment(\.modelContext) var modelContext
@Query var groceryList: [RecipeGroceries] = []
@EnvironmentObject var groceryList: GroceryList
@State var newGroceries: String = ""
@FocusState private var isFocused: Bool
var body: some View {
NavigationStack {
if groceryList.groceryDict.isEmpty {
EmptyGroceryListView(newGroceries: $newGroceries)
} else {
List {
HStack(alignment: .top) {
TextEditor(text: $newGroceries)
@@ -61,9 +32,7 @@ struct GroceryListTabView: View {
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
Task {
await addGroceryItems(items, toCategory: "Other", named: String(localized: "Other"))
}
groceryList.addItems(items, toRecipe: "Other", recipeName: String(localized: "Other"))
}
newGroceries = ""
@@ -74,19 +43,24 @@ struct GroceryListTabView: View {
.buttonStyle(.borderedProminent)
}
ForEach(groceryList, id: \.name) { category in
ForEach(groceryList.groceryDict.keys.sorted(), id: \.self) { key in
Section {
ForEach(category.items, id: \.self) { item in
GroceryListItemView(item: item)
ForEach(groceryList.groceryDict[key]!.items) { item in
GroceryListItemView(item: item, toggleAction: {
groceryList.toggleItemChecked(item)
}, deleteAction: {
withAnimation {
groceryList.deleteItem(item.name, fromRecipe: key)
}
})
}
} header: {
HStack {
Text(category.name)
Text(groceryList.groceryDict[key]!.name)
.foregroundStyle(Color.nextcloudBlue)
Spacer()
Button {
modelContext.delete(category)
groceryList.deleteGroceryRecipe(key)
} label: {
Image(systemName: "trash")
.foregroundStyle(Color.nextcloudBlue)
@@ -94,71 +68,22 @@ struct GroceryListTabView: View {
}
}
}
if groceryList.isEmpty {
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("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
.foregroundStyle(.secondary)
Text("Your grocery list is stored locally and therefore not synchronized across your devices.")
.foregroundStyle(.secondary)
}
}
.listStyle(.plain)
.navigationTitle("Grocery List")
.toolbar {
Button {
do {
try modelContext.delete(model: RecipeGroceries.self)
} catch {
print("Failed to delete all GroceryCategory models.")
}
groceryList.deleteAll()
} label: {
Text("Delete")
.foregroundStyle(Color.nextcloudBlue)
}
}
.onTapGesture {
isFocused = false
}
}
private func addGroceryItems(_ itemNames: [String], toCategory categoryId: String, named name: String) async {
do {
// Find or create the target category
let categoryPredicate = #Predicate<RecipeGroceries> { $0.id == categoryId }
let fetchDescriptor = FetchDescriptor<RecipeGroceries>(predicate: categoryPredicate)
var targetCategory: RecipeGroceries?
if let existingCategory = try modelContext.fetch(fetchDescriptor).first {
targetCategory = existingCategory
} else {
// Create the category if it doesn't exist
let newCategory = RecipeGroceries(id: categoryId, name: name)
modelContext.insert(newCategory)
targetCategory = newCategory
}
guard let category = targetCategory else { return }
// Add new GroceryItems to the category
for itemName in itemNames {
let newItem = GroceryItem(name: itemName, isChecked: false)
category.items.append(newItem)
}
try modelContext.save()
} catch {
print("Error adding grocery items: \(error.localizedDescription)")
}
}
private func deleteGroceryItems(at offsets: IndexSet, in category: RecipeGroceries) {
for index in offsets {
let itemToDelete = category.items[index]
modelContext.delete(itemToDelete)
}
}
}
@@ -166,8 +91,9 @@ struct GroceryListTabView: View {
fileprivate struct GroceryListItemView: View {
@Environment(\.modelContext) var modelContext
@Bindable var item: GroceryItem
let item: GroceryRecipeItem
let toggleAction: () -> Void
let deleteAction: () -> Void
var body: some View {
HStack(alignment: .top) {
@@ -183,13 +109,191 @@ fileprivate struct GroceryListItemView: View {
}
.padding(5)
.foregroundStyle(item.isChecked ? Color.secondary : Color.primary)
.onTapGesture(perform: { item.isChecked.toggle() })
.onTapGesture(perform: toggleAction)
.animation(.easeInOut, value: item.isChecked)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(action: { modelContext.delete(item) }) {
Button(action: deleteAction) {
Label("Delete", systemImage: "trash")
}
.tint(.red)
}
}
}
fileprivate struct EmptyGroceryListView: View {
@EnvironmentObject var groceryList: GroceryList
@Binding var newGroceries: String
@FocusState private var isFocused: Bool
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)
Text("To add grocieries manually, type them in the box below and press the button. To add multiple items at once, separate them by a new line.")
.foregroundStyle(.secondary)
HStack(alignment: .top) {
TextEditor(text: $newGroceries)
.padding(4)
.overlay(RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary).opacity(0.5))
.focused($isFocused)
Button {
if !newGroceries.isEmpty {
let items = newGroceries
.split(separator: "\n")
.compactMap { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
groceryList.addItems(items, toRecipe: "Other", recipeName: String(localized: "Other"))
}
newGroceries = ""
} label: {
Text("Add")
}
.disabled(newGroceries.isEmpty)
.buttonStyle(.borderedProminent)
}
.padding(.bottom, 4)
}
.navigationTitle("Grocery List")
.onTapGesture {
isFocused = false
}
}
}
// Grocery List Logic
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)
}
self.save()
self.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()
self.objectWillChange.send()
}
func deleteGroceryRecipe(_ recipeId: String) {
print("Deleting grocery recipe with id \(recipeId)")
groceryDict.removeValue(forKey: recipeId)
save()
self.objectWillChange.send()
}
func deleteAll() {
print("Deleting all grocery items")
groceryDict = [:]
save()
self.objectWillChange.send()
}
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
print("Item checked: \(groceryItem.name)")
groceryItem.isChecked.toggle()
save()
self.objectWillChange.send()
}
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

@@ -7,117 +7,37 @@
import Foundation
import SwiftUI
import SwiftData
struct RecipeTabView: View {
//@State var cookbookState: CookbookState = CookbookState()
@Environment(\.modelContext) var modelContext
@Query var recipes: [Recipe]
@State var categories: [(String, Int)] = []
@State private var selectedRecipe: Recipe?
@State private var selectedCategory: String? = "*"
@EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryList
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
var body: some View {
NavigationSplitView {
List(selection: $selectedCategory) {
CategoryListItem(category: "All Recipes", count: recipes.count, isSelected: selectedCategory == "*")
.tag("*") // Tag nil to select all recipes
Section("Categories") {
ForEach(categories, id: \.0.self) { category in
CategoryListItem(category: category.0, count: category.1, isSelected: selectedCategory == category.0)
.tag(category.0)
}
}
}
.navigationTitle("Categories")
} content: {
RecipeListView(selectedCategory: $selectedCategory, selectedRecipe: $selectedRecipe)
} detail: {
// Use a conditional view based on selection
if let selectedRecipe {
//RecipeDetailView(recipe: recipe) // Create a dedicated detail view
RecipeView(recipe: selectedRecipe, viewModel: RecipeView.ViewModel(recipe: selectedRecipe))
} else {
ContentUnavailableView("Select a Recipe", systemImage: "fork.knife.circle")
}
}
.task {
initCategories()
return
do {
try modelContext.delete(model: Recipe.self)
} catch {
print("Failed to delete recipes and categories.")
}
guard let categories = await CookbookApiV1.getCategories(auth: UserSettings.shared.authString).0 else { return }
for category in categories {
guard let recipeStubs = await CookbookApiV1.getCategory(auth: UserSettings.shared.authString, named: category.name).0 else { return }
for recipeStub in recipeStubs {
guard let recipe = await CookbookApiV1.getRecipe(auth: UserSettings.shared.authString, id: recipeStub.id).0 else { return }
modelContext.insert(recipe)
}
}
}/*
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(action: {
//cookbookState.showSettings = true
}) {
Label("Settings", systemImage: "gearshape")
}
}
}*/
}
func initCategories() {
// Load Categories
var categoryDict: [String: Int] = [:]
for recipe in recipes {
// Ensure "Uncategorized" is a valid category if used
if !recipe.category.isEmpty {
categoryDict[recipe.category, default: 0] += 1
} else {
categoryDict["Other", default: 0] += 1
}
}
categories = categoryDict.map {
($0.key, $0.value)
}.sorted { $0.0 < $1.0 }
}
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
}
}
fileprivate struct CategoryListItem: View {
var category: String
var count: Int
var isSelected: Bool
var body: some View {
List(selection: $viewModel.selectedCategory) {
// Categories
ForEach(appState.categories) { category in
NavigationLink(value: category) {
HStack(alignment: .center) {
if isSelected {
if viewModel.selectedCategory != nil &&
category.name == viewModel.selectedCategory!.name {
Image(systemName: "book")
} else {
Image(systemName: "book.closed.fill")
}
Text(category)
if category.name == "*" {
Text("Other")
.font(.system(size: 20, weight: .medium, design: .default))
} else {
Text(category.name)
.font(.system(size: 20, weight: .medium, design: .default))
}
Spacer()
Text("\(count)")
Text("\(category.recipe_count)")
.font(.system(size: 15, weight: .bold, design: .default))
.foregroundStyle(Color.background)
.frame(width: 25, height: 25, alignment: .center)
@@ -129,7 +49,62 @@ fileprivate struct CategoryListItem: View {
}.padding(7)
}
}
/*
}
.navigationTitle("Cookbooks")
.toolbar {
RecipeTabViewToolBar()
}
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
SettingsView()
.environmentObject(appState)
}
.navigationDestination(isPresented: $viewModel.presentEditView) {
RecipeView(viewModel: RecipeView.ViewModel())
.environmentObject(appState)
.environmentObject(groceryList)
}
} detail: {
NavigationStack {
if let category = viewModel.selectedCategory {
RecipeListView(
categoryName: category.name,
showEditView: $viewModel.presentEditView
)
.id(category.id) // Workaround: This is needed to update the detail view when the selection changes
}
}
}
.tint(.nextcloudBlue)
.task {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
}
.refreshable {
let connection = await appState.checkServerConnection()
DispatchQueue.main.async {
viewModel.serverConnection = connection
}
await appState.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 appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
@@ -204,5 +179,3 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
}
}
*/

View File

@@ -7,7 +7,7 @@
import Foundation
import SwiftUI
/*
struct SearchTabView: View {
@EnvironmentObject var viewModel: SearchTabView.ViewModel
@EnvironmentObject var appState: AppState
@@ -30,7 +30,7 @@ struct SearchTabView: View {
.listRowSeparatorTint(.clear)
}
.listStyle(.plain)
.navigationDestination(for: CookbookApiRecipeV1.self) { recipe in
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
}
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
@@ -48,7 +48,7 @@ struct SearchTabView: View {
}
class ViewModel: ObservableObject {
@Published var allRecipes: [CookbookApiRecipeV1] = []
@Published var allRecipes: [Recipe] = []
@Published var searchText: String = ""
@Published var searchMode: SearchMode = .name
@@ -58,7 +58,7 @@ struct SearchTabView: View {
case name = "Name & Keywords", ingredient = "Ingredients"
}
func recipesFiltered() -> [CookbookApiRecipeV1] {
func recipesFiltered() -> [Recipe] {
if searchMode == .name {
guard searchText != "" else { return allRecipes }
return allRecipes.filter { recipe in
@@ -72,4 +72,3 @@ struct SearchTabView: View {
}
}
}
*/

View File

@@ -1,234 +0,0 @@
//
// SettingsTabView.swift
// Nextcloud Cookbook iOS Client
//
// Created by Vincent Meilinger on 29.05.25.
//
import Foundation
import SwiftUI
struct SettingsTabView: View {
@ObservedObject var userSettings = UserSettings.shared
@State private var avatarImage: UIImage?
@State private var userData: UserData?
@State private var showAlert: Bool = false
@State private var alertType: SettingsAlert = .NONE
@State private var presentLoginSheet: Bool = false
enum SettingsAlert {
case LOG_OUT,
DELETE_CACHE,
NONE
func getTitle() -> String {
switch self {
case .LOG_OUT: return "Log out"
case .DELETE_CACHE: return "Delete local data"
default: return "Please confirm your action."
}
}
func getMessage() -> String {
switch self {
case .LOG_OUT: return "Are you sure that you want to log out of your account?"
case .DELETE_CACHE: return "Are you sure that you want to delete the downloaded recipes? This action will not affect any recipes stored on your server."
default: return ""
}
}
}
var body: some View {
Form {
Section {
if userSettings.authString.isEmpty {
HStack(alignment: .center) {
if let avatarImage = avatarImage {
Image(uiImage: avatarImage)
.resizable()
.clipShape(Circle())
.frame(width: 100, height: 100)
}
if let userData = userData {
VStack(alignment: .leading) {
Text(userData.userDisplayName)
.font(.title)
.padding(.leading)
Text("Username: \(userData.userId)")
.font(.subheadline)
.padding(.leading)
// TODO: Add actions
}
}
Spacer()
}
Button("Log out") {
print("Log out.")
alertType = .LOG_OUT
showAlert = true
}
.tint(.red)
} else {
Button("Log in") {
print("Log in.")
presentLoginSheet.toggle()
}
}
} header: {
Text("Nextcloud")
} footer: {
Text("Log in to your Nextcloud account to sync your recipes. This requires a Nextcloud server with the Nextcloud Cookbook application installed.")
}
Section {
Toggle(isOn: $userSettings.expandNutritionSection) {
Text("Expand nutrition section")
}
Toggle(isOn: $userSettings.expandKeywordSection) {
Text("Expand keyword section")
}
Toggle(isOn: $userSettings.expandInfoSection) {
Text("Expand information section")
}
} header: {
Text("Recipes")
} footer: {
Text("Configure which sections in your recipes are expanded by default.")
}
Section {
Toggle(isOn: $userSettings.keepScreenAwake) {
Text("Keep screen awake when viewing recipes")
}
}
Section {
HStack {
Text("Decimal number format")
Spacer()
Picker("", selection: $userSettings.decimalNumberSeparator) {
Text("Point (e.g. 1.42)").tag(".")
Text("Comma (e.g. 1,42)").tag(",")
}
.pickerStyle(.menu)
}
} footer: {
Text("This setting will take effect after the app is restarted. It affects the adjustment of ingredient quantities.")
}
Section {
Toggle(isOn: $userSettings.storeRecipes) {
Text("Offline recipes")
}
Toggle(isOn: $userSettings.storeImages) {
Text("Store recipe images locally")
}
Toggle(isOn: $userSettings.storeThumb) {
Text("Store recipe thumbnails locally")
}
} header: {
Text("Downloads")
} footer: {
Text("Configure what is stored on your device.")
}
Section {
Picker("Language", selection: $userSettings.language) {
ForEach(SupportedLanguage.allValues, id: \.self) { lang in
Text(lang.descriptor()).tag(lang.rawValue)
}
}
} footer: {
Text("If \'Same as Device\' is selected and your device language is not supported yet, this option will default to english.")
}
Section {
Link("Visit the GitHub page", destination: URL(string: "https://github.com/VincentMeilinger/Nextcloud-Cookbook-iOS")!)
} header: {
Text("About")
} footer: {
Text("If you are interested in contributing to this project or simply wish to review its source code, we encourage you to visit the GitHub repository for this application.")
}
Section {
Link("Get support", destination: URL(string: "https://vincentmeilinger.github.io/Nextcloud-Cookbook-Client-Support/")!)
} header: {
Text("Support")
} footer: {
Text("If you have any inquiries, feedback, or require assistance, please refer to the support page for contact information.")
}
Section {
Button("Delete local data") {
print("Clear cache.")
alertType = .DELETE_CACHE
showAlert = true
}
.tint(.red)
} header: {
Text("Other")
} footer: {
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")
.alert(alertType.getTitle(), isPresented: $showAlert) {
Button("Cancel", role: .cancel) { }
if alertType == .DELETE_CACHE {
Button("Delete", role: .destructive) { deleteCachedData() }
}
} message: {
Text(alertType.getMessage())
}
.task {
await getUserData()
}
.sheet(isPresented: $presentLoginSheet, onDismiss: {}) {
V2LoginView()
}
}
func getUserData() async {
let (data, _) = await NextcloudApi.getAvatar()
let (userData, _) = await NextcloudApi.getHoverCard()
DispatchQueue.main.async {
self.avatarImage = data
self.userData = userData
}
}
func deleteCachedData() {
print("TODO: Delete cached data\n")
}
}

View File

@@ -32,17 +32,15 @@ You can download the app from the AppStore:
- [x] **Version 1.9**: Enhancements to recipe editing for better intuitiveness; user interface design improvements for recipe viewing.
- [x] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.10**: Recipe ingredient calculator: Enables calculation of ingredient quantities based on a specifiable yield number.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images. This update will take some time, but will therefore result in simpler, better maintainable code.
- [ ] **Version 1.11**: Decoupling of internal recipe representation from the Nextcloud Cookbook recipe representation. This change provides increased flexibility for API updates and enables the introduction of features not currently supported by the Cookbook API, such as uploading images.
- [ ] **Version 1.12 and beyond** (Ideas for the future; integration not guaranteed!):
- Allow adding custom items to the grocery list.
- Fuzzy search for recipe names and keywords.
- An in-app timer for the cook time specified in a recipe.
- In-app timer for the cook time specified in a recipe.
- Search for recipes based on left-over ingredients.