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 */; };
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user