diff --git a/.gitignore b/.gitignore
index fd6e1ac..86a73c3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +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
diff --git a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj
index 2fd33ac..418d0ab 100644
--- a/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj
+++ b/Nextcloud Cookbook iOS Client.xcodeproj/project.pbxproj
@@ -933,13 +933,14 @@
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
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=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -955,7 +956,7 @@
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -977,13 +978,14 @@
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_ASSET_PATHS = "\"Nextcloud Cookbook iOS Client/Preview Content\"";
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Nextcloud-Cookbook-iOS-Client-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = Cookbook;
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=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -999,7 +1001,7 @@
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.10.1;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -1017,12 +1019,12 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -1040,12 +1042,12 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientTests";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -1062,12 +1064,12 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -1084,12 +1086,12 @@
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-ClientUITests";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx";
@@ -1107,7 +1109,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
@@ -1121,7 +1123,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client.ShareExtension";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
@@ -1144,7 +1146,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
- DEVELOPMENT_TEAM = EF2ABA36D9;
+ DEVELOPMENT_TEAM = JGFU6788BP;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ShareExtension/Info.plist;
@@ -1158,7 +1160,7 @@
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
- PRODUCT_BUNDLE_IDENTIFIER = "VincentMeilinger.Nextcloud-Cookbook-iOS-Client.ShareExtension";
+ PRODUCT_BUNDLE_IDENTIFIER = eu.hogertz.cookbook.shareExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
diff --git a/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift b/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
index 06325cd..cbedc53 100644
--- a/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
+++ b/Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
@@ -101,7 +101,7 @@ class GroceryStateSyncManager {
// MARK: - Initial Sync
/// Pushes any local-only items and reconciles server items on app launch.
- func performInitialSync() async {
+ func performSync() async {
guard let appState, let groceryManager else { return }
await loadPendingRemovals()
@@ -208,7 +208,7 @@ class GroceryStateSyncManager {
// MARK: - Pending Removal Tracking
/// Records a recipe ID whose grocery items were fully removed, so that
- /// `performInitialSync` can push the deletion even after the key disappears
+ /// `performSync` can push the deletion even after the key disappears
/// from `groceryDict`.
func trackPendingRemoval(recipeId: String) {
pendingRemovalRecipeIds.insert(recipeId)
diff --git a/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift b/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
index 4f07b68..57525a1 100644
--- a/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
+++ b/Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
@@ -60,27 +60,75 @@ class MealPlanSyncManager {
mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName)
}
- // MARK: - Initial Sync
+ // MARK: - Full Sync
- func performInitialSync() async {
+ func performSync() async {
guard let appState, let mealPlanManager else { return }
- let recipeIds = Array(mealPlanManager.entriesByDate.values.flatMap { $0 }.map(\.recipeId))
- let uniqueIds = Array(Set(recipeIds))
-
- for recipeId in uniqueIds {
- guard let recipeIdInt = Int(recipeId) else { continue }
-
+ // 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)
+ }
- if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
- reconcileFromServer(
- serverAssignment: serverRecipe.mealPlanAssignment,
- recipeId: recipeId,
- recipeName: serverRecipe.name
- )
+ // 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
diff --git a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift
index a4a8cde..0bafe56 100644
--- a/Nextcloud Cookbook iOS Client/Data/UserSettings.swift
+++ b/Nextcloud Cookbook iOS Client/Data/UserSettings.swift
@@ -175,6 +175,12 @@ class UserSettings: ObservableObject {
}
}
+ @Published var lastMealPlanSyncDate: Date? {
+ didSet {
+ UserDefaults.standard.set(lastMealPlanSyncDate, forKey: "lastMealPlanSyncDate")
+ }
+ }
+
init() {
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
self.token = UserDefaults.standard.object(forKey: "token") as? String ?? ""
@@ -203,6 +209,7 @@ class UserSettings: ObservableObject {
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 token != "" && username != "" {
diff --git a/Nextcloud Cookbook iOS Client/Views/MainView.swift b/Nextcloud Cookbook iOS Client/Views/MainView.swift
index 01729d9..fffde16 100644
--- a/Nextcloud Cookbook iOS Client/Views/MainView.swift
+++ b/Nextcloud Cookbook iOS Client/Views/MainView.swift
@@ -99,12 +99,12 @@ struct MainView: View {
await groceryList.load()
groceryList.configureSyncManager(appState: appState)
if UserSettings.shared.grocerySyncEnabled {
- await groceryList.syncManager?.performInitialSync()
+ await groceryList.syncManager?.performSync()
}
await mealPlan.load()
mealPlan.configureSyncManager(appState: appState)
if UserSettings.shared.mealPlanSyncEnabled {
- await mealPlan.syncManager?.performInitialSync()
+ await mealPlan.syncManager?.performSync()
}
recipeViewModel.presentLoadingIndicator = false
}
diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
index 2a39b0d..5ba17b2 100644
--- a/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
+++ b/Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
@@ -73,6 +73,15 @@ struct MealPlanTabView: View {
}
}
.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)
diff --git a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
index 6fb04ce..3a7e348 100644
--- a/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
+++ b/Nextcloud Cookbook iOS Client/Views/Tabs/RecipeTabView.swift
@@ -286,6 +286,9 @@ struct RecipeTabView: View {
await appState.getCategory(named: category.name, fetchMode: .preferServer)
await appState.getCategoryImage(for: category.name)
}
+ if UserSettings.shared.mealPlanSyncEnabled {
+ await mealPlan.syncManager?.performSync()
+ }
}
}
@@ -342,6 +345,7 @@ struct RecipeTabView: View {
fileprivate struct RecipeTabViewToolBar: ToolbarContent {
@EnvironmentObject var appState: AppState
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
+ @EnvironmentObject var mealPlan: MealPlanManager
var body: some ToolbarContent {
// Top left menu toolbar item
@@ -356,6 +360,9 @@ fileprivate struct RecipeTabViewToolBar: ToolbarContent {
await appState.getCategory(named: category.name, fetchMode: .preferServer)
}
await appState.updateAllRecipeDetails()
+ if UserSettings.shared.mealPlanSyncEnabled {
+ await mealPlan.syncManager?.performSync()
+ }
viewModel.presentLoadingIndicator = false
}
} label: {
diff --git a/Nextcloud-Cookbook-iOS-Client-Info.plist b/Nextcloud-Cookbook-iOS-Client-Info.plist
index 5f291fa..9532590 100644
--- a/Nextcloud-Cookbook-iOS-Client-Info.plist
+++ b/Nextcloud-Cookbook-iOS-Client-Info.plist
@@ -13,7 +13,5 @@
- NSRemindersFullAccessUsageDescription
- This app uses Reminders to save your grocery list items.