diff --git a/MealMood/Resources/de.lproj/Localizable.strings b/MealMood/Resources/de.lproj/Localizable.strings index bbdb191..fa1bed7 100644 --- a/MealMood/Resources/de.lproj/Localizable.strings +++ b/MealMood/Resources/de.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Mahlzeit"; "home_pick_second_dish" = "Als zweites Gericht hinzufügen"; "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"; diff --git a/MealMood/Resources/en.lproj/Localizable.strings b/MealMood/Resources/en.lproj/Localizable.strings index 506dfd5..69d0b3a 100644 --- a/MealMood/Resources/en.lproj/Localizable.strings +++ b/MealMood/Resources/en.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Meal"; "home_pick_second_dish" = "Add as 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"; diff --git a/MealMood/Resources/es.lproj/Localizable.strings b/MealMood/Resources/es.lproj/Localizable.strings index 66d369e..28c55ab 100644 --- a/MealMood/Resources/es.lproj/Localizable.strings +++ b/MealMood/Resources/es.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Comida"; "home_pick_second_dish" = "Añadir como 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"; diff --git a/MealMood/Resources/fr.lproj/Localizable.strings b/MealMood/Resources/fr.lproj/Localizable.strings index 0150317..50db6e9 100644 --- a/MealMood/Resources/fr.lproj/Localizable.strings +++ b/MealMood/Resources/fr.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Repas"; "home_pick_second_dish" = "Ajouter comme 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"; diff --git a/MealMood/Resources/it.lproj/Localizable.strings b/MealMood/Resources/it.lproj/Localizable.strings index df99619..8180a7c 100644 --- a/MealMood/Resources/it.lproj/Localizable.strings +++ b/MealMood/Resources/it.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Pasto"; "home_pick_second_dish" = "Aggiungi come 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"; diff --git a/MealMood/Resources/pt-BR.lproj/Localizable.strings b/MealMood/Resources/pt-BR.lproj/Localizable.strings index ce3b97a..0042572 100644 --- a/MealMood/Resources/pt-BR.lproj/Localizable.strings +++ b/MealMood/Resources/pt-BR.lproj/Localizable.strings @@ -428,3 +428,6 @@ "dish_fixed_slot_meal" = "Refeição"; "home_pick_second_dish" = "Adicionar como 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"; diff --git a/MealMood/Views/Home/HomeView.swift b/MealMood/Views/Home/HomeView.swift index 758db10..a0ae384 100644 --- a/MealMood/Views/Home/HomeView.swift +++ b/MealMood/Views/Home/HomeView.swift @@ -37,10 +37,7 @@ struct HomeView: View { private static let widgetPromoDismissedKey = "widget_promo_dismissed" private static let homeSessionCountKey = "home_session_count" - @State private var showExportStylePicker: Bool = false - @State private var pendingExportPlan: WeekPlan? - @State private var shareImageURL: URL? - @State private var showShareSheet: Bool = false + @State private var exportPreviewPlan: WeekPlan? @State private var showViolationsPanel: Bool = false private var settings: AppSettings? { allSettings.first } @@ -598,25 +595,8 @@ struct HomeView: View { PremiumView(settings: settings, source: presentation.source) } } - .sheet(isPresented: $showExportStylePicker) { - WeekExportStylePickerSheet( - 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(item: $exportPreviewPlan) { exportPlan in + WeekExportPreviewSheet(plan: exportPlan, settings: settings, dishes: dishes, tags: tags) } .sheet(isPresented: $showViolationsPanel) { RuleViolationsPanelSheet( @@ -1101,44 +1081,7 @@ struct HomeView: View { } private func startShareFlow(plan: WeekPlan, settings: AppSettings) { - if settings.weekExportStyle == nil { - 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 + exportPreviewPlan = plan } private func fetchWeekPlan(for weekStartDate: Date) -> WeekPlan? { @@ -1449,174 +1392,124 @@ private struct ShareSheet: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } -private struct WeekExportStylePickerSheet: View { - let selectedStyle: WeekExportStyle - let onConfirm: (WeekExportStyle) -> Void +/// Export screen: live preview of the rendered week image with a style switcher, +/// so the user can compare styles before sharing. Doesn't touch the saved default +/// (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) { - self.selectedStyle = selectedStyle - self.onConfirm = onConfirm - _temporarySelection = State(initialValue: selectedStyle) + private struct ShareFile: Identifiable { + let url: URL + var id: String { url.absoluteString } + } + + 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 { NavigationStack { VStack(spacing: 14) { - Text("share_export_style_picker_title") - .font(.mealMoodH2) - .foregroundColor(.mealMoodTextPrimary) - .padding(.top, 8) - - Text("share_export_style_picker_subtitle") - .font(.mealMoodCaption) - .foregroundColor(.mealMoodTextSecondary) - - ScrollView { - 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) - .background(Color.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke( - temporarySelection == style ? Color.mealMoodCoral : Color.mealMoodMint.opacity(0.55), - lineWidth: temporarySelection == style ? 2 : 1 - ) - ) - } - .buttonStyle(.plain) - } + Picker("share_export_style_picker_title", selection: $selectedStyle) { + ForEach(WeekExportStyle.allCases, id: \.self) { style in + Text(LocalizedStringKey(style.localizedKey)).tag(style) } - .padding(.vertical, 6) } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.top, 10) - Button("share_export_style_picker_apply") { - onConfirm(temporarySelection) + 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) + .foregroundColor(.mealMoodTextSecondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } - .buttonStyle(.borderedProminent) - .tint(.mealMoodCoral) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + PrimaryButton( + title: String(localized: "share_export_share_button"), + action: shareCurrentStyle, + isEnabled: previews[selectedStyle] != nil + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) } - .padding(.horizontal, 16) - .padding(.bottom, 12) .background(Color.mealMoodBackground.ignoresSafeArea()) + .navigationTitle("share_export_preview_title") .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 { let weekPlans: [WeekPlan]