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
+4
View File
@@ -254,12 +254,16 @@ enum WeekExportStyle: String, CaseIterable {
case defaultStyle
case schoolTimetable
case vertical
case story // 9:16, for Instagram/WhatsApp stories
case minimal // ink-friendly A4, thin lines, no fills
var localizedKey: String {
switch self {
case .defaultStyle: return "share_export_style_default"
case .schoolTimetable: return "share_export_style_school"
case .vertical: return "share_export_style_vertical"
case .story: return "share_export_style_story"
case .minimal: return "share_export_style_minimal"
}
}
}
@@ -433,3 +433,7 @@
"share_export_share_button" = "Teilen";
"share_footer_tagline" = "Deine Woche, mit Liebe geplant";
"share_footer_scan" = "Scann mich";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (Druck)";
"share_export_copy_text" = "Als Text kopieren (WhatsApp)";
"share_text_copied" = "Kopiert!";
@@ -433,3 +433,7 @@
"share_export_share_button" = "Share";
"share_footer_tagline" = "Your week, planned with love";
"share_footer_scan" = "Scan me";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (print)";
"share_export_copy_text" = "Copy as text (WhatsApp)";
"share_text_copied" = "Copied!";
@@ -235,8 +235,8 @@
"share_week_title" = "Plan semanal MealMood";
"share_week_callout_title" = "Tu semana está lista para exportar";
"share_week_callout_subtitle" = "Genera una imagen bonita para compartir o imprimir tu planificación.";
"share_export_style_default" = "Default";
"share_export_style_school" = "School Timetable";
"share_export_style_default" = "Clásico";
"share_export_style_school" = "Horario";
"share_export_style_vertical" = "Vertical";
"share_export_style_picker_title" = "Elige estilo de exportación";
"share_export_style_picker_subtitle" = "Puedes cambiarlo cuando quieras en Ajustes.";
@@ -433,3 +433,7 @@
"share_export_share_button" = "Compartir";
"share_footer_tagline" = "Tu semana, planificada con cariño";
"share_footer_scan" = "Escanéame";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (imprimir)";
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
"share_text_copied" = "¡Copiado!";
@@ -433,3 +433,7 @@
"share_export_share_button" = "Partager";
"share_footer_tagline" = "Votre semaine, planifiée avec amour";
"share_footer_scan" = "Scannez-moi";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (impression)";
"share_export_copy_text" = "Copier en texte (WhatsApp)";
"share_text_copied" = "Copié !";
@@ -433,3 +433,7 @@
"share_export_share_button" = "Condividi";
"share_footer_tagline" = "La tua settimana, pianificata con amore";
"share_footer_scan" = "Scansionami";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (stampa)";
"share_export_copy_text" = "Copia come testo (WhatsApp)";
"share_text_copied" = "Copiato!";
@@ -433,3 +433,7 @@
"share_export_share_button" = "Compartilhar";
"share_footer_tagline" = "Sua semana, planejada com carinho";
"share_footer_scan" = "Escaneie-me";
"share_export_style_story" = "Story";
"share_export_style_minimal" = "Minimal (imprimir)";
"share_export_copy_text" = "Copiar como texto (WhatsApp)";
"share_text_copied" = "Copiado!";
+37 -5
View File
@@ -1406,6 +1406,7 @@ private struct WeekExportPreviewSheet: View {
@State private var selectedStyle: WeekExportStyle
@State private var previews: [WeekExportStyle: UIImage] = [:]
@State private var shareFile: ShareFile?
@State private var didCopyText: Bool = false
private struct ShareFile: Identifiable {
let url: URL
@@ -1423,13 +1424,25 @@ private struct WeekExportPreviewSheet: View {
var body: some View {
NavigationStack {
VStack(spacing: 14) {
Picker("share_export_style_picker_title", selection: $selectedStyle) {
ForEach(WeekExportStyle.allCases, id: \.self) { style in
Text(LocalizedStringKey(style.localizedKey)).tag(style)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(WeekExportStyle.allCases, id: \.self) { style in
Button {
selectedStyle = style
} label: {
Text(LocalizedStringKey(style.localizedKey))
.font(.mealMoodSmall.weight(.semibold))
.foregroundColor(selectedStyle == style ? .white : .mealMoodTextPrimary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(selectedStyle == style ? Color.mealMoodCoral : Color.mealMoodSurface)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.top, 10)
Group {
@@ -1461,6 +1474,15 @@ private struct WeekExportPreviewSheet: View {
isEnabled: previews[selectedStyle] != nil
)
.padding(.horizontal, 16)
Button(action: copyAsText) {
Label(
String(localized: didCopyText ? "share_text_copied" : "share_export_copy_text"),
systemImage: didCopyText ? "checkmark.circle.fill" : "doc.on.doc"
)
.font(.mealMoodBodyBold)
.foregroundColor(didCopyText ? .mealMoodSuccess : .mealMoodCoral)
}
.padding(.bottom, 12)
}
.background(Color.mealMoodBackground.ignoresSafeArea())
@@ -1508,6 +1530,16 @@ private struct WeekExportPreviewSheet: View {
AnalyticsService.logWeekPlanShared(format: selectedStyle.rawValue)
shareFile = ShareFile(url: url)
}
private func copyAsText() {
UIPasteboard.general.string = WeekPlanTextExporter.text(plan: plan, settings: settings, dishes: dishes)
AnalyticsService.logWeekPlanShared(format: "text")
HapticManager.shared.notification(type: .success)
withAnimation { didCopyText = true }
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation { didCopyText = false }
}
}
}
+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(
+71
View File
@@ -1,6 +1,77 @@
import XCTest
import SwiftUI
import SwiftData
@testable import MealMood
/// Not a real assertion suite: renders every export style with sample data to
/// /tmp/mealmood-export-previews/ so design changes can be reviewed as images.
final class ExportStyleRenderTests: XCTestCase {
@MainActor
func testRenderAllExportStyles() throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(
for: AppSettings.self, Dish.self, Tag.self, WeekPlan.self, MealSlot.self,
configurations: config
)
let context = container.mainContext
let settings = AppSettings()
settings.activeMealTypes = [.lunch, .dinner]
settings.includeWeekends = true
settings.language = "es"
context.insert(settings)
let names = [
"Tortilla de patatas", "Salmón al horno", "Ensalada César",
"Lentejas con chorizo", "Pizza casera", "Pollo asado con patatas",
"Crema de calabaza", "Espaguetis boloñesa", "Merluza en salsa verde",
"Arroz tres delicias", "Fajitas de pollo", "Gazpacho", "Croquetas caseras", "Sopa de fideos"
]
let dishes = names.map { Dish(name: $0) }
dishes.forEach { context.insert($0) }
let monday = Calendar(identifier: .gregorian).date(from: DateComponents(year: 2026, month: 8, day: 31))!
let plan = WeekPlan(weekStartDate: monday)
context.insert(plan)
var dishIndex = 0
for day in 0...6 {
for meal in [MealType.lunch, .dinner] {
let slot = MealSlot(dayOfWeek: day, mealType: meal.rawValue)
if day == 5 && meal == .dinner {
slot.isEatingOut = true
} else {
slot.dishId = dishes[dishIndex % dishes.count].id
if day == 2 && meal == .dinner {
slot.secondaryDishId = dishes[(dishIndex + 5) % dishes.count].id
}
dishIndex += 1
}
slot.weekPlan = plan
plan.slotList.append(slot)
context.insert(slot)
}
}
try context.save()
let outputDir = URL(fileURLWithPath: "/tmp/mealmood-export-previews")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
for style in WeekExportStyle.allCases {
let renderer = ImageRenderer(content: WeekPlanShareView(
plan: plan, settings: settings, dishes: dishes, tags: [],
exportStyleOverride: style
))
let size = WeekPlanShareView.canvasSize(for: style)
renderer.proposedSize = ProposedViewSize(width: size.width, height: size.height)
renderer.scale = 1
let image = try XCTUnwrap(renderer.uiImage, "render failed for \(style.rawValue)")
let data = try XCTUnwrap(image.pngData())
try data.write(to: outputDir.appendingPathComponent("export-\(style.rawValue).png"))
}
}
}
final class DateHelpersTests: XCTestCase {
func testStartOfWeekReturnsMonday() {