Compare commits
15 Commits
c8ddb098d1
...
285e91a429
| Author | SHA1 | Date | |
|---|---|---|---|
| 285e91a429 | |||
| 1f7f19c74b | |||
| c8d9ab7397 | |||
| ce2a814e5a | |||
| 151e69ff28 | |||
| 5307b502e9 | |||
| fb6b16c1fc | |||
| 02118e3d7a | |||
| c38d4075be | |||
| 8b23652f10 | |||
| 5890dbcad4 | |||
| 501434bd0e | |||
| 1536174586 | |||
| 98c82dc537 | |||
| 6824dbea6b |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/hendrik.hogertz.xcuserdatad/UserInterfaceState.xcuserstate
|
||||||
|
Nextcloud Cookbook iOS Client.xcodeproj/xcuserdata/hendrik.hogertz.xcuserdatad/xcschemes/xcschememanagement.plist
|
||||||
|
Nextcloud Cookbook iOS Client.xcodeproj/project.xcworkspace/xcuserdata/hendrik.hogertz.xcuserdatad/IDEFindNavigatorScopes.plist
|
||||||
11
CLAUDE.md
11
CLAUDE.md
@@ -26,7 +26,7 @@ xcodebuild -project "Nextcloud Cookbook iOS Client.xcodeproj" \
|
|||||||
test
|
test
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Deployment target**: iOS 16.4
|
- **Deployment target**: iOS 18
|
||||||
- **Swift version**: 5.0
|
- **Swift version**: 5.0
|
||||||
- **Targets**: iPhone and iPad, Mac Catalyst enabled
|
- **Targets**: iPhone and iPad, Mac Catalyst enabled
|
||||||
|
|
||||||
@@ -56,7 +56,8 @@ Additional ViewModels exist as nested classes within their views (`RecipeTabView
|
|||||||
```
|
```
|
||||||
SwiftUI Views
|
SwiftUI Views
|
||||||
├── @EnvironmentObject appState: AppState
|
├── @EnvironmentObject appState: AppState
|
||||||
├── @EnvironmentObject groceryList: GroceryList
|
├── @EnvironmentObject groceryList: GroceryListManager
|
||||||
|
├── @EnvironmentObject mealPlan: MealPlanManager
|
||||||
└── Per-view @StateObject ViewModels
|
└── Per-view @StateObject ViewModels
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -66,6 +67,8 @@ AppState
|
|||||||
└── UserSettings.shared (UserDefaults singleton)
|
└── UserSettings.shared (UserDefaults singleton)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Both `GroceryListManager` and `MealPlanManager` use custom metadata fields (`_groceryState`, `_mealPlanAssignment`) embedded in recipe JSON on the Nextcloud Cookbook API for cross-device sync. Each has a dedicated sync manager (`GroceryStateSyncManager`, `MealPlanSyncManager`) that handles debounced push, pull reconciliation, and per-item/per-date last-writer-wins merge.
|
||||||
|
|
||||||
### Network Layer
|
### Network Layer
|
||||||
|
|
||||||
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
||||||
@@ -83,11 +86,11 @@ AppState
|
|||||||
|
|
||||||
```
|
```
|
||||||
Nextcloud Cookbook iOS Client/
|
Nextcloud Cookbook iOS Client/
|
||||||
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings
|
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings + MealPlan + GroceryList
|
||||||
├── Models/ # RecipeEditViewModel
|
├── Models/ # RecipeEditViewModel
|
||||||
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
||||||
├── Views/
|
├── Views/
|
||||||
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab)
|
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, MealPlanTab, GroceryListTab)
|
||||||
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
||||||
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
||||||
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 56;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42F0A000100000001 /* AppearanceMode.swift */; };
|
||||||
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */; };
|
A70171822AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */; };
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171832AA8E71900064C43 /* MainView.swift */; };
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171832AA8E71900064C43 /* MainView.swift */; };
|
||||||
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A70171852AA8E71F00064C43 /* Assets.xcassets */; };
|
A70171862AA8E71F00064C43 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A70171852AA8E71F00064C43 /* Assets.xcassets */; };
|
||||||
@@ -20,8 +21,6 @@
|
|||||||
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
A70171BE2AB4987900064C43 /* RecipeListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BD2AB4987900064C43 /* RecipeListView.swift */; };
|
||||||
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
A70171C02AB498A900064C43 /* RecipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171BF2AB498A900064C43 /* RecipeView.swift */; };
|
||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C12AB498C600064C43 /* RecipeCardView.swift */; };
|
||||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
|
||||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */; };
|
|
||||||
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
A70171C42AB4A31200064C43 /* DataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C32AB4A31200064C43 /* DataStore.swift */; };
|
||||||
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
A70171C62AB4C43A00064C43 /* DataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171C52AB4C43A00064C43 /* DataModels.swift */; };
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70171CA2AB4CD1700064C43 /* UserSettings.swift */; };
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
A79AA8ED2B063AD5007D25F2 /* NextcloudApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */; };
|
||||||
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
A7AEAE642AD5521400135378 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = A7AEAE632AD5521400135378 /* Localizable.xcstrings */; };
|
||||||
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
A7CD3FD22B2C546A00D764AD /* CollapsibleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */; };
|
||||||
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
|
|
||||||
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
A7F3F8E82ACBFC760076C227 /* RecipeKeywordSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */; };
|
||||||
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
A7FB0D7A2B25C66600A3469E /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D792B25C66600A3469E /* OnboardingView.swift */; };
|
||||||
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
A7FB0D7C2B25C68500A3469E /* TokenLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */; };
|
||||||
@@ -63,9 +61,27 @@
|
|||||||
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
A9CA6CEF2B4C086100F78AB5 /* RecipeExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9CA6CEE2B4C086100F78AB5 /* RecipeExporter.swift */; };
|
||||||
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
A9CA6CF62B4C63F200F78AB5 /* TPPDF in Frameworks */ = {isa = PBXBuildFile; productRef = A9CA6CF52B4C63F200F78AB5 /* TPPDF */; };
|
||||||
A9D89AB02B4FE97800F49D92 /* TimerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9D89AAF2B4FE97800F49D92 /* TimerView.swift */; };
|
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 */; };
|
A9E78A2B2BE7799F00206866 /* JsonAny.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E78A2A2BE7799F00206866 /* JsonAny.swift */; };
|
||||||
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
|
A9FA2AB62B5079B200A43702 /* alarm_sound_0.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */; };
|
||||||
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE012CF0000100000001 /* CategoryCardView.swift */; };
|
||||||
|
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */; };
|
||||||
|
B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */; };
|
||||||
|
B1C0DE082CF0000400000004 /* AllRecipesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C0DE072CF0000400000004 /* AllRecipesListView.swift */; };
|
||||||
|
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F0AB012D0B000100000001 /* ImportURLSheet.swift */; };
|
||||||
|
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; };
|
||||||
|
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; };
|
||||||
|
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; };
|
||||||
|
DFCB4E9FD4E0884AF217E5C5 /* LiquidGlassModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */; };
|
||||||
|
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; };
|
||||||
|
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; };
|
||||||
|
E498A7A42F41C35500D7D7A4 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE012E0C000100000001 /* MealPlanModels.swift */; };
|
||||||
|
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE032E0C000200000002 /* MealPlanManager.swift */; };
|
||||||
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; };
|
||||||
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; };
|
||||||
|
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */; };
|
||||||
|
G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE002F0B000100000001 /* CategorySortMode.swift */; };
|
||||||
|
G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -83,9 +99,32 @@
|
|||||||
remoteGlobalIDString = A701717D2AA8E71900064C43;
|
remoteGlobalIDString = A701717D2AA8E71900064C43;
|
||||||
remoteInfo = "Nextcloud Cookbook iOS Client";
|
remoteInfo = "Nextcloud Cookbook iOS Client";
|
||||||
};
|
};
|
||||||
|
E498A7A22F41C35500D7D7A4 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = A70171762AA8E71900064C43 /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = E498A7992F41C35500D7D7A4;
|
||||||
|
remoteInfo = ShareExtension;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
E498A7A52F41C35500D7D7A4 /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
E498A7A42F41C35500D7D7A4 /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
|
||||||
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nextcloud Cookbook iOS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Nextcloud Cookbook iOS Client.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientApp.swift; sourceTree = "<group>"; };
|
A70171812AA8E71900064C43 /* Nextcloud_Cookbook_iOS_ClientApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nextcloud_Cookbook_iOS_ClientApp.swift; sourceTree = "<group>"; };
|
||||||
A70171832AA8E71900064C43 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
A70171832AA8E71900064C43 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||||
@@ -103,8 +142,6 @@
|
|||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeListView.swift; sourceTree = "<group>"; };
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeView.swift; sourceTree = "<group>"; };
|
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>"; };
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeCardView.swift; sourceTree = "<group>"; };
|
||||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
|
|
||||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = "<group>"; };
|
|
||||||
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
A70171C32AB4A31200064C43 /* DataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStore.swift; sourceTree = "<group>"; };
|
||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
A70171C52AB4C43A00064C43 /* DataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataModels.swift; sourceTree = "<group>"; };
|
||||||
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
A70171CA2AB4CD1700064C43 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = "<group>"; };
|
||||||
@@ -122,7 +159,6 @@
|
|||||||
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
A79AA8EC2B063AD5007D25F2 /* NextcloudApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextcloudApi.swift; sourceTree = "<group>"; };
|
||||||
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
A7AEAE632AD5521400135378 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
|
||||||
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
A7CD3FD12B2C546A00D764AD /* CollapsibleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleView.swift; sourceTree = "<group>"; };
|
||||||
04B6ECAD063AEE501543FC76 /* LiquidGlassModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidGlassModifiers.swift; sourceTree = "<group>"; };
|
|
||||||
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
A7F3F8E72ACBFC760076C227 /* RecipeKeywordSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipeKeywordSection.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
A7FB0D792B25C66600A3469E /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
A7FB0D7B2B25C68500A3469E /* TokenLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenLoginView.swift; sourceTree = "<group>"; };
|
||||||
@@ -149,8 +185,40 @@
|
|||||||
A9DA25D42B82096B0061FC2B /* Nextcloud-Cookbook-iOS-Client-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Nextcloud-Cookbook-iOS-Client-Info.plist"; sourceTree = SOURCE_ROOT; };
|
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>"; };
|
A9E78A2A2BE7799F00206866 /* JsonAny.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonAny.swift; sourceTree = "<group>"; };
|
||||||
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
A9FA2AB52B5079B200A43702 /* alarm_sound_0.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = alarm_sound_0.mp3; sourceTree = "<group>"; };
|
||||||
|
B1C0DE012CF0000100000001 /* CategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryCardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentRecipesSection.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesCategoryCardView.swift; sourceTree = "<group>"; };
|
||||||
|
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllRecipesListView.swift; sourceTree = "<group>"; };
|
||||||
|
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportURLSheet.swift; sourceTree = "<group>"; };
|
||||||
|
D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.swift; sourceTree = "<group>"; };
|
||||||
|
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemindersGroceryStore.swift; sourceTree = "<group>"; };
|
||||||
|
D1A0CE042D0A000300000003 /* GroceryListManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListManager.swift; sourceTree = "<group>"; };
|
||||||
|
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateModels.swift; sourceTree = "<group>"; };
|
||||||
|
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; };
|
||||||
|
E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
F1A0DE012E0C000100000001 /* MealPlanModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanModels.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE032E0C000200000002 /* MealPlanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanManager.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
|
||||||
|
G1A0CE002F0B000100000001 /* CategorySortMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategorySortMode.swift; sourceTree = "<group>"; };
|
||||||
|
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryReorderSheet.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
E498A7A82F41C35500D7D7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = E498A7992F41C35500D7D7A4 /* ShareExtension */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
E498A79B2F41C35500D7D7A4 /* ShareExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (E498A7A82F41C35500D7D7A4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = ShareExtension; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
A701717B2AA8E71900064C43 /* Frameworks */ = {
|
A701717B2AA8E71900064C43 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
@@ -174,6 +242,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E498A7972F41C35500D7D7A4 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
@@ -185,6 +260,7 @@
|
|||||||
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
A70171802AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
||||||
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
A70171922AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
||||||
A701719C2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
A701719C2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
||||||
|
E498A79B2F41C35500D7D7A4 /* ShareExtension */,
|
||||||
A701717F2AA8E71900064C43 /* Products */,
|
A701717F2AA8E71900064C43 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -195,6 +271,7 @@
|
|||||||
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */,
|
A701717E2AA8E71900064C43 /* Nextcloud Cookbook iOS Client.app */,
|
||||||
A701718F2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests.xctest */,
|
A701718F2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests.xctest */,
|
||||||
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */,
|
A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */,
|
||||||
|
E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -286,6 +363,16 @@
|
|||||||
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
A70171C52AB4C43A00064C43 /* DataModels.swift */,
|
||||||
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
|
A97B4D312B80B3E900EC1A88 /* RecipeModels.swift */,
|
||||||
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
|
A9BBB38F2B91BE31002DA7FF /* ObservableRecipeDetail.swift */,
|
||||||
|
A1B2C3D42F0A000100000001 /* AppearanceMode.swift */,
|
||||||
|
D1A0CE002D0A000100000001 /* GroceryListMode.swift */,
|
||||||
|
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */,
|
||||||
|
D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
|
||||||
|
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
|
||||||
|
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
|
||||||
|
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
|
||||||
|
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
|
||||||
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
|
||||||
|
G1A0CE002F0B000100000001 /* CategorySortMode.swift */,
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -364,6 +451,7 @@
|
|||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
|
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */,
|
||||||
);
|
);
|
||||||
path = Tabs;
|
path = Tabs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -386,11 +474,16 @@
|
|||||||
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
A70171BD2AB4987900064C43 /* RecipeListView.swift */,
|
||||||
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
A70171C12AB498C600064C43 /* RecipeCardView.swift */,
|
||||||
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
B1C0DE012CF0000100000001 /* CategoryCardView.swift */,
|
||||||
|
B1C0DE052CF0000300000003 /* AllRecipesCategoryCardView.swift */,
|
||||||
|
B1C0DE072CF0000400000004 /* AllRecipesListView.swift */,
|
||||||
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
B1C0DE032CF0000200000002 /* RecentRecipesSection.swift */,
|
||||||
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
A70171BF2AB498A900064C43 /* RecipeView.swift */,
|
||||||
A97506112B920D8100E86029 /* RecipeViewSections */,
|
A97506112B920D8100E86029 /* RecipeViewSections */,
|
||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||||
|
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
||||||
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
|
||||||
|
G1A0CE022F0B000200000002 /* CategoryReorderSheet.swift */,
|
||||||
);
|
);
|
||||||
path = Recipes;
|
path = Recipes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -432,10 +525,12 @@
|
|||||||
A701717A2AA8E71900064C43 /* Sources */,
|
A701717A2AA8E71900064C43 /* Sources */,
|
||||||
A701717B2AA8E71900064C43 /* Frameworks */,
|
A701717B2AA8E71900064C43 /* Frameworks */,
|
||||||
A701717C2AA8E71900064C43 /* Resources */,
|
A701717C2AA8E71900064C43 /* Resources */,
|
||||||
|
E498A7A52F41C35500D7D7A4 /* Embed Foundation Extensions */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
dependencies = (
|
dependencies = (
|
||||||
|
E498A7A32F41C35500D7D7A4 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = "Nextcloud Cookbook iOS Client";
|
name = "Nextcloud Cookbook iOS Client";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
@@ -481,6 +576,28 @@
|
|||||||
productReference = A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */;
|
productReference = A70171992AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.ui-testing";
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
};
|
};
|
||||||
|
E498A7992F41C35500D7D7A4 /* ShareExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = E498A7A92F41C35500D7D7A4 /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
E498A7962F41C35500D7D7A4 /* Sources */,
|
||||||
|
E498A7972F41C35500D7D7A4 /* Frameworks */,
|
||||||
|
E498A7982F41C35500D7D7A4 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
E498A79B2F41C35500D7D7A4 /* ShareExtension */,
|
||||||
|
);
|
||||||
|
name = ShareExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = ShareExtension;
|
||||||
|
productReference = E498A79A2F41C35500D7D7A4 /* ShareExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
/* End PBXNativeTarget section */
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
/* Begin PBXProject section */
|
||||||
@@ -488,7 +605,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 1430;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 1500;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
A701717D2AA8E71900064C43 = {
|
A701717D2AA8E71900064C43 = {
|
||||||
@@ -502,6 +619,10 @@
|
|||||||
CreatedOnToolsVersion = 14.3;
|
CreatedOnToolsVersion = 14.3;
|
||||||
TestTargetID = A701717D2AA8E71900064C43;
|
TestTargetID = A701717D2AA8E71900064C43;
|
||||||
};
|
};
|
||||||
|
E498A7992F41C35500D7D7A4 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
LastSwiftMigration = 2620;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
buildConfigurationList = A70171792AA8E71900064C43 /* Build configuration list for PBXProject "Nextcloud Cookbook iOS Client" */;
|
buildConfigurationList = A70171792AA8E71900064C43 /* Build configuration list for PBXProject "Nextcloud Cookbook iOS Client" */;
|
||||||
@@ -526,6 +647,7 @@
|
|||||||
A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */,
|
||||||
A701718E2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
A701718E2AA8E72000064C43 /* Nextcloud Cookbook iOS ClientTests */,
|
||||||
A70171982AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
A70171982AA8E72000064C43 /* Nextcloud Cookbook iOS ClientUITests */,
|
||||||
|
E498A7992F41C35500D7D7A4 /* ShareExtension */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -556,6 +678,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E498A7982F41C35500D7D7A4 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -563,7 +692,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A9D8F9052B99F3E5009BACAE /* RecipeImportSection.swift in Sources */,
|
C1F0AB022D0B000100000001 /* ImportURLSheet.swift in Sources */,
|
||||||
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
|
A9BBB38E2B8E44B3002DA7FF /* BottomClipper.swift in Sources */,
|
||||||
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
|
A97506192B920EC200E86029 /* RecipeIngredientSection.swift in Sources */,
|
||||||
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
A97B4D352B80B82A00EC1A88 /* ShareView.swift in Sources */,
|
||||||
@@ -604,6 +733,8 @@
|
|||||||
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
A70171C22AB498C600064C43 /* RecipeCardView.swift in Sources */,
|
||||||
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
B1C0DE022CF0000100000001 /* CategoryCardView.swift in Sources */,
|
||||||
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
B1C0DE042CF0000200000002 /* RecentRecipesSection.swift in Sources */,
|
||||||
|
B1C0DE062CF0000300000003 /* AllRecipesCategoryCardView.swift in Sources */,
|
||||||
|
B1C0DE082CF0000400000004 /* AllRecipesListView.swift in Sources */,
|
||||||
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
A70171842AA8E71900064C43 /* MainView.swift in Sources */,
|
||||||
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
A70171CB2AB4CD1700064C43 /* UserSettings.swift in Sources */,
|
||||||
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
A977D0DE2B600300009783A9 /* SearchTabView.swift in Sources */,
|
||||||
@@ -615,6 +746,19 @@
|
|||||||
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
A76B8A6F2ADFFA8800096CEC /* SupportedLanguage.swift in Sources */,
|
||||||
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
A977D0E02B600318009783A9 /* RecipeTabView.swift in Sources */,
|
||||||
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
A703226F2ABB1DD700D7C4ED /* ColorExtension.swift in Sources */,
|
||||||
|
A1B2C3D52F0A000100000001 /* AppearanceMode.swift in Sources */,
|
||||||
|
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */,
|
||||||
|
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */,
|
||||||
|
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
|
||||||
|
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
|
||||||
|
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */,
|
||||||
|
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */,
|
||||||
|
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */,
|
||||||
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */,
|
||||||
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */,
|
||||||
|
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */,
|
||||||
|
G1A0CE012F0B000100000001 /* CategorySortMode.swift in Sources */,
|
||||||
|
G1A0CE032F0B000200000002 /* CategoryReorderSheet.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -635,6 +779,13 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
E498A7962F41C35500D7D7A4 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -648,6 +799,11 @@
|
|||||||
target = A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */;
|
target = A701717D2AA8E71900064C43 /* Nextcloud Cookbook iOS Client */;
|
||||||
targetProxy = A701719A2AA8E72000064C43 /* PBXContainerItemProxy */;
|
targetProxy = A701719A2AA8E72000064C43 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
E498A7A32F41C35500D7D7A4 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = E498A7992F41C35500D7D7A4 /* ShareExtension */;
|
||||||
|
targetProxy = E498A7A22F41C35500D7D7A4 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -777,13 +933,14 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
|
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "This app uses Reminders to save your grocery list items.";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -799,7 +956,7 @@
|
|||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.10.1;
|
MARKETING_VERSION = 1.10.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -821,13 +978,14 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
|
||||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.food-and-drink";
|
||||||
|
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "This app uses Reminders to save your grocery list items.";
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
|
||||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||||
@@ -843,7 +1001,7 @@
|
|||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
MACOSX_DEPLOYMENT_TARGET = 14.0;
|
||||||
MARKETING_VERSION = 1.10.1;
|
MARKETING_VERSION = 1.10.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -857,17 +1015,16 @@
|
|||||||
A70171A72AA8E72000064C43 /* Debug */ = {
|
A70171A72AA8E72000064C43 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -881,17 +1038,16 @@
|
|||||||
A70171A82AA8E72000064C43 /* Release */ = {
|
A70171A82AA8E72000064C43 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -905,16 +1061,15 @@
|
|||||||
A70171AA2AA8E72000064C43 /* Debug */ = {
|
A70171AA2AA8E72000064C43 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -928,16 +1083,15 @@
|
|||||||
A70171AB2AA8E72000064C43 /* Release */ = {
|
A70171AB2AA8E72000064C43 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = EF2ABA36D9;
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
|
||||||
@@ -948,6 +1102,79 @@
|
|||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
|
E498A7A62F41C35500D7D7A4 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E498A7A72F41C35500D7D7A4 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = JGFU6788BP;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = ShareExtension;
|
||||||
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = "";
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -987,6 +1214,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
E498A7A92F41C35500D7D7A4 /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
E498A7A62F41C35500D7D7A4 /* Debug */,
|
||||||
|
E498A7A72F41C35500D7D7A4 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701718E2AA8E72000064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS ClientTests.xctest"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS ClientTests"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A70171982AA8E72000064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS ClientUITests.xctest"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS ClientUITests"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
wasCreatedForAppExtension = "YES"
|
||||||
|
version = "2.0">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E498A7992F41C35500D7D7A4"
|
||||||
|
BuildableName = "ShareExtension.appex"
|
||||||
|
BlueprintName = "ShareExtension"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = ""
|
||||||
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
|
launchStyle = "0"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
askForAppToLaunch = "Yes"
|
||||||
|
launchAutomaticallySubstyle = "2">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "A701717D2AA8E71900064C43"
|
||||||
|
BuildableName = "Nextcloud Cookbook iOS Client.app"
|
||||||
|
BlueprintName = "Nextcloud Cookbook iOS Client"
|
||||||
|
ReferencedContainer = "container:Nextcloud Cookbook iOS Client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -17,7 +17,10 @@ import UIKit
|
|||||||
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
@Published var recipeDetails: [Int: RecipeDetail] = [:]
|
||||||
@Published var timers: [String: RecipeTimer] = [:]
|
@Published var timers: [String: RecipeTimer] = [:]
|
||||||
@Published var categoryImages: [String: UIImage] = [:]
|
@Published var categoryImages: [String: UIImage] = [:]
|
||||||
|
@Published var categoryImageRecipeIds: Set<Int> = []
|
||||||
@Published var recentRecipes: [Recipe] = []
|
@Published var recentRecipes: [Recipe] = []
|
||||||
|
@Published var categoryAccessDates: [String: Date] = [:]
|
||||||
|
@Published var manualCategoryOrder: [String] = []
|
||||||
var recipeImages: [Int: [String: UIImage]] = [:]
|
var recipeImages: [Int: [String: UIImage]] = [:]
|
||||||
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
var imagesNeedUpdate: [Int: [String: Bool]] = [:]
|
||||||
var lastUpdates: [String: Date] = [:]
|
var lastUpdates: [String: Date] = [:]
|
||||||
@@ -318,6 +321,7 @@ import UIKit
|
|||||||
for recipe in recipes {
|
for recipe in recipes {
|
||||||
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
if let image = await getImage(id: recipe.recipe_id, size: .THUMB, fetchMode: .preferLocal) {
|
||||||
self.categoryImages[categoryName] = image
|
self.categoryImages[categoryName] = image
|
||||||
|
self.categoryImageRecipeIds.insert(recipe.recipe_id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,6 +351,34 @@ import UIKit
|
|||||||
dataStore.delete(path: "recent_recipes.data")
|
dataStore.delete(path: "recent_recipes.data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Category sorting
|
||||||
|
|
||||||
|
func trackCategoryAccess(_ categoryName: String) {
|
||||||
|
categoryAccessDates[categoryName] = Date()
|
||||||
|
Task {
|
||||||
|
await saveLocal(categoryAccessDates, path: "category_access_dates.data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCategoryAccessDates() async {
|
||||||
|
if let loaded: [String: Date] = await loadLocal(path: "category_access_dates.data") {
|
||||||
|
self.categoryAccessDates = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateManualCategoryOrder(_ order: [String]) {
|
||||||
|
manualCategoryOrder = order
|
||||||
|
Task {
|
||||||
|
await saveLocal(manualCategoryOrder, path: "manual_category_order.data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadManualCategoryOrder() async {
|
||||||
|
if let loaded: [String] = await loadLocal(path: "manual_category_order.data") {
|
||||||
|
self.manualCategoryOrder = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Data management
|
// MARK: - Data management
|
||||||
|
|
||||||
func deleteAllData() {
|
func deleteAllData() {
|
||||||
@@ -374,6 +406,8 @@ import UIKit
|
|||||||
})
|
})
|
||||||
recipeDetails.removeValue(forKey: id)
|
recipeDetails.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
|
recentRecipes.removeAll { $0.recipe_id == id }
|
||||||
|
await saveLocal(recentRecipes, path: "recent_recipes.data")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
Nextcloud Cookbook iOS Client/Data/AppearanceMode.swift
Normal file
22
Nextcloud Cookbook iOS Client/Data/AppearanceMode.swift
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// AppearanceMode.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AppearanceMode: String, CaseIterable {
|
||||||
|
case system = "system"
|
||||||
|
case light = "light"
|
||||||
|
case dark = "dark"
|
||||||
|
|
||||||
|
func descriptor() -> String {
|
||||||
|
switch self {
|
||||||
|
case .system: return String(localized: "System")
|
||||||
|
case .light: return String(localized: "Light")
|
||||||
|
case .dark: return String(localized: "Dark")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let allValues: [AppearanceMode] = AppearanceMode.allCases
|
||||||
|
}
|
||||||
55
Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift
Normal file
55
Nextcloud Cookbook iOS Client/Data/CategorySortMode.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// CategorySortMode.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CategorySortMode: String, CaseIterable {
|
||||||
|
case recentlyUsed = "recentlyUsed"
|
||||||
|
case alphabetical = "alphabetical"
|
||||||
|
case manual = "manual"
|
||||||
|
|
||||||
|
func descriptor() -> String {
|
||||||
|
switch self {
|
||||||
|
case .recentlyUsed: return String(localized: "Recently Used")
|
||||||
|
case .alphabetical: return String(localized: "Alphabetical")
|
||||||
|
case .manual: return String(localized: "Manual")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlyUsed: return "clock"
|
||||||
|
case .alphabetical: return "textformat.abc"
|
||||||
|
case .manual: return "line.3.horizontal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportsInvert: Bool {
|
||||||
|
self != .manual
|
||||||
|
}
|
||||||
|
|
||||||
|
static let allValues: [CategorySortMode] = CategorySortMode.allCases
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RecipeSortMode: String, CaseIterable {
|
||||||
|
case recentlyAdded = "recentlyAdded"
|
||||||
|
case alphabetical = "alphabetical"
|
||||||
|
|
||||||
|
func descriptor() -> String {
|
||||||
|
switch self {
|
||||||
|
case .recentlyAdded: return String(localized: "Recently Added")
|
||||||
|
case .alphabetical: return String(localized: "Alphabetical")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .recentlyAdded: return "clock"
|
||||||
|
case .alphabetical: return "textformat.abc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let allValues: [RecipeSortMode] = RecipeSortMode.allCases
|
||||||
|
}
|
||||||
173
Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift
Normal file
173
Nextcloud Cookbook iOS Client/Data/GroceryListManager.swift
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
//
|
||||||
|
// GroceryListManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class GroceryListManager: ObservableObject {
|
||||||
|
@Published var groceryDict: [String: GroceryRecipe] = [:]
|
||||||
|
|
||||||
|
let localStore = GroceryList()
|
||||||
|
let remindersStore = RemindersGroceryStore()
|
||||||
|
var syncManager: GroceryStateSyncManager?
|
||||||
|
|
||||||
|
/// Recipe IDs modified by our own CRUD — skip these in the onDataChanged callback
|
||||||
|
/// to avoid duplicate syncs.
|
||||||
|
private var recentlyModifiedByUs: Set<String> = []
|
||||||
|
|
||||||
|
private var mode: GroceryListMode {
|
||||||
|
GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
remindersStore.onDataChanged = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.mode == .appleReminders {
|
||||||
|
self.groceryDict = self.remindersStore.groceryDict
|
||||||
|
self.recentlyModifiedByUs.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureSyncManager(appState: AppState) {
|
||||||
|
syncManager = GroceryStateSyncManager(appState: appState, groceryManager: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Grocery Operations
|
||||||
|
|
||||||
|
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
recentlyModifiedByUs.insert(recipeId)
|
||||||
|
remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
||||||
|
groceryDict = remindersStore.groceryDict
|
||||||
|
}
|
||||||
|
syncManager?.clearPendingRemoval(recipeId: recipeId)
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
recentlyModifiedByUs.insert(recipeId)
|
||||||
|
remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
|
||||||
|
groceryDict = remindersStore.groceryDict
|
||||||
|
}
|
||||||
|
syncManager?.clearPendingRemoval(recipeId: recipeId)
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
recentlyModifiedByUs.insert(recipeId)
|
||||||
|
remindersStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||||
|
}
|
||||||
|
if groceryDict[recipeId] == nil {
|
||||||
|
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
||||||
|
}
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroceryRecipe(_ recipeId: String) {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.deleteGroceryRecipe(recipeId)
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
recentlyModifiedByUs.insert(recipeId)
|
||||||
|
remindersStore.deleteGroceryRecipe(recipeId)
|
||||||
|
}
|
||||||
|
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAll() {
|
||||||
|
let recipeIds = Array(groceryDict.keys)
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.deleteAll()
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
recentlyModifiedByUs.formUnion(recipeIds)
|
||||||
|
remindersStore.deleteAll()
|
||||||
|
}
|
||||||
|
for recipeId in recipeIds {
|
||||||
|
syncManager?.trackPendingRemoval(recipeId: recipeId)
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
localStore.toggleItemChecked(groceryItem)
|
||||||
|
case .appleReminders:
|
||||||
|
// Reminders don't support checked state in our model
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItem(at recipeId: String, item: String) -> Bool {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
return localStore.containsItem(at: recipeId, item: item)
|
||||||
|
case .appleReminders:
|
||||||
|
return remindersStore.containsItem(at: recipeId, item: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRecipe(_ recipeId: String) -> Bool {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
return localStore.containsRecipe(recipeId)
|
||||||
|
case .appleReminders:
|
||||||
|
return remindersStore.containsRecipe(recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
switch mode {
|
||||||
|
case .inApp:
|
||||||
|
await localStore.load()
|
||||||
|
groceryDict = localStore.groceryDict
|
||||||
|
case .appleReminders:
|
||||||
|
await remindersStore.load()
|
||||||
|
groceryDict = remindersStore.groceryDict
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
if mode == .inApp {
|
||||||
|
localStore.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reminders Helpers (for Settings UI)
|
||||||
|
|
||||||
|
var remindersPermissionStatus: EKAuthorizationStatus {
|
||||||
|
remindersStore.checkPermissionStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestRemindersAccess() async -> Bool {
|
||||||
|
await remindersStore.requestAccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
func availableReminderLists() -> [EKCalendar] {
|
||||||
|
remindersStore.availableReminderLists()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift
Normal file
20
Nextcloud Cookbook iOS Client/Data/GroceryListMode.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//
|
||||||
|
// GroceryListMode.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GroceryListMode: String, CaseIterable {
|
||||||
|
case inApp = "inApp"
|
||||||
|
case appleReminders = "appleReminders"
|
||||||
|
|
||||||
|
func descriptor() -> String {
|
||||||
|
switch self {
|
||||||
|
case .inApp: return String(localized: "In-App")
|
||||||
|
case .appleReminders: return String(localized: "Apple Reminders")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static let allValues: [GroceryListMode] = GroceryListMode.allCases
|
||||||
|
}
|
||||||
58
Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift
Normal file
58
Nextcloud Cookbook iOS Client/Data/GroceryStateModels.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
//
|
||||||
|
// GroceryStateModels.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks grocery list state for a recipe, stored as `_groceryState` in the recipe JSON on the server.
|
||||||
|
struct GroceryState: Codable {
|
||||||
|
var version: Int = 1
|
||||||
|
var lastModified: String
|
||||||
|
var items: [String: GroceryItemState]
|
||||||
|
|
||||||
|
init(lastModified: String = GroceryStateDate.now(), items: [String: GroceryItemState] = [:]) {
|
||||||
|
self.version = 1
|
||||||
|
self.lastModified = lastModified
|
||||||
|
self.items = items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GroceryItemState: Codable {
|
||||||
|
enum Status: String, Codable {
|
||||||
|
case added
|
||||||
|
case completed
|
||||||
|
case removed
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: Status
|
||||||
|
var addedAt: String
|
||||||
|
var modifiedAt: String
|
||||||
|
|
||||||
|
init(status: Status, addedAt: String = GroceryStateDate.now(), modifiedAt: String = GroceryStateDate.now()) {
|
||||||
|
self.status = status
|
||||||
|
self.addedAt = addedAt
|
||||||
|
self.modifiedAt = modifiedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 8601 date helpers. Dates are stored as strings to avoid coupling to a parent encoder's date strategy.
|
||||||
|
enum GroceryStateDate {
|
||||||
|
private static let formatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func now() -> String {
|
||||||
|
formatter.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from string: String) -> Date? {
|
||||||
|
formatter.date(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func string(from date: Date) -> String {
|
||||||
|
formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
233
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal file
233
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
//
|
||||||
|
// GroceryStateSyncManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class GroceryStateSyncManager {
|
||||||
|
private weak var appState: AppState?
|
||||||
|
private weak var groceryManager: GroceryListManager?
|
||||||
|
|
||||||
|
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
||||||
|
private let debounceInterval: TimeInterval = 2.0
|
||||||
|
private var isReconciling = false
|
||||||
|
|
||||||
|
private let dataStore = DataStore()
|
||||||
|
private let pendingRemovalPath = "grocery_pending_removals.data"
|
||||||
|
private(set) var pendingRemovalRecipeIds: Set<String> = []
|
||||||
|
|
||||||
|
init(appState: AppState, groceryManager: GroceryListManager) {
|
||||||
|
self.appState = appState
|
||||||
|
self.groceryManager = groceryManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Push Flow
|
||||||
|
|
||||||
|
/// Debounced sync trigger. Waits `debounceInterval` seconds then pushes state for the recipe.
|
||||||
|
func scheduleSync(forRecipeId recipeId: String) {
|
||||||
|
guard UserSettings.shared.grocerySyncEnabled else { return }
|
||||||
|
guard !isReconciling else { return }
|
||||||
|
|
||||||
|
debounceTimers[recipeId]?.cancel()
|
||||||
|
debounceTimers[recipeId] = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(2_000_000_000))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.pushGroceryState(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds local grocery state, fetches server recipe, merges, and PUTs back.
|
||||||
|
func pushGroceryState(forRecipeId recipeId: String) async {
|
||||||
|
guard let appState, let groceryManager else { return }
|
||||||
|
guard let recipeIdInt = Int(recipeId) else { return }
|
||||||
|
|
||||||
|
// Fetch latest recipe from server first so we can detect deletions
|
||||||
|
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
||||||
|
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverState = serverRecipe.groceryState
|
||||||
|
|
||||||
|
// Build local state, passing server state so deleted items can be marked .removed
|
||||||
|
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager, serverState: serverState)
|
||||||
|
|
||||||
|
// Merge local state with server state
|
||||||
|
let merged = mergeStates(local: localState, server: serverState)
|
||||||
|
|
||||||
|
// Upload merged state
|
||||||
|
var updatedRecipe = serverRecipe
|
||||||
|
updatedRecipe.groceryState = merged
|
||||||
|
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
||||||
|
if let alert {
|
||||||
|
Logger.data.error("Grocery sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pull Flow
|
||||||
|
|
||||||
|
/// Reconciles server grocery state with local grocery data. Called when a recipe is loaded.
|
||||||
|
func reconcileFromServer(serverState: GroceryState?, recipeId: String, recipeName: String) {
|
||||||
|
guard let groceryManager else { return }
|
||||||
|
guard let serverState, !serverState.items.isEmpty else { return }
|
||||||
|
|
||||||
|
isReconciling = true
|
||||||
|
defer { isReconciling = false }
|
||||||
|
|
||||||
|
let localItems = Set(
|
||||||
|
groceryManager.groceryDict[recipeId]?.items.map(\.name) ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
for (itemName, itemState) in serverState.items {
|
||||||
|
switch itemState.status {
|
||||||
|
case .added:
|
||||||
|
if !localItems.contains(itemName) {
|
||||||
|
groceryManager.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
|
||||||
|
}
|
||||||
|
case .removed:
|
||||||
|
if localItems.contains(itemName) {
|
||||||
|
groceryManager.deleteItem(itemName, fromRecipe: recipeId)
|
||||||
|
}
|
||||||
|
case .completed:
|
||||||
|
// Don't re-add completed items; leave local state as-is
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initial Sync
|
||||||
|
|
||||||
|
/// Pushes any local-only items and reconciles server items on app launch.
|
||||||
|
func performSync() async {
|
||||||
|
guard let appState, let groceryManager else { return }
|
||||||
|
|
||||||
|
await loadPendingRemovals()
|
||||||
|
|
||||||
|
let recipeIds = Array(groceryManager.groceryDict.keys)
|
||||||
|
for recipeId in recipeIds {
|
||||||
|
guard let recipeIdInt = Int(recipeId) else { continue }
|
||||||
|
|
||||||
|
// Push local state to server
|
||||||
|
await pushGroceryState(forRecipeId: recipeId)
|
||||||
|
|
||||||
|
// Fetch back and reconcile
|
||||||
|
if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
|
||||||
|
let recipeName = groceryManager.groceryDict[recipeId]?.name ?? serverRecipe.name
|
||||||
|
reconcileFromServer(
|
||||||
|
serverState: serverRecipe.groceryState,
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: recipeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push deletion state for recipes whose items were fully removed
|
||||||
|
for recipeId in pendingRemovalRecipeIds {
|
||||||
|
guard !recipeIds.contains(recipeId) else {
|
||||||
|
// Recipe was re-added locally since removal was tracked; clear it
|
||||||
|
pendingRemovalRecipeIds.remove(recipeId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await pushGroceryState(forRecipeId: recipeId)
|
||||||
|
pendingRemovalRecipeIds.remove(recipeId)
|
||||||
|
}
|
||||||
|
savePendingRemovals()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Logic
|
||||||
|
|
||||||
|
/// Merges local and server states using per-item last-writer-wins on `modifiedAt`.
|
||||||
|
private func mergeStates(local: GroceryState, server: GroceryState?) -> GroceryState {
|
||||||
|
guard let server else { return local }
|
||||||
|
|
||||||
|
var merged = local.items
|
||||||
|
for (itemName, serverItem) in server.items {
|
||||||
|
if let localItem = merged[itemName] {
|
||||||
|
// Both have the item — keep the one with the later modifiedAt
|
||||||
|
let localDate = GroceryStateDate.date(from: localItem.modifiedAt) ?? .distantPast
|
||||||
|
let serverDate = GroceryStateDate.date(from: serverItem.modifiedAt) ?? .distantPast
|
||||||
|
if serverDate > localDate {
|
||||||
|
merged[itemName] = serverItem
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only server has this item — keep it
|
||||||
|
merged[itemName] = serverItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garbage collection: remove items that are removed/completed and older than 30 days
|
||||||
|
let thirtyDaysAgo = Date().addingTimeInterval(-30 * 24 * 60 * 60)
|
||||||
|
merged = merged.filter { _, item in
|
||||||
|
if item.status == .added { return true }
|
||||||
|
guard let modDate = GroceryStateDate.date(from: item.modifiedAt) else { return true }
|
||||||
|
return modDate > thirtyDaysAgo
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroceryState(
|
||||||
|
lastModified: GroceryStateDate.now(),
|
||||||
|
items: merged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Build Local State
|
||||||
|
|
||||||
|
/// Builds a `GroceryState` from the current local grocery data for a recipe.
|
||||||
|
/// When `serverState` is provided, any server item with `.added` status that is
|
||||||
|
/// absent locally is emitted as `.removed` so the deletion propagates to the server.
|
||||||
|
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager, serverState: GroceryState?) -> GroceryState {
|
||||||
|
var items: [String: GroceryItemState] = [:]
|
||||||
|
let now = GroceryStateDate.now()
|
||||||
|
|
||||||
|
// Existing local items
|
||||||
|
if let groceryRecipe = groceryManager.groceryDict[recipeId] {
|
||||||
|
for item in groceryRecipe.items {
|
||||||
|
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
|
||||||
|
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark items that exist on server as .added but are absent locally as .removed
|
||||||
|
if let serverState {
|
||||||
|
for (itemName, serverItem) in serverState.items {
|
||||||
|
if items[itemName] == nil && serverItem.status == .added {
|
||||||
|
items[itemName] = GroceryItemState(
|
||||||
|
status: .removed,
|
||||||
|
addedAt: serverItem.addedAt,
|
||||||
|
modifiedAt: now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return GroceryState(lastModified: now, items: items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending Removal Tracking
|
||||||
|
|
||||||
|
/// Records a recipe ID whose grocery items were fully removed, so that
|
||||||
|
/// `performSync` can push the deletion even after the key disappears
|
||||||
|
/// from `groceryDict`.
|
||||||
|
func trackPendingRemoval(recipeId: String) {
|
||||||
|
pendingRemovalRecipeIds.insert(recipeId)
|
||||||
|
savePendingRemovals()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPendingRemoval(recipeId: String) {
|
||||||
|
guard pendingRemovalRecipeIds.remove(recipeId) != nil else { return }
|
||||||
|
savePendingRemovals()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadPendingRemovals() async {
|
||||||
|
guard let loaded: Set<String> = try? await dataStore.load(fromPath: pendingRemovalPath) else { return }
|
||||||
|
pendingRemovalRecipeIds = loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func savePendingRemovals() {
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: pendingRemovalRecipeIds, toPath: pendingRemovalPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
205
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
//
|
||||||
|
// MealPlanManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MealPlanManager: ObservableObject {
|
||||||
|
@Published var entriesByDate: [String: [MealPlanEntry]] = [:]
|
||||||
|
|
||||||
|
private var assignmentsByRecipe: [String: MealPlanAssignment] = [:]
|
||||||
|
private var recipeNames: [String: String] = [:]
|
||||||
|
private let dataStore = DataStore()
|
||||||
|
var syncManager: MealPlanSyncManager?
|
||||||
|
var syncStartTime: String?
|
||||||
|
|
||||||
|
private static let persistencePath = "meal_plan.data"
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
struct PersistenceData: Codable {
|
||||||
|
var assignmentsByRecipe: [String: MealPlanAssignment]
|
||||||
|
var recipeNames: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
do {
|
||||||
|
guard let data: PersistenceData = try await dataStore.load(fromPath: Self.persistencePath) else { return }
|
||||||
|
assignmentsByRecipe = data.assignmentsByRecipe
|
||||||
|
recipeNames = data.recipeNames
|
||||||
|
pruneOldEntries()
|
||||||
|
rebuildEntries()
|
||||||
|
} catch {
|
||||||
|
Logger.data.error("Unable to load meal plan data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
pruneOldEntries()
|
||||||
|
let data = PersistenceData(assignmentsByRecipe: assignmentsByRecipe, recipeNames: recipeNames)
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: data, toPath: Self.persistencePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureSyncManager(appState: AppState) {
|
||||||
|
syncManager = MealPlanSyncManager(appState: appState, mealPlanManager: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CRUD
|
||||||
|
|
||||||
|
func assignRecipe(recipeId: String, recipeName: String, toDates dates: [Date]) {
|
||||||
|
recipeNames[recipeId] = recipeName
|
||||||
|
var assignment = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
||||||
|
|
||||||
|
for date in dates {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
assignment.dates[dayStr] = MealPlanDateEntry(status: .assigned)
|
||||||
|
}
|
||||||
|
assignment.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRecipe(recipeId: String, fromDate dateString: String) {
|
||||||
|
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
||||||
|
|
||||||
|
assignment.dates[dateString] = MealPlanDateEntry(status: .removed)
|
||||||
|
assignment.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllAssignments(forRecipeId recipeId: String) {
|
||||||
|
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
||||||
|
|
||||||
|
let now = MealPlanDate.now()
|
||||||
|
for key in assignment.dates.keys {
|
||||||
|
assignment.dates[key] = MealPlanDateEntry(status: .removed, modifiedAt: now)
|
||||||
|
}
|
||||||
|
assignment.lastModified = now
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queries
|
||||||
|
|
||||||
|
func entries(for date: Date) -> [MealPlanEntry] {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
return entriesByDate[dayStr] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecipeAssigned(_ recipeId: String, on date: Date) -> Bool {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
guard let assignment = assignmentsByRecipe[recipeId],
|
||||||
|
let entry = assignment.dates[dayStr] else { return false }
|
||||||
|
return entry.status == .assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignedDates(forRecipeId recipeId: String) -> [String] {
|
||||||
|
guard let assignment = assignmentsByRecipe[recipeId] else { return [] }
|
||||||
|
return assignment.dates.compactMap { key, entry in
|
||||||
|
entry.status == .assigned ? key : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignment(forRecipeId recipeId: String) -> MealPlanAssignment? {
|
||||||
|
assignmentsByRecipe[recipeId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconciliation (Pull)
|
||||||
|
|
||||||
|
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
||||||
|
guard let serverAssignment, !serverAssignment.dates.isEmpty else { return }
|
||||||
|
|
||||||
|
recipeNames[recipeId] = recipeName
|
||||||
|
var local = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
||||||
|
|
||||||
|
for (dayStr, serverEntry) in serverAssignment.dates {
|
||||||
|
if let localEntry = local.dates[dayStr] {
|
||||||
|
// Skip entries modified locally during this sync cycle
|
||||||
|
if let syncStart = syncStartTime,
|
||||||
|
let syncStartDate = MealPlanDate.date(from: syncStart),
|
||||||
|
let localModDate = MealPlanDate.date(from: localEntry.modifiedAt),
|
||||||
|
localModDate >= syncStartDate {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
||||||
|
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
||||||
|
if serverDate > localDate {
|
||||||
|
local.dates[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.dates[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = local
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
|
||||||
|
private func pruneOldEntries() {
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
||||||
|
var emptyRecipeIds: [String] = []
|
||||||
|
|
||||||
|
for (recipeId, var assignment) in assignmentsByRecipe {
|
||||||
|
assignment.dates = assignment.dates.filter { dayStr, _ in
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
||||||
|
return date >= cutoff
|
||||||
|
}
|
||||||
|
if assignment.dates.isEmpty {
|
||||||
|
emptyRecipeIds.append(recipeId)
|
||||||
|
} else {
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for recipeId in emptyRecipeIds {
|
||||||
|
assignmentsByRecipe.removeValue(forKey: recipeId)
|
||||||
|
recipeNames.removeValue(forKey: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildEntries() {
|
||||||
|
var newEntries: [String: [MealPlanEntry]] = [:]
|
||||||
|
|
||||||
|
for (recipeId, assignment) in assignmentsByRecipe {
|
||||||
|
let name = recipeNames[recipeId] ?? "Recipe \(recipeId)"
|
||||||
|
for (dayStr, entry) in assignment.dates where entry.status == .assigned {
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { continue }
|
||||||
|
let mealEntry = MealPlanEntry(
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: name,
|
||||||
|
date: date,
|
||||||
|
dateString: dayStr,
|
||||||
|
mealType: entry.mealType
|
||||||
|
)
|
||||||
|
newEntries[dayStr, default: []].append(mealEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries within each day by recipe name
|
||||||
|
for key in newEntries.keys {
|
||||||
|
newEntries[key]?.sort(by: { $0.recipeName < $1.recipeName })
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesByDate = newEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// MealPlanModels.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks meal plan assignments for a recipe, stored as `_mealPlanAssignment` in the recipe JSON on the server.
|
||||||
|
struct MealPlanAssignment: Codable {
|
||||||
|
var version: Int = 1
|
||||||
|
var lastModified: String
|
||||||
|
var dates: [String: MealPlanDateEntry]
|
||||||
|
|
||||||
|
init(lastModified: String = MealPlanDate.now(), dates: [String: MealPlanDateEntry] = [:]) {
|
||||||
|
self.version = 1
|
||||||
|
self.lastModified = lastModified
|
||||||
|
self.dates = dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MealPlanDateEntry: Codable {
|
||||||
|
enum Status: String, Codable {
|
||||||
|
case assigned
|
||||||
|
case removed
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: Status
|
||||||
|
var mealType: String?
|
||||||
|
var modifiedAt: String
|
||||||
|
|
||||||
|
init(status: Status, mealType: String? = nil, modifiedAt: String = MealPlanDate.now()) {
|
||||||
|
self.status = status
|
||||||
|
self.mealType = mealType
|
||||||
|
self.modifiedAt = modifiedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 8601 date helpers for meal plan dates.
|
||||||
|
enum MealPlanDate {
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let dayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
f.timeZone = .current
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func now() -> String {
|
||||||
|
isoFormatter.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from string: String) -> Date? {
|
||||||
|
isoFormatter.date(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func string(from date: Date) -> String {
|
||||||
|
isoFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dayString(from date: Date) -> String {
|
||||||
|
dayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dateFromDay(_ dayString: String) -> Date? {
|
||||||
|
dayFormatter.date(from: dayString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only aggregated view struct used by the UI.
|
||||||
|
struct MealPlanEntry: Identifiable {
|
||||||
|
let recipeId: String
|
||||||
|
let recipeName: String
|
||||||
|
let date: Date
|
||||||
|
let dateString: String
|
||||||
|
let mealType: String?
|
||||||
|
|
||||||
|
var id: String { "\(recipeId)-\(dateString)" }
|
||||||
|
}
|
||||||
168
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
168
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
//
|
||||||
|
// MealPlanSyncManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MealPlanSyncManager {
|
||||||
|
private weak var appState: AppState?
|
||||||
|
private weak var mealPlanManager: MealPlanManager?
|
||||||
|
|
||||||
|
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
||||||
|
private let debounceInterval: TimeInterval = 2.0
|
||||||
|
|
||||||
|
init(appState: AppState, mealPlanManager: MealPlanManager) {
|
||||||
|
self.appState = appState
|
||||||
|
self.mealPlanManager = mealPlanManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Push Flow
|
||||||
|
|
||||||
|
func scheduleSync(forRecipeId recipeId: String) {
|
||||||
|
guard UserSettings.shared.mealPlanSyncEnabled else { return }
|
||||||
|
|
||||||
|
debounceTimers[recipeId]?.cancel()
|
||||||
|
debounceTimers[recipeId] = Task { [weak self] in
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(2_000_000_000))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await self?.pushMealPlanState(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushMealPlanState(forRecipeId recipeId: String) async {
|
||||||
|
guard let appState, let mealPlanManager else { return }
|
||||||
|
guard let recipeIdInt = Int(recipeId) else { return }
|
||||||
|
|
||||||
|
let localAssignment = mealPlanManager.assignment(forRecipeId: recipeId)
|
||||||
|
|
||||||
|
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
||||||
|
Logger.data.error("Meal plan sync: failed to fetch recipe \(recipeId) from server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = mergeAssignments(local: localAssignment, server: serverRecipe.mealPlanAssignment)
|
||||||
|
|
||||||
|
var updatedRecipe = serverRecipe
|
||||||
|
updatedRecipe.mealPlanAssignment = merged
|
||||||
|
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
||||||
|
if let alert {
|
||||||
|
Logger.data.error("Meal plan sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pull Flow
|
||||||
|
|
||||||
|
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
||||||
|
guard let mealPlanManager else { return }
|
||||||
|
mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Full Sync
|
||||||
|
|
||||||
|
func performSync() async {
|
||||||
|
guard let appState, let mealPlanManager else { return }
|
||||||
|
|
||||||
|
mealPlanManager.syncStartTime = MealPlanDate.now()
|
||||||
|
defer { mealPlanManager.syncStartTime = nil }
|
||||||
|
|
||||||
|
// Phase 1: Push locally-known meal plan state
|
||||||
|
let localRecipeIds = Array(Set(
|
||||||
|
mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId)
|
||||||
|
))
|
||||||
|
for recipeId in localRecipeIds {
|
||||||
|
await pushMealPlanState(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Discover meal plan assignments from server
|
||||||
|
let allRecipes = await appState.getRecipes()
|
||||||
|
let lastSync = UserSettings.shared.lastMealPlanSyncDate
|
||||||
|
|
||||||
|
// Filter to recipes modified since last sync
|
||||||
|
let recipesToCheck: [Recipe]
|
||||||
|
if let lastSync {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
recipesToCheck = allRecipes.filter { recipe in
|
||||||
|
guard let dateStr = recipe.dateModified,
|
||||||
|
let date = formatter.date(from: dateStr) else { return true }
|
||||||
|
return date > lastSync
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recipesToCheck = allRecipes // First sync: check all
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch details concurrently (max 5 parallel)
|
||||||
|
await withTaskGroup(of: (String, String, MealPlanAssignment?)?.self) { group in
|
||||||
|
var iterator = recipesToCheck.makeIterator()
|
||||||
|
let maxConcurrent = 5
|
||||||
|
var active = 0
|
||||||
|
|
||||||
|
while active < maxConcurrent, let recipe = iterator.next() {
|
||||||
|
active += 1
|
||||||
|
group.addTask {
|
||||||
|
guard let detail = await appState.getRecipe(
|
||||||
|
id: recipe.recipe_id, fetchMode: .onlyServer
|
||||||
|
) else { return nil }
|
||||||
|
return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await result in group {
|
||||||
|
if let (recipeId, recipeName, assignment) = result,
|
||||||
|
let assignment, !assignment.dates.isEmpty {
|
||||||
|
mealPlanManager.reconcileFromServer(
|
||||||
|
serverAssignment: assignment,
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: recipeName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if let recipe = iterator.next() {
|
||||||
|
group.addTask {
|
||||||
|
guard let detail = await appState.getRecipe(
|
||||||
|
id: recipe.recipe_id, fetchMode: .onlyServer
|
||||||
|
) else { return nil }
|
||||||
|
return (String(recipe.recipe_id), detail.name, detail.mealPlanAssignment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UserSettings.shared.lastMealPlanSyncDate = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Logic
|
||||||
|
|
||||||
|
private func mergeAssignments(local: MealPlanAssignment?, server: MealPlanAssignment?) -> MealPlanAssignment {
|
||||||
|
guard let local else { return server ?? MealPlanAssignment() }
|
||||||
|
guard let server else { return local }
|
||||||
|
|
||||||
|
var merged = local.dates
|
||||||
|
for (dayStr, serverEntry) in server.dates {
|
||||||
|
if let localEntry = merged[dayStr] {
|
||||||
|
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
||||||
|
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
||||||
|
if serverDate > localDate {
|
||||||
|
merged[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune all date entries older than 30 days
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
||||||
|
merged = merged.filter { dayStr, _ in
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
||||||
|
return date >= cutoff
|
||||||
|
}
|
||||||
|
|
||||||
|
return MealPlanAssignment(
|
||||||
|
lastModified: MealPlanDate.now(),
|
||||||
|
dates: merged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
@Published var recipeIngredient: [String]
|
@Published var recipeIngredient: [String]
|
||||||
@Published var recipeInstructions: [String]
|
@Published var recipeInstructions: [String]
|
||||||
@Published var nutrition: [String:String]
|
@Published var nutrition: [String:String]
|
||||||
|
var groceryState: GroceryState?
|
||||||
|
var mealPlanAssignment: MealPlanAssignment?
|
||||||
|
|
||||||
// Additional functionality
|
// Additional functionality
|
||||||
@Published var ingredientMultiplier: Double
|
@Published var ingredientMultiplier: Double
|
||||||
@@ -43,11 +45,13 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description = ""
|
description = ""
|
||||||
url = ""
|
url = ""
|
||||||
recipeYield = 1
|
recipeYield = 1
|
||||||
recipeCategory = ""
|
recipeCategory = "*"
|
||||||
tool = []
|
tool = []
|
||||||
recipeIngredient = []
|
recipeIngredient = []
|
||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
|
groceryState = nil
|
||||||
|
mealPlanAssignment = nil
|
||||||
|
|
||||||
ingredientMultiplier = 1
|
ingredientMultiplier = 1
|
||||||
}
|
}
|
||||||
@@ -63,11 +67,13 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description = recipeDetail.description
|
description = recipeDetail.description
|
||||||
url = recipeDetail.url ?? ""
|
url = recipeDetail.url ?? ""
|
||||||
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
|
recipeYield = recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield // Recipe yield should not be zero
|
||||||
recipeCategory = recipeDetail.recipeCategory
|
recipeCategory = recipeDetail.recipeCategory.isEmpty ? "*" : recipeDetail.recipeCategory
|
||||||
tool = recipeDetail.tool
|
tool = recipeDetail.tool
|
||||||
recipeIngredient = recipeDetail.recipeIngredient
|
recipeIngredient = recipeDetail.recipeIngredient
|
||||||
recipeInstructions = recipeDetail.recipeInstructions
|
recipeInstructions = recipeDetail.recipeInstructions
|
||||||
nutrition = recipeDetail.nutrition
|
nutrition = recipeDetail.nutrition
|
||||||
|
groceryState = recipeDetail.groceryState
|
||||||
|
mealPlanAssignment = recipeDetail.mealPlanAssignment
|
||||||
|
|
||||||
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
||||||
}
|
}
|
||||||
@@ -86,11 +92,13 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
description: self.description,
|
description: self.description,
|
||||||
url: self.url,
|
url: self.url,
|
||||||
recipeYield: self.recipeYield,
|
recipeYield: self.recipeYield,
|
||||||
recipeCategory: self.recipeCategory,
|
recipeCategory: self.recipeCategory == "*" ? "" : self.recipeCategory,
|
||||||
tool: self.tool,
|
tool: self.tool,
|
||||||
recipeIngredient: self.recipeIngredient,
|
recipeIngredient: self.recipeIngredient,
|
||||||
recipeInstructions: self.recipeInstructions,
|
recipeInstructions: self.recipeInstructions,
|
||||||
nutrition: self.nutrition
|
nutrition: self.nutrition,
|
||||||
|
groceryState: self.groceryState,
|
||||||
|
mealPlanAssignment: self.mealPlanAssignment
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,10 @@ struct RecipeDetail: Codable {
|
|||||||
var recipeIngredient: [String]
|
var recipeIngredient: [String]
|
||||||
var recipeInstructions: [String]
|
var recipeInstructions: [String]
|
||||||
var nutrition: [String:String]
|
var nutrition: [String:String]
|
||||||
|
var groceryState: GroceryState?
|
||||||
|
var mealPlanAssignment: MealPlanAssignment?
|
||||||
|
|
||||||
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, 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], groceryState: GroceryState? = nil, mealPlanAssignment: MealPlanAssignment? = nil) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.keywords = keywords
|
self.keywords = keywords
|
||||||
self.dateCreated = dateCreated
|
self.dateCreated = dateCreated
|
||||||
@@ -69,6 +71,8 @@ struct RecipeDetail: Codable {
|
|||||||
self.recipeIngredient = recipeIngredient
|
self.recipeIngredient = recipeIngredient
|
||||||
self.recipeInstructions = recipeInstructions
|
self.recipeInstructions = recipeInstructions
|
||||||
self.nutrition = nutrition
|
self.nutrition = nutrition
|
||||||
|
self.groceryState = groceryState
|
||||||
|
self.mealPlanAssignment = mealPlanAssignment
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -89,11 +93,15 @@ struct RecipeDetail: Codable {
|
|||||||
recipeIngredient = []
|
recipeIngredient = []
|
||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
|
groceryState = nil
|
||||||
|
mealPlanAssignment = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom decoder to handle value type ambiguity
|
// Custom decoder to handle value type ambiguity
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name, keywords, dateCreated, dateModified, image, 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
|
||||||
|
case groceryState = "_groceryState"
|
||||||
|
case mealPlanAssignment = "_mealPlanAssignment"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -132,6 +140,9 @@ struct RecipeDetail: Codable {
|
|||||||
} else {
|
} else {
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
||||||
|
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -154,6 +165,8 @@ struct RecipeDetail: Codable {
|
|||||||
try container.encode(recipeIngredient, forKey: .recipeIngredient)
|
try container.encode(recipeIngredient, forKey: .recipeIngredient)
|
||||||
try container.encode(recipeInstructions, forKey: .recipeInstructions)
|
try container.encode(recipeInstructions, forKey: .recipeInstructions)
|
||||||
try container.encode(nutrition, forKey: .nutrition)
|
try container.encode(nutrition, forKey: .nutrition)
|
||||||
|
try container.encodeIfPresent(groceryState, forKey: .groceryState)
|
||||||
|
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
345
Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift
Normal file
345
Nextcloud Cookbook iOS Client/Data/RemindersGroceryStore.swift
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
//
|
||||||
|
// RemindersGroceryStore.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
/// Maps a reminder's calendarItemIdentifier to its recipe context.
|
||||||
|
struct ReminderMapping: Codable {
|
||||||
|
let reminderIdentifier: String
|
||||||
|
let recipeId: String
|
||||||
|
let recipeName: String
|
||||||
|
let itemName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persisted mapping file: keyed by recipeId, each holding an array of reminder mappings.
|
||||||
|
struct ReminderMappingStore: Codable {
|
||||||
|
var recipes: [String: RecipeMappingEntry] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecipeMappingEntry: Codable {
|
||||||
|
let recipeName: String
|
||||||
|
var mappings: [ReminderMapping]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class RemindersGroceryStore {
|
||||||
|
private let eventStore = EKEventStore()
|
||||||
|
private(set) var groceryDict: [String: GroceryRecipe] = [:]
|
||||||
|
var onDataChanged: (() -> Void)?
|
||||||
|
|
||||||
|
private let dataStore = DataStore()
|
||||||
|
private let mappingPath = "reminder_mappings.data"
|
||||||
|
private var mappingStore = ReminderMappingStore()
|
||||||
|
|
||||||
|
/// When true, the next `EKEventStoreChanged` notification is skipped because
|
||||||
|
/// it was triggered by our own save. Prevents a race where `load()` reads stale
|
||||||
|
/// mapping data from disk before `saveMappings()` finishes writing.
|
||||||
|
private var ignoreNextExternalChange = false
|
||||||
|
|
||||||
|
init() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .EKEventStoreChanged,
|
||||||
|
object: eventStore,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.ignoreNextExternalChange {
|
||||||
|
self.ignoreNextExternalChange = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await self.load()
|
||||||
|
self.onDataChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self, name: .EKEventStoreChanged, object: eventStore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission
|
||||||
|
|
||||||
|
func checkPermissionStatus() -> EKAuthorizationStatus {
|
||||||
|
EKEventStore.authorizationStatus(for: .reminder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAccess() async -> Bool {
|
||||||
|
do {
|
||||||
|
return try await eventStore.requestFullAccessToReminders()
|
||||||
|
} catch {
|
||||||
|
Logger.view.error("Failed to request Reminders access: \(error.localizedDescription)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lists
|
||||||
|
|
||||||
|
func availableReminderLists() -> [EKCalendar] {
|
||||||
|
eventStore.calendars(for: .reminder)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func targetCalendar() -> EKCalendar? {
|
||||||
|
let identifier = UserSettings.shared.remindersListIdentifier
|
||||||
|
if !identifier.isEmpty,
|
||||||
|
let calendar = eventStore.calendar(withIdentifier: identifier) {
|
||||||
|
return calendar
|
||||||
|
}
|
||||||
|
return eventStore.defaultCalendarForNewReminders()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch Helper
|
||||||
|
|
||||||
|
private nonisolated func fetchReminders(matching predicate: NSPredicate, in store: EKEventStore) async -> [EKReminder] {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
store.fetchReminders(matching: predicate) { reminders in
|
||||||
|
continuation.resume(returning: reminders ?? [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CRUD
|
||||||
|
|
||||||
|
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
||||||
|
guard let calendar = targetCalendar() else {
|
||||||
|
Logger.view.error("No target Reminders calendar available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let reminder = EKReminder(eventStore: eventStore)
|
||||||
|
reminder.title = itemName
|
||||||
|
reminder.calendar = calendar
|
||||||
|
do {
|
||||||
|
ignoreNextExternalChange = true
|
||||||
|
try eventStore.save(reminder, commit: true)
|
||||||
|
let name = recipeName ?? "-"
|
||||||
|
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: itemName)
|
||||||
|
appendToCache(itemName: itemName, recipeId: recipeId, recipeName: name)
|
||||||
|
} catch {
|
||||||
|
ignoreNextExternalChange = false
|
||||||
|
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
||||||
|
guard let calendar = targetCalendar() else {
|
||||||
|
Logger.view.error("No target Reminders calendar available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let name = recipeName ?? "-"
|
||||||
|
for item in items {
|
||||||
|
let reminder = EKReminder(eventStore: eventStore)
|
||||||
|
reminder.title = item
|
||||||
|
reminder.calendar = calendar
|
||||||
|
do {
|
||||||
|
try eventStore.save(reminder, commit: false)
|
||||||
|
addMapping(reminderIdentifier: reminder.calendarItemIdentifier, recipeId: recipeId, recipeName: name, itemName: item)
|
||||||
|
appendToCache(itemName: item, recipeId: recipeId, recipeName: name)
|
||||||
|
} catch {
|
||||||
|
Logger.view.error("Failed to save reminder: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
ignoreNextExternalChange = true
|
||||||
|
try eventStore.commit()
|
||||||
|
} catch {
|
||||||
|
ignoreNextExternalChange = false
|
||||||
|
Logger.view.error("Failed to commit reminders: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
saveMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||||
|
// Find the reminder identifier from our mapping
|
||||||
|
guard let entry = mappingStore.recipes[recipeId],
|
||||||
|
let mapping = entry.mappings.first(where: { $0.itemName == itemName }) else { return }
|
||||||
|
let identifier = mapping.reminderIdentifier
|
||||||
|
|
||||||
|
// Find and remove the actual reminder
|
||||||
|
guard let calendar = targetCalendar() else { return }
|
||||||
|
let predicate = eventStore.predicateForReminders(in: [calendar])
|
||||||
|
let store = eventStore
|
||||||
|
Task {
|
||||||
|
let reminders = await fetchReminders(matching: predicate, in: store)
|
||||||
|
for reminder in reminders where reminder.calendarItemIdentifier == identifier {
|
||||||
|
do {
|
||||||
|
self.ignoreNextExternalChange = true
|
||||||
|
try self.eventStore.remove(reminder, commit: true)
|
||||||
|
} catch {
|
||||||
|
self.ignoreNextExternalChange = false
|
||||||
|
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self.removeMapping(reminderIdentifier: identifier, recipeId: recipeId)
|
||||||
|
self.removeFromCache(itemName: itemName, recipeId: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroceryRecipe(_ recipeId: String) {
|
||||||
|
guard let entry = mappingStore.recipes[recipeId] else { return }
|
||||||
|
let identifiers = Set(entry.mappings.map(\.reminderIdentifier))
|
||||||
|
|
||||||
|
guard let calendar = targetCalendar() else { return }
|
||||||
|
let predicate = eventStore.predicateForReminders(in: [calendar])
|
||||||
|
let store = eventStore
|
||||||
|
Task {
|
||||||
|
let reminders = await fetchReminders(matching: predicate, in: store)
|
||||||
|
for reminder in reminders where identifiers.contains(reminder.calendarItemIdentifier) {
|
||||||
|
do {
|
||||||
|
try self.eventStore.remove(reminder, commit: false)
|
||||||
|
} catch {
|
||||||
|
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
self.ignoreNextExternalChange = true
|
||||||
|
try self.eventStore.commit()
|
||||||
|
} catch {
|
||||||
|
self.ignoreNextExternalChange = false
|
||||||
|
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
self.mappingStore.recipes.removeValue(forKey: recipeId)
|
||||||
|
self.saveMappings()
|
||||||
|
self.groceryDict.removeValue(forKey: recipeId)
|
||||||
|
self.onDataChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAll() {
|
||||||
|
let allIdentifiers = Set(mappingStore.recipes.values.flatMap { $0.mappings.map(\.reminderIdentifier) })
|
||||||
|
guard !allIdentifiers.isEmpty else { return }
|
||||||
|
|
||||||
|
guard let calendar = targetCalendar() else { return }
|
||||||
|
let predicate = eventStore.predicateForReminders(in: [calendar])
|
||||||
|
let store = eventStore
|
||||||
|
Task {
|
||||||
|
let reminders = await fetchReminders(matching: predicate, in: store)
|
||||||
|
for reminder in reminders where allIdentifiers.contains(reminder.calendarItemIdentifier) {
|
||||||
|
do {
|
||||||
|
try self.eventStore.remove(reminder, commit: false)
|
||||||
|
} catch {
|
||||||
|
Logger.view.error("Failed to remove reminder: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
self.ignoreNextExternalChange = true
|
||||||
|
try self.eventStore.commit()
|
||||||
|
} catch {
|
||||||
|
self.ignoreNextExternalChange = false
|
||||||
|
Logger.view.error("Failed to commit reminder removal: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
self.mappingStore.recipes = [:]
|
||||||
|
self.saveMappings()
|
||||||
|
self.groceryDict = [:]
|
||||||
|
self.onDataChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsItem(at recipeId: String, item: String) -> Bool {
|
||||||
|
guard let recipe = groceryDict[recipeId] else { return false }
|
||||||
|
return recipe.items.contains(where: { $0.name == item })
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRecipe(_ recipeId: String) -> Bool {
|
||||||
|
groceryDict[recipeId] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load / Sync
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
// Load the local mapping first
|
||||||
|
if let stored: ReminderMappingStore = try? await dataStore.load(fromPath: mappingPath) {
|
||||||
|
mappingStore = stored
|
||||||
|
}
|
||||||
|
|
||||||
|
guard checkPermissionStatus() == .fullAccess else { return }
|
||||||
|
guard let calendar = targetCalendar() else { return }
|
||||||
|
let predicate = eventStore.predicateForReminders(in: [calendar])
|
||||||
|
let store = eventStore
|
||||||
|
let reminders = await fetchReminders(matching: predicate, in: store)
|
||||||
|
|
||||||
|
// Build a set of live reminder identifiers for cleanup
|
||||||
|
let liveIdentifiers = Set(reminders.map(\.calendarItemIdentifier))
|
||||||
|
|
||||||
|
// Prune mappings for reminders that no longer exist (deleted externally)
|
||||||
|
var pruned = false
|
||||||
|
for (recipeId, entry) in mappingStore.recipes {
|
||||||
|
let before = entry.mappings.count
|
||||||
|
let filtered = entry.mappings.filter { liveIdentifiers.contains($0.reminderIdentifier) }
|
||||||
|
if filtered.isEmpty {
|
||||||
|
mappingStore.recipes.removeValue(forKey: recipeId)
|
||||||
|
pruned = true
|
||||||
|
} else if filtered.count != before {
|
||||||
|
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: entry.recipeName, mappings: filtered)
|
||||||
|
pruned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pruned {
|
||||||
|
saveMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild groceryDict from mappings (source of truth for grouping)
|
||||||
|
var dict: [String: GroceryRecipe] = [:]
|
||||||
|
for (recipeId, entry) in mappingStore.recipes {
|
||||||
|
let items = entry.mappings.map { GroceryRecipeItem($0.itemName) }
|
||||||
|
dict[recipeId] = GroceryRecipe(name: entry.recipeName, items: items)
|
||||||
|
}
|
||||||
|
groceryDict = dict
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mapping Persistence
|
||||||
|
|
||||||
|
private func addMapping(reminderIdentifier: String, recipeId: String, recipeName: String, itemName: String) {
|
||||||
|
let mapping = ReminderMapping(
|
||||||
|
reminderIdentifier: reminderIdentifier,
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: recipeName,
|
||||||
|
itemName: itemName
|
||||||
|
)
|
||||||
|
if mappingStore.recipes[recipeId] != nil {
|
||||||
|
mappingStore.recipes[recipeId]?.mappings.append(mapping)
|
||||||
|
} else {
|
||||||
|
mappingStore.recipes[recipeId] = RecipeMappingEntry(recipeName: recipeName, mappings: [mapping])
|
||||||
|
}
|
||||||
|
saveMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeMapping(reminderIdentifier: String, recipeId: String) {
|
||||||
|
guard var entry = mappingStore.recipes[recipeId] else { return }
|
||||||
|
entry.mappings.removeAll { $0.reminderIdentifier == reminderIdentifier }
|
||||||
|
if entry.mappings.isEmpty {
|
||||||
|
mappingStore.recipes.removeValue(forKey: recipeId)
|
||||||
|
} else {
|
||||||
|
mappingStore.recipes[recipeId] = entry
|
||||||
|
}
|
||||||
|
saveMappings()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveMappings() {
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: mappingStore, toPath: mappingPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Helpers
|
||||||
|
|
||||||
|
private func appendToCache(itemName: String, recipeId: String, recipeName: String) {
|
||||||
|
if groceryDict[recipeId] != nil {
|
||||||
|
groceryDict[recipeId]?.items.append(GroceryRecipeItem(itemName))
|
||||||
|
} else {
|
||||||
|
groceryDict[recipeId] = GroceryRecipe(name: recipeName, item: GroceryRecipeItem(itemName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeFromCache(itemName: String, recipeId: String) {
|
||||||
|
guard let itemIndex = groceryDict[recipeId]?.items.firstIndex(where: { $0.name == itemName }) else { return }
|
||||||
|
groceryDict[recipeId]?.items.remove(at: itemIndex)
|
||||||
|
if groceryDict[recipeId]?.items.isEmpty == true {
|
||||||
|
groceryDict.removeValue(forKey: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,66 @@ class UserSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var groceryListMode: String {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(groceryListMode, forKey: "groceryListMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var remindersListIdentifier: String {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(remindersListIdentifier, forKey: "remindersListIdentifier")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var grocerySyncEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var mealPlanSyncEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var appearanceMode: String {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(appearanceMode, forKey: "appearanceMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var categorySortMode: String {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(categorySortMode, forKey: "categorySortMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var categorySortAscending: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(categorySortAscending, forKey: "categorySortAscending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var recipeSortMode: String {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(recipeSortMode, forKey: "recipeSortMode")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var recipeSortAscending: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(recipeSortAscending, forKey: "recipeSortAscending")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published var lastMealPlanSyncDate: Date? {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(lastMealPlanSyncDate, forKey: "lastMealPlanSyncDate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||||
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
|
||||||
@@ -140,6 +200,16 @@ class UserSettings: ObservableObject {
|
|||||||
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
self.expandInfoSection = UserDefaults.standard.object(forKey: "expandInfoSection") as? Bool ?? false
|
||||||
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
self.keepScreenAwake = UserDefaults.standard.object(forKey: "keepScreenAwake") as? Bool ?? true
|
||||||
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
|
self.decimalNumberSeparator = UserDefaults.standard.object(forKey: "decimalNumberSeparator") as? String ?? "."
|
||||||
|
self.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
|
||||||
|
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
|
||||||
|
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
|
||||||
|
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
|
||||||
|
self.appearanceMode = UserDefaults.standard.object(forKey: "appearanceMode") as? String ?? AppearanceMode.system.rawValue
|
||||||
|
self.categorySortMode = UserDefaults.standard.object(forKey: "categorySortMode") as? String ?? CategorySortMode.recentlyUsed.rawValue
|
||||||
|
self.categorySortAscending = UserDefaults.standard.object(forKey: "categorySortAscending") as? Bool ?? true
|
||||||
|
self.recipeSortMode = UserDefaults.standard.object(forKey: "recipeSortMode") as? String ?? RecipeSortMode.recentlyAdded.rawValue
|
||||||
|
self.recipeSortAscending = UserDefaults.standard.object(forKey: "recipeSortAscending") as? Bool ?? true
|
||||||
|
self.lastMealPlanSyncDate = UserDefaults.standard.object(forKey: "lastMealPlanSyncDate") as? Date
|
||||||
|
|
||||||
if authString == "" {
|
if authString == "" {
|
||||||
if token != "" && username != "" {
|
if token != "" && username != "" {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,17 @@ import SwiftUI
|
|||||||
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
||||||
@AppStorage("onboarding") var onboarding = true
|
@AppStorage("onboarding") var onboarding = true
|
||||||
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
@AppStorage("language") var language = Locale.current.language.languageCode?.identifier ?? "en"
|
||||||
|
@AppStorage("appearanceMode") var appearanceMode = AppearanceMode.system.rawValue
|
||||||
|
|
||||||
|
@State private var pendingImportURL: String?
|
||||||
|
|
||||||
|
var colorScheme: ColorScheme? {
|
||||||
|
switch appearanceMode {
|
||||||
|
case AppearanceMode.light.rawValue: return .light
|
||||||
|
case AppearanceMode.dark.rawValue: return .dark
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@@ -20,15 +31,26 @@ struct Nextcloud_Cookbook_iOS_ClientApp: App {
|
|||||||
if onboarding {
|
if onboarding {
|
||||||
OnboardingView()
|
OnboardingView()
|
||||||
} else {
|
} else {
|
||||||
MainView()
|
MainView(pendingImportURL: $pendingImportURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(colorScheme)
|
||||||
.transition(.slide)
|
.transition(.slide)
|
||||||
.environment(
|
.environment(
|
||||||
\.locale,
|
\.locale,
|
||||||
.init(identifier: language ==
|
.init(identifier: language ==
|
||||||
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
SupportedLanguage.DEVICE.rawValue ? (Locale.current.language.languageCode?.identifier ?? "en") : language)
|
||||||
)
|
)
|
||||||
|
.onOpenURL { url in
|
||||||
|
guard !onboarding else { return }
|
||||||
|
guard url.scheme == "nextcloud-cookbook",
|
||||||
|
url.host == "import",
|
||||||
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let recipeURL = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
||||||
|
!recipeURL.isEmpty
|
||||||
|
else { return }
|
||||||
|
pendingImportURL = recipeURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,21 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@StateObject var appState = AppState()
|
@StateObject var appState = AppState()
|
||||||
@StateObject var groceryList = GroceryList()
|
@StateObject var groceryList = GroceryListManager()
|
||||||
|
@StateObject var mealPlan = MealPlanManager()
|
||||||
|
|
||||||
// Tab ViewModels
|
// Tab ViewModels
|
||||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||||
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
@StateObject var searchViewModel = SearchTabView.ViewModel()
|
||||||
|
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
|
|
||||||
@State private var selectedTab: Tab = .recipes
|
@State private var selectedTab: Tab = .recipes
|
||||||
|
|
||||||
|
@Binding var pendingImportURL: String?
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
case recipes, search, groceryList
|
case recipes, search, mealPlan, groceryList
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -28,6 +33,7 @@ struct MainView: View {
|
|||||||
.environmentObject(recipeViewModel)
|
.environmentObject(recipeViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||||
@@ -35,15 +41,33 @@ struct MainView: View {
|
|||||||
.environmentObject(searchViewModel)
|
.environmentObject(searchViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SwiftUI.Tab("Meal Plan", systemImage: "calendar", value: .mealPlan) {
|
||||||
|
MealPlanTabView()
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue {
|
||||||
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
SwiftUI.Tab("Grocery List", systemImage: "storefront", value: .groceryList) {
|
||||||
GroceryListTabView()
|
GroceryListTabView()
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.tabViewStyle(.sidebarAdaptable)
|
.tabViewStyle(.sidebarAdaptable)
|
||||||
.modifier(TabBarMinimizeModifier())
|
.modifier(TabBarMinimizeModifier())
|
||||||
|
.onChange(of: userSettings.groceryListMode) { _, newValue in
|
||||||
|
if newValue == GroceryListMode.appleReminders.rawValue && selectedTab == .groceryList {
|
||||||
|
selectedTab = .recipes
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await groceryList.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
recipeViewModel.presentLoadingIndicator = true
|
recipeViewModel.presentLoadingIndicator = true
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
@@ -57,6 +81,10 @@ struct MainView: View {
|
|||||||
// Load recently viewed recipes
|
// Load recently viewed recipes
|
||||||
await appState.loadRecentRecipes()
|
await appState.loadRecentRecipes()
|
||||||
|
|
||||||
|
// Load category sorting data
|
||||||
|
await appState.loadCategoryAccessDates()
|
||||||
|
await appState.loadManualCategoryOrder()
|
||||||
|
|
||||||
// Open detail view for default category
|
// Open detail view for default category
|
||||||
if UserSettings.shared.defaultCategory != "" {
|
if UserSettings.shared.defaultCategory != "" {
|
||||||
if let cat = appState.categories.first(where: { c in
|
if let cat = appState.categories.first(where: { c in
|
||||||
@@ -69,7 +97,30 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await groceryList.load()
|
await groceryList.load()
|
||||||
|
groceryList.configureSyncManager(appState: appState)
|
||||||
|
if UserSettings.shared.grocerySyncEnabled {
|
||||||
|
await groceryList.syncManager?.performSync()
|
||||||
|
}
|
||||||
|
await mealPlan.load()
|
||||||
|
mealPlan.configureSyncManager(appState: appState)
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
await mealPlan.syncManager?.performSync()
|
||||||
|
}
|
||||||
recipeViewModel.presentLoadingIndicator = false
|
recipeViewModel.presentLoadingIndicator = false
|
||||||
}
|
}
|
||||||
|
.onChange(of: pendingImportURL) { _, newURL in
|
||||||
|
guard let url = newURL, !url.isEmpty else { return }
|
||||||
|
selectedTab = .recipes
|
||||||
|
recipeViewModel.pendingImportURL = url
|
||||||
|
// Dismiss any currently open import sheet before re-presenting
|
||||||
|
if recipeViewModel.showImportURLSheet {
|
||||||
|
recipeViewModel.showImportURLSheet = false
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||||
|
recipeViewModel.showImportURLSheet = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
recipeViewModel.showImportURLSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,41 +10,59 @@ import OSLog
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct OnboardingView: View {
|
struct OnboardingView: View {
|
||||||
@State var selectedTab: Int = 0
|
@State var loginMethod: LoginMethod = .v2
|
||||||
|
|
||||||
|
// Login error alert
|
||||||
|
@State var showAlert: Bool = false
|
||||||
|
@State var alertMessage: String = String(localized: "Error: Could not connect to server.")
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
ScrollView(showsIndicators: false) {
|
||||||
WelcomeTab().tag(0)
|
VStack(spacing: 0) {
|
||||||
LoginTab().tag(1)
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page)
|
|
||||||
.background(
|
|
||||||
selectedTab == 1 ? Color.nextcloudBlue.ignoresSafeArea() : Color(uiColor: .systemBackground).ignoresSafeArea()
|
|
||||||
)
|
|
||||||
.animation(.easeInOut, value: selectedTab)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct WelcomeTab: View {
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .center) {
|
|
||||||
Spacer()
|
|
||||||
Image("cookbook-icon")
|
Image("cookbook-icon")
|
||||||
.resizable()
|
.resizable()
|
||||||
.frame(width: 120, height: 120)
|
.frame(width: 80, height: 80)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||||
Text("Thank you for downloading")
|
.padding(.top, 48)
|
||||||
.font(.headline)
|
|
||||||
Text("Cookbook Client")
|
Text("Cookbook Client")
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
.bold()
|
.bold()
|
||||||
Spacer()
|
.padding(.top, 10)
|
||||||
Text("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.")
|
Text("Thanks for downloading! Sign in to your Nextcloud server to get started.")
|
||||||
.padding()
|
.font(.subheadline)
|
||||||
Spacer()
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, 32)
|
||||||
|
.padding(.top, 6)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Picker("Login Method", selection: $loginMethod) {
|
||||||
|
Text("Nextcloud Login").tag(LoginMethod.v2)
|
||||||
|
Text("App Token Login").tag(LoginMethod.token)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
|
||||||
|
if loginMethod == .token {
|
||||||
|
TokenLoginView(
|
||||||
|
showAlert: $showAlert,
|
||||||
|
alertMessage: $alertMessage
|
||||||
|
)
|
||||||
|
} else if loginMethod == .v2 {
|
||||||
|
V2LoginView(
|
||||||
|
showAlert: $showAlert,
|
||||||
|
alertMessage: $alertMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 28)
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.fontDesign(.rounded)
|
.fontDesign(.rounded)
|
||||||
|
.padding()
|
||||||
|
.alert(alertMessage, isPresented: $showAlert) {
|
||||||
|
Button("Ok", role: .cancel) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color(uiColor: .systemGroupedBackground).ignoresSafeArea())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,108 +97,45 @@ enum TokenLoginStage: LoginStage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct LoginTab: View {
|
|
||||||
@State var loginMethod: LoginMethod = .v2
|
|
||||||
|
|
||||||
// Login error alert
|
|
||||||
@State var showAlert: Bool = false
|
|
||||||
@State var alertMessage: String = "Error: Could not connect to server."
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ScrollView(showsIndicators: false) {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Spacer()
|
|
||||||
Picker("Login Method", selection: $loginMethod) {
|
|
||||||
Text("Nextcloud Login").tag(LoginMethod.v2)
|
|
||||||
Text("App Token Login").tag(LoginMethod.token)
|
|
||||||
}
|
|
||||||
.pickerStyle(.segmented)
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.padding()
|
|
||||||
if loginMethod == .token {
|
|
||||||
TokenLoginView(
|
|
||||||
showAlert: $showAlert,
|
|
||||||
alertMessage: $alertMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else if loginMethod == .v2 {
|
|
||||||
V2LoginView(
|
|
||||||
showAlert: $showAlert,
|
|
||||||
alertMessage: $alertMessage
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
.fontDesign(.rounded)
|
|
||||||
.padding()
|
|
||||||
.alert(alertMessage, isPresented: $showAlert) {
|
|
||||||
Button("Ok", role: .cancel) { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
struct LoginLabel: View {
|
struct LoginLabel: View {
|
||||||
let text: String
|
let text: LocalizedStringKey
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.foregroundColor(.white)
|
.font(.subheadline)
|
||||||
.font(.headline)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.vertical, 5)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BorderedLoginTextField: View {
|
struct BorderedLoginTextField: View {
|
||||||
var example: String
|
var example: LocalizedStringKey
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@State var color: Color = .white
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextField(example, text: $text)
|
TextField(example, text: $text)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
|
||||||
.tint(color)
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.stroke(.white, lineWidth: 2)
|
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct LoginTextField: View {
|
struct LoginTextField: View {
|
||||||
var example: String
|
var example: LocalizedStringKey
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@State var color: Color = .white
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TextField(example, text: $text)
|
TextField(example, text: $text)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundColor(color)
|
|
||||||
.tint(color)
|
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.foregroundColor(Color.white.opacity(0.2))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct ServerAddressField: View {
|
struct ServerAddressField: View {
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
@State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https
|
@State var serverProtocol: ServerProtocol = UserSettings.shared.serverProtocol == ServerProtocol.http.rawValue ? ServerProtocol.http : ServerProtocol.https
|
||||||
@@ -192,17 +147,17 @@ struct ServerAddressField: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
LoginLabel(text: "Server address")
|
LoginLabel(text: "Server address")
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
HStack {
|
HStack {
|
||||||
Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) {
|
Picker(ServerProtocol.https.rawValue, selection: $serverProtocol) {
|
||||||
ForEach(ServerProtocol.all, id: \.self) {
|
ForEach(ServerProtocol.all, id: \.self) {
|
||||||
Text($0.rawValue)
|
Text($0.rawValue)
|
||||||
}
|
}
|
||||||
}.pickerStyle(.menu)
|
}
|
||||||
.tint(.white)
|
.pickerStyle(.menu)
|
||||||
.font(.headline)
|
.tint(.accentColor)
|
||||||
.onChange(of: serverProtocol) { value in
|
.onChange(of: serverProtocol) { value in
|
||||||
Logger.view.debug("\(value.rawValue)")
|
Logger.view.debug("\(value.rawValue)")
|
||||||
userSettings.serverProtocol = value.rawValue
|
userSettings.serverProtocol = value.rawValue
|
||||||
@@ -212,27 +167,18 @@ struct ServerAddressField: View {
|
|||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.foregroundStyle(.white)
|
.padding(10)
|
||||||
.padding()
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
.background(
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
|
||||||
.foregroundColor(Color.white.opacity(0.2))
|
|
||||||
)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LoginLabel(text: "Full server address")
|
|
||||||
.padding(.top)
|
|
||||||
Text(userSettings.serverProtocol + userSettings.serverAddress)
|
Text(userSettings.serverProtocol + userSettings.serverAddress)
|
||||||
.foregroundColor(.white)
|
.font(.footnote)
|
||||||
.padding(.vertical, 5)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(
|
.background(Color(uiColor: .secondarySystemGroupedBackground))
|
||||||
RoundedRectangle(cornerRadius: 10)
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
.stroke(.white, lineWidth: 2)
|
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -242,6 +188,5 @@ struct ServerAddressField_Preview: PreviewProvider {
|
|||||||
ServerAddressField()
|
ServerAddressField()
|
||||||
.previewLayout(.sizeThatFits)
|
.previewLayout(.sizeThatFits)
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.nextcloudBlue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,25 +26,25 @@ struct TokenLoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
ServerAddressField()
|
ServerAddressField()
|
||||||
.padding(.bottom)
|
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
LoginLabel(text: "User name")
|
LoginLabel(text: "User name")
|
||||||
BorderedLoginTextField(example: "username", text: $userSettings.username)
|
BorderedLoginTextField(example: "username", text: $userSettings.username)
|
||||||
.focused($focusedField, equals: .username)
|
.focused($focusedField, equals: .username)
|
||||||
.textContentType(.username)
|
.textContentType(.username)
|
||||||
.submitLabel(.next)
|
.submitLabel(.next)
|
||||||
.padding(.bottom)
|
}
|
||||||
|
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
LoginLabel(text: "App Token")
|
LoginLabel(text: "App Token")
|
||||||
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
|
BorderedLoginTextField(example: "can be generated in security settings of your nextcloud", text: $userSettings.token)
|
||||||
.focused($focusedField, equals: .token)
|
.focused($focusedField, equals: .token)
|
||||||
.textContentType(.password)
|
.textContentType(.password)
|
||||||
.submitLabel(.join)
|
.submitLabel(.join)
|
||||||
HStack{
|
}
|
||||||
Spacer()
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
if await loginCheck(nextcloudLogin: false) {
|
if await loginCheck(nextcloudLogin: false) {
|
||||||
@@ -52,19 +52,18 @@ struct TokenLoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Submit")
|
Label("Submit", systemImage: "person.badge.key")
|
||||||
.foregroundColor(.white)
|
.font(.subheadline)
|
||||||
.font(.headline)
|
.fontWeight(.medium)
|
||||||
.padding()
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(Color.white, lineWidth: 2)
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(.top, 4)
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onSubmit {
|
.onSubmit {
|
||||||
switch focusedField {
|
switch focusedField {
|
||||||
@@ -80,11 +79,11 @@ struct TokenLoginView: View {
|
|||||||
|
|
||||||
func loginCheck(nextcloudLogin: Bool) async -> Bool {
|
func loginCheck(nextcloudLogin: Bool) async -> Bool {
|
||||||
if userSettings.serverAddress == "" {
|
if userSettings.serverAddress == "" {
|
||||||
alertMessage = "Please enter a server address!"
|
alertMessage = String(localized: "Please enter a server address!")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
|
} else if !nextcloudLogin && (userSettings.username == "" || userSettings.token == "") {
|
||||||
alertMessage = "Please enter a user name and app token!"
|
alertMessage = String(localized: "Please enter a user name and app token!")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -95,7 +94,7 @@ struct TokenLoginView: View {
|
|||||||
let _ = try await client.getCategories()
|
let _ = try await client.getCategories()
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
alertMessage = "Login failed. Please check your inputs and internet connection."
|
alertMessage = String(localized: "Login failed. Please check your inputs and internet connection.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,36 +46,35 @@ struct V2LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
VStack(alignment: .leading) {
|
|
||||||
ServerAddressField()
|
ServerAddressField()
|
||||||
CollapsibleView {
|
|
||||||
VStack(alignment: .leading) {
|
CollapsibleView(titleColor: .secondary) {
|
||||||
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.")
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
.padding(.bottom)
|
Text("Make sure to enter the server address in the form 'example.com', or '<server address>:<port>' when a non-standard port is used.")
|
||||||
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'.")
|
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'.")
|
||||||
.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.")
|
Text("If the login button does not open your browser, use the 'Copy Link' button and paste the link in your browser manually.")
|
||||||
}
|
}
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
} title: {
|
} title: {
|
||||||
Text("Show help")
|
Text("Show help")
|
||||||
.foregroundColor(.white)
|
.font(.subheadline)
|
||||||
.font(.headline)
|
}
|
||||||
}.padding()
|
|
||||||
|
|
||||||
if loginRequest != nil {
|
if loginRequest != nil {
|
||||||
Button("Copy Link") {
|
Button {
|
||||||
UIPasteboard.general.string = loginRequest!.login
|
UIPasteboard.general.string = loginRequest!.login
|
||||||
|
} label: {
|
||||||
|
Label("Copy Link", systemImage: "doc.on.doc")
|
||||||
|
.font(.subheadline)
|
||||||
}
|
}
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack {
|
HStack(spacing: 12) {
|
||||||
Button {
|
Button {
|
||||||
if UserSettings.shared.serverAddress == "" {
|
if UserSettings.shared.serverAddress == "" {
|
||||||
alertMessage = "Please enter a valid server address."
|
alertMessage = String(localized: "Please enter a valid server address.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -83,55 +82,52 @@ struct V2LoginView: View {
|
|||||||
Task {
|
Task {
|
||||||
let error = await sendLoginV2Request()
|
let error = await sendLoginV2Request()
|
||||||
if let error = error {
|
if let error = error {
|
||||||
alertMessage = "A network error occured (\(error.localizedDescription))."
|
alertMessage = String(localized: "A network error occurred. Please try again.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
}
|
}
|
||||||
if let loginRequest = loginRequest {
|
if let _ = loginRequest {
|
||||||
presentBrowser = true
|
presentBrowser = true
|
||||||
//await UIApplication.shared.open(URL(string: loginRequest.login)!)
|
|
||||||
} else {
|
} else {
|
||||||
alertMessage = "Unable to reach server. Please check your server address and internet connection."
|
alertMessage = String(localized: "Unable to reach server. Please check your server address and internet connection.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loginStage = loginStage.next()
|
loginStage = loginStage.next()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Login")
|
Label("Login", systemImage: "person.badge.key")
|
||||||
.foregroundColor(.white)
|
.font(.subheadline)
|
||||||
.font(.headline)
|
.fontWeight(.medium)
|
||||||
.padding()
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(Color.white, lineWidth: 2)
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
)
|
||||||
}.padding()
|
}
|
||||||
|
|
||||||
if loginStage == .validate {
|
if loginStage == .validate {
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
// fetch login v2 response
|
|
||||||
Task {
|
Task {
|
||||||
let (response, error) = await fetchLoginV2Response()
|
let (response, error) = await fetchLoginV2Response()
|
||||||
checkLogin(response: response, error: error)
|
checkLogin(response: response, error: error)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Validate")
|
Label("Validate", systemImage: "checkmark.circle.fill")
|
||||||
.foregroundColor(.white)
|
.font(.subheadline)
|
||||||
.font(.headline)
|
.fontWeight(.medium)
|
||||||
.padding()
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: 10)
|
||||||
.stroke(Color.white, lineWidth: 2)
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
.foregroundColor(.clear)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.disabled(loginRequest == nil ? true : false)
|
.disabled(loginRequest == nil)
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $presentBrowser, onDismiss: {
|
.sheet(isPresented: $presentBrowser, onDismiss: {
|
||||||
Task {
|
Task {
|
||||||
@@ -158,12 +154,12 @@ struct V2LoginView: View {
|
|||||||
|
|
||||||
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
func checkLogin(response: LoginV2Response?, error: NetworkError?) {
|
||||||
if let error = error {
|
if let error = error {
|
||||||
alertMessage = "Login failed. Please login via the browser and try again. (\(error.localizedDescription))"
|
alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let response = response else {
|
guard let response = response else {
|
||||||
alertMessage = "Login failed. Please login via the browser and try again."
|
alertMessage = String(localized: "Login failed. Please login via the browser and try again.")
|
||||||
showAlert = true
|
showAlert = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// AddToMealPlanSheet.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddToMealPlanSheet: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let recipeId: String
|
||||||
|
let recipeName: String
|
||||||
|
let prepTime: String?
|
||||||
|
let recipeImage: UIImage?
|
||||||
|
|
||||||
|
@State private var weekOffset: Int = 0
|
||||||
|
@State private var selectedDays: Set<String> = []
|
||||||
|
|
||||||
|
private var calendar: Calendar { Calendar.current }
|
||||||
|
|
||||||
|
private var weekDates: [Date] {
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let weekday = calendar.component(.weekday, from: today)
|
||||||
|
let daysToMonday = (weekday + 5) % 7
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
||||||
|
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekLabel: String {
|
||||||
|
if weekOffset == 0 {
|
||||||
|
return String(localized: "This Week")
|
||||||
|
} else if weekOffset == 1 {
|
||||||
|
return String(localized: "Next Week")
|
||||||
|
} else if weekOffset == -1 {
|
||||||
|
return String(localized: "Last Week")
|
||||||
|
} else {
|
||||||
|
return weekRangeString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekRangeString: String {
|
||||||
|
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM."
|
||||||
|
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Recipe header
|
||||||
|
recipeHeader
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Week navigation
|
||||||
|
weekNavigationHeader
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Day rows with checkboxes
|
||||||
|
List {
|
||||||
|
ForEach(weekDates, id: \.self) { date in
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
let isAlreadyAssigned = mealPlan.isRecipeAssigned(recipeId, on: date)
|
||||||
|
let existingCount = mealPlan.entries(for: date).count
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !isAlreadyAssigned {
|
||||||
|
if selectedDays.contains(dayStr) {
|
||||||
|
selectedDays.remove(dayStr)
|
||||||
|
} else {
|
||||||
|
selectedDays.insert(dayStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: (isAlreadyAssigned || selectedDays.contains(dayStr)) ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isAlreadyAssigned ? Color.secondary : Color.nextcloudBlue)
|
||||||
|
|
||||||
|
Text(dayDisplayName(date))
|
||||||
|
.foregroundStyle(isAlreadyAssigned ? .secondary : .primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if existingCount > 0 {
|
||||||
|
Text("\(existingCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Capsule().fill(Color(.tertiarySystemFill)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isAlreadyAssigned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
.navigationTitle("Schedule Recipe")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
let dates = selectedDays.compactMap { MealPlanDate.dateFromDay($0) }
|
||||||
|
if !dates.isEmpty {
|
||||||
|
mealPlan.assignRecipe(recipeId: recipeId, recipeName: recipeName, toDates: dates)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(selectedDays.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recipeHeader: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let recipeImage {
|
||||||
|
Image(uiImage: recipeImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(recipeName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if let prepTime, !prepTime.isEmpty {
|
||||||
|
let duration = DurationComponents.fromPTString(prepTime)
|
||||||
|
if duration.hourComponent > 0 || duration.minuteComponent > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption)
|
||||||
|
Text(duration.displayString)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekNavigationHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset -= 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(weekLabel)
|
||||||
|
.font(.headline)
|
||||||
|
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
||||||
|
Text(weekRangeString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset += 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayDisplayName(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEEE, d MMM"
|
||||||
|
let name = formatter.string(from: date)
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
return "\(name) (\(String(localized: "Today")))"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//
|
||||||
|
// AllRecipesCategoryCardView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AllRecipesCategoryCardView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State private var mosaicImages: [UIImage] = []
|
||||||
|
|
||||||
|
private var totalRecipeCount: Int {
|
||||||
|
appState.categories.reduce(0) { $0 + $1.recipe_count }
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottomLeading) {
|
||||||
|
// 2x2 image mosaic or gradient fallback
|
||||||
|
if mosaicImages.count >= 4 {
|
||||||
|
mosaicGrid
|
||||||
|
} else {
|
||||||
|
gradientFallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom scrim with text
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Spacer()
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.clear, .black.opacity(0.95)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
.frame(height: 60)
|
||||||
|
.overlay(alignment: .bottomLeading) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("All Recipes")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(totalRecipeCount) recipes")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 17))
|
||||||
|
.shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
||||||
|
.task {
|
||||||
|
await loadMosaicImages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadMosaicImages() async {
|
||||||
|
// Ensure recipes are loaded for each category (they may not be yet)
|
||||||
|
for category in appState.categories {
|
||||||
|
if appState.recipes[category.name] == nil || appState.recipes[category.name]!.isEmpty {
|
||||||
|
await appState.getCategory(named: category.name, fetchMode: .preferLocal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all recipes across categories, shuffled for variety
|
||||||
|
var allRecipes: [Recipe] = []
|
||||||
|
for category in appState.categories {
|
||||||
|
if let recipes = appState.recipes[category.name] {
|
||||||
|
allRecipes.append(contentsOf: recipes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allRecipes.shuffle()
|
||||||
|
|
||||||
|
// Filter to recipes that have an image URL, then pick 4
|
||||||
|
// Prefer recipes not already used as category thumbnails
|
||||||
|
let categoryImageIds = appState.categoryImageRecipeIds
|
||||||
|
var candidates: [Recipe] = []
|
||||||
|
var fallbackCandidates: [Recipe] = []
|
||||||
|
var seenIds: Set<Int> = []
|
||||||
|
for recipe in allRecipes {
|
||||||
|
guard let url = recipe.imageUrl, !url.isEmpty else { continue }
|
||||||
|
guard !seenIds.contains(recipe.recipe_id) else { continue }
|
||||||
|
seenIds.insert(recipe.recipe_id)
|
||||||
|
if categoryImageIds.contains(recipe.recipe_id) {
|
||||||
|
fallbackCandidates.append(recipe)
|
||||||
|
} else {
|
||||||
|
candidates.append(recipe)
|
||||||
|
}
|
||||||
|
if candidates.count >= 4 { break }
|
||||||
|
}
|
||||||
|
// Fill remaining slots from fallback if needed
|
||||||
|
if candidates.count < 4 {
|
||||||
|
for recipe in fallbackCandidates {
|
||||||
|
candidates.append(recipe)
|
||||||
|
if candidates.count >= 4 { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var images: [UIImage] = []
|
||||||
|
for recipe in candidates {
|
||||||
|
if let image = await appState.getImage(
|
||||||
|
id: recipe.recipe_id,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
) {
|
||||||
|
images.append(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !images.isEmpty else { return }
|
||||||
|
// Cycle to fill 4 slots if fewer than 4 unique images
|
||||||
|
var filled: [UIImage] = []
|
||||||
|
for i in 0..<4 {
|
||||||
|
filled.append(images[i % images.count])
|
||||||
|
}
|
||||||
|
mosaicImages = filled
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mosaicGrid: some View {
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
imageCell(mosaicImages[safe: 0])
|
||||||
|
imageCell(mosaicImages[safe: 1])
|
||||||
|
}
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
imageCell(mosaicImages[safe: 2])
|
||||||
|
imageCell(mosaicImages[safe: 3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func imageCell(_ image: UIImage?) -> some View {
|
||||||
|
Group {
|
||||||
|
if let image {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)
|
||||||
|
.clipped()
|
||||||
|
} else {
|
||||||
|
Color.gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gradientFallback: some View {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 140, maxHeight: 140)
|
||||||
|
.overlay(alignment: .center) {
|
||||||
|
Image(systemName: "square.grid.2x2.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
subscript(safe index: Int) -> Element? {
|
||||||
|
indices.contains(index) ? self[index] : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//
|
||||||
|
// AllRecipesListView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AllRecipesListView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
|
var onCreateNew: () -> Void
|
||||||
|
var onImportFromURL: () -> Void
|
||||||
|
@State private var allRecipes: [Recipe] = []
|
||||||
|
@State private var searchText: String = ""
|
||||||
|
|
||||||
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private var currentRecipeSortMode: RecipeSortMode {
|
||||||
|
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
let recipes = recipesFiltered()
|
||||||
|
if !recipes.isEmpty {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("\(recipes.count) recipes")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
|
ForEach(recipes, id: \.recipe_id) { recipe in
|
||||||
|
NavigationLink(value: recipe) {
|
||||||
|
RecipeCardView(recipe: recipe)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No recipes found")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
allRecipes = await appState.getRecipes()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Refresh", systemImage: "arrow.clockwise")
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.primary)
|
||||||
|
}.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Search recipes/keywords")
|
||||||
|
.navigationTitle(String(localized: "All Recipes"))
|
||||||
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
recipeSortMenu
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
onCreateNew()
|
||||||
|
} label: {
|
||||||
|
Label("Create New Recipe", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onImportFromURL()
|
||||||
|
} label: {
|
||||||
|
Label("Import from URL", systemImage: "link")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
allRecipes = await appState.getRecipes()
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
allRecipes = await appState.getRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recipeSortMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
||||||
|
Button {
|
||||||
|
userSettings.recipeSortMode = mode.rawValue
|
||||||
|
userSettings.recipeSortAscending = true
|
||||||
|
} label: {
|
||||||
|
if currentRecipeSortMode == mode {
|
||||||
|
Label(mode.descriptor(), systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(mode.descriptor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
userSettings.recipeSortAscending.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
userSettings.recipeSortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
||||||
|
systemImage: "arrow.up.arrow.down"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recipesFiltered() -> [Recipe] {
|
||||||
|
let filtered: [Recipe]
|
||||||
|
if searchText.isEmpty {
|
||||||
|
filtered = allRecipes
|
||||||
|
} else {
|
||||||
|
filtered = allRecipes.filter { recipe in
|
||||||
|
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
||||||
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortRecipes(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortRecipes(_ recipes: [Recipe]) -> [Recipe] {
|
||||||
|
let mode = currentRecipeSortMode
|
||||||
|
let ascending = userSettings.recipeSortAscending
|
||||||
|
switch mode {
|
||||||
|
case .recentlyAdded:
|
||||||
|
return recipes.sorted { a, b in
|
||||||
|
let dateA = parseDate(a.dateModified ?? a.dateCreated) ?? Date(timeIntervalSince1970: Double(a.recipe_id))
|
||||||
|
let dateB = parseDate(b.dateModified ?? b.dateCreated) ?? Date(timeIntervalSince1970: Double(b.recipe_id))
|
||||||
|
return ascending ? dateA > dateB : dateA < dateB
|
||||||
|
}
|
||||||
|
case .alphabetical:
|
||||||
|
return recipes.sorted { a, b in
|
||||||
|
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
||||||
|
return ascending ? result == .orderedAscending : result == .orderedDescending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let dateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func parseDate(_ string: String?) -> Date? {
|
||||||
|
guard let string, !string.isEmpty else { return nil }
|
||||||
|
// Try "yyyy-MM-dd HH:mm:ss" first
|
||||||
|
if let date = Self.dateFormatter.date(from: string) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
// Try Unix timestamp (integer string)
|
||||||
|
if let timestamp = Double(string), timestamp > 0 {
|
||||||
|
return Date(timeIntervalSince1970: timestamp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// CategoryReorderSheet.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CategoryReorderSheet: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private static let allRecipesSentinel = "__ALL_RECIPES__"
|
||||||
|
|
||||||
|
@State private var orderedNames: [String] = []
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(orderedNames, id: \.self) { name in
|
||||||
|
HStack {
|
||||||
|
if name == Self.allRecipesSentinel {
|
||||||
|
Text("All Recipes")
|
||||||
|
.bold()
|
||||||
|
} else {
|
||||||
|
Text(name)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if name == Self.allRecipesSentinel {
|
||||||
|
let total = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
||||||
|
Text("\(total)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
} else if let count = appState.categories.first(where: { $0.name == name })?.recipe_count {
|
||||||
|
Text("\(count)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
orderedNames.move(fromOffsets: from, toOffset: to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.editMode, .constant(.active))
|
||||||
|
.navigationTitle(String(localized: "Reorder Categories"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(String(localized: "Cancel")) {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button(String(localized: "Done")) {
|
||||||
|
appState.updateManualCategoryOrder(orderedNames)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
let currentCategoryNames = appState.categories
|
||||||
|
.filter { $0.recipe_count > 0 }
|
||||||
|
.map { $0.name }
|
||||||
|
|
||||||
|
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
||||||
|
|
||||||
|
let existing = appState.manualCategoryOrder
|
||||||
|
|
||||||
|
// Keep only names that still exist on the server (or are the sentinel)
|
||||||
|
var reconciled = existing.filter {
|
||||||
|
$0 == Self.allRecipesSentinel || currentCategoryNames.contains($0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the All Recipes sentinel is present
|
||||||
|
if totalCount > 0 && !reconciled.contains(Self.allRecipesSentinel) {
|
||||||
|
reconciled.insert(Self.allRecipesSentinel, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append any new categories not yet in the manual order
|
||||||
|
for name in currentCategoryNames where !reconciled.contains(name) {
|
||||||
|
reconciled.append(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedNames = reconciled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// ImportURLSheet.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ImportURLSheet: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var onImport: (RecipeDetail) -> Void
|
||||||
|
var initialURL: String = ""
|
||||||
|
|
||||||
|
@State private var url: String = ""
|
||||||
|
@State private var isLoading: Bool = false
|
||||||
|
@State private var presentAlert: Bool = false
|
||||||
|
@State private var alertMessage: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("Recipe URL", text: $url)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
} footer: {
|
||||||
|
Text("Paste the URL of a recipe you would like to import.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await importRecipe()
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
if isLoading {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Text("Import")
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isLoading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Import Recipe")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Import Failed", isPresented: $presentAlert) {
|
||||||
|
Button("OK", role: .cancel) { }
|
||||||
|
} message: {
|
||||||
|
Text(alertMessage)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if !initialURL.isEmpty {
|
||||||
|
url = initialURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func importRecipe() async {
|
||||||
|
isLoading = true
|
||||||
|
let (recipeDetail, error) = await appState.importRecipe(url: url)
|
||||||
|
isLoading = false
|
||||||
|
|
||||||
|
if let recipeDetail {
|
||||||
|
dismiss()
|
||||||
|
onImport(recipeDetail)
|
||||||
|
} else {
|
||||||
|
alertMessage = error?.localizedDescription ?? String(localized: "The recipe could not be imported. Please check the URL and try again.")
|
||||||
|
presentAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,14 +12,21 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RecipeListView: View {
|
struct RecipeListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
@Binding var showEditView: Bool
|
var onCreateNew: () -> Void
|
||||||
|
var onImportFromURL: () -> Void
|
||||||
@State var selectedRecipe: Recipe? = nil
|
@State var selectedRecipe: Recipe? = nil
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private var currentRecipeSortMode: RecipeSortMode {
|
||||||
|
RecipeSortMode(rawValue: userSettings.recipeSortMode) ?? .recentlyAdded
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
let recipes = recipesFiltered()
|
let recipes = recipesFiltered()
|
||||||
@@ -66,7 +73,7 @@ struct RecipeListView: View {
|
|||||||
.bold()
|
.bold()
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.nextcloudBlue)
|
.tint(.primary)
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,11 +84,24 @@ struct RecipeListView: View {
|
|||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
recipeSortMenu
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
showEditView = true
|
onCreateNew()
|
||||||
|
} label: {
|
||||||
|
Label("Create New Recipe", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
onImportFromURL()
|
||||||
|
} label: {
|
||||||
|
Label("Import from URL", systemImage: "link")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
@@ -101,12 +121,84 @@ struct RecipeListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var recipeSortMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(RecipeSortMode.allCases, id: \.self) { mode in
|
||||||
|
Button {
|
||||||
|
userSettings.recipeSortMode = mode.rawValue
|
||||||
|
userSettings.recipeSortAscending = true
|
||||||
|
} label: {
|
||||||
|
if currentRecipeSortMode == mode {
|
||||||
|
Label(mode.descriptor(), systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(mode.descriptor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
userSettings.recipeSortAscending.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
userSettings.recipeSortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
||||||
|
systemImage: "arrow.up.arrow.down"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func recipesFiltered() -> [Recipe] {
|
func recipesFiltered() -> [Recipe] {
|
||||||
guard let recipes = appState.recipes[categoryName] else { return [] }
|
guard let recipes = appState.recipes[categoryName] else { return [] }
|
||||||
guard searchText != "" else { return recipes }
|
let filtered: [Recipe]
|
||||||
return recipes.filter { recipe in
|
if searchText.isEmpty {
|
||||||
recipe.name.lowercased().contains(searchText.lowercased()) || // check name for occurence of search term
|
filtered = recipes
|
||||||
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased())) // check keywords for search term
|
} else {
|
||||||
|
filtered = recipes.filter { recipe in
|
||||||
|
recipe.name.lowercased().contains(searchText.lowercased()) ||
|
||||||
|
(recipe.keywords != nil && recipe.keywords!.lowercased().contains(searchText.lowercased()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortRecipes(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortRecipes(_ recipes: [Recipe]) -> [Recipe] {
|
||||||
|
let mode = currentRecipeSortMode
|
||||||
|
let ascending = userSettings.recipeSortAscending
|
||||||
|
switch mode {
|
||||||
|
case .recentlyAdded:
|
||||||
|
return recipes.sorted { a, b in
|
||||||
|
let dateA = parseDate(a.dateModified ?? a.dateCreated) ?? Date(timeIntervalSince1970: Double(a.recipe_id))
|
||||||
|
let dateB = parseDate(b.dateModified ?? b.dateCreated) ?? Date(timeIntervalSince1970: Double(b.recipe_id))
|
||||||
|
return ascending ? dateA > dateB : dateA < dateB
|
||||||
|
}
|
||||||
|
case .alphabetical:
|
||||||
|
return recipes.sorted { a, b in
|
||||||
|
let result = a.name.localizedCaseInsensitiveCompare(b.name)
|
||||||
|
return ascending ? result == .orderedAscending : result == .orderedDescending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let dateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||||
|
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private func parseDate(_ string: String?) -> Date? {
|
||||||
|
guard let string, !string.isEmpty else { return nil }
|
||||||
|
// Try "yyyy-MM-dd HH:mm:ss" first
|
||||||
|
if let date = Self.dateFormatter.date(from: string) {
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
// Try Unix timestamp (integer string)
|
||||||
|
if let timestamp = Double(string), timestamp > 0 {
|
||||||
|
return Date(timeIntervalSince1970: timestamp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RecipeView: View {
|
struct RecipeView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject var viewModel: ViewModel
|
@StateObject var viewModel: ViewModel
|
||||||
@GestureState private var dragOffset = CGSize.zero
|
@GestureState private var dragOffset = CGSize.zero
|
||||||
@@ -28,97 +30,16 @@ struct RecipeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
Group {
|
||||||
VStack(spacing: 0) {
|
if viewModel.editMode {
|
||||||
ParallaxHeader(
|
recipeEditForm
|
||||||
coordinateSpace: CoordinateSpaces.scrollView,
|
|
||||||
defaultHeight: imageHeight
|
|
||||||
) {
|
|
||||||
if let recipeImage = viewModel.recipeImage {
|
|
||||||
Image(uiImage: recipeImage)
|
|
||||||
.resizable()
|
|
||||||
.scaledToFill()
|
|
||||||
.frame(maxHeight: imageHeight + 200)
|
|
||||||
.clipped()
|
|
||||||
} else {
|
} else {
|
||||||
Rectangle()
|
recipeViewContent
|
||||||
.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.observableRecipeDetail.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.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(viewModel: viewModel)
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
|
||||||
if(!viewModel.observableRecipeDetail.recipeIngredient.isEmpty || viewModel.editMode) {
|
|
||||||
RecipeIngredientSection(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
if(!viewModel.observableRecipeDetail.recipeInstructions.isEmpty || viewModel.editMode) {
|
|
||||||
RecipeInstructionSection(viewModel: viewModel)
|
|
||||||
}
|
|
||||||
if(!viewModel.observableRecipeDetail.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)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar(.visible, for: .navigationBar)
|
.toolbar(.visible, for: .navigationBar)
|
||||||
//.toolbarTitleDisplayMode(.inline)
|
.navigationTitle(viewModel.editMode ? "Edit Recipe" : (viewModel.showTitle ? viewModel.recipe.name : ""))
|
||||||
.navigationTitle(viewModel.showTitle ? viewModel.recipe.name : "")
|
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
RecipeViewToolBar(viewModel: viewModel)
|
RecipeViewToolBar(viewModel: viewModel)
|
||||||
}
|
}
|
||||||
@@ -127,37 +48,18 @@ struct RecipeView: View {
|
|||||||
recipeImage: viewModel.recipeImage,
|
recipeImage: viewModel.recipeImage,
|
||||||
presentShareSheet: $viewModel.presentShareSheet)
|
presentShareSheet: $viewModel.presentShareSheet)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $viewModel.presentInstructionEditView) {
|
.sheet(isPresented: $viewModel.presentKeywordSheet) {
|
||||||
EditableListView(
|
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
||||||
isPresented: $viewModel.presentInstructionEditView,
|
|
||||||
items: $viewModel.observableRecipeDetail.recipeInstructions,
|
|
||||||
title: "Instructions",
|
|
||||||
emptyListText: "Add cooking steps for fellow chefs to follow.",
|
|
||||||
titleKey: "Instruction",
|
|
||||||
lineLimit: 0...10,
|
|
||||||
axis: .vertical)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $viewModel.presentIngredientEditView) {
|
.sheet(isPresented: $viewModel.presentMealPlanSheet) {
|
||||||
EditableListView(
|
AddToMealPlanSheet(
|
||||||
isPresented: $viewModel.presentIngredientEditView,
|
recipeId: String(viewModel.recipe.recipe_id),
|
||||||
items: $viewModel.observableRecipeDetail.recipeIngredient,
|
recipeName: viewModel.observableRecipeDetail.name,
|
||||||
title: "Ingredients",
|
prepTime: viewModel.recipeDetail.prepTime,
|
||||||
emptyListText: "Start by adding your first ingredient! 🥬",
|
recipeImage: viewModel.recipeImage
|
||||||
titleKey: "Ingredient",
|
)
|
||||||
lineLimit: 0...1,
|
.environmentObject(mealPlan)
|
||||||
axis: .horizontal)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $viewModel.presentToolEditView) {
|
|
||||||
EditableListView(
|
|
||||||
isPresented: $viewModel.presentToolEditView,
|
|
||||||
items: $viewModel.observableRecipeDetail.tool,
|
|
||||||
title: "Tools",
|
|
||||||
emptyListText: "List your tools here. 🍴",
|
|
||||||
titleKey: "Tool",
|
|
||||||
lineLimit: 0...1,
|
|
||||||
axis: .horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
.task {
|
.task {
|
||||||
// Load recipe detail
|
// Load recipe detail
|
||||||
if !viewModel.newRecipe {
|
if !viewModel.newRecipe {
|
||||||
@@ -184,9 +86,44 @@ struct RecipeView: View {
|
|||||||
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
fetchMode: UserSettings.shared.storeImages ? .preferLocal : .onlyServer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Reconcile server grocery state with local data
|
||||||
|
if UserSettings.shared.grocerySyncEnabled {
|
||||||
|
let serverRecipe = await appState.getRecipe(
|
||||||
|
id: viewModel.recipe.recipe_id,
|
||||||
|
fetchMode: .onlyServer
|
||||||
|
)
|
||||||
|
groceryList.syncManager?.reconcileFromServer(
|
||||||
|
serverState: serverRecipe?.groceryState,
|
||||||
|
recipeId: String(viewModel.recipe.recipe_id),
|
||||||
|
recipeName: viewModel.recipeDetail.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile server meal plan state with local data
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
mealPlan.syncManager?.reconcileFromServer(
|
||||||
|
serverAssignment: viewModel.recipeDetail.mealPlanAssignment,
|
||||||
|
recipeId: String(viewModel.recipe.recipe_id),
|
||||||
|
recipeName: viewModel.recipeDetail.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Prepare view for a new recipe
|
// Prepare view for a new recipe
|
||||||
|
if let preloaded = viewModel.preloadedRecipeDetail {
|
||||||
|
viewModel.setupView(recipeDetail: preloaded)
|
||||||
|
viewModel.preloadedRecipeDetail = nil
|
||||||
|
// Load image if the import created a recipe with a valid id
|
||||||
|
if let recipeId = Int(preloaded.id), recipeId > 0 {
|
||||||
|
viewModel.recipeImage = await appState.getImage(
|
||||||
|
id: recipeId,
|
||||||
|
size: .FULL,
|
||||||
|
fetchMode: .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
viewModel.setupView(recipeDetail: RecipeDetail())
|
viewModel.setupView(recipeDetail: RecipeDetail())
|
||||||
|
}
|
||||||
viewModel.editMode = true
|
viewModel.editMode = true
|
||||||
viewModel.isDownloaded = false
|
viewModel.isDownloaded = false
|
||||||
}
|
}
|
||||||
@@ -231,6 +168,143 @@ struct RecipeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - View Mode
|
||||||
|
|
||||||
|
private var recipeViewContent: 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) {
|
||||||
|
HStack {
|
||||||
|
Text(viewModel.observableRecipeDetail.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.observableRecipeDetail.description != "" {
|
||||||
|
Text(viewModel.observableRecipeDetail.description)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecipeDurationSection(viewModel: viewModel)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.presentMealPlanSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Plan recipe", systemImage: "calendar.badge.plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
|
if !viewModel.observableRecipeDetail.recipeIngredient.isEmpty {
|
||||||
|
RecipeIngredientSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
if !viewModel.observableRecipeDetail.recipeInstructions.isEmpty {
|
||||||
|
RecipeInstructionSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
if !viewModel.observableRecipeDetail.tool.isEmpty {
|
||||||
|
RecipeToolSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
RecipeNutritionSection(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Edit Mode Form
|
||||||
|
|
||||||
|
private var recipeEditForm: some View {
|
||||||
|
Form {
|
||||||
|
if let recipeImage = viewModel.recipeImage {
|
||||||
|
Section {
|
||||||
|
Image(uiImage: recipeImage)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
.clipped()
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Recipe Name", text: $viewModel.observableRecipeDetail.name)
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Description", text: $viewModel.observableRecipeDetail.description, axis: .vertical)
|
||||||
|
.lineLimit(1...5)
|
||||||
|
}
|
||||||
|
|
||||||
|
RecipeEditMetadataSection(viewModel: viewModel)
|
||||||
|
.environmentObject(appState)
|
||||||
|
|
||||||
|
RecipeEditDurationSection(
|
||||||
|
prepTime: viewModel.observableRecipeDetail.prepTime,
|
||||||
|
cookTime: viewModel.observableRecipeDetail.cookTime,
|
||||||
|
totalTime: viewModel.observableRecipeDetail.totalTime
|
||||||
|
)
|
||||||
|
|
||||||
|
RecipeEditIngredientSection(ingredients: $viewModel.observableRecipeDetail.recipeIngredient)
|
||||||
|
|
||||||
|
RecipeEditInstructionSection(instructions: $viewModel.observableRecipeDetail.recipeInstructions)
|
||||||
|
|
||||||
|
RecipeEditToolSection(tools: $viewModel.observableRecipeDetail.tool)
|
||||||
|
|
||||||
|
RecipeEditNutritionSection(nutrition: $viewModel.observableRecipeDetail.nutrition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: - RecipeView ViewModel
|
// MARK: - RecipeView ViewModel
|
||||||
|
|
||||||
@@ -241,16 +315,15 @@ struct RecipeView: View {
|
|||||||
@Published var editMode: Bool = false
|
@Published var editMode: Bool = false
|
||||||
@Published var showTitle: Bool = false
|
@Published var showTitle: Bool = false
|
||||||
@Published var isDownloaded: Bool? = nil
|
@Published var isDownloaded: Bool? = nil
|
||||||
@Published var importUrl: String = ""
|
|
||||||
|
|
||||||
@Published var presentShareSheet: Bool = false
|
@Published var presentShareSheet: Bool = false
|
||||||
@Published var presentInstructionEditView: Bool = false
|
@Published var presentKeywordSheet: Bool = false
|
||||||
@Published var presentIngredientEditView: Bool = false
|
@Published var presentMealPlanSheet: Bool = false
|
||||||
@Published var presentToolEditView: Bool = false
|
|
||||||
|
|
||||||
var recipe: Recipe
|
var recipe: Recipe
|
||||||
var sharedURL: URL? = nil
|
var sharedURL: URL? = nil
|
||||||
var newRecipe: Bool = false
|
var newRecipe: Bool = false
|
||||||
|
var preloadedRecipeDetail: RecipeDetail? = nil
|
||||||
|
|
||||||
// Alerts
|
// Alerts
|
||||||
@Published var presentAlert = false
|
@Published var presentAlert = false
|
||||||
@@ -290,61 +363,25 @@ struct RecipeView: View {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
// Fetch the image from the server if the import created a recipe with a valid id
|
|
||||||
if let recipeId = Int(scrapedRecipe.id), recipeId > 0 {
|
|
||||||
viewModel.recipeImage = await appState.getImage(
|
|
||||||
id: recipeId,
|
|
||||||
size: .FULL,
|
|
||||||
fetchMode: .onlyServer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Tool Bar
|
// MARK: - Tool Bar
|
||||||
|
|
||||||
|
|
||||||
struct RecipeViewToolBar: ToolbarContent {
|
struct RecipeViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
var body: some ToolbarContent {
|
||||||
if viewModel.editMode {
|
if viewModel.editMode {
|
||||||
ToolbarItemGroup(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
viewModel.editMode = false
|
viewModel.editMode = false
|
||||||
if viewModel.newRecipe {
|
if viewModel.newRecipe {
|
||||||
dismiss()
|
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) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
@@ -375,6 +412,19 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
} label: {
|
} label: {
|
||||||
Label("Share Recipe", systemImage: "square.and.arrow.up")
|
Label("Share Recipe", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.presentAlert(
|
||||||
|
RecipeAlert.CONFIRM_DELETE,
|
||||||
|
action: {
|
||||||
|
await handleDelete()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
Label("Delete Recipe", systemImage: "trash")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
Image(systemName: "ellipsis.circle")
|
||||||
}
|
}
|
||||||
@@ -437,6 +487,8 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
|
let _ = await appState.getRecipe(id: id, fetchMode: .onlyServer, save: true)
|
||||||
// Fetch the image after upload so it displays in view mode
|
// Fetch the image after upload so it displays in view mode
|
||||||
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
|
viewModel.recipeImage = await appState.getImage(id: id, size: .FULL, fetchMode: .onlyServer)
|
||||||
|
// Pre-fetch thumbnail so it's cached for recents and category lists
|
||||||
|
let _ = await appState.getImage(id: id, size: .THUMB, fetchMode: .onlyServer)
|
||||||
// Update recipe reference so the view tracks the server-assigned id
|
// Update recipe reference so the view tracks the server-assigned id
|
||||||
viewModel.recipe = Recipe(
|
viewModel.recipe = Recipe(
|
||||||
name: viewModel.observableRecipeDetail.name,
|
name: viewModel.observableRecipeDetail.name,
|
||||||
@@ -447,6 +499,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
imagePlaceholderUrl: "",
|
imagePlaceholderUrl: "",
|
||||||
recipe_id: id
|
recipe_id: id
|
||||||
)
|
)
|
||||||
|
appState.addToRecentRecipes(viewModel.recipe)
|
||||||
}
|
}
|
||||||
viewModel.newRecipe = false
|
viewModel.newRecipe = false
|
||||||
viewModel.editMode = false
|
viewModel.editMode = false
|
||||||
@@ -465,6 +518,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
await appState.getCategory(named: category, fetchMode: .preferServer)
|
await appState.getCategory(named: category, fetchMode: .preferServer)
|
||||||
|
mealPlan.removeAllAssignments(forRecipeId: String(id))
|
||||||
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,23 +21,70 @@ struct RecipeDurationSection: View {
|
|||||||
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
|
DurationView(time: viewModel.observableRecipeDetail.cookTime, title: LocalizedStringKey("Cooking"))
|
||||||
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
|
DurationView(time: viewModel.observableRecipeDetail.totalTime, title: LocalizedStringKey("Total time"))
|
||||||
}
|
}
|
||||||
if viewModel.editMode {
|
|
||||||
Button {
|
|
||||||
presentPopover.toggle()
|
|
||||||
} label: {
|
|
||||||
Text("Edit")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.padding(.top, 5)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.popover(isPresented: $presentPopover) {
|
}
|
||||||
EditableDurationView(
|
}
|
||||||
prepTime: viewModel.observableRecipeDetail.prepTime,
|
|
||||||
cookTime: viewModel.observableRecipeDetail.cookTime,
|
// MARK: - Recipe Edit Duration Section (Form-based)
|
||||||
totalTime: viewModel.observableRecipeDetail.totalTime
|
|
||||||
)
|
struct RecipeEditDurationSection: View {
|
||||||
|
@ObservedObject var prepTime: DurationComponents
|
||||||
|
@ObservedObject var cookTime: DurationComponents
|
||||||
|
@ObservedObject var totalTime: DurationComponents
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Duration") {
|
||||||
|
DurationPickerRow(label: "Preparation", time: prepTime)
|
||||||
|
DurationPickerRow(label: "Cooking", time: cookTime)
|
||||||
|
DurationPickerRow(label: "Total time", time: totalTime)
|
||||||
|
}
|
||||||
|
.onChange(of: prepTime.hourComponent) { _ in updateTotalTime() }
|
||||||
|
.onChange(of: prepTime.minuteComponent) { _ in updateTotalTime() }
|
||||||
|
.onChange(of: cookTime.hourComponent) { _ in updateTotalTime() }
|
||||||
|
.onChange(of: cookTime.minuteComponent) { _ in updateTotalTime() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTotalTime() {
|
||||||
|
var hourComponent = prepTime.hourComponent + cookTime.hourComponent
|
||||||
|
var minuteComponent = prepTime.minuteComponent + cookTime.minuteComponent
|
||||||
|
if minuteComponent >= 60 {
|
||||||
|
hourComponent += minuteComponent / 60
|
||||||
|
minuteComponent %= 60
|
||||||
|
}
|
||||||
|
totalTime.hourComponent = hourComponent
|
||||||
|
totalTime.minuteComponent = minuteComponent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct DurationPickerRow: View {
|
||||||
|
let label: LocalizedStringKey
|
||||||
|
@ObservedObject var time: DurationComponents
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
ForEach(0..<25, id: \.self) { hour in
|
||||||
|
Button("\(hour) h") {
|
||||||
|
time.hourComponent = hour
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("\(time.hourComponent) h")
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
ForEach(0..<60, id: \.self) { minute in
|
||||||
|
Button("\(minute) min") {
|
||||||
|
time.minuteComponent = minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text("\(time.minuteComponent) min")
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,28 +11,12 @@ import SwiftUI
|
|||||||
// MARK: - RecipeView Ingredients Section
|
// MARK: - RecipeView Ingredients Section
|
||||||
|
|
||||||
struct RecipeIngredientSection: View {
|
struct RecipeIngredientSection: View {
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
HStack {
|
HStack {
|
||||||
Button {
|
|
||||||
withAnimation {
|
|
||||||
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
|
||||||
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
|
|
||||||
} else {
|
|
||||||
groceryList.addItems(
|
|
||||||
viewModel.observableRecipeDetail.recipeIngredient,
|
|
||||||
toRecipe: viewModel.observableRecipeDetail.id,
|
|
||||||
recipeName: viewModel.observableRecipeDetail.name
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "storefront")
|
|
||||||
}.disabled(viewModel.editMode)
|
|
||||||
|
|
||||||
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
SecondaryLabel(text: LocalizedStringKey("Ingredients"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -49,14 +33,9 @@ struct RecipeIngredientSection: View {
|
|||||||
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
|
ingredient: $viewModel.observableRecipeDetail.recipeIngredient[ix],
|
||||||
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
|
servings: $viewModel.observableRecipeDetail.ingredientMultiplier,
|
||||||
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
|
recipeYield: Double(viewModel.observableRecipeDetail.recipeYield),
|
||||||
recipeId: viewModel.observableRecipeDetail.id
|
recipeId: viewModel.observableRecipeDetail.id,
|
||||||
) {
|
|
||||||
groceryList.addItem(
|
|
||||||
viewModel.observableRecipeDetail.recipeIngredient[ix],
|
|
||||||
toRecipe: viewModel.observableRecipeDetail.id,
|
|
||||||
recipeName: viewModel.observableRecipeDetail.name
|
recipeName: viewModel.observableRecipeDetail.name
|
||||||
)
|
)
|
||||||
}
|
|
||||||
.padding(4)
|
.padding(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,51 +48,117 @@ struct RecipeIngredientSection: View {
|
|||||||
}.padding(.top)
|
}.padding(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.editMode {
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.presentIngredientEditView.toggle()
|
withAnimation {
|
||||||
|
if groceryList.containsRecipe(viewModel.observableRecipeDetail.id) {
|
||||||
|
groceryList.deleteGroceryRecipe(viewModel.observableRecipeDetail.id)
|
||||||
|
} else {
|
||||||
|
groceryList.addItems(
|
||||||
|
viewModel.observableRecipeDetail.recipeIngredient,
|
||||||
|
toRecipe: viewModel.observableRecipeDetail.id,
|
||||||
|
recipeName: viewModel.observableRecipeDetail.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Text("Edit")
|
Label(
|
||||||
}
|
groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "Remove all from Grocery List" : "Add All to Grocery List",
|
||||||
.buttonStyle(.borderedProminent)
|
systemImage: groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? "cart.badge.minus" : "cart.badge.plus"
|
||||||
|
)
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill((groceryList.containsRecipe(viewModel.observableRecipeDetail.id) ? Color.red : Color.green).opacity(0.1))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
|
.animation(.easeInOut, value: viewModel.observableRecipeDetail.ingredientMultiplier)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipe Edit Ingredient Section (Form-based)
|
||||||
|
|
||||||
|
struct RecipeEditIngredientSection: View {
|
||||||
|
@Binding var ingredients: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(ingredients.indices, id: \.self) { index in
|
||||||
|
HStack {
|
||||||
|
TextField("Ingredient", text: $ingredients[index])
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
ingredients.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
ingredients.move(fromOffsets: from, toOffset: to)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
ingredients.append("")
|
||||||
|
} label: {
|
||||||
|
Label("Add Ingredient", systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Ingredients")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - RecipeIngredientSection List Item
|
// MARK: - RecipeIngredientSection List Item
|
||||||
|
|
||||||
fileprivate struct IngredientListItem: View {
|
fileprivate struct IngredientListItem: View {
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
@Binding var ingredient: String
|
@Binding var ingredient: String
|
||||||
@Binding var servings: Double
|
@Binding var servings: Double
|
||||||
@State var recipeYield: Double
|
@State var recipeYield: Double
|
||||||
@State var recipeId: String
|
@State var recipeId: String
|
||||||
let addToGroceryListAction: () -> Void
|
var recipeName: String
|
||||||
|
|
||||||
@State var modifiedIngredient: AttributedString = ""
|
@State var modifiedIngredient: AttributedString = ""
|
||||||
@State var isSelected: Bool = false
|
|
||||||
var unmodified: Bool {
|
var unmodified: Bool {
|
||||||
servings == Double(recipeYield) || servings == 0
|
servings == Double(recipeYield) || servings == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag animation
|
// Swipe state
|
||||||
@State private var dragOffset: CGFloat = 0
|
@State private var dragOffset: CGFloat = 0
|
||||||
@State private var animationStartOffset: CGFloat = 0
|
@State private var animationStartOffset: CGFloat = 0
|
||||||
let maxDragDistance = 50.0
|
private let maxDragDistance: CGFloat = 80
|
||||||
|
private let swipeThreshold: CGFloat = 0.4
|
||||||
|
|
||||||
|
private var isInGroceryList: Bool {
|
||||||
|
groceryList.containsItem(at: recipeId, item: ingredient)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top) {
|
ZStack(alignment: .leading) {
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
// Swipe background
|
||||||
Image(systemName: "storefront")
|
if dragOffset > 0 {
|
||||||
.foregroundStyle(Color.green)
|
Image(systemName: isInGroceryList ? "cart.badge.minus" : "cart.badge.plus")
|
||||||
} else if isSelected {
|
.font(.caption)
|
||||||
Image(systemName: "checkmark.circle")
|
.bold()
|
||||||
} else {
|
.foregroundStyle(.white)
|
||||||
Image(systemName: "circle")
|
.frame(width: dragOffset, alignment: .center)
|
||||||
|
.frame(maxHeight: .infinity)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isInGroceryList ? Color.red : Color.green)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingredient row
|
||||||
|
HStack(alignment: .center) {
|
||||||
|
Text("•")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
if !unmodified && String(modifiedIngredient.characters) == ingredient {
|
if !unmodified && String(modifiedIngredient.characters) == ingredient {
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
@@ -126,10 +171,18 @@ fileprivate struct IngredientListItem: View {
|
|||||||
Text(modifiedIngredient)
|
Text(modifiedIngredient)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(5)
|
.lineLimit(5)
|
||||||
//.foregroundStyle(String(modifiedIngredient.characters) == ingredient ? .red : .primary)
|
}
|
||||||
|
if isInGroceryList {
|
||||||
|
Image(systemName: "cart")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.green)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.background(Color(.systemBackground))
|
||||||
|
.offset(x: dragOffset)
|
||||||
|
}
|
||||||
|
.clipped()
|
||||||
.onChange(of: servings) { newServings in
|
.onChange(of: servings) { newServings in
|
||||||
if recipeYield == 0 {
|
if recipeYield == 0 {
|
||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings)
|
||||||
@@ -137,34 +190,29 @@ fileprivate struct IngredientListItem: View {
|
|||||||
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
modifiedIngredient = ObservableRecipeDetail.adjustIngredient(ingredient, by: newServings/recipeYield)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.foregroundStyle(isSelected ? Color.secondary : Color.primary)
|
|
||||||
.onTapGesture {
|
|
||||||
isSelected.toggle()
|
|
||||||
}
|
|
||||||
.offset(x: dragOffset, y: 0)
|
|
||||||
.animation(.easeInOut, value: isSelected)
|
|
||||||
|
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture()
|
DragGesture()
|
||||||
.onChanged { gesture in
|
.onChanged { gesture in
|
||||||
// Update drag offset as the user drags
|
|
||||||
if animationStartOffset == 0 {
|
if animationStartOffset == 0 {
|
||||||
animationStartOffset = gesture.translation.width
|
animationStartOffset = gesture.translation.width
|
||||||
}
|
}
|
||||||
let dragAmount = gesture.translation.width
|
let dragAmount = gesture.translation.width
|
||||||
let offset = min(dragAmount, maxDragDistance + pow(dragAmount - maxDragDistance, 0.7)) - animationStartOffset
|
let offset = min(dragAmount, maxDragDistance + pow(max(0, dragAmount - maxDragDistance), 0.7)) - animationStartOffset
|
||||||
self.dragOffset = max(0, offset)
|
self.dragOffset = max(0, offset)
|
||||||
}
|
}
|
||||||
.onEnded { gesture in
|
.onEnded { _ in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
if dragOffset > maxDragDistance * 0.3 { // Swipe threshold
|
if dragOffset > maxDragDistance * swipeThreshold {
|
||||||
if groceryList.containsItem(at: recipeId, item: ingredient) {
|
if isInGroceryList {
|
||||||
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
groceryList.deleteItem(ingredient, fromRecipe: recipeId)
|
||||||
} else {
|
} else {
|
||||||
addToGroceryListAction()
|
groceryList.addItem(
|
||||||
|
ingredient,
|
||||||
|
toRecipe: recipeId,
|
||||||
|
recipeName: recipeName
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Animate back to original position
|
|
||||||
self.dragOffset = 0
|
self.dragOffset = 0
|
||||||
self.animationStartOffset = 0
|
self.animationStartOffset = 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,6 @@ struct RecipeInstructionSection: View {
|
|||||||
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
|
ForEach(viewModel.observableRecipeDetail.recipeInstructions.indices, id: \.self) { ix in
|
||||||
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
|
RecipeInstructionListItem(instruction: $viewModel.observableRecipeDetail.recipeInstructions[ix], index: ix+1)
|
||||||
}
|
}
|
||||||
if viewModel.editMode {
|
|
||||||
Button {
|
|
||||||
viewModel.presentInstructionEditView.toggle()
|
|
||||||
} label: {
|
|
||||||
Text("Edit")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
@@ -37,6 +29,44 @@ struct RecipeInstructionSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe Edit Instruction Section (Form-based)
|
||||||
|
|
||||||
|
struct RecipeEditInstructionSection: View {
|
||||||
|
@Binding var instructions: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(instructions.indices, id: \.self) { index in
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text("\(index + 1).")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
TextField("Step \(index + 1)", text: $instructions[index], axis: .vertical)
|
||||||
|
.lineLimit(1...10)
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
instructions.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
instructions.move(fromOffsets: from, toOffset: to)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
instructions.append("")
|
||||||
|
} label: {
|
||||||
|
Label("Add Step", systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Instructions")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
fileprivate struct RecipeInstructionListItem: View {
|
fileprivate struct RecipeInstructionListItem: View {
|
||||||
@Binding var instruction: String
|
@Binding var instruction: String
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ struct RecipeMetadataSection: View {
|
|||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
|
Picker("Choose", selection: $viewModel.observableRecipeDetail.recipeCategory) {
|
||||||
Text("").tag("")
|
|
||||||
ForEach(categories, id: \.self) { item in
|
ForEach(categories, id: \.self) { item in
|
||||||
Text(item)
|
Text(item == "*" ? String(localized: "Other") : item).tag(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
@@ -87,6 +86,83 @@ struct RecipeMetadataSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipe Edit Metadata Section (Form-based)
|
||||||
|
|
||||||
|
struct RecipeEditMetadataSection: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
|
@State private var showNewCategoryAlert = false
|
||||||
|
@State private var newCategoryName = ""
|
||||||
|
|
||||||
|
private let newCategoryTag = "\0_new_category_"
|
||||||
|
|
||||||
|
var categories: [String] {
|
||||||
|
var list = appState.categories.map { $0.name }
|
||||||
|
let current = viewModel.observableRecipeDetail.recipeCategory
|
||||||
|
if !current.isEmpty && current != newCategoryTag && !list.contains(current) {
|
||||||
|
list.append(current)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section("Details") {
|
||||||
|
Picker("Category", selection: $viewModel.observableRecipeDetail.recipeCategory) {
|
||||||
|
ForEach(categories, id: \.self) { item in
|
||||||
|
Text(item == "*" ? String(localized: "Other") : item).tag(item)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Text("New Category…").tag(newCategoryTag)
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.onChange(of: viewModel.observableRecipeDetail.recipeCategory) { _, newValue in
|
||||||
|
if newValue == newCategoryTag {
|
||||||
|
newCategoryName = ""
|
||||||
|
showNewCategoryAlert = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("New Category", isPresented: $showNewCategoryAlert) {
|
||||||
|
TextField("Category name", text: $newCategoryName)
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
viewModel.observableRecipeDetail.recipeCategory = "*"
|
||||||
|
}
|
||||||
|
Button("Add") {
|
||||||
|
let trimmed = newCategoryName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty {
|
||||||
|
viewModel.observableRecipeDetail.recipeCategory = trimmed
|
||||||
|
} else {
|
||||||
|
viewModel.observableRecipeDetail.recipeCategory = "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Stepper("Servings: \(viewModel.observableRecipeDetail.recipeYield)", value: $viewModel.observableRecipeDetail.recipeYield, in: 1...99)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.presentKeywordSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("Keywords")
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Spacer()
|
||||||
|
if viewModel.observableRecipeDetail.keywords.isEmpty {
|
||||||
|
Text("None")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text(viewModel.observableRecipeDetail.keywords.joined(separator: ", "))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
|
fileprivate struct PickerPopoverView<Item: Hashable & CustomStringConvertible, Collection: Sequence>: View where Collection.Element == Item {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
@Binding var value: Item
|
@Binding var value: Item
|
||||||
|
|||||||
@@ -63,10 +63,42 @@ struct RecipeNutritionSection: View {
|
|||||||
|
|
||||||
func nutritionEmpty() -> Bool {
|
func nutritionEmpty() -> Bool {
|
||||||
for nutrition in Nutrition.allCases {
|
for nutrition in Nutrition.allCases {
|
||||||
if let value = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
|
if let _ = viewModel.observableRecipeDetail.nutrition[nutrition.dictKey] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipe Edit Nutrition Section (Form-based)
|
||||||
|
|
||||||
|
struct RecipeEditNutritionSection: View {
|
||||||
|
@Binding var nutrition: [String: String]
|
||||||
|
|
||||||
|
@State private var isExpanded: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
DisclosureGroup("Nutrition Information", isExpanded: $isExpanded) {
|
||||||
|
ForEach(Nutrition.allCases, id: \.self) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item.localizedDescription)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
TextField("", text: nutritionBinding(for: item.dictKey))
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(maxWidth: 150)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nutritionBinding(for key: String) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { nutrition[key, default: ""] },
|
||||||
|
set: { nutrition[key] = $0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,17 +21,40 @@ struct RecipeToolSection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
|
RecipeListSection(list: $viewModel.observableRecipeDetail.tool)
|
||||||
|
|
||||||
if viewModel.editMode {
|
|
||||||
Button {
|
|
||||||
viewModel.presentToolEditView.toggle()
|
|
||||||
} label: {
|
|
||||||
Text("Edit")
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
}
|
|
||||||
}.padding()
|
}.padding()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipe Edit Tool Section (Form-based)
|
||||||
|
|
||||||
|
struct RecipeEditToolSection: View {
|
||||||
|
@Binding var tools: [String]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
ForEach(tools.indices, id: \.self) { index in
|
||||||
|
HStack {
|
||||||
|
TextField("Tool", text: $tools[index])
|
||||||
|
Image(systemName: "line.3.horizontal")
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete { indexSet in
|
||||||
|
tools.remove(atOffsets: indexSet)
|
||||||
|
}
|
||||||
|
.onMove { from, to in
|
||||||
|
tools.move(fromOffsets: from, toOffset: to)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
tools.append("")
|
||||||
|
} label: {
|
||||||
|
Label("Add Tool", systemImage: "plus.circle.fill")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Tools")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by Vincent Meilinger on 15.09.23.
|
// Created by Vincent Meilinger on 15.09.23.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import EventKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
@@ -13,40 +14,19 @@ import SwiftUI
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var groceryListManager: GroceryListManager
|
||||||
@ObservedObject var userSettings = UserSettings.shared
|
@ObservedObject var userSettings = UserSettings.shared
|
||||||
@ObservedObject var viewModel = ViewModel()
|
@StateObject var viewModel = ViewModel()
|
||||||
|
@State private var reminderLists: [EKCalendar] = []
|
||||||
|
@State private var remindersPermission: EKAuthorizationStatus = .notDetermined
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
HStack(alignment: .center) {
|
|
||||||
if let avatarImage = viewModel.avatarImage {
|
|
||||||
Image(uiImage: avatarImage)
|
|
||||||
.resizable()
|
|
||||||
.clipShape(Circle())
|
|
||||||
.frame(width: 100, height: 100)
|
|
||||||
|
|
||||||
}
|
|
||||||
if let userData = viewModel.userData {
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(userData.userDisplayName)
|
|
||||||
.font(.title)
|
|
||||||
.padding(.leading)
|
|
||||||
Text("Username: \(userData.userId)")
|
|
||||||
.font(.subheadline)
|
|
||||||
.padding(.leading)
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: Add actions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
|
Picker("Select a default cookbook", selection: $userSettings.defaultCategory) {
|
||||||
Text("None").tag("None")
|
Text("None").tag("None")
|
||||||
ForEach(appState.categories, id: \.name) { category in
|
ForEach(appState.categories, id: \.name) { category in
|
||||||
Text(category.name == "*" ? "Other" : category.name).tag(category)
|
Text(category.name == "*" ? String(localized: "Other") : category.name).tag(category)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -55,6 +35,66 @@ struct SettingsView: View {
|
|||||||
Text("The selected cookbook will open on app launch by default.")
|
Text("The selected cookbook will open on app launch by default.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Appearance", selection: $userSettings.appearanceMode) {
|
||||||
|
ForEach(AppearanceMode.allValues, id: \.self) { mode in
|
||||||
|
Text(mode.descriptor()).tag(mode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
Text("Choose whether the app follows the system appearance or always uses light or dark mode.")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
Picker("Grocery list storage", selection: $userSettings.groceryListMode) {
|
||||||
|
ForEach(GroceryListMode.allValues, id: \.self) { mode in
|
||||||
|
Text(mode.descriptor()).tag(mode.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
|
||||||
|
if remindersPermission == .notDetermined {
|
||||||
|
Button("Grant Reminders Access") {
|
||||||
|
Task {
|
||||||
|
let granted = await groceryListManager.requestRemindersAccess()
|
||||||
|
remindersPermission = groceryListManager.remindersPermissionStatus
|
||||||
|
if granted {
|
||||||
|
reminderLists = groceryListManager.availableReminderLists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if remindersPermission == .denied || remindersPermission == .restricted {
|
||||||
|
Text("Reminders access was denied. Please enable it in System Settings to use this feature.")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Open Settings") {
|
||||||
|
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if remindersPermission == .fullAccess {
|
||||||
|
Picker("Reminders list", selection: $userSettings.remindersListIdentifier) {
|
||||||
|
ForEach(reminderLists, id: \.calendarIdentifier) { list in
|
||||||
|
Text(list.title).tag(list.calendarIdentifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toggle(isOn: $userSettings.grocerySyncEnabled) {
|
||||||
|
Text("Sync grocery list across devices")
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Grocery List")
|
||||||
|
} footer: {
|
||||||
|
if userSettings.grocerySyncEnabled {
|
||||||
|
Text("Grocery list state is synced via your Nextcloud server by storing it alongside recipe data.")
|
||||||
|
} else if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue {
|
||||||
|
Text("Grocery items will be saved to Apple Reminders. The Grocery List tab will be hidden since you can manage items directly in the Reminders app.")
|
||||||
|
} else {
|
||||||
|
Text("Grocery items are stored locally on this device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Toggle(isOn: $userSettings.expandNutritionSection) {
|
Toggle(isOn: $userSettings.expandNutritionSection) {
|
||||||
Text("Expand nutrition section")
|
Text("Expand nutrition section")
|
||||||
@@ -157,13 +197,6 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section(header: Text("Acknowledgements")) {
|
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) {
|
VStack(alignment: .leading) {
|
||||||
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
if let url = URL(string: "https://github.com/techprimate/TPPDF") {
|
||||||
Link("TPPDF", destination: url)
|
Link("TPPDF", destination: url)
|
||||||
@@ -186,7 +219,16 @@ struct SettingsView: View {
|
|||||||
Text(viewModel.alertType.getMessage())
|
Text(viewModel.alertType.getMessage())
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await viewModel.getUserData()
|
remindersPermission = groceryListManager.remindersPermissionStatus
|
||||||
|
if remindersPermission == .fullAccess {
|
||||||
|
reminderLists = groceryListManager.availableReminderLists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: userSettings.groceryListMode) { _, _ in
|
||||||
|
remindersPermission = groceryListManager.remindersPermissionStatus
|
||||||
|
if remindersPermission == .fullAccess {
|
||||||
|
reminderLists = groceryListManager.availableReminderLists()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,9 +248,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
extension SettingsView {
|
extension SettingsView {
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var avatarImage: UIImage? = nil
|
|
||||||
@Published var userData: UserData? = nil
|
|
||||||
|
|
||||||
@Published var showAlert: Bool = false
|
@Published var showAlert: Bool = false
|
||||||
fileprivate var alertType: SettingsAlert = .NONE
|
fileprivate var alertType: SettingsAlert = .NONE
|
||||||
|
|
||||||
@@ -233,16 +272,6 @@ extension SettingsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUserData() async {
|
|
||||||
let (data, _) = await NextcloudApi.getAvatar()
|
|
||||||
let (userData, _) = await NextcloudApi.getHoverCard()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.avatarImage = data
|
|
||||||
self.userData = userData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import SwiftUI
|
|||||||
|
|
||||||
|
|
||||||
struct GroceryListTabView: View {
|
struct GroceryListTabView: View {
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -55,7 +55,7 @@ struct GroceryListTabView: View {
|
|||||||
groceryList.deleteAll()
|
groceryList.deleteAll()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Delete")
|
Text("Delete")
|
||||||
.foregroundStyle(Color.nextcloudBlue)
|
.foregroundStyle(.primary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
415
Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
Normal file
415
Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
//
|
||||||
|
// MealPlanTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MealPlanTabView: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
|
||||||
|
@State private var weekOffset: Int = 0
|
||||||
|
@State private var addRecipeDate: Date? = nil
|
||||||
|
|
||||||
|
private var calendar: Calendar { Calendar.current }
|
||||||
|
|
||||||
|
private var weekDates: [Date] {
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
// Find start of current week (Monday)
|
||||||
|
let weekday = calendar.component(.weekday, from: today)
|
||||||
|
let daysToMonday = (weekday + 5) % 7
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
||||||
|
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekLabel: String {
|
||||||
|
if weekOffset == 0 {
|
||||||
|
return String(localized: "This Week")
|
||||||
|
} else if weekOffset == 1 {
|
||||||
|
return String(localized: "Next Week")
|
||||||
|
} else if weekOffset == -1 {
|
||||||
|
return String(localized: "Last Week")
|
||||||
|
} else {
|
||||||
|
return weekRangeString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekRangeString: String {
|
||||||
|
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM."
|
||||||
|
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
weekNavigationHeader
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
ForEach(weekDates, id: \.self) { date in
|
||||||
|
MealPlanDayRow(
|
||||||
|
date: date,
|
||||||
|
entries: mealPlan.entries(for: date),
|
||||||
|
isToday: calendar.isDateInToday(date),
|
||||||
|
onAdd: {
|
||||||
|
addRecipeDate = date
|
||||||
|
},
|
||||||
|
onRemove: { entry in
|
||||||
|
withAnimation {
|
||||||
|
mealPlan.removeRecipe(recipeId: entry.recipeId, fromDate: entry.dateString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Meal Plan")
|
||||||
|
.refreshable {
|
||||||
|
await appState.getCategories()
|
||||||
|
for category in appState.categories {
|
||||||
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
|
}
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
await mealPlan.syncManager?.performSync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
|
.sheet(item: $addRecipeDate) { date in
|
||||||
|
RecipePickerForMealPlan(date: date)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekNavigationHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset -= 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(weekLabel)
|
||||||
|
.font(.headline)
|
||||||
|
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
||||||
|
Text(weekRangeString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset += 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Day Row
|
||||||
|
|
||||||
|
fileprivate struct MealPlanDayRow: View {
|
||||||
|
let date: Date
|
||||||
|
let entries: [MealPlanEntry]
|
||||||
|
let isToday: Bool
|
||||||
|
let onAdd: () -> Void
|
||||||
|
let onRemove: (MealPlanEntry) -> Void
|
||||||
|
|
||||||
|
private var dayNumber: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayName: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEE"
|
||||||
|
return formatter.string(from: date).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
// Day label
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(dayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(isToday ? .white : .secondary)
|
||||||
|
Text(dayNumber)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(isToday ? .white : .primary)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 54)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isToday ? Color.nextcloudBlue : Color.clear)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry or add button
|
||||||
|
if let entry = entries.first, let recipeIdInt = Int(entry.recipeId) {
|
||||||
|
NavigationLink(value: Recipe(
|
||||||
|
name: entry.recipeName,
|
||||||
|
keywords: nil,
|
||||||
|
dateCreated: nil,
|
||||||
|
dateModified: nil,
|
||||||
|
imageUrl: nil,
|
||||||
|
imagePlaceholderUrl: nil,
|
||||||
|
recipe_id: recipeIdInt
|
||||||
|
)) {
|
||||||
|
MealPlanEntryCard(entry: entry, onRemove: {
|
||||||
|
onRemove(entry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if let entry = entries.first {
|
||||||
|
MealPlanEntryCard(entry: entry, onRemove: {
|
||||||
|
onRemove(entry)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Button(action: onAdd) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.nextcloudBlue.opacity(0.15))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 68)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Entry Card
|
||||||
|
|
||||||
|
fileprivate struct MealPlanEntryCard: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let entry: MealPlanEntry
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
@State private var recipeThumb: UIImage?
|
||||||
|
@State private var totalTimeText: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 44)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 44)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.recipeName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if let totalTimeText {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(totalTimeText)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onRemove()
|
||||||
|
} label: {
|
||||||
|
Label("Remove", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard let recipeIdInt = Int(entry.recipeId) else { return }
|
||||||
|
recipeThumb = await appState.getImage(
|
||||||
|
id: recipeIdInt,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
if let detail = await appState.getRecipe(
|
||||||
|
id: recipeIdInt,
|
||||||
|
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||||
|
) {
|
||||||
|
if let totalTime = detail.totalTime, let text = DurationComponents.ptToText(totalTime) {
|
||||||
|
totalTimeText = text
|
||||||
|
} else if let prepTime = detail.prepTime, let text = DurationComponents.ptToText(prepTime) {
|
||||||
|
totalTimeText = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe Picker Sheet
|
||||||
|
|
||||||
|
struct RecipePickerForMealPlan: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var allRecipes: [Recipe] = []
|
||||||
|
|
||||||
|
private var filteredRecipes: [Recipe] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allRecipes
|
||||||
|
}
|
||||||
|
return allRecipes.filter {
|
||||||
|
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateLabel: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(filteredRecipes, id: \.recipe_id) { recipe in
|
||||||
|
Button {
|
||||||
|
mealPlan.assignRecipe(
|
||||||
|
recipeId: String(recipe.recipe_id),
|
||||||
|
recipeName: recipe.name,
|
||||||
|
toDates: [date]
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
RecipePickerRow(recipe: recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(dateLabel)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, prompt: String(localized: "Search recipes"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
allRecipes = await appState.getRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe Picker Row
|
||||||
|
|
||||||
|
fileprivate struct RecipePickerRow: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let recipe: Recipe
|
||||||
|
@State private var recipeThumb: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(recipe.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
recipeThumb = await appState.getImage(
|
||||||
|
id: recipe.recipe_id,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Date Identifiable Extension
|
||||||
|
|
||||||
|
extension Date: @retroactive Identifiable {
|
||||||
|
public var id: TimeInterval { timeIntervalSince1970 }
|
||||||
|
}
|
||||||
@@ -12,14 +12,111 @@ import SwiftUI
|
|||||||
|
|
||||||
struct RecipeTabView: View {
|
struct RecipeTabView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryList
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
@ObservedObject private var userSettings = UserSettings.shared
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
|
@State private var showManualReorderSheet = false
|
||||||
|
|
||||||
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
private let gridColumns = [GridItem(.adaptive(minimum: 160), spacing: 12)]
|
||||||
|
|
||||||
|
private static let allRecipesSentinel = "__ALL_RECIPES__"
|
||||||
|
|
||||||
|
private var allCategoryNames: [String] {
|
||||||
|
let names = appState.categories.filter { $0.recipe_count > 0 }.map { $0.name }
|
||||||
|
let totalCount = appState.categories.reduce(0) { $0 + $1.recipe_count }
|
||||||
|
guard totalCount > 0 else { return names }
|
||||||
|
return [Self.allRecipesSentinel] + names
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortedCategoryNames: [String] {
|
||||||
|
let names = allCategoryNames
|
||||||
|
guard let mode = CategorySortMode(rawValue: userSettings.categorySortMode) else {
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
let ascending = userSettings.categorySortAscending
|
||||||
|
switch mode {
|
||||||
|
case .recentlyUsed:
|
||||||
|
return names.sorted { a, b in
|
||||||
|
let dateA = appState.categoryAccessDates[a] ?? .distantPast
|
||||||
|
let dateB = appState.categoryAccessDates[b] ?? .distantPast
|
||||||
|
return ascending ? dateA > dateB : dateA < dateB
|
||||||
|
}
|
||||||
|
case .alphabetical:
|
||||||
|
return names.sorted { a, b in
|
||||||
|
let nameA = a == Self.allRecipesSentinel ? String(localized: "All Recipes") : a
|
||||||
|
let nameB = b == Self.allRecipesSentinel ? String(localized: "All Recipes") : b
|
||||||
|
let result = nameA.localizedCaseInsensitiveCompare(nameB)
|
||||||
|
return ascending ? result == .orderedAscending : result == .orderedDescending
|
||||||
|
}
|
||||||
|
case .manual:
|
||||||
|
let order = appState.manualCategoryOrder
|
||||||
|
return names.sorted { a, b in
|
||||||
|
let indexA = order.firstIndex(of: a) ?? Int.max
|
||||||
|
let indexB = order.firstIndex(of: b) ?? Int.max
|
||||||
|
return indexA < indexB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasCategories: Bool {
|
||||||
|
appState.categories.contains { $0.recipe_count > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentSortMode: CategorySortMode {
|
||||||
|
CategorySortMode(rawValue: userSettings.categorySortMode) ?? .recentlyUsed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var categorySortMenu: some View {
|
||||||
|
Menu {
|
||||||
|
ForEach(CategorySortMode.allCases, id: \.self) { mode in
|
||||||
|
Button {
|
||||||
|
userSettings.categorySortMode = mode.rawValue
|
||||||
|
userSettings.categorySortAscending = true
|
||||||
|
if mode == .manual && appState.manualCategoryOrder.isEmpty {
|
||||||
|
appState.updateManualCategoryOrder(allCategoryNames)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
if currentSortMode == mode {
|
||||||
|
Label(mode.descriptor(), systemImage: "checkmark")
|
||||||
|
} else {
|
||||||
|
Text(mode.descriptor())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSortMode.supportsInvert {
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
userSettings.categorySortAscending.toggle()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
userSettings.categorySortAscending ? String(localized: "Reverse Order") : String(localized: "Default Order"),
|
||||||
|
systemImage: userSettings.categorySortAscending ? "arrow.up.arrow.down" : "arrow.up.arrow.down"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentSortMode == .manual {
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
showManualReorderSheet = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "Edit Order"), systemImage: "arrow.up.arrow.down.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.up.arrow.down")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
|
NavigationStack(path: $viewModel.sidebarPath) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
// Recently Viewed
|
// Recently Viewed
|
||||||
@@ -27,16 +124,20 @@ struct RecipeTabView: View {
|
|||||||
RecentRecipesSection()
|
RecentRecipesSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories header
|
// Categories header with sort button
|
||||||
if !appState.categories.isEmpty {
|
if hasCategories {
|
||||||
|
HStack {
|
||||||
Text("Categories")
|
Text("Categories")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
.bold()
|
.bold()
|
||||||
|
Spacer()
|
||||||
|
categorySortMenu
|
||||||
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category grid
|
// Category grid
|
||||||
if appState.categories.isEmpty {
|
if !hasCategories {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: "book.closed")
|
Image(systemName: "book.closed")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
@@ -53,21 +154,34 @@ struct RecipeTabView: View {
|
|||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
} else {
|
} else {
|
||||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||||
ForEach(appState.categories) { category in
|
ForEach(sortedCategoryNames, id: \.self) { name in
|
||||||
|
if name == Self.allRecipesSentinel {
|
||||||
Button {
|
Button {
|
||||||
viewModel.selectedCategory = category
|
appState.trackCategoryAccess(Self.allRecipesSentinel)
|
||||||
|
viewModel.navigateToAllRecipes()
|
||||||
|
} label: {
|
||||||
|
AllRecipesCategoryCardView()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if let category = appState.categories.first(where: { $0.name == name && $0.recipe_count > 0 }) {
|
||||||
|
Button {
|
||||||
|
appState.trackCategoryAccess(category.name)
|
||||||
if horizontalSizeClass == .compact {
|
if horizontalSizeClass == .compact {
|
||||||
viewModel.navigateToCategory = true
|
viewModel.navigateToCategory(category)
|
||||||
|
} else {
|
||||||
|
viewModel.selectedCategory = category
|
||||||
|
viewModel.showAllRecipesInDetail = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
CategoryCardView(
|
CategoryCardView(
|
||||||
category: category,
|
category: category,
|
||||||
isSelected: viewModel.selectedCategory?.name == category.name
|
isSelected: !viewModel.showAllRecipesInDetail && viewModel.selectedCategory?.name == category.name
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,43 +191,85 @@ struct RecipeTabView: View {
|
|||||||
.toolbar {
|
.toolbar {
|
||||||
RecipeTabViewToolBar()
|
RecipeTabViewToolBar()
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $viewModel.presentSettingsView) {
|
.navigationDestination(for: SidebarDestination.self) { destination in
|
||||||
|
switch destination {
|
||||||
|
case .settings:
|
||||||
SettingsView()
|
SettingsView()
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
case .newRecipe:
|
||||||
|
RecipeView(viewModel: {
|
||||||
|
let vm = RecipeView.ViewModel()
|
||||||
|
if let imported = viewModel.importedRecipeDetail {
|
||||||
|
vm.preloadedRecipeDetail = imported
|
||||||
}
|
}
|
||||||
.navigationDestination(isPresented: $viewModel.presentEditView) {
|
return vm
|
||||||
RecipeView(viewModel: RecipeView.ViewModel())
|
}())
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
.onAppear {
|
||||||
|
viewModel.importedRecipeDetail = nil
|
||||||
|
}
|
||||||
|
case .category(let category):
|
||||||
|
RecipeListView(
|
||||||
|
categoryName: category.name,
|
||||||
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||||
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
||||||
|
)
|
||||||
|
.id(category.id)
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
case .allRecipes:
|
||||||
|
AllRecipesListView(
|
||||||
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||||
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
||||||
|
)
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
}
|
.environmentObject(mealPlan)
|
||||||
.navigationDestination(isPresented: $viewModel.navigateToCategory) {
|
|
||||||
if let category = viewModel.selectedCategory {
|
|
||||||
RecipeListView(
|
|
||||||
categoryName: category.name,
|
|
||||||
showEditView: $viewModel.presentEditView
|
|
||||||
)
|
|
||||||
.id(category.id)
|
|
||||||
.environmentObject(appState)
|
|
||||||
.environmentObject(groceryList)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
if let category = viewModel.selectedCategory {
|
if viewModel.showAllRecipesInDetail {
|
||||||
|
AllRecipesListView(
|
||||||
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||||
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
||||||
|
)
|
||||||
|
} else if let category = viewModel.selectedCategory {
|
||||||
RecipeListView(
|
RecipeListView(
|
||||||
categoryName: category.name,
|
categoryName: category.name,
|
||||||
showEditView: $viewModel.presentEditView
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||||
|
onImportFromURL: { viewModel.showImportURLSheet = true }
|
||||||
)
|
)
|
||||||
.id(category.id)
|
.id(category.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.nextcloudBlue)
|
.sheet(isPresented: $viewModel.showImportURLSheet, onDismiss: {
|
||||||
|
viewModel.pendingImportURL = nil
|
||||||
|
}) {
|
||||||
|
ImportURLSheet(
|
||||||
|
onImport: { recipeDetail in
|
||||||
|
viewModel.navigateToImportedRecipe(recipeDetail: recipeDetail)
|
||||||
|
},
|
||||||
|
initialURL: viewModel.pendingImportURL ?? ""
|
||||||
|
)
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showManualReorderSheet) {
|
||||||
|
CategoryReorderSheet()
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
let connection = await appState.checkServerConnection()
|
let connection = await appState.checkServerConnection()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -130,19 +286,57 @@ struct RecipeTabView: View {
|
|||||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
await appState.getCategoryImage(for: category.name)
|
await appState.getCategoryImage(for: category.name)
|
||||||
}
|
}
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
await mealPlan.syncManager?.performSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SidebarDestination: Hashable {
|
||||||
|
case settings
|
||||||
|
case newRecipe
|
||||||
|
case category(Category)
|
||||||
|
case allRecipes
|
||||||
|
}
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var presentEditView: Bool = false
|
@Published var sidebarPath = NavigationPath()
|
||||||
@Published var presentSettingsView: Bool = false
|
|
||||||
@Published var navigateToCategory: Bool = false
|
|
||||||
|
|
||||||
@Published var presentLoadingIndicator: Bool = false
|
@Published var presentLoadingIndicator: Bool = false
|
||||||
@Published var presentConnectionPopover: Bool = false
|
@Published var presentConnectionPopover: Bool = false
|
||||||
@Published var serverConnection: Bool = false
|
@Published var serverConnection: Bool = false
|
||||||
|
|
||||||
@Published var selectedCategory: Category? = nil
|
@Published var selectedCategory: Category? = nil
|
||||||
|
@Published var showAllRecipesInDetail: Bool = false
|
||||||
|
|
||||||
|
@Published var showImportURLSheet: Bool = false
|
||||||
|
@Published var importedRecipeDetail: RecipeDetail? = nil
|
||||||
|
@Published var pendingImportURL: String? = nil
|
||||||
|
|
||||||
|
func navigateToSettings() {
|
||||||
|
sidebarPath.append(SidebarDestination.settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateToNewRecipe() {
|
||||||
|
sidebarPath.append(SidebarDestination.newRecipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateToImportedRecipe(recipeDetail: RecipeDetail) {
|
||||||
|
importedRecipeDetail = recipeDetail
|
||||||
|
sidebarPath.append(SidebarDestination.newRecipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateToCategory(_ category: Category) {
|
||||||
|
selectedCategory = category
|
||||||
|
showAllRecipesInDetail = false
|
||||||
|
sidebarPath.append(SidebarDestination.category(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateToAllRecipes() {
|
||||||
|
selectedCategory = nil
|
||||||
|
showAllRecipesInDetail = true
|
||||||
|
sidebarPath.append(SidebarDestination.allRecipes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +345,7 @@ struct RecipeTabView: View {
|
|||||||
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
|
||||||
var body: some ToolbarContent {
|
var body: some ToolbarContent {
|
||||||
// Top left menu toolbar item
|
// Top left menu toolbar item
|
||||||
@@ -165,6 +360,9 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
await appState.getCategory(named: category.name, fetchMode: .preferServer)
|
||||||
}
|
}
|
||||||
await appState.updateAllRecipeDetails()
|
await appState.updateAllRecipeDetails()
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
await mealPlan.syncManager?.performSync()
|
||||||
|
}
|
||||||
viewModel.presentLoadingIndicator = false
|
viewModel.presentLoadingIndicator = false
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -173,7 +371,7 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
viewModel.presentSettingsView = true
|
viewModel.navigateToSettings()
|
||||||
} label: {
|
} label: {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
Image(systemName: "gearshape")
|
Image(systemName: "gearshape")
|
||||||
@@ -212,9 +410,18 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
|
|||||||
|
|
||||||
// Create new recipes
|
// Create new recipes
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
Button {
|
Button {
|
||||||
Logger.view.debug("Add new recipe")
|
Logger.view.debug("Add new recipe")
|
||||||
viewModel.presentEditView = true
|
viewModel.navigateToNewRecipe()
|
||||||
|
} label: {
|
||||||
|
Label("Create New Recipe", systemImage: "square.and.pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
viewModel.showImportURLSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Import from URL", systemImage: "link")
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus.circle.fill")
|
Image(systemName: "plus.circle.fill")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
struct SearchTabView: View {
|
struct SearchTabView: View {
|
||||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -113,6 +114,7 @@ struct SearchTabView: View {
|
|||||||
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||||
.onSubmit(of: .search) {
|
.onSubmit(of: .search) {
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict/>
|
<dict>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.vincentmeilinger.nextcloud-cookbook</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>nextcloud-cookbook</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
21
ShareExtension/Info.plist
Normal file
21
ShareExtension/Info.plist
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
79
ShareExtension/ShareViewController.swift
Normal file
79
ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
//
|
||||||
|
// ShareViewController.swift
|
||||||
|
// ShareExtension
|
||||||
|
//
|
||||||
|
// Created by Hendrik Hogertz on 15.02.26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
handleSharedItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSharedItems() {
|
||||||
|
guard let extensionItems = extensionContext?.inputItems as? [NSExtensionItem] else {
|
||||||
|
completeRequest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in extensionItems {
|
||||||
|
guard let attachments = item.attachments else { continue }
|
||||||
|
for provider in attachments {
|
||||||
|
if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.url.identifier) { [weak self] item, _ in
|
||||||
|
if let url = item as? URL {
|
||||||
|
self?.openMainApp(with: url.absoluteString)
|
||||||
|
} else {
|
||||||
|
self?.completeRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier) { [weak self] item, _ in
|
||||||
|
if let text = item as? String, let url = URL(string: text), url.scheme?.hasPrefix("http") == true {
|
||||||
|
self?.openMainApp(with: url.absoluteString)
|
||||||
|
} else {
|
||||||
|
self?.completeRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completeRequest()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openMainApp(with urlString: String) {
|
||||||
|
guard let encoded = urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||||
|
let appURL = URL(string: "nextcloud-cookbook://import?url=\(encoded)")
|
||||||
|
else {
|
||||||
|
completeRequest()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the responder chain to open the URL
|
||||||
|
var responder: UIResponder? = self
|
||||||
|
while let r = responder {
|
||||||
|
if let application = r as? UIApplication {
|
||||||
|
application.open(appURL, options: [:], completionHandler: nil)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
responder = r.next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give the system a moment to process the URL before dismissing
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
||||||
|
self?.completeRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeRequest() {
|
||||||
|
extensionContext?.completeRequest(returningItems: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user