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:
@@ -72,6 +72,8 @@
|
||||
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 */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -161,6 +163,8 @@
|
||||
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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -301,6 +305,8 @@
|
||||
D1A0CE002D0A000100000001 /* GroceryListMode.swift */,
|
||||
D1A0CE022D0A000200000002 /* RemindersGroceryStore.swift */,
|
||||
D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
|
||||
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
|
||||
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
|
||||
);
|
||||
path = Data;
|
||||
sourceTree = "<group>";
|
||||
@@ -638,6 +644,8 @@
|
||||
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 */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,11 @@ class GroceryListManager: ObservableObject {
|
||||
|
||||
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
|
||||
@@ -23,11 +28,29 @@ class GroceryListManager: ObservableObject {
|
||||
remindersStore.onDataChanged = { [weak self] in
|
||||
guard let self else { return }
|
||||
if self.mode == .appleReminders {
|
||||
let previousDict = self.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
|
||||
|
||||
func addItem(_ itemName: String, toRecipe recipeId: String, recipeName: String? = nil) {
|
||||
@@ -36,9 +59,11 @@ class GroceryListManager: ObservableObject {
|
||||
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?.scheduleSync(forRecipeId: recipeId)
|
||||
}
|
||||
|
||||
func addItems(_ items: [String], toRecipe recipeId: String, recipeName: String? = nil) {
|
||||
@@ -47,9 +72,11 @@ class GroceryListManager: ObservableObject {
|
||||
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?.scheduleSync(forRecipeId: recipeId)
|
||||
}
|
||||
|
||||
func deleteItem(_ itemName: String, fromRecipe recipeId: String) {
|
||||
@@ -58,9 +85,10 @@ class GroceryListManager: ObservableObject {
|
||||
localStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
recentlyModifiedByUs.insert(recipeId)
|
||||
remindersStore.deleteItem(itemName, fromRecipe: recipeId)
|
||||
// Cache update happens async in RemindersGroceryStore via onDataChanged
|
||||
}
|
||||
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||
}
|
||||
|
||||
func deleteGroceryRecipe(_ recipeId: String) {
|
||||
@@ -69,18 +97,25 @@ class GroceryListManager: ObservableObject {
|
||||
localStore.deleteGroceryRecipe(recipeId)
|
||||
groceryDict = localStore.groceryDict
|
||||
case .appleReminders:
|
||||
recentlyModifiedByUs.insert(recipeId)
|
||||
remindersStore.deleteGroceryRecipe(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?.scheduleSync(forRecipeId: recipeId)
|
||||
}
|
||||
}
|
||||
|
||||
func toggleItemChecked(_ groceryItem: GroceryRecipeItem) {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
168
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal file
168
Nextcloud Cookbook iOS Client/Data/GroceryStateSyncManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
@Published var recipeIngredient: [String]
|
||||
@Published var recipeInstructions: [String]
|
||||
@Published var nutrition: [String:String]
|
||||
|
||||
var groceryState: GroceryState?
|
||||
|
||||
// Additional functionality
|
||||
@Published var ingredientMultiplier: Double
|
||||
|
||||
@@ -48,7 +49,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
recipeIngredient = []
|
||||
recipeInstructions = []
|
||||
nutrition = [:]
|
||||
|
||||
groceryState = nil
|
||||
|
||||
ingredientMultiplier = 1
|
||||
}
|
||||
|
||||
@@ -68,7 +70,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
recipeIngredient = recipeDetail.recipeIngredient
|
||||
recipeInstructions = recipeDetail.recipeInstructions
|
||||
nutrition = recipeDetail.nutrition
|
||||
|
||||
groceryState = recipeDetail.groceryState
|
||||
|
||||
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
||||
}
|
||||
|
||||
@@ -90,7 +93,8 @@ class ObservableRecipeDetail: ObservableObject {
|
||||
tool: self.tool,
|
||||
recipeIngredient: self.recipeIngredient,
|
||||
recipeInstructions: self.recipeInstructions,
|
||||
nutrition: self.nutrition
|
||||
nutrition: self.nutrition,
|
||||
groceryState: self.groceryState
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,9 @@ struct RecipeDetail: Codable {
|
||||
var recipeIngredient: [String]
|
||||
var recipeInstructions: [String]
|
||||
var nutrition: [String:String]
|
||||
|
||||
init(name: String, keywords: String, dateCreated: String, dateModified: String, imageUrl: String, id: String, prepTime: String? = nil, cookTime: String? = nil, totalTime: String? = nil, description: String, url: String, recipeYield: Int, recipeCategory: String, tool: [String], recipeIngredient: [String], recipeInstructions: [String], nutrition: [String:String]) {
|
||||
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], groceryState: GroceryState? = nil) {
|
||||
self.name = name
|
||||
self.keywords = keywords
|
||||
self.dateCreated = dateCreated
|
||||
@@ -69,8 +70,9 @@ struct RecipeDetail: Codable {
|
||||
self.recipeIngredient = recipeIngredient
|
||||
self.recipeInstructions = recipeInstructions
|
||||
self.nutrition = nutrition
|
||||
self.groceryState = groceryState
|
||||
}
|
||||
|
||||
|
||||
init() {
|
||||
name = ""
|
||||
keywords = ""
|
||||
@@ -89,11 +91,13 @@ struct RecipeDetail: Codable {
|
||||
recipeIngredient = []
|
||||
recipeInstructions = []
|
||||
nutrition = [:]
|
||||
groceryState = nil
|
||||
}
|
||||
|
||||
|
||||
// Custom decoder to handle value type ambiguity
|
||||
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 groceryState = "_groceryState"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
@@ -132,6 +136,8 @@ struct RecipeDetail: Codable {
|
||||
} else {
|
||||
nutrition = [:]
|
||||
}
|
||||
|
||||
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -154,6 +160,7 @@ struct RecipeDetail: Codable {
|
||||
try container.encode(recipeIngredient, forKey: .recipeIngredient)
|
||||
try container.encode(recipeInstructions, forKey: .recipeInstructions)
|
||||
try container.encode(nutrition, forKey: .nutrition)
|
||||
try container.encodeIfPresent(groceryState, forKey: .groceryState)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@ class UserSettings: ObservableObject {
|
||||
UserDefaults.standard.set(remindersListIdentifier, forKey: "remindersListIdentifier")
|
||||
}
|
||||
}
|
||||
|
||||
@Published var grocerySyncEnabled: Bool {
|
||||
didSet {
|
||||
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||
@@ -154,6 +160,7 @@ class UserSettings: ObservableObject {
|
||||
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
|
||||
|
||||
if authString == "" {
|
||||
if token != "" && username != "" {
|
||||
|
||||
@@ -2023,6 +2023,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Grocery list state is synced via your Nextcloud server by storing it alongside recipe data." : {
|
||||
|
||||
},
|
||||
"Grocery list storage" : {
|
||||
"localizations" : {
|
||||
@@ -4319,6 +4322,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sync grocery list across devices" : {
|
||||
|
||||
},
|
||||
"Thank you for downloading" : {
|
||||
"localizations" : {
|
||||
|
||||
@@ -81,6 +81,10 @@ struct MainView: View {
|
||||
}
|
||||
}
|
||||
await groceryList.load()
|
||||
groceryList.configureSyncManager(appState: appState)
|
||||
if UserSettings.shared.grocerySyncEnabled {
|
||||
await groceryList.syncManager?.performInitialSync()
|
||||
}
|
||||
recipeViewModel.presentLoadingIndicator = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import SwiftUI
|
||||
|
||||
struct RecipeView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject var viewModel: ViewModel
|
||||
@GestureState private var dragOffset = CGSize.zero
|
||||
@@ -75,6 +76,15 @@ struct RecipeView: View {
|
||||
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 {
|
||||
// Prepare view for a new recipe
|
||||
if let preloaded = viewModel.preloadedRecipeDetail {
|
||||
|
||||
@@ -93,10 +93,16 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Toggle(isOn: $userSettings.grocerySyncEnabled) {
|
||||
Text("Sync grocery list across devices")
|
||||
}
|
||||
} header: {
|
||||
Text("Grocery List")
|
||||
} 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.")
|
||||
} else {
|
||||
Text("Grocery items are stored locally on this device.")
|
||||
|
||||
Reference in New Issue
Block a user