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:
@@ -10,6 +10,7 @@ import SwiftUI
|
||||
struct MainView: View {
|
||||
@StateObject var appState = AppState()
|
||||
@StateObject var groceryList = GroceryListManager()
|
||||
@StateObject var mealPlan = MealPlanManager()
|
||||
|
||||
// Tab ViewModels
|
||||
@StateObject var recipeViewModel = RecipeTabView.ViewModel()
|
||||
@@ -20,7 +21,7 @@ struct MainView: View {
|
||||
@State private var selectedTab: Tab = .recipes
|
||||
|
||||
enum Tab {
|
||||
case recipes, search, groceryList
|
||||
case recipes, search, mealPlan, groceryList
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -30,6 +31,7 @@ struct MainView: View {
|
||||
.environmentObject(recipeViewModel)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
|
||||
SwiftUI.Tab("Search", systemImage: "magnifyingglass", value: .search, role: .search) {
|
||||
@@ -37,6 +39,14 @@ struct MainView: View {
|
||||
.environmentObject(searchViewModel)
|
||||
.environmentObject(appState)
|
||||
.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 {
|
||||
@@ -85,6 +95,11 @@ struct MainView: View {
|
||||
if UserSettings.shared.grocerySyncEnabled {
|
||||
await groceryList.syncManager?.performInitialSync()
|
||||
}
|
||||
await mealPlan.load()
|
||||
mealPlan.configureSyncManager(appState: appState)
|
||||
if UserSettings.shared.mealPlanSyncEnabled {
|
||||
await mealPlan.syncManager?.performInitialSync()
|
||||
}
|
||||
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 {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
var onCreateNew: () -> Void
|
||||
var onImportFromURL: () -> Void
|
||||
@State private var allRecipes: [Recipe] = []
|
||||
@@ -65,6 +66,7 @@ struct AllRecipesListView: View {
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import SwiftUI
|
||||
struct RecipeListView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
@State var categoryName: String
|
||||
@State var searchText: String = ""
|
||||
var onCreateNew: () -> Void
|
||||
@@ -78,6 +79,7 @@ struct RecipeListView: View {
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import SwiftUI
|
||||
struct RecipeView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject var viewModel: ViewModel
|
||||
@GestureState private var dragOffset = CGSize.zero
|
||||
@@ -50,6 +51,15 @@ struct RecipeView: View {
|
||||
.sheet(isPresented: $viewModel.presentKeywordSheet) {
|
||||
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 {
|
||||
// Load recipe detail
|
||||
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 {
|
||||
// Prepare view for a new recipe
|
||||
if let preloaded = viewModel.preloadedRecipeDetail {
|
||||
@@ -196,6 +215,22 @@ struct RecipeView: View {
|
||||
|
||||
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()
|
||||
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 400), alignment: .top)]) {
|
||||
@@ -279,6 +314,7 @@ struct RecipeView: View {
|
||||
|
||||
@Published var presentShareSheet: Bool = false
|
||||
@Published var presentKeywordSheet: Bool = false
|
||||
@Published var presentMealPlanSheet: Bool = false
|
||||
|
||||
var recipe: Recipe
|
||||
var sharedURL: URL? = nil
|
||||
@@ -328,6 +364,7 @@ struct RecipeView: View {
|
||||
|
||||
struct RecipeViewToolBar: ToolbarContent {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var viewModel: RecipeView.ViewModel
|
||||
|
||||
@@ -474,6 +511,7 @@ struct RecipeViewToolBar: ToolbarContent {
|
||||
}
|
||||
await appState.getCategories()
|
||||
await appState.getCategory(named: category, fetchMode: .preferServer)
|
||||
mealPlan.removeAllAssignments(forRecipeId: String(id))
|
||||
viewModel.presentAlert(RecipeAlert.DELETE_SUCCESS)
|
||||
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 {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var groceryList: GroceryListManager
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
@EnvironmentObject var viewModel: RecipeTabView.ViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@@ -114,6 +115,7 @@ struct RecipeTabView: View {
|
||||
}())
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
.onAppear {
|
||||
viewModel.importedRecipeDetail = nil
|
||||
}
|
||||
@@ -126,6 +128,7 @@ struct RecipeTabView: View {
|
||||
.id(category.id)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
case .allRecipes:
|
||||
AllRecipesListView(
|
||||
onCreateNew: { viewModel.navigateToNewRecipe() },
|
||||
@@ -133,12 +136,14 @@ struct RecipeTabView: View {
|
||||
)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(appState)
|
||||
.environmentObject(groceryList)
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import SwiftUI
|
||||
struct SearchTabView: View {
|
||||
@EnvironmentObject var viewModel: SearchTabView.ViewModel
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var mealPlan: MealPlanManager
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -113,6 +114,7 @@ struct SearchTabView: View {
|
||||
.navigationTitle(viewModel.searchText.isEmpty ? "Search recipe" : "Search Results")
|
||||
.navigationDestination(for: Recipe.self) { recipe in
|
||||
RecipeView(viewModel: RecipeView.ViewModel(recipe: recipe))
|
||||
.environmentObject(mealPlan)
|
||||
}
|
||||
.searchable(text: $viewModel.searchText, prompt: "Search recipes/keywords")
|
||||
.onSubmit(of: .search) {
|
||||
|
||||
Reference in New Issue
Block a user