Add premium features and reorganize Settings tab
Premium Features: - Journal notes and photo attachments for mood entries - Data export (CSV and PDF reports) - Privacy lock with Face ID/Touch ID - Apple Health integration for mood correlation - 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst) Settings Tab Reorganization: - Combined Customize and Settings into single tab with segmented control - Added upgrade banner with trial countdown above segment - "Why Upgrade?" sheet showing all premium benefits - Subscribe button opens improved StoreKit 2 subscription view UI Improvements: - Enhanced subscription store with feature highlights - Entry detail view for viewing/editing notes and photos - Removed duplicate subscription banners from tab content 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
335
Shared/Views/ExportView.swift
Normal file
335
Shared/Views/ExportView.swift
Normal file
@@ -0,0 +1,335 @@
|
||||
//
|
||||
// ExportView.swift
|
||||
// Feels
|
||||
//
|
||||
// Export mood data to CSV or PDF formats.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExportView: View {
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
|
||||
|
||||
@State private var selectedFormat: ExportFormat = .csv
|
||||
@State private var selectedRange: DateRange = .allTime
|
||||
@State private var isExporting = false
|
||||
@State private var exportedURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
@State private var showError = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
private let entries: [MoodEntryModel]
|
||||
|
||||
enum ExportFormat: String, CaseIterable {
|
||||
case csv = "CSV"
|
||||
case pdf = "PDF"
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .csv: return "tablecells"
|
||||
case .pdf: return "doc.richtext"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .csv: return "Spreadsheet format for data analysis"
|
||||
case .pdf: return "Formatted report with insights"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum DateRange: String, CaseIterable {
|
||||
case lastWeek = "Last 7 Days"
|
||||
case lastMonth = "Last 30 Days"
|
||||
case last3Months = "Last 3 Months"
|
||||
case lastYear = "Last Year"
|
||||
case allTime = "All Time"
|
||||
|
||||
var days: Int? {
|
||||
switch self {
|
||||
case .lastWeek: return 7
|
||||
case .lastMonth: return 30
|
||||
case .last3Months: return 90
|
||||
case .lastYear: return 365
|
||||
case .allTime: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(entries: [MoodEntryModel]) {
|
||||
self.entries = entries
|
||||
}
|
||||
|
||||
private var filteredEntries: [MoodEntryModel] {
|
||||
guard let days = selectedRange.days else {
|
||||
return entries
|
||||
}
|
||||
|
||||
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
|
||||
return entries.filter { $0.forDate >= cutoffDate }
|
||||
}
|
||||
|
||||
private var validEntries: [MoodEntryModel] {
|
||||
filteredEntries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Preview stats
|
||||
statsCard
|
||||
|
||||
// Format selection
|
||||
formatSelection
|
||||
|
||||
// Date range selection
|
||||
dateRangeSelection
|
||||
|
||||
// Export button
|
||||
exportButton
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Export Data")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = exportedURL {
|
||||
ExportShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.alert("Export Failed", isPresented: $showError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statsCard: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Image(systemName: "chart.bar.doc.horizontal")
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
|
||||
Text("Export Preview")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 32) {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(validEntries.count)")
|
||||
.font(.title)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Entries")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(spacing: 4) {
|
||||
Text(dateRangeText)
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text("Date Range")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(.systemBackground))
|
||||
)
|
||||
}
|
||||
|
||||
private var dateRangeText: String {
|
||||
guard !validEntries.isEmpty else { return "No data" }
|
||||
|
||||
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
|
||||
guard let first = sorted.first, let last = sorted.last else { return "No data" }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
|
||||
if Calendar.current.isDate(first.forDate, equalTo: last.forDate, toGranularity: .day) {
|
||||
return formatter.string(from: first.forDate)
|
||||
}
|
||||
|
||||
return "\(formatter.string(from: first.forDate)) - \(formatter.string(from: last.forDate))"
|
||||
}
|
||||
|
||||
private var formatSelection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Format")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
ForEach(ExportFormat.allCases, id: \.self) { format in
|
||||
Button {
|
||||
selectedFormat = format
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: format.icon)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(selectedFormat == format ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
|
||||
.foregroundColor(selectedFormat == format ? .accentColor : .gray)
|
||||
.clipShape(Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(format.rawValue)
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Text(format.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedFormat == format {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(.systemBackground))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(selectedFormat == format ? Color.accentColor : Color.clear, lineWidth: 2)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dateRangeSelection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Date Range")
|
||||
.font(.headline)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ForEach(DateRange.allCases, id: \.self) { range in
|
||||
Button {
|
||||
selectedRange = range
|
||||
} label: {
|
||||
HStack {
|
||||
Text(range.rawValue)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedRange == range {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if range != DateRange.allCases.last {
|
||||
Divider()
|
||||
.padding(.leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
private var exportButton: some View {
|
||||
Button {
|
||||
performExport()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isExporting {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Text(isExporting ? "Exporting..." : "Export \(selectedFormat.rawValue)")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(validEntries.isEmpty ? Color.gray : Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.disabled(isExporting || validEntries.isEmpty)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private func performExport() {
|
||||
isExporting = true
|
||||
|
||||
Task {
|
||||
let url: URL?
|
||||
|
||||
switch selectedFormat {
|
||||
case .csv:
|
||||
url = ExportService.shared.exportCSV(entries: validEntries)
|
||||
case .pdf:
|
||||
url = ExportService.shared.exportPDF(entries: validEntries, title: "Feels Mood Report")
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isExporting = false
|
||||
|
||||
if let url = url {
|
||||
exportedURL = url
|
||||
showShareSheet = true
|
||||
} else {
|
||||
errorMessage = "Failed to create export file. Please try again."
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Share Sheet
|
||||
|
||||
struct ExportShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
Reference in New Issue
Block a user