Files
FamilyMealPlanner/MealMood/Views/Home/WeekScheduleSheet.swift
T
alexandrev-tibco 66128e7a5c premium: configurar días y comidas de una semana concreta
Hasta ahora includeWeekends y activeMealTypes vivían solo en AppSettings y
aplicaban a todo el calendario. WeekPlan puede ahora sobreescribirlos para su
semana (includeWeekendsOverride / mealTypesOverrideRaw) y WeekSchedule resuelve
qué manda en cada semana: su override si lo tiene, los ajustes globales si no.

Todo lo que pintaba días y comidas —planificador, exports, compartir texto,
widget, watch— pasa por settings.schedule(for:), así que una semana con fin de
semana añadido o con desayuno activo se ve igual en todas partes.

Crear o editar el override es premium; los ya guardados se respetan aunque la
suscripción caduque, para no borrar comidas ya planificadas. La hoja avisa y
pide confirmación cuando quitar un día o una comida se lleva por delante algo
ya planificado.

Refs #32

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
2026-09-12 12:05:34 +02:00

173 lines
6.9 KiB
Swift

import SwiftUI
/// "Just this week" (premium): adds the weekend or turns meals on/off for a
/// single week without touching the global settings. Removing a day or a meal
/// deletes its slots, so anything already planned there is confirmed first.
struct WeekScheduleSheet: View {
let plan: WeekPlan
let settings: AppSettings
let onApply: (_ includeWeekends: Bool, _ mealTypes: [MealType]) -> Void
let onReset: () -> Void
let onUpsell: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var includeWeekends: Bool
@State private var selectedMealTypes: Set<MealType>
@State private var showDestructiveConfirm: Bool = false
private var canEdit: Bool {
PremiumAccess.canCustomizeWeekSchedule(isPremium: settings.isPremium)
}
init(
plan: WeekPlan,
settings: AppSettings,
onApply: @escaping (_ includeWeekends: Bool, _ mealTypes: [MealType]) -> Void,
onReset: @escaping () -> Void,
onUpsell: @escaping () -> Void
) {
self.plan = plan
self.settings = settings
self.onApply = onApply
self.onReset = onReset
self.onUpsell = onUpsell
let schedule = settings.schedule(for: plan)
_includeWeekends = State(initialValue: schedule.includeWeekends)
_selectedMealTypes = State(initialValue: Set(schedule.mealTypes))
}
/// Slots that would be deleted by the current selection because their day
/// or meal is no longer planned and that already hold something.
private var slotsAtRisk: [MealSlot] {
let lastDay = includeWeekends ? 6 : 4
return plan.slotList.filter { slot in
let dropped = slot.dayOfWeek > lastDay || !selectedMealTypes.contains(slot.mealTypeEnum)
let hasContent = slot.dishId != nil || slot.isEatingOut || slot.isSkipped
return dropped && hasContent
}
}
private var hasChanges: Bool {
let current = settings.schedule(for: plan)
return includeWeekends != current.includeWeekends || selectedMealTypes != Set(current.mealTypes)
}
var body: some View {
NavigationStack {
Form {
Section {
Text("week_schedule_intro")
.font(.mealMoodCaption)
.foregroundColor(.mealMoodTextSecondary)
if !canEdit {
PremiumUpsellBanner(
messageKey: "week_schedule_premium_message",
actionTitleKey: "premium_subscribe",
action: onUpsell
)
.listRowInsets(EdgeInsets(top: 6, leading: 8, bottom: 6, trailing: 8))
}
} header: {
Text(plan.weekStartDate.formattedWeekRange())
}
.listRowBackground(Color.mealMoodSurface)
Section {
ForEach(MealType.allCases, id: \.self) { mealType in
Toggle(isOn: Binding(
get: { selectedMealTypes.contains(mealType) },
set: { isOn in
if isOn {
selectedMealTypes.insert(mealType)
} else if selectedMealTypes.count > 1 {
selectedMealTypes.remove(mealType)
}
}
)) {
Label {
Text(String(localized: String.LocalizationValue(mealType.localizedKey)))
} icon: {
Image(systemName: mealType.icon)
.foregroundStyle(Color.mealMoodCoral)
}
}
.disabled(!canEdit)
}
} header: {
Text("week_schedule_meals_section")
} footer: {
Text("week_schedule_meals_footer")
}
.listRowBackground(Color.mealMoodSurface)
Section {
Toggle(isOn: $includeWeekends) {
Label {
Text("settings_include_weekends")
} icon: {
Image(systemName: "calendar")
.foregroundStyle(Color.mealMoodCoral)
}
}
.disabled(!canEdit)
} header: {
Text("week_schedule_days_section")
}
.listRowBackground(Color.mealMoodSurface)
if plan.hasScheduleOverride {
Section {
Button(role: .destructive) {
onReset()
dismiss()
} label: {
Label("week_schedule_reset", systemImage: "arrow.counterclockwise")
}
.disabled(!canEdit)
} footer: {
Text("week_schedule_reset_footer")
}
.listRowBackground(Color.mealMoodSurface)
}
}
.scrollContentBackground(.hidden)
.background(Color.mealMoodBackground.ignoresSafeArea())
.tint(.mealMoodCoral)
.navigationTitle("week_schedule_title")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("reset_cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("week_schedule_apply") {
if slotsAtRisk.isEmpty {
apply()
} else {
showDestructiveConfirm = true
}
}
.disabled(!canEdit || !hasChanges)
}
}
.alert("week_schedule_warning_title", isPresented: $showDestructiveConfirm) {
Button("reset_cancel", role: .cancel) {}
Button("week_schedule_warning_confirm", role: .destructive) { apply() }
} message: {
Text(String(format: String(localized: "week_schedule_warning_message"), slotsAtRisk.count))
}
}
// Full height: at medium the weekend toggle and the reset button fall
// below the fold, which is exactly what the sheet is for.
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
private func apply() {
onApply(includeWeekends, MealType.allCases.filter { selectedMealTypes.contains($0) })
dismiss()
}
}