Files
Reflect/Shared/Services/ExportService.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

796 lines
32 KiB
Swift

//
// ExportService.swift
// Reflect
//
// Handles exporting mood data to CSV and PDF formats with beautiful visualizations.
//
import Foundation
import SwiftUI
import PDFKit
import UIKit
class ExportService {
static let shared = ExportService()
// MARK: - Date Formatter
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
private let shortDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return formatter
}()
private let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
// MARK: - Colors (using default mood tint colors)
private let moodColors: [Mood: UIColor] = [
.great: UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0), // Green
.good: UIColor(red: 0.56, green: 0.79, blue: 0.35, alpha: 1.0), // Light Green
.average: UIColor(red: 1.0, green: 0.76, blue: 0.03, alpha: 1.0), // Yellow
.bad: UIColor(red: 1.0, green: 0.58, blue: 0.0, alpha: 1.0), // Orange
.horrible: UIColor(red: 0.91, green: 0.30, blue: 0.24, alpha: 1.0) // Red
]
private func trackDataExported(format: String, count: Int) {
Task { @MainActor in
AnalyticsManager.shared.track(.dataExported(format: format, count: count))
}
}
// MARK: - CSV Export
func generateCSV(entries: [MoodEntryModel]) -> String {
var csv = "Date,Mood,Mood Value,Notes,Weekday,Entry Type,Timestamp\n"
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
for entry in sortedEntries {
let date = dateFormatter.string(from: entry.forDate)
let mood = entry.mood.widgetDisplayName
let moodValue = entry.moodValue + 1 // 1-5 scale
let notes = escapeCSV(entry.notes ?? "")
let weekday = weekdayName(from: entry.weekDay)
let entryType = EntryType(rawValue: entry.entryType)?.description ?? "Unknown"
let timestamp = isoFormatter.string(from: entry.timestamp)
csv += "\(date),\(mood),\(moodValue),\(notes),\(weekday),\(entryType),\(timestamp)\n"
}
return csv
}
func exportCSV(entries: [MoodEntryModel]) -> URL? {
let csv = generateCSV(entries: entries)
let filename = "Reflect-Export-\(formattedDate()).csv"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try csv.write(to: tempURL, atomically: true, encoding: .utf8)
trackDataExported(format: "csv", count: entries.count)
return tempURL
} catch {
print("ExportService: Failed to write CSV: \(error)")
return nil
}
}
// MARK: - PDF Export
func generatePDF(entries: [MoodEntryModel], title: String = "Mood Report") -> Data? {
let sortedEntries = entries.sorted { $0.forDate > $1.forDate }
let validEntries = sortedEntries.filter { ![.missing, .placeholder].contains($0.mood) }
guard !validEntries.isEmpty else { return nil }
// Calculate statistics
let stats = calculateStats(entries: validEntries)
// PDF Setup
let pageWidth: CGFloat = 612 // US Letter
let pageHeight: CGFloat = 792
let margin: CGFloat = 40
let contentWidth = pageWidth - (margin * 2)
let pdfMetaData = [
kCGPDFContextCreator: "Reflect App",
kCGPDFContextTitle: title
]
let format = UIGraphicsPDFRendererFormat()
format.documentInfo = pdfMetaData as [String: Any]
let renderer = UIGraphicsPDFRenderer(
bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight),
format: format
)
let data = renderer.pdfData { context in
// PAGE 1: Overview
context.beginPage()
var yPosition: CGFloat = margin
// Header with gradient background
yPosition = drawHeader(at: yPosition, title: title, stats: stats, margin: margin, width: contentWidth, pageWidth: pageWidth, in: context)
// Summary Cards
yPosition = drawSummaryCards(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
// Mood Distribution Chart
yPosition = drawMoodDistributionChart(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
// Weekday Analysis
if yPosition < pageHeight - 250 {
yPosition = drawWeekdayAnalysis(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context)
}
// PAGE 2: Trends & Details
context.beginPage()
yPosition = margin + 20
// Page 2 Header
yPosition = drawSectionTitle("Trends & Patterns", at: yPosition, margin: margin)
// Mood Trend Line (last 30 entries)
yPosition = drawTrendChart(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, in: context)
// Streaks Section
yPosition = drawStreaksSection(at: yPosition, stats: stats, margin: margin, width: contentWidth, in: context)
// Recent Entries Table
yPosition = drawRecentEntries(at: yPosition, entries: validEntries, margin: margin, width: contentWidth, pageHeight: pageHeight, in: context)
// Footer
drawFooter(pageWidth: pageWidth, pageHeight: pageHeight, margin: margin, in: context)
}
trackDataExported(format: "pdf", count: entries.count)
return data
}
func exportPDF(entries: [MoodEntryModel], title: String = "Mood Report") -> URL? {
guard let data = generatePDF(entries: entries, title: title) else {
return nil
}
let filename = "Reflect-Report-\(formattedDate()).pdf"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try data.write(to: tempURL)
return tempURL
} catch {
print("ExportService: Failed to write PDF: \(error)")
return nil
}
}
// MARK: - PDF Drawing: Header
private func drawHeader(at y: CGFloat, title: String, stats: ExportStats, margin: CGFloat, width: CGFloat, pageWidth: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
let headerHeight: CGFloat = 120
// Draw gradient background
let gradientColors = [
UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).cgColor,
UIColor(red: 0.56, green: 0.35, blue: 0.89, alpha: 1.0).cgColor
]
let colorSpace = CGColorSpaceCreateDeviceRGB()
if let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors as CFArray, locations: [0, 1]) {
context.cgContext.saveGState()
context.cgContext.addRect(CGRect(x: 0, y: y, width: pageWidth, height: headerHeight))
context.cgContext.clip()
context.cgContext.drawLinearGradient(gradient, start: CGPoint(x: 0, y: y), end: CGPoint(x: pageWidth, y: y + headerHeight), options: [])
context.cgContext.restoreGState()
}
// Title
let titleFont = UIFont.systemFont(ofSize: 28, weight: .bold)
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: titleFont,
.foregroundColor: UIColor.white
]
let titleString = NSAttributedString(string: title, attributes: titleAttributes)
titleString.draw(at: CGPoint(x: margin, y: y + 25))
// Subtitle with date range
let subtitleFont = UIFont.systemFont(ofSize: 14, weight: .medium)
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: subtitleFont,
.foregroundColor: UIColor.white.withAlphaComponent(0.9)
]
let subtitleString = NSAttributedString(string: stats.dateRange, attributes: subtitleAttributes)
subtitleString.draw(at: CGPoint(x: margin, y: y + 60))
// Generated date
let dateFont = UIFont.systemFont(ofSize: 11, weight: .regular)
let dateAttributes: [NSAttributedString.Key: Any] = [
.font: dateFont,
.foregroundColor: UIColor.white.withAlphaComponent(0.7)
]
let generatedString = NSAttributedString(string: "Generated \(dateFormatter.string(from: Date()))", attributes: dateAttributes)
generatedString.draw(at: CGPoint(x: margin, y: y + 85))
return y + headerHeight + 25
}
// MARK: - PDF Drawing: Summary Cards
private func drawSummaryCards(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
let cardHeight: CGFloat = 80
let cardWidth = (width - 20) / 3
let cornerRadius: CGFloat = 10
let cards: [(String, String, UIColor)] = [
("\(stats.totalEntries)", "Total Entries", UIColor(red: 0.29, green: 0.78, blue: 0.55, alpha: 1.0)),
(String(format: "%.1f", stats.averageMood), "Avg Mood (1-5)", UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0)),
(stats.mostCommonMood, "Top Mood", moodColors[Mood.allValues.first { $0.widgetDisplayName == stats.mostCommonMood } ?? .average] ?? .gray)
]
for (index, card) in cards.enumerated() {
let xPos = margin + CGFloat(index) * (cardWidth + 10)
// Card background
let cardRect = CGRect(x: xPos, y: y, width: cardWidth, height: cardHeight)
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: cornerRadius)
// Light background
UIColor(white: 0.97, alpha: 1.0).setFill()
cardPath.fill()
// Accent bar on left
let accentRect = CGRect(x: xPos, y: y, width: 4, height: cardHeight)
let accentPath = UIBezierPath(roundedRect: accentRect, byRoundingCorners: [.topLeft, .bottomLeft], cornerRadii: CGSize(width: cornerRadius, height: cornerRadius))
card.2.setFill()
accentPath.fill()
// Value
let valueFont = UIFont.systemFont(ofSize: 24, weight: .bold)
let valueAttributes: [NSAttributedString.Key: Any] = [
.font: valueFont,
.foregroundColor: UIColor.darkGray
]
let valueString = NSAttributedString(string: card.0, attributes: valueAttributes)
valueString.draw(at: CGPoint(x: xPos + 15, y: y + 18))
// Label
let labelFont = UIFont.systemFont(ofSize: 11, weight: .medium)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: labelFont,
.foregroundColor: UIColor.gray
]
let labelString = NSAttributedString(string: card.1, attributes: labelAttributes)
labelString.draw(at: CGPoint(x: xPos + 15, y: y + 50))
}
return y + cardHeight + 30
}
// MARK: - PDF Drawing: Mood Distribution Chart
private func drawMoodDistributionChart(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
let currentY = drawSectionTitle("Mood Distribution", at: y, margin: margin)
let chartHeight: CGFloat = 140
let barHeight: CGFloat = 22
let barSpacing: CGFloat = 8
let labelWidth: CGFloat = 70
let percentWidth: CGFloat = 50
let sortedMoods: [Mood] = [.great, .good, .average, .bad, .horrible]
for (index, mood) in sortedMoods.enumerated() {
let count = stats.moodDistribution[mood.widgetDisplayName] ?? 0
let percentage = stats.totalEntries > 0 ? (Double(count) / Double(stats.totalEntries)) * 100 : 0
let barY = currentY + CGFloat(index) * (barHeight + barSpacing)
// Mood label
let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: labelFont,
.foregroundColor: UIColor.darkGray
]
let labelString = NSAttributedString(string: mood.widgetDisplayName, attributes: labelAttributes)
labelString.draw(at: CGPoint(x: margin, y: barY + 3))
// Bar background
let barX = margin + labelWidth
let maxBarWidth = width - labelWidth - percentWidth - 10
let barRect = CGRect(x: barX, y: barY, width: maxBarWidth, height: barHeight)
let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 4)
UIColor(white: 0.92, alpha: 1.0).setFill()
barPath.fill()
// Filled bar
let filledWidth = maxBarWidth * CGFloat(percentage / 100)
if filledWidth > 0 {
let filledRect = CGRect(x: barX, y: barY, width: max(8, filledWidth), height: barHeight)
let filledPath = UIBezierPath(roundedRect: filledRect, cornerRadius: 4)
(moodColors[mood] ?? .gray).setFill()
filledPath.fill()
}
// Percentage label
let percentFont = UIFont.systemFont(ofSize: 12, weight: .semibold)
let percentAttributes: [NSAttributedString.Key: Any] = [
.font: percentFont,
.foregroundColor: moodColors[mood] ?? .gray
]
let percentString = NSAttributedString(string: String(format: "%.0f%%", percentage), attributes: percentAttributes)
percentString.draw(at: CGPoint(x: margin + width - percentWidth + 10, y: barY + 3))
}
return currentY + chartHeight + 20
}
// MARK: - PDF Drawing: Weekday Analysis
private func drawWeekdayAnalysis(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
let currentY = drawSectionTitle("Mood by Day of Week", at: y, margin: margin)
let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
var weekdayAverages: [Double] = Array(repeating: 0, count: 7)
var weekdayCounts: [Int] = Array(repeating: 0, count: 7)
for entry in entries {
let weekdayIndex = entry.weekDay - 1
if weekdayIndex >= 0 && weekdayIndex < 7 {
weekdayAverages[weekdayIndex] += Double(entry.moodValue + 1)
weekdayCounts[weekdayIndex] += 1
}
}
for i in 0..<7 {
if weekdayCounts[i] > 0 {
weekdayAverages[i] /= Double(weekdayCounts[i])
}
}
// Draw bar chart
let chartHeight: CGFloat = 100
let barWidth = (width - 60) / 7
let maxValue: Double = 5.0
for (index, avg) in weekdayAverages.enumerated() {
let barX = margin + CGFloat(index) * (barWidth + 5) + 10
let barHeight = chartHeight * CGFloat(avg / maxValue)
// Bar
if barHeight > 0 {
let barRect = CGRect(x: barX, y: currentY + chartHeight - barHeight, width: barWidth - 5, height: barHeight)
let barPath = UIBezierPath(roundedRect: barRect, byRoundingCorners: [.topLeft, .topRight], cornerRadii: CGSize(width: 4, height: 4))
// Color based on average
let color: UIColor
if avg >= 4.0 { color = moodColors[.great]! }
else if avg >= 3.0 { color = moodColors[.good]! }
else if avg >= 2.5 { color = moodColors[.average]! }
else if avg >= 1.5 { color = moodColors[.bad]! }
else { color = moodColors[.horrible]! }
color.setFill()
barPath.fill()
}
// Day label
let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: labelFont,
.foregroundColor: UIColor.gray
]
let labelString = NSAttributedString(string: weekdays[index], attributes: labelAttributes)
let labelSize = labelString.size()
labelString.draw(at: CGPoint(x: barX + (barWidth - 5 - labelSize.width) / 2, y: currentY + chartHeight + 5))
// Value label
if weekdayCounts[index] > 0 {
let valueFont = UIFont.systemFont(ofSize: 9, weight: .semibold)
let valueAttributes: [NSAttributedString.Key: Any] = [
.font: valueFont,
.foregroundColor: UIColor.darkGray
]
let valueString = NSAttributedString(string: String(format: "%.1f", avg), attributes: valueAttributes)
let valueSize = valueString.size()
valueString.draw(at: CGPoint(x: barX + (barWidth - 5 - valueSize.width) / 2, y: currentY + chartHeight - barHeight - 15))
}
}
return currentY + chartHeight + 40
}
// MARK: - PDF Drawing: Trend Chart
private func drawTrendChart(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
var currentY = y + 10
let chartHeight: CGFloat = 120
let recentEntries = Array(entries.prefix(30).reversed())
guard recentEntries.count >= 2 else {
return currentY
}
// Section label
let labelFont = UIFont.systemFont(ofSize: 12, weight: .medium)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: labelFont,
.foregroundColor: UIColor.gray
]
let labelString = NSAttributedString(string: "Last \(recentEntries.count) Entries", attributes: labelAttributes)
labelString.draw(at: CGPoint(x: margin, y: currentY))
currentY += 25
// Draw chart background
let chartRect = CGRect(x: margin, y: currentY, width: width, height: chartHeight)
let chartPath = UIBezierPath(roundedRect: chartRect, cornerRadius: 8)
UIColor(white: 0.97, alpha: 1.0).setFill()
chartPath.fill()
// Draw grid lines
context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor)
context.cgContext.setLineWidth(1)
for i in 1...4 {
let gridY = currentY + chartHeight - (chartHeight * CGFloat(i) / 5)
context.cgContext.move(to: CGPoint(x: margin + 10, y: gridY))
context.cgContext.addLine(to: CGPoint(x: margin + width - 10, y: gridY))
context.cgContext.strokePath()
}
// Draw trend line
let pointSpacing = (width - 40) / CGFloat(recentEntries.count - 1)
var points: [CGPoint] = []
for (index, entry) in recentEntries.enumerated() {
let x = margin + 20 + CGFloat(index) * pointSpacing
let normalizedMood = CGFloat(entry.moodValue) / 4.0 // 0-4 scale to 0-1
let pointY = currentY + chartHeight - 15 - (normalizedMood * (chartHeight - 30))
points.append(CGPoint(x: x, y: pointY))
}
// Draw the line
let linePath = UIBezierPath()
linePath.move(to: points[0])
for point in points.dropFirst() {
linePath.addLine(to: point)
}
UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0).setStroke()
linePath.lineWidth = 2.5
linePath.lineCapStyle = .round
linePath.lineJoinStyle = .round
linePath.stroke()
// Draw points
for (index, point) in points.enumerated() {
let entry = recentEntries[index]
let color = moodColors[entry.mood] ?? .gray
let pointRect = CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8)
let pointPath = UIBezierPath(ovalIn: pointRect)
color.setFill()
pointPath.fill()
UIColor.white.setStroke()
pointPath.lineWidth = 1.5
pointPath.stroke()
}
return currentY + chartHeight + 30
}
// MARK: - PDF Drawing: Streaks Section
private func drawStreaksSection(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
let currentY = drawSectionTitle("Streaks & Consistency", at: y, margin: margin)
let cardWidth = (width - 15) / 2
let cardHeight: CGFloat = 60
let streaks: [(String, String, String)] = [
("\(stats.currentStreak)", "Current Streak", "days in a row"),
("\(stats.longestStreak)", "Longest Streak", "days tracked"),
("\(stats.positiveStreak)", "Best Mood Streak", "good/great days"),
(String(format: "%.0f%%", stats.stabilityScore * 100), "Mood Stability", "consistency score")
]
for (index, streak) in streaks.enumerated() {
let row = index / 2
let col = index % 2
let xPos = margin + CGFloat(col) * (cardWidth + 15)
let yPos = currentY + CGFloat(row) * (cardHeight + 10)
// Card background
let cardRect = CGRect(x: xPos, y: yPos, width: cardWidth, height: cardHeight)
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8)
UIColor(white: 0.97, alpha: 1.0).setFill()
cardPath.fill()
// Value
let valueFont = UIFont.systemFont(ofSize: 22, weight: .bold)
let valueAttributes: [NSAttributedString.Key: Any] = [
.font: valueFont,
.foregroundColor: UIColor(red: 0.29, green: 0.56, blue: 0.89, alpha: 1.0)
]
let valueString = NSAttributedString(string: streak.0, attributes: valueAttributes)
valueString.draw(at: CGPoint(x: xPos + 12, y: yPos + 10))
// Label
let labelFont = UIFont.systemFont(ofSize: 10, weight: .medium)
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: labelFont,
.foregroundColor: UIColor.gray
]
let labelString = NSAttributedString(string: "\(streak.1) - \(streak.2)", attributes: labelAttributes)
labelString.draw(at: CGPoint(x: xPos + 12, y: yPos + 38))
}
return currentY + (cardHeight + 10) * 2 + 20
}
// MARK: - PDF Drawing: Recent Entries
private func drawRecentEntries(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, pageHeight: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat {
var currentY = drawSectionTitle("Recent Entries", at: y, margin: margin)
let recentEntries = Array(entries.prefix(15))
let rowHeight: CGFloat = 28
// Header
let headerFont = UIFont.systemFont(ofSize: 10, weight: .semibold)
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: headerFont,
.foregroundColor: UIColor.gray
]
NSAttributedString(string: "Date", attributes: headerAttributes).draw(at: CGPoint(x: margin, y: currentY))
NSAttributedString(string: "Mood", attributes: headerAttributes).draw(at: CGPoint(x: margin + 120, y: currentY))
NSAttributedString(string: "Notes", attributes: headerAttributes).draw(at: CGPoint(x: margin + 200, y: currentY))
currentY += 20
// Divider
context.cgContext.setStrokeColor(UIColor(white: 0.9, alpha: 1.0).cgColor)
context.cgContext.setLineWidth(1)
context.cgContext.move(to: CGPoint(x: margin, y: currentY))
context.cgContext.addLine(to: CGPoint(x: margin + width, y: currentY))
context.cgContext.strokePath()
currentY += 8
// Entries
let bodyFont = UIFont.systemFont(ofSize: 10, weight: .regular)
for entry in recentEntries {
if currentY > pageHeight - 60 { break }
// Date
let dateAttributes: [NSAttributedString.Key: Any] = [
.font: bodyFont,
.foregroundColor: UIColor.darkGray
]
let dateString = NSAttributedString(string: shortDateFormatter.string(from: entry.forDate), attributes: dateAttributes)
dateString.draw(at: CGPoint(x: margin, y: currentY + 5))
// Mood indicator dot
let dotRect = CGRect(x: margin + 120, y: currentY + 8, width: 10, height: 10)
let dotPath = UIBezierPath(ovalIn: dotRect)
(moodColors[entry.mood] ?? .gray).setFill()
dotPath.fill()
// Mood name
let moodAttributes: [NSAttributedString.Key: Any] = [
.font: bodyFont,
.foregroundColor: moodColors[entry.mood] ?? .gray
]
let moodString = NSAttributedString(string: entry.mood.widgetDisplayName, attributes: moodAttributes)
moodString.draw(at: CGPoint(x: margin + 135, y: currentY + 5))
// Notes (truncated)
if let notes = entry.notes, !notes.isEmpty {
let truncatedNotes = notes.count > 40 ? String(notes.prefix(40)) + "..." : notes
let notesAttributes: [NSAttributedString.Key: Any] = [
.font: bodyFont,
.foregroundColor: UIColor.gray
]
let notesString = NSAttributedString(string: truncatedNotes, attributes: notesAttributes)
notesString.draw(at: CGPoint(x: margin + 200, y: currentY + 5))
}
currentY += rowHeight
}
return currentY + 20
}
// MARK: - PDF Drawing: Footer
private func drawFooter(pageWidth: CGFloat, pageHeight: CGFloat, margin: CGFloat, in context: UIGraphicsPDFRendererContext) {
let footerY = pageHeight - 30
let footerFont = UIFont.systemFont(ofSize: 9, weight: .regular)
let footerAttributes: [NSAttributedString.Key: Any] = [
.font: footerFont,
.foregroundColor: UIColor.lightGray
]
let footerString = NSAttributedString(string: "Generated by Reflect - Your Mood Tracking Companion", attributes: footerAttributes)
let footerSize = footerString.size()
footerString.draw(at: CGPoint(x: (pageWidth - footerSize.width) / 2, y: footerY))
}
// MARK: - PDF Drawing: Section Title
private func drawSectionTitle(_ title: String, at y: CGFloat, margin: CGFloat) -> CGFloat {
let font = UIFont.systemFont(ofSize: 16, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.darkGray
]
let string = NSAttributedString(string: title, attributes: attributes)
string.draw(at: CGPoint(x: margin, y: y))
return y + 30
}
// MARK: - Statistics
struct ExportStats {
let totalEntries: Int
let dateRange: String
let averageMood: Double
let mostCommonMood: String
let moodDistribution: [String: Int]
let currentStreak: Int
let longestStreak: Int
let positiveStreak: Int
let stabilityScore: Double
}
private func calculateStats(entries: [MoodEntryModel]) -> ExportStats {
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
var moodCounts: [String: Int] = [:]
var totalMoodValue = 0
for entry in validEntries {
let moodName = entry.mood.widgetDisplayName
moodCounts[moodName, default: 0] += 1
totalMoodValue += entry.moodValue + 1
}
let avgMood = validEntries.isEmpty ? 0 : Double(totalMoodValue) / Double(validEntries.count)
let mostCommon = moodCounts.max(by: { $0.value < $1.value })?.key ?? "N/A"
var dateRange = "No entries"
if let first = validEntries.last?.forDate, let last = validEntries.first?.forDate {
dateRange = "\(shortDateFormatter.string(from: first)) - \(shortDateFormatter.string(from: last))"
}
// Calculate streaks
let calendar = Calendar.current
let sortedByDateDesc = validEntries.sorted { $0.forDate > $1.forDate }
var currentStreak = 0
var longestStreak = 1
var tempStreak = 1
if let mostRecent = sortedByDateDesc.first?.forDate {
let today = calendar.startOfDay(for: Date())
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) {
currentStreak = 1
for i in 1..<sortedByDateDesc.count {
let dayDiff = calendar.dateComponents([.day], from: sortedByDateDesc[i].forDate, to: sortedByDateDesc[i-1].forDate).day ?? 0
if dayDiff == 1 {
currentStreak += 1
} else {
break
}
}
}
}
let sortedByDateAsc = validEntries.sorted { $0.forDate < $1.forDate }
for i in 1..<sortedByDateAsc.count {
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
if dayDiff == 1 {
tempStreak += 1
longestStreak = max(longestStreak, tempStreak)
} else {
tempStreak = 1
}
}
// Positive streak
var positiveStreak = 0
var tempPositive = 0
for entry in sortedByDateAsc {
if [Mood.good, Mood.great].contains(entry.mood) {
tempPositive += 1
positiveStreak = max(positiveStreak, tempPositive)
} else {
tempPositive = 0
}
}
// Stability score
var swings = 0
for i in 1..<sortedByDateAsc.count {
let diff = abs(sortedByDateAsc[i].moodValue - sortedByDateAsc[i-1].moodValue)
if diff >= 2 { swings += 1 }
}
let stabilityScore = sortedByDateAsc.count > 1 ? 1.0 - min(Double(swings) / Double(sortedByDateAsc.count - 1), 1.0) : 1.0
return ExportStats(
totalEntries: validEntries.count,
dateRange: dateRange,
averageMood: avgMood,
mostCommonMood: mostCommon,
moodDistribution: moodCounts,
currentStreak: currentStreak,
longestStreak: longestStreak,
positiveStreak: positiveStreak,
stabilityScore: stabilityScore
)
}
// MARK: - Helpers
private func escapeCSV(_ string: String) -> String {
var result = string
if result.contains("\"") || result.contains(",") || result.contains("\n") {
result = result.replacingOccurrences(of: "\"", with: "\"\"")
result = "\"\(result)\""
}
return result
}
private func weekdayName(from weekday: Int) -> String {
let weekdays = ["", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
return weekdays[safe: weekday] ?? "Unknown"
}
private func formattedDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: Date())
}
}
// MARK: - EntryType Description
extension EntryType: CustomStringConvertible {
public var description: String {
switch self {
case .listView: return "Manual"
case .widget: return "Widget"
case .watch: return "Watch"
case .shortcut: return "Shortcut"
case .filledInMissing: return "Auto-filled"
case .notification: return "Notification"
case .header: return "Header"
case .siri: return "Siri"
case .controlCenter: return "Control Center"
case .liveActivity: return "Live Activity"
}
}
}
// MARK: - Safe Array Access
private extension Array {
subscript(safe index: Int) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}