import SwiftUI import CoreImage.CIFilterBuiltins /// Plain-text version of the week for WhatsApp/Telegram โ€” uses `*bold*` /// markers, which both render as bold. enum WeekPlanTextExporter { static func text(plan: WeekPlan, settings: AppSettings, dishes: [Dish]) -> String { let locale = Locale(identifier: settings.languageEnum.resolved().localeIdentifier) let dishById = Dictionary(dishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let dayFormatter = DateFormatter() dayFormatter.locale = locale dayFormatter.setLocalizedDateFormatFromTemplate("EEEE d") var lines: [String] = [] lines.append("๐Ÿฝ๏ธ *\(String(localized: "share_week_title")) ยท \(plan.weekStartDate.formattedWeekRange())*") lines.append("") let dayRange = settings.includeWeekends ? 0...6 : 0...4 for day in dayRange { let mealLines = settings.activeMealTypes.compactMap { meal -> String? in guard let slot = plan.slotList.first(where: { $0.dayOfWeek == day && $0.mealType == meal.rawValue }) else { return nil } let label = String(localized: String.LocalizationValue(meal.localizedKey)) if slot.isEatingOut { return "\(emoji(for: meal)) \(label): \(String(localized: "slot_eating_out"))" } guard let dishId = slot.dishId, let dish = dishById[dishId] else { return nil } var name = dish.name if let secondaryId = slot.secondaryDishId, let secondary = dishById[secondaryId] { name += " + \(secondary.name)" } return "\(emoji(for: meal)) \(label): \(name)" } guard !mealLines.isEmpty else { continue } let dayDate = plan.weekStartDate.addingDays(day) lines.append("*\(dayFormatter.string(from: dayDate).capitalized(with: locale))*") lines.append(contentsOf: mealLines) lines.append("") } lines.append("๐Ÿ“ฑ MealMood โ€” https://mealmood.app") return lines.joined(separator: "\n") } private static func emoji(for meal: MealType) -> String { switch meal { case .breakfast: return "โ˜•๏ธ" case .lunch: return "โ˜€๏ธ" case .snack: return "๐Ÿฅ•" case .dinner: return "๐ŸŒ™" } } } 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 let storyWidth: CGFloat = 2160 static let storyHeight: CGFloat = 3840 static func canvasSize(for style: WeekExportStyle) -> CGSize { switch style { case .vertical: return CGSize(width: a4PortraitWidth, height: a4PortraitHeight) case .story: return CGSize(width: storyWidth, height: storyHeight) case .defaultStyle, .schoolTimetable, .minimal: 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 case .story: storyBackground case .minimal: Color.white } VStack(spacing: 24) { headerBlock Group { switch exportStyle { case .defaultStyle: defaultGridLayout case .schoolTimetable: schoolTimetableLayout case .vertical: verticalLayout case .story: storyLayout case .minimal: minimalLayout } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) footerBlock } .padding(.horizontal, horizontalCanvasPadding) .padding(.vertical, verticalCanvasPadding) } .frame(width: canvasSize.width, height: canvasSize.height) } private var horizontalCanvasPadding: CGFloat { switch exportStyle { case .vertical: return 72 case .story: return 96 default: return 84 } } private var verticalCanvasPadding: CGFloat { switch exportStyle { case .vertical: return 58 case .story: return 110 default: return 70 } } 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 case .story: storyHeader case .minimal: minimalHeader } } 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 { // Logo pinned left, date pinned right; the title block stays centered // on the page independently of both. ZStack { VStack(spacing: 8) { Text(plan.weekStartDate.formattedWeekRange()) .font(.custom("Didot", size: exportStyle == .vertical ? 126 : 108)) .foregroundColor(Color(hex: "#30473F")) .lineLimit(1) .minimumScaleFactor(0.45) Text(exportHeaderTitle) .font(.system(size: exportStyle == .vertical ? 58 : 54, weight: .semibold, design: .serif)) .foregroundColor(.mealMoodTextPrimary) } .frame(maxWidth: .infinity) .padding(.horizontal, 170) HStack { MealMoodLogoMark(size: 110) Spacer() 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, .story, .minimal: return String(localized: "share_week_title") case .schoolTimetable: return String(localized: "share_export_school_title") case .vertical: return String(localized: "share_export_vertical_title") } } // MARK: - Story style (9:16) private var storyBackground: some View { ZStack { LinearGradient( colors: [Color(hex: "#FF8A70"), Color(hex: "#FFB99A"), Color(hex: "#FFE9D6")], startPoint: .top, endPoint: .bottom ) Circle() .fill(Color.white.opacity(0.16)) .frame(width: 1500, height: 1500) .blur(radius: 60) .offset(x: -800, y: -1500) Circle() .fill(Color(hex: "#B8E8D4").opacity(0.4)) .frame(width: 1200, height: 1200) .blur(radius: 80) .offset(x: 900, y: 1600) } } private var storyHeader: some View { VStack(spacing: 14) { MealMoodLogoMark(size: 150) Text(exportHeaderTitle) .font(.system(size: 88, weight: .heavy, design: .rounded)) .foregroundColor(.white) .shadow(color: .black.opacity(0.18), radius: 6, y: 3) Text(plan.weekStartDate.formattedWeekRange()) .font(.system(size: 48, weight: .semibold, design: .rounded)) .foregroundColor(.white.opacity(0.92)) } .frame(maxWidth: .infinity) } private var storyLayout: some View { VStack(spacing: 22) { ForEach(Array(dayRange), id: \.self) { day in HStack(alignment: .center, spacing: 26) { VStack(spacing: 0) { Text(dayShortTitle(for: day).uppercased(with: locale)) .font(.system(size: 40, weight: .heavy, design: .rounded)) .foregroundColor(Color(hex: "#B4523A")) .lineLimit(1) .minimumScaleFactor(0.5) } .frame(width: 260) .frame(maxHeight: .infinity) .background(Color.white.opacity(0.85)) .clipShape(RoundedRectangle(cornerRadius: 22)) VStack(alignment: .leading, spacing: 10) { ForEach(mealTypes, id: \.self) { mealType in HStack(spacing: 14) { Image(systemName: mealType.icon) .font(.system(size: 34, weight: .semibold)) .foregroundColor(mealTypeAccent(mealType)) .frame(width: 44) Text(dishName(day: day, mealType: mealType)) .font(.system(size: 42, weight: .semibold, design: .rounded)) .foregroundColor(.mealMoodTextPrimary) .lineLimit(1) .minimumScaleFactor(0.5) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) .padding(.horizontal, 30) .padding(.vertical, 20) .background(Color.white.opacity(0.94)) .clipShape(RoundedRectangle(cornerRadius: 22)) } .frame(maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Minimal style (ink-friendly print) private var minimalHeader: some View { VStack(spacing: 14) { HStack(alignment: .firstTextBaseline) { Text(exportHeaderTitle) .font(.system(size: 64, weight: .regular, design: .serif)) .foregroundColor(.black) Spacer() Text(plan.weekStartDate.formattedWeekRange()) .font(.system(size: 44, weight: .light, design: .serif)) .foregroundColor(Color(hex: "#444444")) } Rectangle() .fill(Color.black) .frame(height: 3) } } private var minimalLayout: some View { VStack(spacing: 0) { HStack(spacing: 0) { Text(" ") .frame(width: 230) ForEach(Array(dayRange), id: \.self) { day in Text(dayHeaderTitle(for: day).uppercased(with: locale)) .font(.system(size: 30, weight: .semibold, design: .serif)) .foregroundColor(.black) .lineLimit(1) .minimumScaleFactor(0.55) .frame(maxWidth: .infinity, minHeight: 90) } } Rectangle().fill(Color.black).frame(height: 2) ForEach(Array(mealTypes.enumerated()), id: \.element) { index, mealType in HStack(spacing: 0) { Text(mealTypeLabel(mealType).uppercased(with: locale)) .font(.system(size: 28, weight: .semibold, design: .serif)) .foregroundColor(Color(hex: "#333333")) .multilineTextAlignment(.leading) .lineLimit(2) .minimumScaleFactor(0.6) .frame(width: 230, alignment: .leading) .frame(maxHeight: .infinity) ForEach(Array(dayRange), id: \.self) { day in Text(dishName(day: day, mealType: mealType)) .font(.system(size: 33, weight: .regular, design: .serif)) .foregroundColor(.black) .multilineTextAlignment(.center) .lineLimit(4) .minimumScaleFactor(0.55) .padding(.horizontal, 14) .padding(.vertical, 16) .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay(alignment: .leading) { Rectangle().fill(Color(hex: "#BBBBBB")).frame(width: 1) } } } .frame(maxHeight: .infinity) if index < mealTypes.count - 1 { Rectangle().fill(Color(hex: "#BBBBBB")).frame(height: 1) } } Rectangle().fill(Color.black).frame(height: 2) } } 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) .frame(maxHeight: .infinity) ForEach(Array(dayRange), id: \.self) { day in gridDishCell(dishName(day: day, mealType: mealType), day: day) } } .frame(maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(18) } .shadow(color: Color(hex: "#DDAA99").opacity(0.18), radius: 26, x: 0, y: 16) } /// Days as columns, meals as rows โ€” like an actual school timetable. The /// previous transposed layout (days as rows) left huge blank cells on a /// landscape page whenever only one or two meal types were planned. 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) Grid(horizontalSpacing: 12, verticalSpacing: 12) { GridRow { Text(" ") .frame(width: 250) ForEach(Array(dayRange), id: \.self) { day in VStack(spacing: 2) { Text(dayLongTitle(for: day).uppercased(with: locale)) .font(.system(size: 32, weight: .bold, design: .serif)) .lineLimit(1) .minimumScaleFactor(0.6) Text(dayNumberOnly(for: day)) .font(.system(size: 26, weight: .semibold, design: .serif)) .opacity(0.55) } .foregroundColor(.mealMoodTextPrimary) .frame(maxWidth: .infinity, minHeight: 104) .background(dayHeaderBackground(day: day)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } ForEach(mealTypes, id: \.self) { mealType in GridRow { VStack(spacing: 8) { Image(systemName: mealType.icon) .font(.system(size: 36, weight: .medium)) Text(mealTypeLabel(mealType).uppercased(with: locale)) .font(.system(size: 30, weight: .bold, design: .serif)) .multilineTextAlignment(.center) .lineLimit(2) .minimumScaleFactor(0.6) } .foregroundColor(Color(hex: "#355548")) .frame(width: 250) .frame(maxHeight: .infinity) .background(Color.mealMoodMint.opacity(0.3)) .clipShape(RoundedRectangle(cornerRadius: 12)) ForEach(Array(dayRange), id: \.self) { day in Text(dishName(day: day, mealType: mealType)) .font(.system(size: 34, weight: .medium, design: .serif)) .foregroundColor(.mealMoodTextPrimary) .multilineTextAlignment(.center) .lineLimit(4) .minimumScaleFactor(0.55) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 14) .padding(.vertical, 12) .background(Color(hex: "#FFFDFB")) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(Color(hex: "#ECDDD5"), lineWidth: 1) ) .clipShape(RoundedRectangle(cornerRadius: 12)) } } .frame(maxHeight: .infinity) } } .padding(.horizontal, 24) .padding(.vertical, 24) } } 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") } if slot.isSkipped { return "โ€”" } 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, maxHeight: .infinity) .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: 30, weight: .medium)) .foregroundColor(.mealMoodTextPrimary) .multilineTextAlignment(.center) .lineLimit(4) .minimumScaleFactor(0.6) .frame(maxWidth: .infinity, minHeight: 130, maxHeight: .infinity) .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))] } }