export: revision UX de estilos, 2 estilos nuevos y copia como texto

- Header elegante: logo fijado a la izquierda y titulo centrado de verdad
  (ZStack), fecha a la derecha
- School transpuesto: dias como columnas y comidas como filas — antes con
  1-2 comidas quedaba media pagina A4 apaisada en blanco
- Default: las celdas del grid se expanden para llenar la pagina (mismo
  sintoma de hueco que el school)
- Estilo nuevo "Story" (9:16, 2160x3840) para stories de IG/WhatsApp y
  "Minimal (imprimir)" en A4 con lineas finas sin fondos (ahorro tinta)
- Picker del preview pasa de segmentado a chips desplazables (5 estilos)
- WeekPlanTextExporter + boton "Copiar como texto (WhatsApp)" en el
  preview: menu semanal con *negritas* y emojis listo para pegar en
  WhatsApp/Telegram (evento analytics format=text)
- Harness de render en tests: genera PNG de los 5 estilos con datos de
  ejemplo en /tmp/mealmood-export-previews para revisar el diseno
- Nombres de estilos en espanol corregidos (estaban en ingles)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
alexandrev-tibco
2026-09-01 16:04:07 +02:00
parent dc1a76275b
commit 715dd4ec5e
10 changed files with 455 additions and 65 deletions
+317 -58
View File
@@ -1,6 +1,56 @@
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
@@ -31,11 +81,16 @@ struct WeekPlanShareView: View {
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 .defaultStyle, .schoolTimetable:
case .story:
return CGSize(width: storyWidth, height: storyHeight)
case .defaultStyle, .schoolTimetable, .minimal:
return CGSize(width: a4LandscapeWidth, height: a4LandscapeHeight)
}
}
@@ -82,6 +137,10 @@ struct WeekPlanShareView: View {
schoolBackground
case .vertical:
verticalBackground
case .story:
storyBackground
case .minimal:
Color.white
}
VStack(spacing: 24) {
@@ -95,18 +154,38 @@ struct WeekPlanShareView: View {
schoolTimetableLayout
case .vertical:
verticalLayout
case .story:
storyLayout
case .minimal:
minimalLayout
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
footerBlock
}
.padding(.horizontal, exportStyle == .vertical ? 72 : 84)
.padding(.vertical, exportStyle == .vertical ? 58 : 70)
.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(
@@ -180,6 +259,10 @@ struct WeekPlanShareView: View {
defaultHeader
case .schoolTimetable, .vertical:
elegantHeader
case .story:
storyHeader
case .minimal:
minimalHeader
}
}
@@ -218,10 +301,10 @@ struct WeekPlanShareView: View {
}
private var elegantHeader: some View {
HStack(alignment: .center, spacing: 28) {
MealMoodLogoMark(size: 110)
VStack(alignment: .leading, spacing: 8) {
// 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"))
@@ -232,12 +315,16 @@ struct WeekPlanShareView: View {
.font(.system(size: exportStyle == .vertical ? 58 : 54, weight: .semibold, design: .serif))
.foregroundColor(.mealMoodTextPrimary)
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 170)
Spacer()
Text(exportDateString)
.font(.system(size: 34, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextSecondary)
HStack {
MealMoodLogoMark(size: 110)
Spacer()
Text(exportDateString)
.font(.system(size: 34, weight: .medium, design: .serif))
.foregroundColor(.mealMoodTextSecondary)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 24)
@@ -251,7 +338,7 @@ struct WeekPlanShareView: View {
private var exportHeaderTitle: String {
switch exportStyle {
case .defaultStyle:
case .defaultStyle, .story, .minimal:
return String(localized: "share_week_title")
case .schoolTimetable:
return String(localized: "share_export_school_title")
@@ -260,6 +347,162 @@ struct WeekPlanShareView: View {
}
}
// 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)
@@ -280,74 +523,90 @@ struct WeekPlanShareView: View {
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)
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))
}
}
Grid(horizontalSpacing: 12, verticalSpacing: 12) {
GridRow {
Text(" ")
.frame(width: 250)
ForEach(Array(dayRange), id: \.self) { day in
GridRow {
Text(dayHeaderTitle(for: day).uppercased(with: locale))
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)
.frame(width: 280)
.frame(minHeight: 138)
.background(dayHeaderBackground(day: day))
.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))
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))
}
}
}
.frame(maxHeight: .infinity)
}
}
.padding(.horizontal, 24)
.padding(.vertical, 22)
.padding(.vertical, 24)
}
}
@@ -577,7 +836,7 @@ struct WeekPlanShareView: View {
}
.font(.system(size: 25, weight: .bold, design: .rounded))
.foregroundColor(Color(hex: "#365548"))
.frame(maxWidth: .infinity, minHeight: 130)
.frame(maxWidth: .infinity, minHeight: 130, maxHeight: .infinity)
.padding(.horizontal, 14)
.background(background)
.clipShape(RoundedRectangle(cornerRadius: 14))
@@ -590,12 +849,12 @@ struct WeekPlanShareView: View {
private func gridDishCell(_ title: String, day: Int) -> some View {
let tint = dayCellTint(day: day)
return Text(title)
.font(.system(size: 26, weight: .medium))
.font(.system(size: 30, weight: .medium))
.foregroundColor(.mealMoodTextPrimary)
.multilineTextAlignment(.center)
.lineLimit(3)
.lineLimit(4)
.minimumScaleFactor(0.6)
.frame(maxWidth: .infinity, minHeight: 130)
.frame(maxWidth: .infinity, minHeight: 130, maxHeight: .infinity)
.padding(.horizontal, 10)
.background(
LinearGradient(