Files
FamilyMealPlanner/MealMood/Views/Home/WeekPlanShareView.swift
T
alexandrev-tibco a0f9fa63d3 platos: segundo plato por comida (ensalada + salmon)
- MealSlot.secondaryDishId (opcional, aditivo CloudKit); se limpia al
  sustituir/quitar/vaciar/marcar comer fuera y viaja en swaps, copia de
  semana anterior, undo y snapshot KV de iCloud (retrocompatible)
- Picker de hueco lleno: toggle "anadir como segundo plato" + boton para
  quitarlo; HomeViewModel.assignSecondaryDish/removeSecondaryDish
- Se muestra "Plato + Segundo" en calendario, widget y export; la lista
  de la compra incluye los ingredientes del segundo; stats lo cuentan
- Evento analytics secondary_dish_added; strings en 6 idiomas

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 11:37:23 +02:00

628 lines
24 KiB
Swift

import SwiftUI
import CoreImage.CIFilterBuiltins
struct WeekPlanShareView: View {
let plan: WeekPlan
let settings: AppSettings
let dishes: [Dish]
let tags: [Tag]
let exportStyleOverride: WeekExportStyle?
static let a4LandscapeWidth: CGFloat = 3508
static let a4LandscapeHeight: CGFloat = 2480
static let a4PortraitWidth: CGFloat = 2480
static let a4PortraitHeight: CGFloat = 3508
private let shareURL = "https://mealmood.app"
private let qrContext = CIContext()
private let qrFilter = CIFilter.qrCodeGenerator()
init(
plan: WeekPlan,
settings: AppSettings,
dishes: [Dish],
tags: [Tag],
exportStyleOverride: WeekExportStyle? = nil
) {
self.plan = plan
self.settings = settings
self.dishes = dishes
self.tags = tags
self.exportStyleOverride = exportStyleOverride
}
static func canvasSize(for style: WeekExportStyle) -> CGSize {
switch style {
case .vertical:
return CGSize(width: a4PortraitWidth, height: a4PortraitHeight)
case .defaultStyle, .schoolTimetable:
return CGSize(width: a4LandscapeWidth, height: a4LandscapeHeight)
}
}
private var dayRange: ClosedRange<Int> {
settings.includeWeekends ? 0...6 : 0...4
}
private var mealTypes: [MealType] {
settings.activeMealTypes
}
private var exportStyle: WeekExportStyle {
exportStyleOverride ?? settings.weekExportStyleEnum
}
private var canvasSize: CGSize {
Self.canvasSize(for: exportStyle)
}
private var locale: Locale {
Locale(identifier: settings.languageEnum.resolved().localeIdentifier)
}
private var exportDateString: String {
Date.now.formatted(date: .abbreviated, time: .omitted)
}
private var qrImage: UIImage? {
qrFilter.setValue(Data(shareURL.utf8), forKey: "inputMessage")
qrFilter.setValue("M", forKey: "inputCorrectionLevel")
guard let outputImage = qrFilter.outputImage else { return nil }
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: 10, y: 10))
guard let cgImage = qrContext.createCGImage(transformed, from: transformed.extent) else { return nil }
return UIImage(cgImage: cgImage)
}
var body: some View {
ZStack {
switch exportStyle {
case .defaultStyle:
defaultBackground
case .schoolTimetable:
schoolBackground
case .vertical:
verticalBackground
}
VStack(spacing: 24) {
headerBlock
Group {
switch exportStyle {
case .defaultStyle:
defaultGridLayout
case .schoolTimetable:
schoolTimetableLayout
case .vertical:
verticalLayout
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
footerBlock
}
.padding(.horizontal, exportStyle == .vertical ? 72 : 84)
.padding(.vertical, exportStyle == .vertical ? 58 : 70)
}
.frame(width: canvasSize.width, height: canvasSize.height)
}
private var defaultBackground: some View {
ZStack {
LinearGradient(
colors: [Color(hex: "#FFEFE6"), Color(hex: "#F1FBF5")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(Color(hex: "#FFD6E2").opacity(0.55))
.frame(width: 760, height: 760)
.blur(radius: 20)
.offset(x: -980, y: -760)
Circle()
.fill(Color(hex: "#CFF2E8").opacity(0.55))
.frame(width: 680, height: 680)
.blur(radius: 20)
.offset(x: 1050, y: 760)
}
}
private var schoolBackground: some View {
ZStack {
Color(hex: "#FFF9F5")
RoundedRectangle(cornerRadius: 220)
.fill(Color.mealMoodMint.opacity(0.22))
.frame(width: 1500, height: 520)
.offset(x: 920, y: -920)
RoundedRectangle(cornerRadius: 220)
.fill(Color.mealMoodCoral.opacity(0.18))
.frame(width: 1600, height: 560)
.offset(x: -920, y: 940)
}
}
private var verticalBackground: some View {
ZStack {
LinearGradient(
colors: [Color(hex: "#FFF7F4"), Color(hex: "#FFF3F7"), Color(hex: "#F6FFF8")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Circle()
.fill(Color(hex: "#FFD0DF").opacity(0.6))
.frame(width: 1100, height: 1100)
.blur(radius: 90)
.offset(x: 950, y: -1100)
Circle()
.fill(Color(hex: "#C0EAD8").opacity(0.55))
.frame(width: 1000, height: 1000)
.blur(radius: 80)
.offset(x: -900, y: 1300)
Circle()
.fill(Color(hex: "#FFE0C0").opacity(0.4))
.frame(width: 700, height: 700)
.blur(radius: 100)
.offset(x: 100, y: 200)
}
}
@ViewBuilder
private var headerBlock: some View {
switch exportStyle {
case .defaultStyle:
defaultHeader
case .schoolTimetable, .vertical:
elegantHeader
}
}
private var defaultHeader: some View {
HStack(alignment: .center, spacing: 24) {
AppIconPlaceholder(size: 84)
VStack(alignment: .leading, spacing: 6) {
Text(exportHeaderTitle)
.font(.system(size: 52, weight: .bold, design: .rounded))
.foregroundColor(.mealMoodTextPrimary)
Text(plan.weekStartDate.formattedWeekRange())
.font(.system(size: 28, weight: .semibold, design: .rounded))
.foregroundColor(Color(hex: "#5D645F"))
}
Spacer()
Text(exportDateString)
.font(.system(size: 24, weight: .medium, design: .rounded))
.foregroundColor(.mealMoodTextSecondary)
}
.padding(.horizontal, 30)
.padding(.vertical, 24)
.background(Color.white.opacity(0.8))
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.mealMoodCoral.opacity(0.5), lineWidth: 1)
)
}
private var elegantHeader: some View {
VStack(spacing: 8) {
Text(plan.weekStartDate.formattedWeekRange())
.font(.custom("Didot", size: exportStyle == .vertical ? 126 : 108))
.foregroundColor(Color(hex: "#30473F"))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.45)
.frame(maxWidth: .infinity)
Text(exportHeaderTitle)
.font(.system(size: exportStyle == .vertical ? 58 : 54, weight: .semibold, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
Text(exportDateString)
.font(.system(size: 34, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextSecondary)
}
.padding(.horizontal, 24)
.padding(.vertical, 24)
.background(Color.white.opacity(0.88))
.clipShape(RoundedRectangle(cornerRadius: 24))
.overlay(
RoundedRectangle(cornerRadius: 24)
.stroke(Color.mealMoodCoral.opacity(0.45), lineWidth: 1)
)
}
private var exportHeaderTitle: String {
switch exportStyle {
case .defaultStyle:
return String(localized: "share_week_title")
case .schoolTimetable:
return String(localized: "share_export_school_title")
case .vertical:
return String(localized: "share_export_vertical_title")
}
}
private var defaultGridLayout: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(Color.white.opacity(0.62))
RoundedRectangle(cornerRadius: 30)
.stroke(Color.white.opacity(0.75), lineWidth: 2)
Grid(horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
gridHeaderCell("", day: nil)
.frame(minWidth: 220)
ForEach(Array(dayRange), id: \.self) { day in
gridHeaderCell(dayShortTitle(for: day), day: day)
}
}
ForEach(mealTypes, id: \.self) { mealType in
GridRow {
gridMealCell(mealTypeLabel(mealType), icon: mealType.icon, mealType: mealType)
.frame(minWidth: 220)
ForEach(Array(dayRange), id: \.self) { day in
gridDishCell(dishName(day: day, mealType: mealType), day: day)
}
}
}
}
.padding(18)
}
.shadow(color: Color(hex: "#DDAA99").opacity(0.18), radius: 26, x: 0, y: 16)
}
private var schoolTimetableLayout: some View {
ZStack {
RoundedRectangle(cornerRadius: 28)
.fill(Color.white)
.shadow(color: Color.black.opacity(0.06), radius: 20, x: 0, y: 10)
VStack(spacing: 18) {
Text(String(localized: "share_export_school_subtitle"))
.font(.custom("Didot", size: 48))
.foregroundColor(.mealMoodTextSecondary)
Grid(horizontalSpacing: 12, verticalSpacing: 12) {
GridRow {
Text(" ")
.frame(width: 280)
ForEach(mealTypes, id: \.self) { mealType in
Text(mealTypeLabel(mealType).uppercased(with: locale))
.font(.system(size: 34, weight: .bold, design: .serif))
.foregroundColor(Color(hex: "#355548"))
.frame(maxWidth: .infinity, minHeight: 88)
.background(Color.mealMoodMint.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
ForEach(Array(dayRange), id: \.self) { day in
GridRow {
Text(dayHeaderTitle(for: day).uppercased(with: locale))
.font(.system(size: 30, weight: .bold, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
.frame(width: 280)
.frame(minHeight: 138)
.background(dayHeaderBackground(day: day))
.clipShape(RoundedRectangle(cornerRadius: 12))
ForEach(mealTypes, id: \.self) { mealType in
Text(dishName(day: day, mealType: mealType))
.font(.system(size: 31, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.leading)
.lineLimit(3)
.frame(maxWidth: .infinity, minHeight: 138, alignment: .leading)
.padding(.horizontal, 16)
.background(Color(hex: "#FFFDFB"))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(hex: "#ECDDD5"), lineWidth: 1)
)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 22)
}
}
private var verticalLayout: some View {
ZStack {
RoundedRectangle(cornerRadius: 32)
.fill(Color.white.opacity(0.88))
.overlay(
RoundedRectangle(cornerRadius: 32)
.stroke(
LinearGradient(
colors: [Color(hex: "#F0C0CC").opacity(0.7), Color(hex: "#B8E8D4").opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 3
)
)
VStack(spacing: 20) {
ForEach(Array(dayRange), id: \.self) { day in
HStack(spacing: 0) {
VStack(spacing: 12) {
Text(dayLongTitle(for: day).uppercased(with: locale))
.font(.custom("Didot", size: 68))
.foregroundColor(Color(hex: "#2C3E35"))
.multilineTextAlignment(.center)
.lineLimit(1)
.minimumScaleFactor(0.38)
.frame(maxWidth: .infinity)
Text(dayNumberOnly(for: day))
.font(.system(size: 96, weight: .bold, design: .rounded))
.foregroundColor(Color(hex: "#2C3E35").opacity(0.6))
}
.frame(width: 420)
.frame(maxHeight: .infinity)
.padding(.vertical, 28)
.background(dayHeaderBackground(day: day))
VStack(alignment: .leading, spacing: 24) {
ForEach(mealTypes, id: \.self) { mealType in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 14) {
Image(systemName: mealType.icon)
.font(.system(size: 42, weight: .medium))
.foregroundColor(mealTypeAccent(mealType))
.frame(width: 56)
Text(mealTypeLabel(mealType).uppercased(with: locale))
.font(.system(size: 42, weight: .bold, design: .serif))
.foregroundColor(mealTypeAccent(mealType))
}
Text(dishName(day: day, mealType: mealType))
.font(.system(size: 62, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
.lineLimit(2)
.minimumScaleFactor(0.5)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 70)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(.horizontal, 40)
.padding(.vertical, 28)
.background(Color(hex: "#FFFCF8"))
}
.frame(maxHeight: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
RoundedRectangle(cornerRadius: 20)
.stroke(Color(hex: "#EAD8D0"), lineWidth: 1.5)
)
.shadow(color: Color.black.opacity(0.04), radius: 10, x: 0, y: 4)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.horizontal, 28)
.padding(.vertical, 26)
}
}
private func dayNumberOnly(for day: Int) -> String {
let dayDate = plan.weekStartDate.addingDays(day)
let formatter = DateFormatter()
formatter.locale = locale
formatter.dateFormat = "d"
return formatter.string(from: dayDate)
}
private var footerBlock: some View {
HStack(alignment: .center, spacing: 20) {
VStack(alignment: .leading, spacing: 6) {
Text("Powered by MealMood")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundColor(Color(hex: "#3A3A3A"))
Text(shareURL)
.font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundColor(Color(hex: "#588A77"))
}
Spacer()
if let qrImage {
VStack(spacing: 6) {
Image(uiImage: qrImage)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: 162, height: 162)
.padding(10)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 18))
Text("Scan me")
.font(.system(size: 18, weight: .semibold, design: .rounded))
.foregroundColor(.mealMoodTextSecondary)
}
}
}
.padding(.horizontal, 26)
.padding(.vertical, 18)
.background(Color.white.opacity(0.82))
.clipShape(RoundedRectangle(cornerRadius: 22))
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(Color(hex: "#DDEEE6"), lineWidth: 1)
)
}
private func dayShortTitle(for day: Int) -> String {
let dayDate = plan.weekStartDate.addingDays(day)
let formatter = DateFormatter()
formatter.locale = locale
formatter.setLocalizedDateFormatFromTemplate("EEE d")
return formatter.string(from: dayDate).capitalized(with: locale)
}
private func dayLongTitle(for day: Int) -> String {
let dayDate = plan.weekStartDate.addingDays(day)
let formatter = DateFormatter()
formatter.locale = locale
formatter.setLocalizedDateFormatFromTemplate("EEEE")
return formatter.string(from: dayDate).capitalized(with: locale)
}
private func dayHeaderTitle(for day: Int) -> String {
let dayDate = plan.weekStartDate.addingDays(day)
let formatter = DateFormatter()
formatter.locale = locale
formatter.setLocalizedDateFormatFromTemplate("EEEE d")
return formatter.string(from: dayDate).capitalized(with: locale)
}
private func mealTypeLabel(_ mealType: MealType) -> String {
String(localized: String.LocalizationValue(mealType.localizedKey))
}
/// Accent color per meal row in the vertical/school styles.
private func mealTypeAccent(_ mealType: MealType) -> Color {
switch mealType {
case .breakfast: return Color(hex: "#B07C2B")
case .lunch: return Color(hex: "#C85E2A")
case .snack: return Color(hex: "#7B5EA7")
case .dinner: return Color(hex: "#27604A")
}
}
private func dishName(day: Int, mealType: MealType) -> String {
let slot = plan.slotList.first { $0.dayOfWeek == day && $0.mealType == mealType.rawValue }
guard let slot else { return String(localized: "share_slot_empty") }
if slot.isEatingOut { return String(localized: "slot_eating_out") }
guard let dishId = slot.dishId, let dish = dishes.first(where: { $0.id == dishId }) else {
return String(localized: "share_slot_empty")
}
if let secondaryId = slot.secondaryDishId,
let secondary = dishes.first(where: { $0.id == secondaryId }) {
return "\(dish.name) + \(secondary.name)"
}
return dish.name
}
private func gridHeaderCell(_ title: String, day: Int?) -> some View {
let background = dayHeaderBackground(day: day)
return Text(title)
.font(.system(size: 25, weight: .bold, design: .rounded))
.foregroundColor(.mealMoodTextPrimary)
.frame(maxWidth: .infinity, minHeight: 92)
.padding(.horizontal, 10)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.white.opacity(0.6), lineWidth: 1)
)
}
private func gridMealCell(_ title: String, icon: String, mealType: MealType) -> some View {
let background: LinearGradient
switch mealType {
case .breakfast:
background = LinearGradient(colors: [Color(hex: "#FFEFC9"), Color(hex: "#FFE5A9")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .lunch:
background = LinearGradient(colors: [Color(hex: "#FFE2C8"), Color(hex: "#FFD4B2")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .snack:
background = LinearGradient(colors: [Color(hex: "#EBE0F5"), Color(hex: "#DFD0EF")], startPoint: .topLeading, endPoint: .bottomTrailing)
case .dinner:
background = LinearGradient(colors: [Color(hex: "#D7EFE7"), Color(hex: "#C2E7DB")], startPoint: .topLeading, endPoint: .bottomTrailing)
}
return HStack(spacing: 8) {
Image(systemName: icon)
Text(title)
}
.font(.system(size: 25, weight: .bold, design: .rounded))
.foregroundColor(Color(hex: "#365548"))
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.horizontal, 14)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color.white.opacity(0.6), lineWidth: 1)
)
}
private func gridDishCell(_ title: String, day: Int) -> some View {
let tint = dayCellTint(day: day)
return Text(title)
.font(.system(size: 26, weight: .medium))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.6)
.frame(maxWidth: .infinity, minHeight: 130)
.padding(.horizontal, 10)
.background(
LinearGradient(
colors: [Color.white.opacity(0.95), tint.opacity(0.32)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(Color(hex: "#F2E5DE"), lineWidth: 1)
)
}
private func dayHeaderBackground(day: Int?) -> LinearGradient {
guard let day else {
return LinearGradient(
colors: [Color(hex: "#F6E7DE"), Color(hex: "#F2DED2")],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
let palettes: [(String, String)] = [
("#FFD5D9", "#FFC8D2"),
("#FFE1C8", "#FFD3AF"),
("#FFF0C8", "#FFE6A8"),
("#DFF2CC", "#CFEAB6"),
("#D3F0E7", "#BFE6DA"),
("#D9E8FF", "#C9DDFF"),
("#E6D9FF", "#D9C8FF")
]
let index = max(0, min(day, palettes.count - 1))
return LinearGradient(
colors: [Color(hex: palettes[index].0), Color(hex: palettes[index].1)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
private func dayCellTint(day: Int) -> Color {
let colors = [
Color(hex: "#FFD0D8"),
Color(hex: "#FFE1C6"),
Color(hex: "#FFF0C3"),
Color(hex: "#DFF1CA"),
Color(hex: "#D5EFE6"),
Color(hex: "#D7E7FF"),
Color(hex: "#E7DCFF")
]
return colors[max(0, min(day, colors.count - 1))]
}
}