6fe16d8e29
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de hueco vacio — cuenta como resuelto (semana completa, notificaciones, stats) y el autocompletado lo respeta; vista gris con guion, tap para desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que faltaba en el payload) y exports (— en imagen, omitido en texto) - ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de su seccion); van a la seccion "Ya en casa" con restauracion de un tap, y quedan fuera del export de texto. No se borran porque reconcile los recrearia - Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record type CD_ShoppingItem entero, que no existia — la lista de la compra no estaba sincronizando entre dispositivos (mismo bug que photoData) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
912 lines
36 KiB
Swift
912 lines
36 KiB
Swift
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<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
|
|
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))]
|
|
}
|
|
}
|