diff --git a/MealMood/Models/AppSettings.swift b/MealMood/Models/AppSettings.swift index ead2ea2..e352b88 100644 --- a/MealMood/Models/AppSettings.swift +++ b/MealMood/Models/AppSettings.swift @@ -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" } } } diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index a3a8448..52d3385 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -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!"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 7258848..03cf345 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -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!"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 9e8e340..e5f3d1d 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -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!"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 6f7a128..bd9bc2b 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -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é !"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index 1002e9f..7d0f124 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -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!"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index c77ce0f..ba45302 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -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!"; diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index a0ae384..9704e72 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -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 } + } + } } diff --git a/MealMood/Views/Home/WeekPlanShareView.swift b/MealMood/Views/Home/WeekPlanShareView.swift index 9b061dd..90dc9a6 100644 --- a/MealMood/Views/Home/WeekPlanShareView.swift +++ b/MealMood/Views/Home/WeekPlanShareView.swift @@ -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( diff --git a/MealMoodTests/DateHelpersTests.swift b/MealMoodTests/DateHelpersTests.swift index d9c4a15..0c7aaea 100644 --- a/MealMoodTests/DateHelpersTests.swift +++ b/MealMoodTests/DateHelpersTests.swift @@ -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() {