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 { 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: 104) VStack(alignment: .leading, spacing: 6) { Text(exportHeaderTitle) .font(.system(size: 52, weight: .bold, design: .rounded)) .foregroundStyle( LinearGradient( colors: [Color.mealMoodCoral, Color(hex: "#3F7A64")], startPoint: .leading, endPoint: .trailing ) ) 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: 10) { MealMoodLogoMark(size: 96) 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: 24) { MealMoodLogoMark(size: 118) VStack(alignment: .leading, spacing: 5) { Text(verbatim: "MealMood") .font(.system(size: 44, weight: .heavy, design: .rounded)) .foregroundStyle( LinearGradient( colors: [Color.mealMoodCoral, Color(hex: "#3F7A64")], startPoint: .leading, endPoint: .trailing ) ) Text(String(localized: "share_footer_tagline")) .font(.system(size: 26, weight: .semibold, design: .rounded)) .italic() .foregroundColor(Color(hex: "#5D645F")) Text(shareURL) .font(.system(size: 22, 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)) .overlay( RoundedRectangle(cornerRadius: 18) .stroke(Color.mealMoodCoral.opacity(0.35), lineWidth: 1) ) Text(String(localized: "share_footer_scan")) .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))] } }