export: vista previa con selector de estilo en la propia pantalla de export
- WeekExportPreviewSheet: preview renderizado del export con picker segmentado de los 3 estilos (cachea cada render); compartir usa el estilo elegido sin tocar el predeterminado (salvo primera vez) - Sustituye al WeekExportStylePickerSheet de thumbnails y al doble sheet picker+share de HomeView; strings nuevas en 6 idiomas Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
This commit is contained in:
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Mahlzeit";
|
"dish_fixed_slot_meal" = "Mahlzeit";
|
||||||
"home_pick_second_dish" = "Als zweites Gericht hinzufügen";
|
"home_pick_second_dish" = "Als zweites Gericht hinzufügen";
|
||||||
"home_remove_second_dish" = "Zweites Gericht entfernen";
|
"home_remove_second_dish" = "Zweites Gericht entfernen";
|
||||||
|
"share_export_preview_title" = "Woche exportieren";
|
||||||
|
"share_export_preview_rendering" = "Vorschau wird erstellt…";
|
||||||
|
"share_export_share_button" = "Teilen";
|
||||||
|
|||||||
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Meal";
|
"dish_fixed_slot_meal" = "Meal";
|
||||||
"home_pick_second_dish" = "Add as second dish";
|
"home_pick_second_dish" = "Add as second dish";
|
||||||
"home_remove_second_dish" = "Remove second dish";
|
"home_remove_second_dish" = "Remove second dish";
|
||||||
|
"share_export_preview_title" = "Export week";
|
||||||
|
"share_export_preview_rendering" = "Generating preview…";
|
||||||
|
"share_export_share_button" = "Share";
|
||||||
|
|||||||
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Comida";
|
"dish_fixed_slot_meal" = "Comida";
|
||||||
"home_pick_second_dish" = "Añadir como segundo plato";
|
"home_pick_second_dish" = "Añadir como segundo plato";
|
||||||
"home_remove_second_dish" = "Quitar el segundo plato";
|
"home_remove_second_dish" = "Quitar el segundo plato";
|
||||||
|
"share_export_preview_title" = "Exportar semana";
|
||||||
|
"share_export_preview_rendering" = "Generando vista previa…";
|
||||||
|
"share_export_share_button" = "Compartir";
|
||||||
|
|||||||
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Repas";
|
"dish_fixed_slot_meal" = "Repas";
|
||||||
"home_pick_second_dish" = "Ajouter comme deuxième plat";
|
"home_pick_second_dish" = "Ajouter comme deuxième plat";
|
||||||
"home_remove_second_dish" = "Retirer le deuxième plat";
|
"home_remove_second_dish" = "Retirer le deuxième plat";
|
||||||
|
"share_export_preview_title" = "Exporter la semaine";
|
||||||
|
"share_export_preview_rendering" = "Génération de l'aperçu…";
|
||||||
|
"share_export_share_button" = "Partager";
|
||||||
|
|||||||
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Pasto";
|
"dish_fixed_slot_meal" = "Pasto";
|
||||||
"home_pick_second_dish" = "Aggiungi come secondo piatto";
|
"home_pick_second_dish" = "Aggiungi come secondo piatto";
|
||||||
"home_remove_second_dish" = "Rimuovi il secondo piatto";
|
"home_remove_second_dish" = "Rimuovi il secondo piatto";
|
||||||
|
"share_export_preview_title" = "Esporta settimana";
|
||||||
|
"share_export_preview_rendering" = "Generazione anteprima…";
|
||||||
|
"share_export_share_button" = "Condividi";
|
||||||
|
|||||||
@@ -428,3 +428,6 @@
|
|||||||
"dish_fixed_slot_meal" = "Refeição";
|
"dish_fixed_slot_meal" = "Refeição";
|
||||||
"home_pick_second_dish" = "Adicionar como segundo prato";
|
"home_pick_second_dish" = "Adicionar como segundo prato";
|
||||||
"home_remove_second_dish" = "Remover o segundo prato";
|
"home_remove_second_dish" = "Remover o segundo prato";
|
||||||
|
"share_export_preview_title" = "Exportar semana";
|
||||||
|
"share_export_preview_rendering" = "Gerando pré-visualização…";
|
||||||
|
"share_export_share_button" = "Compartilhar";
|
||||||
|
|||||||
+101
-208
@@ -37,10 +37,7 @@ struct HomeView: View {
|
|||||||
|
|
||||||
private static let widgetPromoDismissedKey = "widget_promo_dismissed"
|
private static let widgetPromoDismissedKey = "widget_promo_dismissed"
|
||||||
private static let homeSessionCountKey = "home_session_count"
|
private static let homeSessionCountKey = "home_session_count"
|
||||||
@State private var showExportStylePicker: Bool = false
|
@State private var exportPreviewPlan: WeekPlan?
|
||||||
@State private var pendingExportPlan: WeekPlan?
|
|
||||||
@State private var shareImageURL: URL?
|
|
||||||
@State private var showShareSheet: Bool = false
|
|
||||||
@State private var showViolationsPanel: Bool = false
|
@State private var showViolationsPanel: Bool = false
|
||||||
|
|
||||||
private var settings: AppSettings? { allSettings.first }
|
private var settings: AppSettings? { allSettings.first }
|
||||||
@@ -598,25 +595,8 @@ struct HomeView: View {
|
|||||||
PremiumView(settings: settings, source: presentation.source)
|
PremiumView(settings: settings, source: presentation.source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showExportStylePicker) {
|
.sheet(item: $exportPreviewPlan) { exportPlan in
|
||||||
WeekExportStylePickerSheet(
|
WeekExportPreviewSheet(plan: exportPlan, settings: settings, dishes: dishes, tags: tags)
|
||||||
selectedStyle: settings.weekExportStyleEnum
|
|
||||||
) { selectedStyle in
|
|
||||||
settings.weekExportStyleEnum = selectedStyle
|
|
||||||
try? context.save()
|
|
||||||
showExportStylePicker = false
|
|
||||||
|
|
||||||
if let planToShare = pendingExportPlan {
|
|
||||||
prepareShareImage(plan: planToShare, settings: settings)
|
|
||||||
}
|
|
||||||
pendingExportPlan = nil
|
|
||||||
}
|
|
||||||
.presentationDetents([.medium, .large])
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showShareSheet) {
|
|
||||||
if let shareImageURL {
|
|
||||||
SocialShareSheet(imageURL: shareImageURL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showViolationsPanel) {
|
.sheet(isPresented: $showViolationsPanel) {
|
||||||
RuleViolationsPanelSheet(
|
RuleViolationsPanelSheet(
|
||||||
@@ -1101,44 +1081,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startShareFlow(plan: WeekPlan, settings: AppSettings) {
|
private func startShareFlow(plan: WeekPlan, settings: AppSettings) {
|
||||||
if settings.weekExportStyle == nil {
|
exportPreviewPlan = plan
|
||||||
pendingExportPlan = plan
|
|
||||||
showExportStylePicker = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prepareShareImage(plan: plan, settings: settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareShareImage(plan: WeekPlan, settings: AppSettings) {
|
|
||||||
showShareSheet = false
|
|
||||||
shareImageURL = nil
|
|
||||||
guard let image = renderWeekShareImage(plan: plan, settings: settings),
|
|
||||||
let url = persistShareImage(image, style: settings.weekExportStyleEnum) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shareImageURL = url
|
|
||||||
showShareSheet = true
|
|
||||||
AnalyticsService.logWeekPlanShared(format: settings.weekExportStyleEnum.rawValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func renderWeekShareImage(plan: WeekPlan, settings: AppSettings) -> UIImage? {
|
|
||||||
let renderer = ImageRenderer(content: WeekPlanShareView(plan: plan, settings: settings, dishes: dishes, tags: tags))
|
|
||||||
let canvasSize = WeekPlanShareView.canvasSize(for: settings.weekExportStyleEnum)
|
|
||||||
renderer.proposedSize = ProposedViewSize(
|
|
||||||
width: canvasSize.width,
|
|
||||||
height: canvasSize.height
|
|
||||||
)
|
|
||||||
renderer.scale = 1
|
|
||||||
return renderer.uiImage
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistShareImage(_ image: UIImage, style: WeekExportStyle) -> URL? {
|
|
||||||
guard let data = image.pngData() else { return nil }
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let filename = "mealmood-week-plan-\(style.rawValue)-\(timestamp).png"
|
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
||||||
try? data.write(to: url, options: .atomic)
|
|
||||||
return url
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? {
|
||||||
@@ -1449,174 +1392,124 @@ private struct ShareSheet: UIViewControllerRepresentable {
|
|||||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WeekExportStylePickerSheet: View {
|
/// Export screen: live preview of the rendered week image with a style switcher,
|
||||||
let selectedStyle: WeekExportStyle
|
/// so the user can compare styles before sharing. Doesn't touch the saved default
|
||||||
let onConfirm: (WeekExportStyle) -> Void
|
/// (unless none was chosen yet — then the shared style becomes it).
|
||||||
|
private struct WeekExportPreviewSheet: View {
|
||||||
|
let plan: WeekPlan
|
||||||
|
let settings: AppSettings
|
||||||
|
let dishes: [Dish]
|
||||||
|
let tags: [Tag]
|
||||||
|
|
||||||
@State private var temporarySelection: WeekExportStyle
|
@Environment(\.modelContext) private var context
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var selectedStyle: WeekExportStyle
|
||||||
|
@State private var previews: [WeekExportStyle: UIImage] = [:]
|
||||||
|
@State private var shareFile: ShareFile?
|
||||||
|
|
||||||
init(selectedStyle: WeekExportStyle, onConfirm: @escaping (WeekExportStyle) -> Void) {
|
private struct ShareFile: Identifiable {
|
||||||
self.selectedStyle = selectedStyle
|
let url: URL
|
||||||
self.onConfirm = onConfirm
|
var id: String { url.absoluteString }
|
||||||
_temporarySelection = State(initialValue: selectedStyle)
|
}
|
||||||
|
|
||||||
|
init(plan: WeekPlan, settings: AppSettings, dishes: [Dish], tags: [Tag]) {
|
||||||
|
self.plan = plan
|
||||||
|
self.settings = settings
|
||||||
|
self.dishes = dishes
|
||||||
|
self.tags = tags
|
||||||
|
_selectedStyle = State(initialValue: settings.weekExportStyleEnum)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Text("share_export_style_picker_title")
|
Picker("share_export_style_picker_title", selection: $selectedStyle) {
|
||||||
.font(.mealMoodH2)
|
ForEach(WeekExportStyle.allCases, id: \.self) { style in
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
Text(LocalizedStringKey(style.localizedKey)).tag(style)
|
||||||
.padding(.top, 8)
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 10)
|
||||||
|
|
||||||
Text("share_export_style_picker_subtitle")
|
Group {
|
||||||
|
if let image = previews[selectedStyle] {
|
||||||
|
ScrollView {
|
||||||
|
Image(uiImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.shadow(color: .black.opacity(0.12), radius: 8, y: 3)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("share_export_preview_rendering")
|
||||||
.font(.mealMoodCaption)
|
.font(.mealMoodCaption)
|
||||||
.foregroundColor(.mealMoodTextSecondary)
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
|
}
|
||||||
ScrollView {
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
VStack(spacing: 12) {
|
|
||||||
ForEach(WeekExportStyle.allCases, id: \.self) { style in
|
|
||||||
Button {
|
|
||||||
temporarySelection = style
|
|
||||||
} label: {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
WeekExportStyleThumbnail(style: style)
|
|
||||||
.frame(height: 110)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
Text(LocalizedStringKey(style.localizedKey))
|
|
||||||
.font(.mealMoodBodyBold)
|
|
||||||
.foregroundColor(.mealMoodTextPrimary)
|
|
||||||
Spacer()
|
|
||||||
Image(systemName: temporarySelection == style ? "checkmark.circle.fill" : "circle")
|
|
||||||
.foregroundColor(temporarySelection == style ? .mealMoodCoral : .mealMoodTextSecondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(10)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(Color.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
PrimaryButton(
|
||||||
.overlay(
|
title: String(localized: "share_export_share_button"),
|
||||||
RoundedRectangle(cornerRadius: 12)
|
action: shareCurrentStyle,
|
||||||
.stroke(
|
isEnabled: previews[selectedStyle] != nil
|
||||||
temporarySelection == style ? Color.mealMoodCoral : Color.mealMoodMint.opacity(0.55),
|
|
||||||
lineWidth: temporarySelection == style ? 2 : 1
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("share_export_style_picker_apply") {
|
|
||||||
onConfirm(temporarySelection)
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.tint(.mealMoodCoral)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
|
}
|
||||||
.background(Color.mealMoodBackground.ignoresSafeArea())
|
.background(Color.mealMoodBackground.ignoresSafeArea())
|
||||||
|
.navigationTitle("share_export_preview_title")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("reset_cancel") { dismiss() }
|
||||||
|
.foregroundColor(.mealMoodTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task(id: selectedStyle) { renderIfNeeded(selectedStyle) }
|
||||||
|
.sheet(item: $shareFile) { file in
|
||||||
|
SocialShareSheet(imageURL: file.url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func renderIfNeeded(_ style: WeekExportStyle) {
|
||||||
|
guard previews[style] == nil else { return }
|
||||||
|
let renderer = ImageRenderer(content: WeekPlanShareView(
|
||||||
|
plan: plan, settings: settings, dishes: dishes, tags: tags,
|
||||||
|
exportStyleOverride: style
|
||||||
|
))
|
||||||
|
let canvasSize = WeekPlanShareView.canvasSize(for: style)
|
||||||
|
renderer.proposedSize = ProposedViewSize(width: canvasSize.width, height: canvasSize.height)
|
||||||
|
renderer.scale = 1
|
||||||
|
if let image = renderer.uiImage {
|
||||||
|
previews[style] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shareCurrentStyle() {
|
||||||
|
guard let image = previews[selectedStyle], let data = image.pngData() else { return }
|
||||||
|
let timestamp = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let filename = "mealmood-week-plan-\(selectedStyle.rawValue)-\(timestamp).png"
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
|
||||||
|
if settings.weekExportStyle == nil {
|
||||||
|
settings.weekExportStyleEnum = selectedStyle
|
||||||
|
try? context.save()
|
||||||
|
}
|
||||||
|
AnalyticsService.logWeekPlanShared(format: selectedStyle.rawValue)
|
||||||
|
shareFile = ShareFile(url: url)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct WeekExportStyleThumbnail: View {
|
|
||||||
let style: WeekExportStyle
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
switch style {
|
|
||||||
case .defaultStyle:
|
|
||||||
defaultPreview
|
|
||||||
case .schoolTimetable:
|
|
||||||
schoolPreview
|
|
||||||
case .vertical:
|
|
||||||
verticalPreview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var defaultPreview: some View {
|
|
||||||
ZStack {
|
|
||||||
LinearGradient(colors: [Color(hex: "#FFEFE6"), Color(hex: "#F1FBF5")], startPoint: .topLeading, endPoint: .bottomTrailing)
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.fill(Color.white.opacity(0.9))
|
|
||||||
.frame(height: 20)
|
|
||||||
RoundedRectangle(cornerRadius: 8)
|
|
||||||
.fill(Color.white.opacity(0.8))
|
|
||||||
.frame(height: 62)
|
|
||||||
.overlay(
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Rectangle().fill(Color.mealMoodCoral.opacity(0.35)).frame(height: 8)
|
|
||||||
Rectangle().fill(Color.mealMoodMint.opacity(0.35)).frame(height: 8)
|
|
||||||
Rectangle().fill(Color(hex: "#E9EDF7")).frame(height: 8)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var schoolPreview: some View {
|
|
||||||
ZStack {
|
|
||||||
Color(hex: "#FFF9F5")
|
|
||||||
VStack(spacing: 5) {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.fill(Color.white)
|
|
||||||
.frame(height: 16)
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
RoundedRectangle(cornerRadius: 4)
|
|
||||||
.fill(Color.mealMoodCoral.opacity(0.5))
|
|
||||||
.frame(width: 42)
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Rectangle().fill(Color.mealMoodMint.opacity(0.4)).frame(height: 12)
|
|
||||||
Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20)
|
|
||||||
Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20)
|
|
||||||
}
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Rectangle().fill(Color.mealMoodMint.opacity(0.4)).frame(height: 12)
|
|
||||||
Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20)
|
|
||||||
Rectangle().fill(Color(hex: "#FFFDFB")).frame(height: 20)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var verticalPreview: some View {
|
|
||||||
ZStack {
|
|
||||||
Color(hex: "#FFFDF8")
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
RoundedRectangle(cornerRadius: 5)
|
|
||||||
.fill(Color.white)
|
|
||||||
.frame(height: 18)
|
|
||||||
ForEach(0..<3, id: \.self) { _ in
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Capsule()
|
|
||||||
.fill(Color.mealMoodCoral.opacity(0.45))
|
|
||||||
.frame(width: 46, height: 14)
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Rectangle().fill(Color.mealMoodMint.opacity(0.35)).frame(height: 7)
|
|
||||||
Rectangle().fill(Color(hex: "#EDE8E1")).frame(height: 7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 6)
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
.background(Color.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct MonthlyHistoryView: View {
|
private struct MonthlyHistoryView: View {
|
||||||
let weekPlans: [WeekPlan]
|
let weekPlans: [WeekPlan]
|
||||||
|
|||||||
Reference in New Issue
Block a user