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:
Trey t
2025-12-13 12:22:06 -06:00
parent 6c92cf4ec3
commit 920aaee35c
26 changed files with 4295 additions and 99 deletions

View File

@@ -0,0 +1,173 @@
//
// BiometricAuthManager.swift
// Feels
//
// Manages Face ID / Touch ID authentication for app privacy lock.
//
import Foundation
import LocalAuthentication
import SwiftUI
@MainActor
class BiometricAuthManager: ObservableObject {
// MARK: - Published State
@Published var isUnlocked: Bool = true
@Published var isAuthenticating: Bool = false
// MARK: - App Storage
@AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults)
var isLockEnabled: Bool = false
// MARK: - Biometric Capabilities
var canUseBiometrics: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}
var canUseDevicePasscode: Bool {
let context = LAContext()
var error: NSError?
return context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)
}
var biometricType: LABiometryType {
let context = LAContext()
var error: NSError?
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
return context.biometryType
}
var biometricName: String {
switch biometricType {
case .faceID:
return "Face ID"
case .touchID:
return "Touch ID"
case .opticID:
return "Optic ID"
@unknown default:
return "Biometrics"
}
}
var biometricIcon: String {
switch biometricType {
case .faceID:
return "faceid"
case .touchID:
return "touchid"
case .opticID:
return "opticid"
@unknown default:
return "lock.fill"
}
}
// MARK: - Authentication
func authenticate() async -> Bool {
guard isLockEnabled else {
isUnlocked = true
return true
}
let context = LAContext()
context.localizedCancelTitle = "Cancel"
// Try biometrics first, fall back to device passcode
let policy: LAPolicy = canUseBiometrics ? .deviceOwnerAuthenticationWithBiometrics : .deviceOwnerAuthentication
isAuthenticating = true
defer { isAuthenticating = false }
do {
let success = try await context.evaluatePolicy(
policy,
localizedReason: "Unlock Feels to access your mood data"
)
isUnlocked = success
if success {
EventLogger.log(event: "biometric_unlock_success")
}
return success
} catch {
print("Authentication failed: \(error.localizedDescription)")
EventLogger.log(event: "biometric_unlock_failed", withData: ["error": error.localizedDescription])
// If biometrics failed, try device passcode as fallback
if canUseDevicePasscode && policy == .deviceOwnerAuthenticationWithBiometrics {
return await authenticateWithPasscode()
}
return false
}
}
private func authenticateWithPasscode() async -> Bool {
let context = LAContext()
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthentication,
localizedReason: "Unlock Feels to access your mood data"
)
isUnlocked = success
return success
} catch {
print("Passcode authentication failed: \(error.localizedDescription)")
return false
}
}
// MARK: - Lock Management
func lock() {
guard isLockEnabled else { return }
isUnlocked = false
EventLogger.log(event: "app_locked")
}
func enableLock() async -> Bool {
// Authenticate first to enable lock - require biometrics
let context = LAContext()
var error: NSError?
// Only allow enabling if biometrics are available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
return false
}
do {
let success = try await context.evaluatePolicy(
.deviceOwnerAuthenticationWithBiometrics,
localizedReason: "Verify your identity to enable app lock"
)
if success {
isLockEnabled = true
isUnlocked = true
EventLogger.log(event: "privacy_lock_enabled")
}
return success
} catch {
print("Failed to enable lock: \(error.localizedDescription)")
return false
}
}
func disableLock() {
isLockEnabled = false
isUnlocked = true
EventLogger.log(event: "privacy_lock_disabled")
}
}

View File

@@ -0,0 +1,786 @@
//
// ExportService.swift
// Feels
//
// 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
]
// 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 = "Feels-Export-\(formattedDate()).csv"
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
try csv.write(to: tempURL, atomically: true, encoding: .utf8)
EventLogger.log(event: "csv_exported", withData: ["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: "Feels 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)
}
EventLogger.log(event: "pdf_exported", withData: ["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 = "Feels-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 {
var 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 {
var 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 {
var 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 Feels - 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"
}
}
}
// MARK: - Safe Array Access
private extension Array {
subscript(safe index: Int) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}

View File

@@ -97,6 +97,14 @@ class FoundationModelsInsightService: ObservableObject {
return defaultSystemInstructions
case .Rude:
return rudeSystemInstructions
case .MotivationalCoach:
return coachSystemInstructions
case .ZenMaster:
return zenSystemInstructions
case .BestFriend:
return bestFriendSystemInstructions
case .DataAnalyst:
return analystSystemInstructions
}
}
@@ -122,6 +130,54 @@ class FoundationModelsInsightService: ObservableObject {
"""
}
private var coachSystemInstructions: String {
"""
You are a HIGH ENERGY motivational coach analyzing mood data! Think Tony Robbins meets sports coach.
Style: Enthusiastic, empowering, action-oriented! Use exclamations! Celebrate wins BIG, frame struggles as opportunities for GROWTH. Every insight ends with a call to action.
Phrases: "Let's GO!", "Champion move!", "That's the winner's mindset!", "You're in the ZONE!", "Level up!"
SF Symbols: figure.run, trophy.fill, flame.fill, bolt.fill, star.fill, flag.checkered, medal.fill
"""
}
private var zenSystemInstructions: String {
"""
You are a calm, mindful Zen master reflecting on mood data. Think Buddhist monk meets gentle therapist.
Style: Serene, philosophical, uses nature metaphors. Speak in calm, measured tones. Find wisdom in all emotions. No judgment, only observation and acceptance.
Phrases: "Like the seasons...", "The river of emotion...", "In stillness we find...", "This too shall pass...", "With gentle awareness..."
SF Symbols: leaf.fill, moon.fill, drop.fill, wind, cloud.fill, sunrise.fill, sparkles, peacesign
"""
}
private var bestFriendSystemInstructions: String {
"""
You are their supportive best friend analyzing their mood data! Think caring bestie who's always got their back.
Style: Warm, casual, uses "you" and "we" language. Validate feelings, celebrate with them, commiserate together. Use conversational tone with occasional gentle humor.
Phrases: "Okay but...", "Not gonna lie...", "I see you!", "That's so valid!", "Girl/Dude...", "Honestly though..."
SF Symbols: heart.fill, hand.thumbsup.fill, sparkles, star.fill, sun.max.fill, face.smiling.fill, balloon.fill
"""
}
private var analystSystemInstructions: String {
"""
You are a clinical data analyst examining mood metrics. Think spreadsheet expert meets research scientist.
Style: Objective, statistical, data-driven. Reference exact numbers, percentages, and trends. Avoid emotional language. Present findings like a research report.
Phrases: "Data indicates...", "Statistically significant...", "Correlation observed...", "Trend analysis shows...", "Based on the metrics..."
SF Symbols: chart.bar.fill, chart.line.uptrend.xyaxis, function, number, percent, chart.pie.fill, doc.text.magnifyingglass
"""
}
// MARK: - Insight Generation
/// Generate AI-powered insights for the given mood entries
@@ -129,11 +185,13 @@ class FoundationModelsInsightService: ObservableObject {
/// - entries: Array of mood entries to analyze
/// - periodName: The time period name (e.g., "this month", "this year", "all time")
/// - count: Number of insights to generate (default 5)
/// - healthCorrelations: Optional health data correlations to include
/// - Returns: Array of Insight objects
func generateInsights(
for entries: [MoodEntryModel],
periodName: String,
count: Int = 5
count: Int = 5,
healthCorrelations: [HealthCorrelation] = []
) async throws -> [Insight] {
// Check cache first
if let cached = cachedInsights[periodName],
@@ -158,8 +216,8 @@ class FoundationModelsInsightService: ObservableObject {
isGenerating = true
defer { isGenerating = false }
// Prepare data summary
let summary = summarizer.summarize(entries: validEntries, periodName: periodName)
// Prepare data summary with health correlations
let summary = summarizer.summarize(entries: validEntries, periodName: periodName, healthCorrelations: healthCorrelations)
let prompt = buildPrompt(from: summary, count: count)
do {

View File

@@ -0,0 +1,378 @@
//
// HealthService.swift
// Feels
//
// Manages Apple Health integration for mood correlation insights.
//
import Foundation
import HealthKit
import SwiftUI
@MainActor
class HealthService: ObservableObject {
static let shared = HealthService()
// MARK: - Published State
@Published var isAuthorized: Bool = false
@Published var isAvailable: Bool = false
// MARK: - App Storage
@AppStorage(UserDefaultsStore.Keys.healthKitEnabled.rawValue, store: GroupUserDefaults.groupDefaults)
var isEnabled: Bool = false
// MARK: - HealthKit Store
private let healthStore = HKHealthStore()
// MARK: - Data Types
private let stepCountType = HKQuantityType.quantityType(forIdentifier: .stepCount)!
private let exerciseTimeType = HKQuantityType.quantityType(forIdentifier: .appleExerciseTime)!
private let heartRateType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
private let sleepAnalysisType = HKCategoryType.categoryType(forIdentifier: .sleepAnalysis)!
private var readTypes: Set<HKObjectType> {
[stepCountType, exerciseTimeType, heartRateType, sleepAnalysisType]
}
// MARK: - Initialization
init() {
isAvailable = HKHealthStore.isHealthDataAvailable()
}
// MARK: - Authorization
func requestAuthorization() async -> Bool {
guard isAvailable else {
print("HealthService: HealthKit not available on this device")
return false
}
do {
try await healthStore.requestAuthorization(toShare: [], read: readTypes)
isAuthorized = true
isEnabled = true
EventLogger.log(event: "healthkit_authorized")
return true
} catch {
print("HealthService: Authorization failed: \(error.localizedDescription)")
EventLogger.log(event: "healthkit_auth_failed", withData: ["error": error.localizedDescription])
return false
}
}
// MARK: - Fetch Health Data for Date
struct DailyHealthData {
let date: Date
let steps: Int?
let exerciseMinutes: Int?
let averageHeartRate: Double?
let sleepHours: Double?
var hasData: Bool {
steps != nil || exerciseMinutes != nil || averageHeartRate != nil || sleepHours != nil
}
}
func fetchHealthData(for date: Date) async -> DailyHealthData {
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
async let steps = fetchSteps(start: startOfDay, end: endOfDay)
async let exercise = fetchExerciseMinutes(start: startOfDay, end: endOfDay)
async let heartRate = fetchAverageHeartRate(start: startOfDay, end: endOfDay)
async let sleep = fetchSleepHours(for: date)
return await DailyHealthData(
date: date,
steps: steps,
exerciseMinutes: exercise,
averageHeartRate: heartRate,
sleepHours: sleep
)
}
// MARK: - Steps
private func fetchSteps(start: Date, end: Date) async -> Int? {
guard isAuthorized else { return nil }
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
return await withCheckedContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: stepCountType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, result, error in
guard error == nil,
let sum = result?.sumQuantity() else {
continuation.resume(returning: nil)
return
}
let steps = Int(sum.doubleValue(for: .count()))
continuation.resume(returning: steps)
}
healthStore.execute(query)
}
}
// MARK: - Exercise Minutes
private func fetchExerciseMinutes(start: Date, end: Date) async -> Int? {
guard isAuthorized else { return nil }
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
return await withCheckedContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: exerciseTimeType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, result, error in
guard error == nil,
let sum = result?.sumQuantity() else {
continuation.resume(returning: nil)
return
}
let minutes = Int(sum.doubleValue(for: .minute()))
continuation.resume(returning: minutes)
}
healthStore.execute(query)
}
}
// MARK: - Heart Rate
private func fetchAverageHeartRate(start: Date, end: Date) async -> Double? {
guard isAuthorized else { return nil }
let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
return await withCheckedContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: heartRateType,
quantitySamplePredicate: predicate,
options: .discreteAverage
) { _, result, error in
guard error == nil,
let avg = result?.averageQuantity() else {
continuation.resume(returning: nil)
return
}
let bpm = avg.doubleValue(for: HKUnit.count().unitDivided(by: .minute()))
continuation.resume(returning: bpm)
}
healthStore.execute(query)
}
}
// MARK: - Sleep
private func fetchSleepHours(for date: Date) async -> Double? {
guard isAuthorized else { return nil }
// Sleep data is typically recorded for the night before
// So for mood on date X, we look at sleep from evening of X-1 to morning of X
let calendar = Calendar.current
let endOfSleep = calendar.date(bySettingHour: 12, minute: 0, second: 0, of: date)!
let startOfSleep = calendar.date(byAdding: .hour, value: -18, to: endOfSleep)!
let predicate = HKQuery.predicateForSamples(withStart: startOfSleep, end: endOfSleep, options: .strictStartDate)
return await withCheckedContinuation { continuation in
let query = HKSampleQuery(
sampleType: sleepAnalysisType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
guard error == nil,
let sleepSamples = samples as? [HKCategorySample] else {
continuation.resume(returning: nil)
return
}
// Sum up asleep time (not in bed time)
var totalSleepSeconds: TimeInterval = 0
for sample in sleepSamples {
// Filter for actual sleep states (not in bed)
if sample.value == HKCategoryValueSleepAnalysis.asleepCore.rawValue ||
sample.value == HKCategoryValueSleepAnalysis.asleepDeep.rawValue ||
sample.value == HKCategoryValueSleepAnalysis.asleepREM.rawValue ||
sample.value == HKCategoryValueSleepAnalysis.asleepUnspecified.rawValue {
totalSleepSeconds += sample.endDate.timeIntervalSince(sample.startDate)
}
}
if totalSleepSeconds > 0 {
let hours = totalSleepSeconds / 3600
continuation.resume(returning: hours)
} else {
continuation.resume(returning: nil)
}
}
healthStore.execute(query)
}
}
// MARK: - Batch Fetch for Insights
func fetchHealthData(for entries: [MoodEntryModel]) async -> [Date: DailyHealthData] {
guard isEnabled && isAuthorized else { return [:] }
var results: [Date: DailyHealthData] = [:]
let calendar = Calendar.current
// Get unique dates
let dates = Set(entries.map { calendar.startOfDay(for: $0.forDate) })
// Fetch health data for each date
await withTaskGroup(of: (Date, DailyHealthData).self) { group in
for date in dates {
group.addTask {
let data = await self.fetchHealthData(for: date)
return (date, data)
}
}
for await (date, data) in group {
results[date] = data
}
}
return results
}
// MARK: - Correlation Analysis
struct HealthMoodCorrelation {
let metric: String
let correlation: String // "positive", "negative", or "none"
let insight: String
let averageWithHighMetric: Double
let averageWithLowMetric: Double
}
func analyzeCorrelations(entries: [MoodEntryModel], healthData: [Date: DailyHealthData]) -> [HealthMoodCorrelation] {
var correlations: [HealthMoodCorrelation] = []
let calendar = Calendar.current
// Prepare data pairs
var stepsAndMoods: [(steps: Int, mood: Int)] = []
var exerciseAndMoods: [(minutes: Int, mood: Int)] = []
var sleepAndMoods: [(hours: Double, mood: Int)] = []
var heartRateAndMoods: [(bpm: Double, mood: Int)] = []
for entry in entries {
let date = calendar.startOfDay(for: entry.forDate)
guard let health = healthData[date] else { continue }
let moodValue = entry.moodValue + 1 // Use 1-5 scale
if let steps = health.steps {
stepsAndMoods.append((steps, moodValue))
}
if let exercise = health.exerciseMinutes {
exerciseAndMoods.append((exercise, moodValue))
}
if let sleep = health.sleepHours {
sleepAndMoods.append((sleep, moodValue))
}
if let hr = health.averageHeartRate {
heartRateAndMoods.append((hr, moodValue))
}
}
// Analyze steps correlation
if stepsAndMoods.count >= 5 {
let threshold = 8000
let highSteps = stepsAndMoods.filter { $0.steps >= threshold }
let lowSteps = stepsAndMoods.filter { $0.steps < threshold }
if !highSteps.isEmpty && !lowSteps.isEmpty {
let avgHigh = Double(highSteps.map { $0.mood }.reduce(0, +)) / Double(highSteps.count)
let avgLow = Double(lowSteps.map { $0.mood }.reduce(0, +)) / Double(lowSteps.count)
let diff = avgHigh - avgLow
if abs(diff) >= 0.3 {
correlations.append(HealthMoodCorrelation(
metric: "Steps",
correlation: diff > 0 ? "positive" : "negative",
insight: diff > 0
? "Your mood averages \(String(format: "%.1f", diff)) points higher on days with 8k+ steps"
: "Interestingly, your mood is slightly lower on high-step days",
averageWithHighMetric: avgHigh,
averageWithLowMetric: avgLow
))
}
}
}
// Analyze sleep correlation
if sleepAndMoods.count >= 5 {
let threshold = 7.0
let goodSleep = sleepAndMoods.filter { $0.hours >= threshold }
let poorSleep = sleepAndMoods.filter { $0.hours < threshold }
if !goodSleep.isEmpty && !poorSleep.isEmpty {
let avgGood = Double(goodSleep.map { $0.mood }.reduce(0, +)) / Double(goodSleep.count)
let avgPoor = Double(poorSleep.map { $0.mood }.reduce(0, +)) / Double(poorSleep.count)
let diff = avgGood - avgPoor
if abs(diff) >= 0.3 {
correlations.append(HealthMoodCorrelation(
metric: "Sleep",
correlation: diff > 0 ? "positive" : "negative",
insight: diff > 0
? "7+ hours of sleep correlates with \(String(format: "%.1f", diff)) point higher mood"
: "Sleep duration doesn't seem to strongly affect your mood",
averageWithHighMetric: avgGood,
averageWithLowMetric: avgPoor
))
}
}
}
// Analyze exercise correlation
if exerciseAndMoods.count >= 5 {
let threshold = 30
let active = exerciseAndMoods.filter { $0.minutes >= threshold }
let inactive = exerciseAndMoods.filter { $0.minutes < threshold }
if !active.isEmpty && !inactive.isEmpty {
let avgActive = Double(active.map { $0.mood }.reduce(0, +)) / Double(active.count)
let avgInactive = Double(inactive.map { $0.mood }.reduce(0, +)) / Double(inactive.count)
let diff = avgActive - avgInactive
if abs(diff) >= 0.3 {
correlations.append(HealthMoodCorrelation(
metric: "Exercise",
correlation: diff > 0 ? "positive" : "negative",
insight: diff > 0
? "30+ minutes of exercise correlates with \(String(format: "%.1f", diff)) point mood boost"
: "Exercise doesn't show a strong mood correlation for you",
averageWithHighMetric: avgActive,
averageWithLowMetric: avgInactive
))
}
}
}
return correlations
}
}

View File

@@ -46,6 +46,16 @@ struct MoodDataSummary {
// Notable observations
let hasAllMoodTypes: Bool
let missingMoodTypes: [String]
// Health correlations (optional)
let healthCorrelations: [HealthCorrelation]
}
/// Health correlation data for AI insights
struct HealthCorrelation {
let metric: String
let insight: String
let correlation: String // "positive", "negative", or "none"
}
/// Transforms raw MoodEntryModel data into AI-optimized summaries
@@ -59,7 +69,7 @@ class MoodDataSummarizer {
// MARK: - Main Summarization
func summarize(entries: [MoodEntryModel], periodName: String) -> MoodDataSummary {
func summarize(entries: [MoodEntryModel], periodName: String, healthCorrelations: [HealthCorrelation] = []) -> MoodDataSummary {
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
guard !validEntries.isEmpty else {
@@ -103,7 +113,8 @@ class MoodDataSummarizer {
last7DaysAverage: recentContext.average,
last7DaysMoods: recentContext.moods,
hasAllMoodTypes: moodTypes.hasAll,
missingMoodTypes: moodTypes.missing
missingMoodTypes: moodTypes.missing,
healthCorrelations: healthCorrelations
)
}
@@ -379,7 +390,8 @@ class MoodDataSummarizer {
last7DaysAverage: 0,
last7DaysMoods: [],
hasAllMoodTypes: false,
missingMoodTypes: ["great", "good", "average", "bad", "horrible"]
missingMoodTypes: ["great", "good", "average", "bad", "horrible"],
healthCorrelations: []
)
}
@@ -411,6 +423,14 @@ class MoodDataSummarizer {
// Stability
lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)")
// Health correlations (if available)
if !summary.healthCorrelations.isEmpty {
lines.append("Health correlations:")
for correlation in summary.healthCorrelations {
lines.append("- \(correlation.metric): \(correlation.insight)")
}
}
return lines.joined(separator: "\n")
}
}

View File

@@ -0,0 +1,246 @@
//
// PhotoManager.swift
// Feels
//
// Manages photo storage for mood entries.
// Photos are stored as JPEG files in the app group Documents directory.
//
import Foundation
import UIKit
import SwiftUI
@MainActor
class PhotoManager: ObservableObject {
static let shared = PhotoManager()
// MARK: - Constants
private let compressionQuality: CGFloat = 0.8
private let thumbnailSize = CGSize(width: 200, height: 200)
// MARK: - Storage Location
private var photosDirectory: URL? {
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: Constants.currentGroupShareId
) else {
print("PhotoManager: Failed to get app group container")
return nil
}
let photosURL = containerURL.appendingPathComponent("Photos", isDirectory: true)
// Create directory if it doesn't exist
if !FileManager.default.fileExists(atPath: photosURL.path) {
do {
try FileManager.default.createDirectory(at: photosURL, withIntermediateDirectories: true)
} catch {
print("PhotoManager: Failed to create photos directory: \(error)")
return nil
}
}
return photosURL
}
private var thumbnailsDirectory: URL? {
guard let photosDir = photosDirectory else { return nil }
let thumbnailsURL = photosDir.appendingPathComponent("Thumbnails", isDirectory: true)
if !FileManager.default.fileExists(atPath: thumbnailsURL.path) {
do {
try FileManager.default.createDirectory(at: thumbnailsURL, withIntermediateDirectories: true)
} catch {
print("PhotoManager: Failed to create thumbnails directory: \(error)")
return nil
}
}
return thumbnailsURL
}
// MARK: - Save Photo
func savePhoto(_ image: UIImage) -> UUID? {
guard let photosDir = photosDirectory,
let thumbnailsDir = thumbnailsDirectory else {
return nil
}
let photoID = UUID()
let filename = "\(photoID.uuidString).jpg"
// Save full resolution
let fullURL = photosDir.appendingPathComponent(filename)
guard let fullData = image.jpegData(compressionQuality: compressionQuality) else {
print("PhotoManager: Failed to create JPEG data")
return nil
}
do {
try fullData.write(to: fullURL)
} catch {
print("PhotoManager: Failed to save photo: \(error)")
return nil
}
// Save thumbnail
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
if let thumbnail = createThumbnail(from: image),
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
try? thumbnailData.write(to: thumbnailURL)
}
EventLogger.log(event: "photo_saved")
return photoID
}
// MARK: - Load Photo
func loadPhoto(id: UUID) -> UIImage? {
guard let photosDir = photosDirectory else { return nil }
let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename)
guard FileManager.default.fileExists(atPath: fullURL.path),
let data = try? Data(contentsOf: fullURL),
let image = UIImage(data: data) else {
return nil
}
return image
}
func loadThumbnail(id: UUID) -> UIImage? {
guard let thumbnailsDir = thumbnailsDirectory else { return nil }
let filename = "\(id.uuidString).jpg"
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
// Try thumbnail first
if FileManager.default.fileExists(atPath: thumbnailURL.path),
let data = try? Data(contentsOf: thumbnailURL),
let image = UIImage(data: data) {
return image
}
// Fall back to full image if thumbnail doesn't exist
return loadPhoto(id: id)
}
// MARK: - Delete Photo
func deletePhoto(id: UUID) -> Bool {
guard let photosDir = photosDirectory,
let thumbnailsDir = thumbnailsDirectory else {
return false
}
let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename)
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
var success = true
// Delete full image
if FileManager.default.fileExists(atPath: fullURL.path) {
do {
try FileManager.default.removeItem(at: fullURL)
} catch {
print("PhotoManager: Failed to delete photo: \(error)")
success = false
}
}
// Delete thumbnail
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
try? FileManager.default.removeItem(at: thumbnailURL)
}
if success {
EventLogger.log(event: "photo_deleted")
}
return success
}
// MARK: - Helpers
private func createThumbnail(from image: UIImage) -> UIImage? {
let size = thumbnailSize
let aspectRatio = image.size.width / image.size.height
var targetSize: CGSize
if aspectRatio > 1 {
// Landscape
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
} else {
// Portrait or square
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
}
UIGraphicsBeginImageContextWithOptions(targetSize, false, 1.0)
image.draw(in: CGRect(origin: .zero, size: targetSize))
let thumbnail = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return thumbnail
}
// MARK: - Storage Info
var totalPhotoCount: Int {
guard let photosDir = photosDirectory else { return 0 }
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path)
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 0
}
var totalStorageUsed: Int64 {
guard let photosDir = photosDirectory else { return 0 }
var totalSize: Int64 = 0
let fileManager = FileManager.default
if let enumerator = fileManager.enumerator(at: photosDir, includingPropertiesForKeys: [.fileSizeKey]) {
for case let fileURL as URL in enumerator {
if let fileSize = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
totalSize += Int64(fileSize)
}
}
}
return totalSize
}
var formattedStorageUsed: String {
let bytes = totalStorageUsed
let formatter = ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: bytes)
}
}
// MARK: - SwiftUI Image Loading
extension PhotoManager {
func image(for id: UUID?) -> Image? {
guard let id = id,
let uiImage = loadPhoto(id: id) else {
return nil
}
return Image(uiImage: uiImage)
}
func thumbnail(for id: UUID?) -> Image? {
guard let id = id,
let uiImage = loadThumbnail(id: id) else {
return nil
}
return Image(uiImage: uiImage)
}
}