Add user-facing appearance setting (System/Light/Dark) wired via preferredColorScheme at the app root. Replace hardcoded .black tints and foreground styles with .primary so toolbar buttons and text remain visible in dark mode. Remove profile picture from settings and SwiftSoup from acknowledgements. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
407 lines
13 KiB
Swift
407 lines
13 KiB
Swift
//
|
||
// 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.15))
|
||
)
|
||
}
|
||
}
|
||
}
|
||
.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 }
|
||
}
|