Add meal plan feature with cross-device sync and automatic stale data cleanup
Introduces weekly meal planning with a calendar-based tab view, per-recipe date assignments synced via Nextcloud Cookbook custom metadata, and 30-day automatic pruning of old entries on load, save, and sync merge. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -56,7 +56,8 @@ Additional ViewModels exist as nested classes within their views (`RecipeTabView
|
|||||||
```
|
```
|
||||||
SwiftUI Views
|
SwiftUI Views
|
||||||
├── @EnvironmentObject appState: AppState
|
├── @EnvironmentObject appState: AppState
|
||||||
├── @EnvironmentObject groceryList: GroceryList
|
├── @EnvironmentObject groceryList: GroceryListManager
|
||||||
|
├── @EnvironmentObject mealPlan: MealPlanManager
|
||||||
└── Per-view @StateObject ViewModels
|
└── Per-view @StateObject ViewModels
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
@@ -66,6 +67,8 @@ AppState
|
|||||||
└── UserSettings.shared (UserDefaults singleton)
|
└── UserSettings.shared (UserDefaults singleton)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Both `GroceryListManager` and `MealPlanManager` use custom metadata fields (`_groceryState`, `_mealPlanAssignment`) embedded in recipe JSON on the Nextcloud Cookbook API for cross-device sync. Each has a dedicated sync manager (`GroceryStateSyncManager`, `MealPlanSyncManager`) that handles debounced push, pull reconciliation, and per-item/per-date last-writer-wins merge.
|
||||||
|
|
||||||
### Network Layer
|
### Network Layer
|
||||||
|
|
||||||
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
- `CookbookApi` protocol defines all endpoints; `CookbookApiV1` is the concrete implementation with all `static` methods.
|
||||||
@@ -83,11 +86,11 @@ AppState
|
|||||||
|
|
||||||
```
|
```
|
||||||
Nextcloud Cookbook iOS Client/
|
Nextcloud Cookbook iOS Client/
|
||||||
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings
|
├── Data/ # Models (Category, Recipe, RecipeDetail, Nutrition) + DataStore + UserSettings + MealPlan + GroceryList
|
||||||
├── Models/ # RecipeEditViewModel
|
├── Models/ # RecipeEditViewModel
|
||||||
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
├── Network/ # ApiRequest, NetworkError, CookbookApi protocol + V1, NextcloudApi
|
||||||
├── Views/
|
├── Views/
|
||||||
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, GroceryListTab)
|
│ ├── Tabs/ # Main tab views (RecipeTab, SearchTab, MealPlanTab, GroceryListTab)
|
||||||
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
│ ├── Recipes/ # Recipe detail, list, card, share, timer views
|
||||||
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
│ ├── RecipeViewSections/ # Decomposed recipe detail sections (ingredients, instructions, etc.)
|
||||||
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
│ ├── Onboarding/ # Login flows (V2LoginView, TokenLoginView)
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
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 */; };
|
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF062D0B000400000004 /* GroceryStateModels.swift */; };
|
||||||
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; };
|
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */; };
|
||||||
|
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE012E0C000100000001 /* MealPlanModels.swift */; };
|
||||||
|
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE032E0C000200000002 /* MealPlanManager.swift */; };
|
||||||
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */; };
|
||||||
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE072E0C000400000004 /* MealPlanTabView.swift */; };
|
||||||
|
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -165,6 +170,11 @@
|
|||||||
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>"; };
|
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>"; };
|
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroceryStateSyncManager.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE012E0C000100000001 /* MealPlanModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanModels.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE032E0C000200000002 /* MealPlanManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanManager.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanSyncManager.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MealPlanTabView.swift; sourceTree = "<group>"; };
|
||||||
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToMealPlanSheet.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -307,6 +317,9 @@
|
|||||||
D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
|
D1A0CE042D0A000300000003 /* GroceryListManager.swift */,
|
||||||
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
|
E1B0CF062D0B000400000004 /* GroceryStateModels.swift */,
|
||||||
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
|
E1B0CF082D0B000500000005 /* GroceryStateSyncManager.swift */,
|
||||||
|
F1A0DE012E0C000100000001 /* MealPlanModels.swift */,
|
||||||
|
F1A0DE032E0C000200000002 /* MealPlanManager.swift */,
|
||||||
|
F1A0DE052E0C000300000003 /* MealPlanSyncManager.swift */,
|
||||||
);
|
);
|
||||||
path = Data;
|
path = Data;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -385,6 +398,7 @@
|
|||||||
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
A977D0DD2B600300009783A9 /* SearchTabView.swift */,
|
||||||
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
A977D0DF2B600318009783A9 /* RecipeTabView.swift */,
|
||||||
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
A977D0E12B60034E009783A9 /* GroceryListTabView.swift */,
|
||||||
|
F1A0DE072E0C000400000004 /* MealPlanTabView.swift */,
|
||||||
);
|
);
|
||||||
path = Tabs;
|
path = Tabs;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -415,6 +429,7 @@
|
|||||||
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
A9D89AAF2B4FE97800F49D92 /* TimerView.swift */,
|
||||||
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
A97B4D342B80B82A00EC1A88 /* ShareView.swift */,
|
||||||
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
C1F0AB012D0B000100000001 /* ImportURLSheet.swift */,
|
||||||
|
F1A0DE092E0C000500000005 /* AddToMealPlanSheet.swift */,
|
||||||
);
|
);
|
||||||
path = Recipes;
|
path = Recipes;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -646,6 +661,11 @@
|
|||||||
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
|
D1A0CE052D0A000300000003 /* GroceryListManager.swift in Sources */,
|
||||||
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
|
E1B0CF072D0B000400000004 /* GroceryStateModels.swift in Sources */,
|
||||||
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */,
|
E1B0CF092D0B000500000005 /* GroceryStateSyncManager.swift in Sources */,
|
||||||
|
F1A0DE022E0C000100000001 /* MealPlanModels.swift in Sources */,
|
||||||
|
F1A0DE042E0C000200000002 /* MealPlanManager.swift in Sources */,
|
||||||
|
F1A0DE062E0C000300000003 /* MealPlanSyncManager.swift in Sources */,
|
||||||
|
F1A0DE082E0C000400000004 /* MealPlanTabView.swift in Sources */,
|
||||||
|
F1A0DE0A2E0C000500000005 /* AddToMealPlanSheet.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
197
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
197
Nextcloud Cookbook iOS Client/Data/MealPlanManager.swift
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//
|
||||||
|
// MealPlanManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MealPlanManager: ObservableObject {
|
||||||
|
@Published var entriesByDate: [String: [MealPlanEntry]] = [:]
|
||||||
|
|
||||||
|
private var assignmentsByRecipe: [String: MealPlanAssignment] = [:]
|
||||||
|
private var recipeNames: [String: String] = [:]
|
||||||
|
private let dataStore = DataStore()
|
||||||
|
var syncManager: MealPlanSyncManager?
|
||||||
|
|
||||||
|
private static let persistencePath = "meal_plan.data"
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
struct PersistenceData: Codable {
|
||||||
|
var assignmentsByRecipe: [String: MealPlanAssignment]
|
||||||
|
var recipeNames: [String: String]
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
do {
|
||||||
|
guard let data: PersistenceData = try await dataStore.load(fromPath: Self.persistencePath) else { return }
|
||||||
|
assignmentsByRecipe = data.assignmentsByRecipe
|
||||||
|
recipeNames = data.recipeNames
|
||||||
|
pruneOldEntries()
|
||||||
|
rebuildEntries()
|
||||||
|
} catch {
|
||||||
|
Logger.data.error("Unable to load meal plan data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
pruneOldEntries()
|
||||||
|
let data = PersistenceData(assignmentsByRecipe: assignmentsByRecipe, recipeNames: recipeNames)
|
||||||
|
Task {
|
||||||
|
await dataStore.save(data: data, toPath: Self.persistencePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureSyncManager(appState: AppState) {
|
||||||
|
syncManager = MealPlanSyncManager(appState: appState, mealPlanManager: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - CRUD
|
||||||
|
|
||||||
|
func assignRecipe(recipeId: String, recipeName: String, toDates dates: [Date]) {
|
||||||
|
recipeNames[recipeId] = recipeName
|
||||||
|
var assignment = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
||||||
|
|
||||||
|
for date in dates {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
assignment.dates[dayStr] = MealPlanDateEntry(status: .assigned)
|
||||||
|
}
|
||||||
|
assignment.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeRecipe(recipeId: String, fromDate dateString: String) {
|
||||||
|
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
||||||
|
|
||||||
|
assignment.dates[dateString] = MealPlanDateEntry(status: .removed)
|
||||||
|
assignment.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllAssignments(forRecipeId recipeId: String) {
|
||||||
|
guard var assignment = assignmentsByRecipe[recipeId] else { return }
|
||||||
|
|
||||||
|
let now = MealPlanDate.now()
|
||||||
|
for key in assignment.dates.keys {
|
||||||
|
assignment.dates[key] = MealPlanDateEntry(status: .removed, modifiedAt: now)
|
||||||
|
}
|
||||||
|
assignment.lastModified = now
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
syncManager?.scheduleSync(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Queries
|
||||||
|
|
||||||
|
func entries(for date: Date) -> [MealPlanEntry] {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
return entriesByDate[dayStr] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRecipeAssigned(_ recipeId: String, on date: Date) -> Bool {
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
guard let assignment = assignmentsByRecipe[recipeId],
|
||||||
|
let entry = assignment.dates[dayStr] else { return false }
|
||||||
|
return entry.status == .assigned
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignedDates(forRecipeId recipeId: String) -> [String] {
|
||||||
|
guard let assignment = assignmentsByRecipe[recipeId] else { return [] }
|
||||||
|
return assignment.dates.compactMap { key, entry in
|
||||||
|
entry.status == .assigned ? key : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assignment(forRecipeId recipeId: String) -> MealPlanAssignment? {
|
||||||
|
assignmentsByRecipe[recipeId]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reconciliation (Pull)
|
||||||
|
|
||||||
|
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
||||||
|
guard let serverAssignment, !serverAssignment.dates.isEmpty else { return }
|
||||||
|
|
||||||
|
recipeNames[recipeId] = recipeName
|
||||||
|
var local = assignmentsByRecipe[recipeId] ?? MealPlanAssignment()
|
||||||
|
|
||||||
|
for (dayStr, serverEntry) in serverAssignment.dates {
|
||||||
|
if let localEntry = local.dates[dayStr] {
|
||||||
|
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
||||||
|
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
||||||
|
if serverDate > localDate {
|
||||||
|
local.dates[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local.dates[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local.lastModified = MealPlanDate.now()
|
||||||
|
assignmentsByRecipe[recipeId] = local
|
||||||
|
|
||||||
|
rebuildEntries()
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
|
||||||
|
private func pruneOldEntries() {
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
||||||
|
var emptyRecipeIds: [String] = []
|
||||||
|
|
||||||
|
for (recipeId, var assignment) in assignmentsByRecipe {
|
||||||
|
assignment.dates = assignment.dates.filter { dayStr, _ in
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
||||||
|
return date >= cutoff
|
||||||
|
}
|
||||||
|
if assignment.dates.isEmpty {
|
||||||
|
emptyRecipeIds.append(recipeId)
|
||||||
|
} else {
|
||||||
|
assignmentsByRecipe[recipeId] = assignment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for recipeId in emptyRecipeIds {
|
||||||
|
assignmentsByRecipe.removeValue(forKey: recipeId)
|
||||||
|
recipeNames.removeValue(forKey: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rebuildEntries() {
|
||||||
|
var newEntries: [String: [MealPlanEntry]] = [:]
|
||||||
|
|
||||||
|
for (recipeId, assignment) in assignmentsByRecipe {
|
||||||
|
let name = recipeNames[recipeId] ?? "Recipe \(recipeId)"
|
||||||
|
for (dayStr, entry) in assignment.dates where entry.status == .assigned {
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { continue }
|
||||||
|
let mealEntry = MealPlanEntry(
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: name,
|
||||||
|
date: date,
|
||||||
|
dateString: dayStr,
|
||||||
|
mealType: entry.mealType
|
||||||
|
)
|
||||||
|
newEntries[dayStr, default: []].append(mealEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort entries within each day by recipe name
|
||||||
|
for key in newEntries.keys {
|
||||||
|
newEntries[key]?.sort(by: { $0.recipeName < $1.recipeName })
|
||||||
|
}
|
||||||
|
|
||||||
|
entriesByDate = newEntries
|
||||||
|
}
|
||||||
|
}
|
||||||
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
83
Nextcloud Cookbook iOS Client/Data/MealPlanModels.swift
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
//
|
||||||
|
// MealPlanModels.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Tracks meal plan assignments for a recipe, stored as `_mealPlanAssignment` in the recipe JSON on the server.
|
||||||
|
struct MealPlanAssignment: Codable {
|
||||||
|
var version: Int = 1
|
||||||
|
var lastModified: String
|
||||||
|
var dates: [String: MealPlanDateEntry]
|
||||||
|
|
||||||
|
init(lastModified: String = MealPlanDate.now(), dates: [String: MealPlanDateEntry] = [:]) {
|
||||||
|
self.version = 1
|
||||||
|
self.lastModified = lastModified
|
||||||
|
self.dates = dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MealPlanDateEntry: Codable {
|
||||||
|
enum Status: String, Codable {
|
||||||
|
case assigned
|
||||||
|
case removed
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: Status
|
||||||
|
var mealType: String?
|
||||||
|
var modifiedAt: String
|
||||||
|
|
||||||
|
init(status: Status, mealType: String? = nil, modifiedAt: String = MealPlanDate.now()) {
|
||||||
|
self.status = status
|
||||||
|
self.mealType = mealType
|
||||||
|
self.modifiedAt = modifiedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ISO 8601 date helpers for meal plan dates.
|
||||||
|
enum MealPlanDate {
|
||||||
|
private static let isoFormatter: ISO8601DateFormatter = {
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let dayFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
f.timeZone = .current
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func now() -> String {
|
||||||
|
isoFormatter.string(from: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
static func date(from string: String) -> Date? {
|
||||||
|
isoFormatter.date(from: string)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func string(from date: Date) -> String {
|
||||||
|
isoFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dayString(from date: Date) -> String {
|
||||||
|
dayFormatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dateFromDay(_ dayString: String) -> Date? {
|
||||||
|
dayFormatter.date(from: dayString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only aggregated view struct used by the UI.
|
||||||
|
struct MealPlanEntry: Identifiable {
|
||||||
|
let recipeId: String
|
||||||
|
let recipeName: String
|
||||||
|
let date: Date
|
||||||
|
let dateString: String
|
||||||
|
let mealType: String?
|
||||||
|
|
||||||
|
var id: String { "\(recipeId)-\(dateString)" }
|
||||||
|
}
|
||||||
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
117
Nextcloud Cookbook iOS Client/Data/MealPlanSyncManager.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//
|
||||||
|
// MealPlanSyncManager.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
class MealPlanSyncManager {
|
||||||
|
private weak var appState: AppState?
|
||||||
|
private weak var mealPlanManager: MealPlanManager?
|
||||||
|
|
||||||
|
private var debounceTimers: [String: Task<Void, Never>] = [:]
|
||||||
|
private let debounceInterval: TimeInterval = 2.0
|
||||||
|
|
||||||
|
init(appState: AppState, mealPlanManager: MealPlanManager) {
|
||||||
|
self.appState = appState
|
||||||
|
self.mealPlanManager = mealPlanManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Push Flow
|
||||||
|
|
||||||
|
func scheduleSync(forRecipeId recipeId: String) {
|
||||||
|
guard UserSettings.shared.mealPlanSyncEnabled 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?.pushMealPlanState(forRecipeId: recipeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pushMealPlanState(forRecipeId recipeId: String) async {
|
||||||
|
guard let appState, let mealPlanManager else { return }
|
||||||
|
guard let recipeIdInt = Int(recipeId) else { return }
|
||||||
|
|
||||||
|
let localAssignment = mealPlanManager.assignment(forRecipeId: recipeId)
|
||||||
|
|
||||||
|
guard let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) else {
|
||||||
|
Logger.data.error("Meal plan sync: failed to fetch recipe \(recipeId) from server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged = mergeAssignments(local: localAssignment, server: serverRecipe.mealPlanAssignment)
|
||||||
|
|
||||||
|
var updatedRecipe = serverRecipe
|
||||||
|
updatedRecipe.mealPlanAssignment = merged
|
||||||
|
let (_, alert) = await appState.uploadRecipe(recipeDetail: updatedRecipe, createNew: false)
|
||||||
|
if let alert {
|
||||||
|
Logger.data.error("Meal plan sync: failed to push state for recipe \(recipeId): \(String(describing: alert))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pull Flow
|
||||||
|
|
||||||
|
func reconcileFromServer(serverAssignment: MealPlanAssignment?, recipeId: String, recipeName: String) {
|
||||||
|
guard let mealPlanManager else { return }
|
||||||
|
mealPlanManager.reconcileFromServer(serverAssignment: serverAssignment, recipeId: recipeId, recipeName: recipeName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initial Sync
|
||||||
|
|
||||||
|
func performInitialSync() 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 }
|
||||||
|
|
||||||
|
await pushMealPlanState(forRecipeId: recipeId)
|
||||||
|
|
||||||
|
if let serverRecipe = await appState.getRecipe(id: recipeIdInt, fetchMode: .onlyServer) {
|
||||||
|
reconcileFromServer(
|
||||||
|
serverAssignment: serverRecipe.mealPlanAssignment,
|
||||||
|
recipeId: recipeId,
|
||||||
|
recipeName: serverRecipe.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Merge Logic
|
||||||
|
|
||||||
|
private func mergeAssignments(local: MealPlanAssignment?, server: MealPlanAssignment?) -> MealPlanAssignment {
|
||||||
|
guard let local else { return server ?? MealPlanAssignment() }
|
||||||
|
guard let server else { return local }
|
||||||
|
|
||||||
|
var merged = local.dates
|
||||||
|
for (dayStr, serverEntry) in server.dates {
|
||||||
|
if let localEntry = merged[dayStr] {
|
||||||
|
let localDate = MealPlanDate.date(from: localEntry.modifiedAt) ?? .distantPast
|
||||||
|
let serverDate = MealPlanDate.date(from: serverEntry.modifiedAt) ?? .distantPast
|
||||||
|
if serverDate > localDate {
|
||||||
|
merged[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merged[dayStr] = serverEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune all date entries older than 30 days
|
||||||
|
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Calendar.current.startOfDay(for: Date()))!
|
||||||
|
merged = merged.filter { dayStr, _ in
|
||||||
|
guard let date = MealPlanDate.dateFromDay(dayStr) else { return true }
|
||||||
|
return date >= cutoff
|
||||||
|
}
|
||||||
|
|
||||||
|
return MealPlanAssignment(
|
||||||
|
lastModified: MealPlanDate.now(),
|
||||||
|
dates: merged
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
@Published var recipeInstructions: [String]
|
@Published var recipeInstructions: [String]
|
||||||
@Published var nutrition: [String:String]
|
@Published var nutrition: [String:String]
|
||||||
var groceryState: GroceryState?
|
var groceryState: GroceryState?
|
||||||
|
var mealPlanAssignment: MealPlanAssignment?
|
||||||
|
|
||||||
// Additional functionality
|
// Additional functionality
|
||||||
@Published var ingredientMultiplier: Double
|
@Published var ingredientMultiplier: Double
|
||||||
@@ -50,6 +51,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
groceryState = nil
|
groceryState = nil
|
||||||
|
mealPlanAssignment = nil
|
||||||
|
|
||||||
ingredientMultiplier = 1
|
ingredientMultiplier = 1
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,7 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
recipeInstructions = recipeDetail.recipeInstructions
|
recipeInstructions = recipeDetail.recipeInstructions
|
||||||
nutrition = recipeDetail.nutrition
|
nutrition = recipeDetail.nutrition
|
||||||
groceryState = recipeDetail.groceryState
|
groceryState = recipeDetail.groceryState
|
||||||
|
mealPlanAssignment = recipeDetail.mealPlanAssignment
|
||||||
|
|
||||||
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
ingredientMultiplier = Double(recipeDetail.recipeYield == 0 ? 1 : recipeDetail.recipeYield)
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,8 @@ class ObservableRecipeDetail: ObservableObject {
|
|||||||
recipeIngredient: self.recipeIngredient,
|
recipeIngredient: self.recipeIngredient,
|
||||||
recipeInstructions: self.recipeInstructions,
|
recipeInstructions: self.recipeInstructions,
|
||||||
nutrition: self.nutrition,
|
nutrition: self.nutrition,
|
||||||
groceryState: self.groceryState
|
groceryState: self.groceryState,
|
||||||
|
mealPlanAssignment: self.mealPlanAssignment
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,8 +51,9 @@ struct RecipeDetail: Codable {
|
|||||||
var recipeInstructions: [String]
|
var recipeInstructions: [String]
|
||||||
var nutrition: [String:String]
|
var nutrition: [String:String]
|
||||||
var groceryState: GroceryState?
|
var groceryState: GroceryState?
|
||||||
|
var mealPlanAssignment: MealPlanAssignment?
|
||||||
|
|
||||||
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) {
|
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, mealPlanAssignment: MealPlanAssignment? = nil) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.keywords = keywords
|
self.keywords = keywords
|
||||||
self.dateCreated = dateCreated
|
self.dateCreated = dateCreated
|
||||||
@@ -71,6 +72,7 @@ struct RecipeDetail: Codable {
|
|||||||
self.recipeInstructions = recipeInstructions
|
self.recipeInstructions = recipeInstructions
|
||||||
self.nutrition = nutrition
|
self.nutrition = nutrition
|
||||||
self.groceryState = groceryState
|
self.groceryState = groceryState
|
||||||
|
self.mealPlanAssignment = mealPlanAssignment
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -92,12 +94,14 @@ struct RecipeDetail: Codable {
|
|||||||
recipeInstructions = []
|
recipeInstructions = []
|
||||||
nutrition = [:]
|
nutrition = [:]
|
||||||
groceryState = nil
|
groceryState = nil
|
||||||
|
mealPlanAssignment = 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"
|
case groceryState = "_groceryState"
|
||||||
|
case mealPlanAssignment = "_mealPlanAssignment"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
@@ -138,6 +142,7 @@ struct RecipeDetail: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
groceryState = try? container.decode(GroceryState.self, forKey: .groceryState)
|
||||||
|
mealPlanAssignment = try? container.decode(MealPlanAssignment.self, forKey: .mealPlanAssignment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
@@ -161,6 +166,7 @@ struct RecipeDetail: Codable {
|
|||||||
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)
|
try container.encodeIfPresent(groceryState, forKey: .groceryState)
|
||||||
|
try container.encodeIfPresent(mealPlanAssignment, forKey: .mealPlanAssignment)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ class UserSettings: ObservableObject {
|
|||||||
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
UserDefaults.standard.set(grocerySyncEnabled, forKey: "grocerySyncEnabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var mealPlanSyncEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
UserDefaults.standard.set(mealPlanSyncEnabled, forKey: "mealPlanSyncEnabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
|
||||||
@@ -161,6 +167,7 @@ class UserSettings: ObservableObject {
|
|||||||
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
|
self.grocerySyncEnabled = UserDefaults.standard.object(forKey: "grocerySyncEnabled") as? Bool ?? true
|
||||||
|
self.mealPlanSyncEnabled = UserDefaults.standard.object(forKey: "mealPlanSyncEnabled") as? Bool ?? true
|
||||||
|
|
||||||
if authString == "" {
|
if authString == "" {
|
||||||
if token != "" && username != "" {
|
if token != "" && username != "" {
|
||||||
|
|||||||
@@ -2514,6 +2514,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Last Week" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Letzte Woche"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Semana Pasada"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Semaine Dernière"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"List your tools here. 🍴" : {
|
"List your tools here. 🍴" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -2669,6 +2691,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Meal Plan" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Essensplan"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Plan de Comidas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Plan de Repas"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Minutes" : {
|
"Minutes" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -2870,6 +2914,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Next Week" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Nächste Woche"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Próxima Semana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Semaine Prochaine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Nextcloud Login" : {
|
"Nextcloud Login" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3293,6 +3359,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Plan recipe" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rezept einplanen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Planificar receta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Planifier la recette"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Please check the entered URL." : {
|
"Please check the entered URL." : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -3690,6 +3778,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Remove" : {
|
||||||
|
"comment" : "A menu item that allows a user to remove an item from a meal plan.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Entfernen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Eliminar"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Supprimer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Remove from Grocery List" : {
|
"Remove from Grocery List" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3757,6 +3869,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Schedule Recipe" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rezept einplanen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Programar Receta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Planifier la Recette"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Search" : {
|
"Search" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -3813,6 +3947,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Search recipes" : {
|
||||||
|
"comment" : "A prompt for searching recipes.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rezepte suchen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Buscar recetas"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Rechercher des recettes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Search recipes/keywords" : {
|
"Search recipes/keywords" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -4572,6 +4730,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"This Week" : {
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Diese Woche"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Esta Semana"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Cette Semaine"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Title" : {
|
"Title" : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -4595,6 +4775,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Today" : {
|
||||||
|
"comment" : "Suffix added to the name of a day when it is the current day.",
|
||||||
|
"isCommentAutoGenerated" : true,
|
||||||
|
"localizations" : {
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Heute"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Hoy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Aujourd'hui"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"Tool" : {
|
"Tool" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SwiftUI
|
|||||||
struct MainView: View {
|
struct MainView: View {
|
||||||
@StateObject var appState = AppState()
|
@StateObject var appState = AppState()
|
||||||
@StateObject var groceryList = GroceryListManager()
|
@StateObject var groceryList = GroceryListManager()
|
||||||
|
@StateObject var mealPlan = MealPlanManager()
|
||||||
|
|
||||||
// Tab ViewModels
|
// Tab ViewModels
|
||||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||||
@@ -20,7 +21,7 @@ struct MainView: View {
|
|||||||
@State private var selectedTab: Tab = .recipes
|
@State private var selectedTab: Tab = .recipes
|
||||||
|
|
||||||
enum Tab {
|
enum Tab {
|
||||||
case recipes, search, groceryList
|
case recipes, search, mealPlan, groceryList
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -30,6 +31,7 @@ struct MainView: View {
|
|||||||
.environmentObject(recipeViewModel)
|
.environmentObject(recipeViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
|
|
||||||
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||||
@@ -37,6 +39,14 @@ struct MainView: View {
|
|||||||
.environmentObject(searchViewModel)
|
.environmentObject(searchViewModel)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
|
|
||||||
|
SwiftUI.Tab("Meal Plan", systemImage: "calendar", value: .mealPlan) {
|
||||||
|
MealPlanTabView()
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
}
|
}
|
||||||
|
|
||||||
if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue {
|
if userSettings.groceryListMode != GroceryListMode.appleReminders.rawValue {
|
||||||
@@ -85,6 +95,11 @@ struct MainView: View {
|
|||||||
if UserSettings.shared.grocerySyncEnabled {
|
if UserSettings.shared.grocerySyncEnabled {
|
||||||
await groceryList.syncManager?.performInitialSync()
|
await groceryList.syncManager?.performInitialSync()
|
||||||
}
|
}
|
||||||
|
await mealPlan.load()
|
||||||
|
mealPlan.configureSyncManager(appState: appState)
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
await mealPlan.syncManager?.performInitialSync()
|
||||||
|
}
|
||||||
recipeViewModel.presentLoadingIndicator = false
|
recipeViewModel.presentLoadingIndicator = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//
|
||||||
|
// AddToMealPlanSheet.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddToMealPlanSheet: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let recipeId: String
|
||||||
|
let recipeName: String
|
||||||
|
let prepTime: String?
|
||||||
|
let recipeImage: UIImage?
|
||||||
|
|
||||||
|
@State private var weekOffset: Int = 0
|
||||||
|
@State private var selectedDays: Set<String> = []
|
||||||
|
|
||||||
|
private var calendar: Calendar { Calendar.current }
|
||||||
|
|
||||||
|
private var weekDates: [Date] {
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
let weekday = calendar.component(.weekday, from: today)
|
||||||
|
let daysToMonday = (weekday + 5) % 7
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
||||||
|
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekLabel: String {
|
||||||
|
if weekOffset == 0 {
|
||||||
|
return String(localized: "This Week")
|
||||||
|
} else if weekOffset == 1 {
|
||||||
|
return String(localized: "Next Week")
|
||||||
|
} else if weekOffset == -1 {
|
||||||
|
return String(localized: "Last Week")
|
||||||
|
} else {
|
||||||
|
return weekRangeString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekRangeString: String {
|
||||||
|
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM."
|
||||||
|
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Recipe header
|
||||||
|
recipeHeader
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Week navigation
|
||||||
|
weekNavigationHeader
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Day rows with checkboxes
|
||||||
|
List {
|
||||||
|
ForEach(weekDates, id: \.self) { date in
|
||||||
|
let dayStr = MealPlanDate.dayString(from: date)
|
||||||
|
let isAlreadyAssigned = mealPlan.isRecipeAssigned(recipeId, on: date)
|
||||||
|
let existingCount = mealPlan.entries(for: date).count
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if !isAlreadyAssigned {
|
||||||
|
if selectedDays.contains(dayStr) {
|
||||||
|
selectedDays.remove(dayStr)
|
||||||
|
} else {
|
||||||
|
selectedDays.insert(dayStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: (isAlreadyAssigned || selectedDays.contains(dayStr)) ? "checkmark.circle.fill" : "circle")
|
||||||
|
.foregroundStyle(isAlreadyAssigned ? Color.secondary : Color.nextcloudBlue)
|
||||||
|
|
||||||
|
Text(dayDisplayName(date))
|
||||||
|
.foregroundStyle(isAlreadyAssigned ? .secondary : .primary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if existingCount > 0 {
|
||||||
|
Text("\(existingCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(Capsule().fill(Color(.tertiarySystemFill)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(isAlreadyAssigned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
}
|
||||||
|
.navigationTitle("Schedule Recipe")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") {
|
||||||
|
let dates = selectedDays.compactMap { MealPlanDate.dateFromDay($0) }
|
||||||
|
if !dates.isEmpty {
|
||||||
|
mealPlan.assignRecipe(recipeId: recipeId, recipeName: recipeName, toDates: dates)
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.disabled(selectedDays.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var recipeHeader: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if let recipeImage {
|
||||||
|
Image(uiImage: recipeImage)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 60, height: 60)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(recipeName)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
if let prepTime, !prepTime.isEmpty {
|
||||||
|
let duration = DurationComponents.fromPTString(prepTime)
|
||||||
|
if duration.hourComponent > 0 || duration.minuteComponent > 0 {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption)
|
||||||
|
Text(duration.displayString)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekNavigationHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset -= 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(weekLabel)
|
||||||
|
.font(.headline)
|
||||||
|
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
||||||
|
Text(weekRangeString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset += 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dayDisplayName(_ date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEEE, d MMM"
|
||||||
|
let name = formatter.string(from: date)
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
return "\(name) (\(String(localized: "Today")))"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import SwiftUI
|
|||||||
struct AllRecipesListView: View {
|
struct AllRecipesListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
var onCreateNew: () -> Void
|
var onCreateNew: () -> Void
|
||||||
var onImportFromURL: () -> Void
|
var onImportFromURL: () -> Void
|
||||||
@State private var allRecipes: [Recipe] = []
|
@State private var allRecipes: [Recipe] = []
|
||||||
@@ -65,6 +66,7 @@ struct AllRecipesListView: View {
|
|||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SwiftUI
|
|||||||
struct RecipeListView: View {
|
struct RecipeListView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@State var categoryName: String
|
@State var categoryName: String
|
||||||
@State var searchText: String = ""
|
@State var searchText: String = ""
|
||||||
var onCreateNew: () -> Void
|
var onCreateNew: () -> Void
|
||||||
@@ -78,6 +79,7 @@ struct RecipeListView: View {
|
|||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import SwiftUI
|
|||||||
struct RecipeView: View {
|
struct RecipeView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@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
|
||||||
@@ -50,6 +51,15 @@ struct RecipeView: View {
|
|||||||
.sheet(isPresented: $viewModel.presentKeywordSheet) {
|
.sheet(isPresented: $viewModel.presentKeywordSheet) {
|
||||||
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
KeywordPickerView(title: "Keywords", searchSuggestions: appState.allKeywords, selection: $viewModel.observableRecipeDetail.keywords)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $viewModel.presentMealPlanSheet) {
|
||||||
|
AddToMealPlanSheet(
|
||||||
|
recipeId: String(viewModel.recipe.recipe_id),
|
||||||
|
recipeName: viewModel.observableRecipeDetail.name,
|
||||||
|
prepTime: viewModel.recipeDetail.prepTime,
|
||||||
|
recipeImage: viewModel.recipeImage
|
||||||
|
)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
// Load recipe detail
|
// Load recipe detail
|
||||||
if !viewModel.newRecipe {
|
if !viewModel.newRecipe {
|
||||||
@@ -85,6 +95,15 @@ struct RecipeView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconcile server meal plan state with local data
|
||||||
|
if UserSettings.shared.mealPlanSyncEnabled {
|
||||||
|
mealPlan.syncManager?.reconcileFromServer(
|
||||||
|
serverAssignment: viewModel.recipeDetail.mealPlanAssignment,
|
||||||
|
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 {
|
||||||
@@ -196,6 +215,22 @@ struct RecipeView: View {
|
|||||||
|
|
||||||
RecipeDurationSection(viewModel: viewModel)
|
RecipeDurationSection(viewModel: viewModel)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
viewModel.presentMealPlanSheet = true
|
||||||
|
} label: {
|
||||||
|
Label("Plan recipe", systemImage: "calendar.badge.plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||||
@@ -279,6 +314,7 @@ struct RecipeView: View {
|
|||||||
|
|
||||||
@Published var presentShareSheet: Bool = false
|
@Published var presentShareSheet: Bool = false
|
||||||
@Published var presentKeywordSheet: Bool = false
|
@Published var presentKeywordSheet: Bool = false
|
||||||
|
@Published var presentMealPlanSheet: Bool = false
|
||||||
|
|
||||||
var recipe: Recipe
|
var recipe: Recipe
|
||||||
var sharedURL: URL? = nil
|
var sharedURL: URL? = nil
|
||||||
@@ -328,6 +364,7 @@ struct RecipeView: View {
|
|||||||
|
|
||||||
struct RecipeViewToolBar: ToolbarContent {
|
struct RecipeViewToolBar: ToolbarContent {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||||
|
|
||||||
@@ -474,6 +511,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
|||||||
}
|
}
|
||||||
await appState.getCategories()
|
await appState.getCategories()
|
||||||
await appState.getCategory(named: category, fetchMode: .preferServer)
|
await appState.getCategory(named: category, fetchMode: .preferServer)
|
||||||
|
mealPlan.removeAllAssignments(forRecipeId: String(id))
|
||||||
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|||||||
406
Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
Normal file
406
Nextcloud Cookbook iOS Client/Views/Tabs/MealPlanTabView.swift
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
//
|
||||||
|
// MealPlanTabView.swift
|
||||||
|
// Nextcloud Cookbook iOS Client
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MealPlanTabView: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
|
||||||
|
@State private var weekOffset: Int = 0
|
||||||
|
@State private var addRecipeDate: Date? = nil
|
||||||
|
|
||||||
|
private var calendar: Calendar { Calendar.current }
|
||||||
|
|
||||||
|
private var weekDates: [Date] {
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
// Find start of current week (Monday)
|
||||||
|
let weekday = calendar.component(.weekday, from: today)
|
||||||
|
let daysToMonday = (weekday + 5) % 7
|
||||||
|
guard let monday = calendar.date(byAdding: .day, value: -daysToMonday, to: today),
|
||||||
|
let offsetMonday = calendar.date(byAdding: .weekOfYear, value: weekOffset, to: monday) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: offsetMonday) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekLabel: String {
|
||||||
|
if weekOffset == 0 {
|
||||||
|
return String(localized: "This Week")
|
||||||
|
} else if weekOffset == 1 {
|
||||||
|
return String(localized: "Next Week")
|
||||||
|
} else if weekOffset == -1 {
|
||||||
|
return String(localized: "Last Week")
|
||||||
|
} else {
|
||||||
|
return weekRangeString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekRangeString: String {
|
||||||
|
guard let first = weekDates.first, let last = weekDates.last else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "dd.MM."
|
||||||
|
return "\(formatter.string(from: first)) – \(formatter.string(from: last))"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
weekNavigationHeader
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
ForEach(weekDates, id: \.self) { date in
|
||||||
|
MealPlanDayRow(
|
||||||
|
date: date,
|
||||||
|
entries: mealPlan.entries(for: date),
|
||||||
|
isToday: calendar.isDateInToday(date),
|
||||||
|
onAdd: {
|
||||||
|
addRecipeDate = date
|
||||||
|
},
|
||||||
|
onRemove: { entry in
|
||||||
|
withAnimation {
|
||||||
|
mealPlan.removeRecipe(recipeId: entry.recipeId, fromDate: entry.dateString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Meal Plan")
|
||||||
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(appState)
|
||||||
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
}
|
||||||
|
.sheet(item: $addRecipeDate) { date in
|
||||||
|
RecipePickerForMealPlan(date: date)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var weekNavigationHeader: some View {
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset -= 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(weekLabel)
|
||||||
|
.font(.headline)
|
||||||
|
if weekOffset == 0 || weekOffset == 1 || weekOffset == -1 {
|
||||||
|
Text(weekRangeString)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
withAnimation { weekOffset += 1 }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Day Row
|
||||||
|
|
||||||
|
fileprivate struct MealPlanDayRow: View {
|
||||||
|
let date: Date
|
||||||
|
let entries: [MealPlanEntry]
|
||||||
|
let isToday: Bool
|
||||||
|
let onAdd: () -> Void
|
||||||
|
let onRemove: (MealPlanEntry) -> Void
|
||||||
|
|
||||||
|
private var dayNumber: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "d"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dayName: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEE"
|
||||||
|
return formatter.string(from: date).uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
// Day label
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(dayName)
|
||||||
|
.font(.caption2)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(isToday ? .white : .secondary)
|
||||||
|
Text(dayNumber)
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(isToday ? .white : .primary)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 54)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 10)
|
||||||
|
.fill(isToday ? Color.nextcloudBlue : Color.clear)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry or add button
|
||||||
|
if let entry = entries.first, let recipeIdInt = Int(entry.recipeId) {
|
||||||
|
NavigationLink(value: Recipe(
|
||||||
|
name: entry.recipeName,
|
||||||
|
keywords: nil,
|
||||||
|
dateCreated: nil,
|
||||||
|
dateModified: nil,
|
||||||
|
imageUrl: nil,
|
||||||
|
imagePlaceholderUrl: nil,
|
||||||
|
recipe_id: recipeIdInt
|
||||||
|
)) {
|
||||||
|
MealPlanEntryCard(entry: entry, onRemove: {
|
||||||
|
onRemove(entry)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if let entry = entries.first {
|
||||||
|
MealPlanEntryCard(entry: entry, onRemove: {
|
||||||
|
onRemove(entry)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Button(action: onAdd) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(Color.nextcloudBlue)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 44)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.nextcloudBlue.opacity(0.1))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.leading, 68)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Entry Card
|
||||||
|
|
||||||
|
fileprivate struct MealPlanEntryCard: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let entry: MealPlanEntry
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
@State private var recipeThumb: UIImage?
|
||||||
|
@State private var totalTimeText: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if let recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 44)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 44)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(entry.recipeName)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(3)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if let totalTimeText {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Image(systemName: "clock")
|
||||||
|
.font(.caption2)
|
||||||
|
Text(totalTimeText)
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(6)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(.secondarySystemBackground))
|
||||||
|
)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
onRemove()
|
||||||
|
} label: {
|
||||||
|
Label("Remove", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
guard let recipeIdInt = Int(entry.recipeId) else { return }
|
||||||
|
recipeThumb = await appState.getImage(
|
||||||
|
id: recipeIdInt,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
if let detail = await appState.getRecipe(
|
||||||
|
id: recipeIdInt,
|
||||||
|
fetchMode: UserSettings.shared.storeRecipes ? .preferLocal : .onlyServer
|
||||||
|
) {
|
||||||
|
if let totalTime = detail.totalTime, let text = DurationComponents.ptToText(totalTime) {
|
||||||
|
totalTimeText = text
|
||||||
|
} else if let prepTime = detail.prepTime, let text = DurationComponents.ptToText(prepTime) {
|
||||||
|
totalTimeText = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe Picker Sheet
|
||||||
|
|
||||||
|
struct RecipePickerForMealPlan: View {
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
let date: Date
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var allRecipes: [Recipe] = []
|
||||||
|
|
||||||
|
private var filteredRecipes: [Recipe] {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
return allRecipes
|
||||||
|
}
|
||||||
|
return allRecipes.filter {
|
||||||
|
$0.name.localizedCaseInsensitiveContains(searchText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dateLabel: String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(filteredRecipes, id: \.recipe_id) { recipe in
|
||||||
|
Button {
|
||||||
|
mealPlan.assignRecipe(
|
||||||
|
recipeId: String(recipe.recipe_id),
|
||||||
|
recipeName: recipe.name,
|
||||||
|
toDates: [date]
|
||||||
|
)
|
||||||
|
dismiss()
|
||||||
|
} label: {
|
||||||
|
RecipePickerRow(recipe: recipe)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(dateLabel)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.searchable(text: $searchText, prompt: String(localized: "Search recipes"))
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
allRecipes = await appState.getRecipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Recipe Picker Row
|
||||||
|
|
||||||
|
fileprivate struct RecipePickerRow: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
let recipe: Recipe
|
||||||
|
@State private var recipeThumb: UIImage?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let recipeThumb {
|
||||||
|
Image(uiImage: recipeThumb)
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else {
|
||||||
|
LinearGradient(
|
||||||
|
gradient: Gradient(colors: [.ncGradientDark, .ncGradientLight]),
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
.frame(width: 48, height: 48)
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "fork.knife")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(recipe.name)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
recipeThumb = await appState.getImage(
|
||||||
|
id: recipe.recipe_id,
|
||||||
|
size: .THUMB,
|
||||||
|
fetchMode: UserSettings.shared.storeThumb ? .preferLocal : .onlyServer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Date Identifiable Extension
|
||||||
|
|
||||||
|
extension Date: @retroactive Identifiable {
|
||||||
|
public var id: TimeInterval { timeIntervalSince1970 }
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import SwiftUI
|
|||||||
struct RecipeTabView: View {
|
struct RecipeTabView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@EnvironmentObject var groceryList: GroceryListManager
|
@EnvironmentObject var groceryList: GroceryListManager
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ struct RecipeTabView: View {
|
|||||||
}())
|
}())
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
viewModel.importedRecipeDetail = nil
|
viewModel.importedRecipeDetail = nil
|
||||||
}
|
}
|
||||||
@@ -126,6 +128,7 @@ struct RecipeTabView: View {
|
|||||||
.id(category.id)
|
.id(category.id)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
case .allRecipes:
|
case .allRecipes:
|
||||||
AllRecipesListView(
|
AllRecipesListView(
|
||||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||||
@@ -133,12 +136,14 @@ struct RecipeTabView: View {
|
|||||||
)
|
)
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
.environmentObject(appState)
|
.environmentObject(appState)
|
||||||
.environmentObject(groceryList)
|
.environmentObject(groceryList)
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} detail: {
|
} detail: {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import SwiftUI
|
|||||||
struct SearchTabView: View {
|
struct SearchTabView: View {
|
||||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@EnvironmentObject var mealPlan: MealPlanManager
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -113,6 +114,7 @@ struct SearchTabView: View {
|
|||||||
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
||||||
.navigationDestination(for: Recipe.self) { recipe in
|
.navigationDestination(for: Recipe.self) { recipe in
|
||||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||||
|
.environmentObject(mealPlan)
|
||||||
}
|
}
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||||
.onSubmit(of: .search) {
|
.onSubmit(of: .search) {
|
||||||
|
|||||||
Reference in New Issue
Block a user