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.