Allow PDF data export when AI is unavailable on Reports screen

Users without Apple Intelligence can now export their mood data as a
visual PDF with charts and statistics instead of seeing a disabled
Generate button. The existing ExportService.exportPDF is reused for
the non-AI path, gated behind the same privacy confirmation dialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-17 23:04:50 -05:00
parent c7f05335c8
commit 0f128da154
4 changed files with 154 additions and 24 deletions

View File

@@ -8115,6 +8115,86 @@
} }
} }
}, },
"AI reports require Apple Intelligence. You can still export your mood entries as a visual PDF report with charts and statistics." : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "KI-Berichte erfordern Apple Intelligence. Du kannst deine Stimmungseinträge trotzdem als visuellen PDF-Bericht mit Diagrammen und Statistiken exportieren."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Los informes con IA requieren Apple Intelligence. Aún puedes exportar tus entradas de ánimo como un informe PDF visual con gráficos y estadísticas."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les rapports IA nécessitent Apple Intelligence. Vous pouvez toujours exporter vos entrées d'humeur sous forme de rapport PDF visuel avec graphiques et statistiques."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "AIレポートにはApple Intelligenceが必要です。気分の記録をグラフや統計付きのPDFレポートとしてエクスポートできます。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "AI 보고서에는 Apple Intelligence가 필요합니다. 차트와 통계가 포함된 시각적 PDF 보고서로 기분 기록을 내보낼 수 있습니다."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Relatórios com IA requerem Apple Intelligence. Você ainda pode exportar suas entradas de humor como um relatório PDF visual com gráficos e estatísticas."
}
}
}
},
"Export Your Data" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Deine Daten exportieren"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Exporta tus datos"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Exporter vos données"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "データをエクスポート"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "데이터 내보내기"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "Exporte seus dados"
}
}
}
},
"Export PDF" : { "Export PDF" : {
"comment" : "A button label that triggers the export of a user's mood report as a PDF file.", "comment" : "A button label that triggers the export of a user's mood report as a PDF file.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,

View File

@@ -179,6 +179,7 @@ enum AccessibilityID {
static let exportButton = "reports_export_button" static let exportButton = "reports_export_button"
static let privacyConfirmation = "reports_privacy_confirmation" static let privacyConfirmation = "reports_privacy_confirmation"
static let minimumEntriesWarning = "reports_minimum_entries_warning" static let minimumEntriesWarning = "reports_minimum_entries_warning"
static let exportDataButton = "reports_export_data_button"
} }
// MARK: - Common // MARK: - Common

View File

@@ -22,8 +22,10 @@ struct ReportsView: View {
ZStack { ZStack {
ScrollView { ScrollView {
VStack(spacing: 20) { VStack(spacing: 20) {
// Report type selector if viewModel.isAIAvailable {
reportTypeSelector // Report type selector (AI only)
reportTypeSelector
}
// Date range picker // Date range picker
ReportDateRangePicker( ReportDateRangePicker(
@@ -35,22 +37,23 @@ struct ReportsView: View {
// Entry count / validation // Entry count / validation
entryValidationCard entryValidationCard
// AI unavailable warning if viewModel.isAIAvailable {
if !viewModel.isAIAvailable { // Generate button
aiUnavailableCard generateButton
}
// Generate button // Report ready card
generateButton if viewModel.generationState == .completed {
reportReadyCard
}
// Report ready card // Error message
if viewModel.generationState == .completed { if case .failed(let message) = viewModel.generationState {
reportReadyCard errorCard(message: message)
} }
} else {
// Error message // Non-AI export path
if case .failed(let message) = viewModel.generationState { dataExportCard
errorCard(message: message) exportDataButton
} }
} }
.padding(.vertical) .padding(.vertical)
@@ -86,7 +89,11 @@ struct ReportsView: View {
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button(String(localized: "Share Report")) { Button(String(localized: "Share Report")) {
viewModel.exportPDF() if viewModel.isAIAvailable {
viewModel.exportPDF()
} else {
viewModel.exportDataPDF()
}
} }
Button(String(localized: "Cancel"), role: .cancel) {} Button(String(localized: "Cancel"), role: .cancel) {}
} message: { } message: {
@@ -184,19 +191,19 @@ struct ReportsView: View {
.accessibilityIdentifier(AccessibilityID.Reports.minimumEntriesWarning) .accessibilityIdentifier(AccessibilityID.Reports.minimumEntriesWarning)
} }
// MARK: - AI Unavailable Card // MARK: - Data Export Card (No AI)
private var aiUnavailableCard: some View { private var dataExportCard: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "brain.head.profile") Image(systemName: "chart.bar.doc.horizontal")
.foregroundColor(.orange) .foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Apple Intelligence Required") Text(String(localized: "Export Your Data"))
.font(.subheadline.weight(.medium)) .font(.subheadline.weight(.medium))
.foregroundColor(textColor) .foregroundColor(textColor)
Text("AI report generation requires Apple Intelligence to be enabled in Settings.") Text(String(localized: "AI reports require Apple Intelligence. You can still export your mood entries as a visual PDF report with charts and statistics."))
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -206,11 +213,33 @@ struct ReportsView: View {
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 14) RoundedRectangle(cornerRadius: 14)
.fill(Color.orange.opacity(0.1)) .fill(colorScheme == .dark ? Color(.systemGray6) : .white)
) )
.padding(.horizontal) .padding(.horizontal)
} }
// MARK: - Export Data Button
private var exportDataButton: some View {
Button {
viewModel.showPrivacyConfirmation = true
} label: {
HStack(spacing: 8) {
Image(systemName: "square.and.arrow.up")
Text("Export PDF")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canExportData ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!viewModel.canExportData)
.padding(.horizontal)
.accessibilityIdentifier(AccessibilityID.Reports.exportDataButton)
}
// MARK: - Generate Button // MARK: - Generate Button
private var generateButton: some View { private var generateButton: some View {

View File

@@ -55,6 +55,10 @@ class ReportsViewModel: ObservableObject {
validEntryCount >= 3 && isAIAvailable validEntryCount >= 3 && isAIAvailable
} }
var canExportData: Bool {
validEntryCount >= 1
}
var daySpan: Int { var daySpan: Int {
let components = Calendar.current.dateComponents([.day], from: startDate, to: endDate) let components = Calendar.current.dateComponents([.day], from: startDate, to: endDate)
return (components.day ?? 0) + 1 return (components.day ?? 0) + 1
@@ -139,6 +143,22 @@ class ReportsViewModel: ObservableObject {
// MARK: - PDF Export // MARK: - PDF Export
func exportDataPDF() {
let entries = entriesInRange
guard !entries.isEmpty else { return }
let url = ExportService.shared.exportPDF(entries: entries)
if let url {
exportedPDFURL = url
showShareSheet = true
AnalyticsManager.shared.track(.reportExported(
type: "data_export",
entryCount: entries.count
))
}
}
func exportPDF() { func exportPDF() {
guard let report = generatedReport else { return } guard let report = generatedReport else { return }