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:
@@ -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!";
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user