Add cross-device grocery list sync via Nextcloud Cookbook API

Store a _groceryState JSON field on each recipe to track which
ingredients have been added, completed, or removed. Uses per-item
last-writer-wins conflict resolution with ISO 8601 timestamps.
Debounced push (2s) avoids excessive API calls; pull reconciles
on recipe open and app launch. Includes a settings toggle to
enable/disable sync.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 04:14:02 +01:00
parent 501434bd0e
commit 5890dbcad4
11 changed files with 323 additions and 10 deletions

View File

@@ -72,6 +72,8 @@
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; }; D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE002D0A000100000001 /* GroceryListMode.swift */; };
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; }; D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */; };
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; }; D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0CE042D0A000300000003 /* GroceryListManager.swift */; };
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; };
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -161,6 +163,8 @@
D1A0CE002D0A000100000001 /* GroceryListMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryListMode.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>"; }; 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>"; }; 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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@@ -301,6 +305,8 @@
D1A0CE002D0A000100000001 /* GroceryListMode.swift */, D1A0CE002D0A000100000001 /* GroceryListMode.swift */,
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */, D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */,
D1A0CE042D0A000300000003 /* GroceryListManager.swift */, D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -638,6 +644,8 @@
D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */, D1A0CE012D0A000100000001 /* GroceryListMode.swift in Sources */,
D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */, D1A0CE032D0A000200000002 /* RemindersGroceryStore.swift in Sources */,
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */, D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -14,6 +14,11 @@ class GroceryListManager: ObservableObject {
let localStore = GroceryList() let localStore = GroceryList()
let remindersStore = RemindersGroceryStore() 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 { private var mode: GroceryListMode {
GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp GroceryListMode(rawValue: UserSettings.shared.groceryListMode) ?? .inApp
@@ -23,11 +28,29 @@ class GroceryListManager: ObservableObject {
remindersStore.onDataChanged = { [weak self] in remindersStore.onDataChanged = { [weak self] in
guard let self else { return } guard let self else { return }
if self.mode == .appleReminders { if self.mode == .appleReminders {
let previousDict = self.groceryDict
self.groceryDict = self.remindersStore.groceryDict self.groceryDict = self.remindersStore.groceryDict
// Only sync recipes that changed externally (e.g. checked off in Reminders app),
// not ones we just modified ourselves.
for recipeId in self.remindersStore.groceryDict.keys {
guard !self.recentlyModifiedByUs.contains(recipeId) else { continue }
// Detect if item count changed (external add/remove/complete)
let oldCount = previousDict[recipeId]?.items.count ?? 0
let newCount = self.remindersStore.groceryDict[recipeId]?.items.count ?? 0
if oldCount != newCount {
self.syncManager?.scheduleSync(forRecipeId: recipeId)
}
}
self.recentlyModifiedByUs.removeAll()
} }
} }
} }
func configureSyncManager(appState: AppState) {
syncManager = GroceryStateSyncManager(appState: appState, groceryManager: self)
}
// MARK: - Grocery Operations // MARK: - Grocery Operations
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) { func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
@@ -36,9 +59,11 @@ class GroceryListManager: ObservableObject {
localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) localStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
groceryDict = localStore.groceryDict groceryDict = localStore.groceryDict
case .appleReminders: case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName) remindersStore.addItem(itemName, toRecipe: recipeId, recipeName: recipeName)
groceryDict = remindersStore.groceryDict groceryDict = remindersStore.groceryDict
} }
syncManager?.scheduleSync(forRecipeId: recipeId)
} }
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) { func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
@@ -47,9 +72,11 @@ class GroceryListManager: ObservableObject {
localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName) localStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
groceryDict = localStore.groceryDict groceryDict = localStore.groceryDict
case .appleReminders: case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName) remindersStore.addItems(items, toRecipe: recipeId, recipeName: recipeName)
groceryDict = remindersStore.groceryDict groceryDict = remindersStore.groceryDict
} }
syncManager?.scheduleSync(forRecipeId: recipeId)
} }
func deleteItem(_ itemName: String, fromRecipe recipeId: String) { func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
@@ -58,9 +85,10 @@ class GroceryListManager: ObservableObject {
localStore.deleteItem(itemName, fromRecipe: recipeId) localStore.deleteItem(itemName, fromRecipe: recipeId)
groceryDict = localStore.groceryDict groceryDict = localStore.groceryDict
case .appleReminders: case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.deleteItem(itemName, fromRecipe: recipeId) remindersStore.deleteItem(itemName, fromRecipe: recipeId)
// Cache update happens async in RemindersGroceryStore via onDataChanged
} }
syncManager?.scheduleSync(forRecipeId: recipeId)
} }
func deleteGroceryRecipe(_ recipeId: String) { func deleteGroceryRecipe(_ recipeId: String) {
@@ -69,18 +97,25 @@ class GroceryListManager: ObservableObject {
localStore.deleteGroceryRecipe(recipeId) localStore.deleteGroceryRecipe(recipeId)
groceryDict = localStore.groceryDict groceryDict = localStore.groceryDict
case .appleReminders: case .appleReminders:
recentlyModifiedByUs.insert(recipeId)
remindersStore.deleteGroceryRecipe(recipeId) remindersStore.deleteGroceryRecipe(recipeId)
} }
syncManager?.scheduleSync(forRecipeId: recipeId)
} }
func deleteAll() { func deleteAll() {
let recipeIds = Array(groceryDict.keys)
switch mode { switch mode {
case .inApp: case .inApp:
localStore.deleteAll() localStore.deleteAll()
groceryDict = localStore.groceryDict groceryDict = localStore.groceryDict
case .appleReminders: case .appleReminders:
recentlyModifiedByUs.formUnion(recipeIds)
remindersStore.deleteAll() remindersStore.deleteAll()
} }
for recipeId in recipeIds {
syncManager?.scheduleSync(forRecipeId: recipeId)
}
} }
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) { func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {

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

View File

@@ -0,0 +1,168 @@
//
// 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
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 }
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 }
// Build local state from current grocery data
let localState = buildLocalState(forRecipeId: recipeId, groceryManager: groceryManager)
// Fetch latest recipe from server
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
Logger.data.error("Grocery sync: failed to fetch recipe \(recipeId) from server")
return
}
// Merge local state with server state
let serverState = serverRecipe.groceryState
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 }
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 performInitialSync() async {
guard let appState, let groceryManager else { return }
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
)
}
}
}
// 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.
private func buildLocalState(forRecipeId recipeId: String, groceryManager: GroceryListManager) -> GroceryState {
guard let groceryRecipe = groceryManager.groceryDict[recipeId] else {
return GroceryState()
}
var items: [String: GroceryItemState] = [:]
let now = GroceryStateDate.now()
for item in groceryRecipe.items {
let status: GroceryItemState.Status = item.isChecked ? .completed : .added
items[item.name] = GroceryItemState(status: status, addedAt: now, modifiedAt: now)
}
return GroceryState(lastModified: now, items: items)
}
}

View File

@@ -26,6 +26,7 @@ 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?
// Additional functionality // Additional functionality
@Published var ingredientMultiplier: Double @Published var ingredientMultiplier: Double
@@ -48,6 +49,7 @@ class ObservableRecipeDetail: ObservableObject {
recipeIngredient = [] recipeIngredient = []
recipeInstructions = [] recipeInstructions = []
nutrition = [:] nutrition = [:]
groceryState = nil
ingredientMultiplier = 1 ingredientMultiplier = 1
} }
@@ -68,6 +70,7 @@ class ObservableRecipeDetail: ObservableObject {
recipeIngredient = recipeDetail.recipeIngredient recipeIngredient = recipeDetail.recipeIngredient
recipeInstructions = recipeDetail.recipeInstructions recipeInstructions = recipeDetail.recipeInstructions
nutrition = recipeDetail.nutrition nutrition = recipeDetail.nutrition
groceryState = recipeDetail.groceryState
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield) ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
} }
@@ -90,7 +93,8 @@ class ObservableRecipeDetail: ObservableObject {
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
) )
} }

View File

@@ -50,8 +50,9 @@ 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?
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) {
self.name = name self.name = name
self.keywords = keywords self.keywords = keywords
self.dateCreated = dateCreated self.dateCreated = dateCreated
@@ -69,6 +70,7 @@ struct RecipeDetail: Codable {
self.recipeIngredient = recipeIngredient self.recipeIngredient = recipeIngredient
self.recipeInstructions = recipeInstructions self.recipeInstructions = recipeInstructions
self.nutrition = nutrition self.nutrition = nutrition
self.groceryState = groceryState
} }
init() { init() {
@@ -89,11 +91,13 @@ struct RecipeDetail: Codable {
recipeIngredient = [] recipeIngredient = []
recipeInstructions = [] recipeInstructions = []
nutrition = [:] nutrition = [:]
groceryState = 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"
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -132,6 +136,8 @@ struct RecipeDetail: Codable {
} else { } else {
nutrition = [:] nutrition = [:]
} }
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -154,6 +160,7 @@ 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)
} }
} }

View File

@@ -133,6 +133,12 @@ class UserSettings: ObservableObject {
} }
} }
@Published var grocerySyncEnabled: Bool {
didSet {
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
}
}
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 ?? ""
@@ -154,6 +160,7 @@ class UserSettings: ObservableObject {
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.groceryListMode = UserDefaults.standard.object(forKey: "groceryListMode") as? String ?? GroceryListMode.inApp.rawValue
self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? "" self.remindersListIdentifier = UserDefaults.standard.object(forKey: "remindersListIdentifier") as? String ?? ""
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
if authString == "" { if authString == "" {
if token != "" && username != "" { if token != "" && username != "" {

View File

@@ -2023,6 +2023,9 @@
} }
} }
} }
},
"Grocery list state is synced via your Nextcloud server by storing it alongside recipe data." : {
}, },
"Grocery list storage" : { "Grocery list storage" : {
"localizations" : { "localizations" : {
@@ -4319,6 +4322,9 @@
} }
} }
} }
},
"Sync grocery list across devices" : {
}, },
"Thank you for downloading" : { "Thank you for downloading" : {
"localizations" : { "localizations" : {

View File

@@ -81,6 +81,10 @@ struct MainView: View {
} }
} }
await groceryList.load() await groceryList.load()
groceryList.configureSyncManager(appState: appState)
if UserSettings.shared.grocerySyncEnabled {
await groceryList.syncManager?.performInitialSync()
}
recipeViewModel.presentLoadingIndicator = false recipeViewModel.presentLoadingIndicator = false
} }
} }

View File

@@ -12,6 +12,7 @@ import SwiftUI
struct RecipeView: View { struct RecipeView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@EnvironmentObject var groceryList: GroceryListManager
@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
@@ -75,6 +76,15 @@ 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 {
groceryList.syncManager?.reconcileFromServer(
serverState: viewModel.recipeDetail.groceryState,
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 { if let preloaded = viewModel.preloadedRecipeDetail {

View File

@@ -93,10 +93,16 @@ struct SettingsView: View {
} }
} }
} }
Toggle(isOn: $userSettings.grocerySyncEnabled) {
Text("Sync grocery list across devices")
}
} header: { } header: {
Text("Grocery List") Text("Grocery List")
} footer: { } footer: {
if userSettings.groceryListMode == GroceryListMode.appleReminders.rawValue { 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.") 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 { } else {
Text("Grocery items are stored locally on this device.") Text("Grocery items are stored locally on this device.")