diff --git a/Feels (iOS).entitlements b/Feels (iOS).entitlements
index 2393105..ffaafb6 100644
--- a/Feels (iOS).entitlements
+++ b/Feels (iOS).entitlements
@@ -14,5 +14,9 @@
group.com.tt.ifeelDebug
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
diff --git a/Feels (iOS)Dev.entitlements b/Feels (iOS)Dev.entitlements
index cd8c8e3..b775e0b 100644
--- a/Feels (iOS)Dev.entitlements
+++ b/Feels (iOS)Dev.entitlements
@@ -16,5 +16,9 @@
group.com.tt.ifeelDebug
+ com.apple.developer.healthkit
+
+ com.apple.developer.healthkit.access
+
diff --git a/Feels--iOS--Info.plist b/Feels--iOS--Info.plist
index 1c2cf79..49629b7 100644
--- a/Feels--iOS--Info.plist
+++ b/Feels--iOS--Info.plist
@@ -23,5 +23,15 @@
processing
remote-notification
+ NSHealthShareUsageDescription
+ Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.
+ NSHealthUpdateUsageDescription
+ Feels does not write any health data.
+ NSCameraUsageDescription
+ Feels uses the camera to take photos for your mood journal entries.
+ NSPhotoLibraryUsageDescription
+ Feels accesses your photo library to attach photos to your mood journal entries.
+ NSFaceIDUsageDescription
+ Feels uses Face ID to protect your private mood data.
diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift
index 7608be9..12e3d6e 100644
--- a/Shared/FeelsApp.swift
+++ b/Shared/FeelsApp.swift
@@ -17,6 +17,7 @@ struct FeelsApp: App {
let dataController = DataController.shared
@StateObject var iapManager = IAPManager()
+ @StateObject var authManager = BiometricAuthManager()
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@State private var showSubscriptionFromWidget = false
@@ -30,26 +31,36 @@ struct FeelsApp: App {
var body: some Scene {
WindowGroup {
- MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)),
- monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)),
- yearView: YearView(viewModel: YearViewModel()),
- insightsView: InsightsView(),
- customizeView: CustomizeView())
- .modelContainer(dataController.container)
- .environmentObject(iapManager)
- .sheet(isPresented: $showSubscriptionFromWidget) {
- FeelsSubscriptionStoreView()
- .environmentObject(iapManager)
- }
- .onOpenURL { url in
- if url.scheme == "feels" && url.host == "subscribe" {
- showSubscriptionFromWidget = true
+ ZStack {
+ MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)),
+ monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)),
+ yearView: YearView(viewModel: YearViewModel()),
+ insightsView: InsightsView())
+ .modelContainer(dataController.container)
+ .environmentObject(iapManager)
+ .environmentObject(authManager)
+ .sheet(isPresented: $showSubscriptionFromWidget) {
+ FeelsSubscriptionStoreView()
+ .environmentObject(iapManager)
+ }
+ .onOpenURL { url in
+ if url.scheme == "feels" && url.host == "subscribe" {
+ showSubscriptionFromWidget = true
+ }
+ }
+
+ // Lock screen overlay
+ if authManager.isLockEnabled && !authManager.isUnlocked {
+ LockScreenView(authManager: authManager)
+ .transition(.opacity)
}
}
}.onChange(of: scenePhase) { _, newPhase in
if newPhase == .background {
//BGTask.scheduleBackgroundProcessing()
WidgetCenter.shared.reloadAllTimelines()
+ // Lock the app when going to background
+ authManager.lock()
}
if newPhase == .active {
@@ -58,6 +69,12 @@ struct FeelsApp: App {
Task {
await iapManager.checkSubscriptionStatus()
}
+ // Authenticate if locked
+ if authManager.isLockEnabled && !authManager.isUnlocked {
+ Task {
+ await authManager.authenticate()
+ }
+ }
}
}
}
diff --git a/Shared/Models/MoodEntryModel.swift b/Shared/Models/MoodEntryModel.swift
index 61673c4..601477c 100644
--- a/Shared/Models/MoodEntryModel.swift
+++ b/Shared/Models/MoodEntryModel.swift
@@ -35,6 +35,10 @@ final class MoodEntryModel {
var canEdit: Bool
var canDelete: Bool
+ // Journal & Media (NEW)
+ var notes: String?
+ var photoID: UUID?
+
// Computed properties
var mood: Mood {
Mood(rawValue: moodValue) ?? .missing
@@ -49,7 +53,9 @@ final class MoodEntryModel {
mood: Mood,
entryType: EntryType,
canEdit: Bool = true,
- canDelete: Bool = true
+ canDelete: Bool = true,
+ notes: String? = nil,
+ photoID: UUID? = nil
) {
self.forDate = forDate
self.moodValue = mood.rawValue
@@ -58,6 +64,8 @@ final class MoodEntryModel {
self.entryType = entryType.rawValue
self.canEdit = canEdit
self.canDelete = canDelete
+ self.notes = notes
+ self.photoID = photoID
}
// Convenience initializer for raw values
@@ -68,7 +76,9 @@ final class MoodEntryModel {
timestamp: Date = Date(),
weekDay: Int? = nil,
canEdit: Bool = true,
- canDelete: Bool = true
+ canDelete: Bool = true,
+ notes: String? = nil,
+ photoID: UUID? = nil
) {
self.forDate = forDate
self.moodValue = moodValue
@@ -77,5 +87,7 @@ final class MoodEntryModel {
self.entryType = entryType
self.canEdit = canEdit
self.canDelete = canDelete
+ self.notes = notes
+ self.photoID = photoID
}
}
diff --git a/Shared/Models/PersonalityPackable.swift b/Shared/Models/PersonalityPackable.swift
index 4feca3a..e4e915d 100644
--- a/Shared/Models/PersonalityPackable.swift
+++ b/Shared/Models/PersonalityPackable.swift
@@ -16,12 +16,16 @@ protocol PersonalityPackable {
}
enum PersonalityPack: Int, CaseIterable {
- case Default
- case Rude
-
+ case Default = 0
+ case Rude = 1
+ case MotivationalCoach = 2
+ case ZenMaster = 3
+ case BestFriend = 4
+ case DataAnalyst = 5
+
func randomPushNotificationStrings() -> (title: String, body: String) {
let onboarding = UserDefaultsStore.getOnboarding()
-
+
switch (self, onboarding.inputDay) {
case (.Default, .Today):
return (DefaultTitles.notificationTitles.randomElement()!,
@@ -35,15 +39,69 @@ enum PersonalityPack: Int, CaseIterable {
case (.Rude, .Previous):
return (RudeTitles.notificationTitles.randomElement()!,
RudeTitles.notificationBodyYesterday.randomElement()!)
+ case (.MotivationalCoach, .Today):
+ return (MotivationalCoachTitles.notificationTitles.randomElement()!,
+ MotivationalCoachTitles.notificationBodyToday.randomElement()!)
+ case (.MotivationalCoach, .Previous):
+ return (MotivationalCoachTitles.notificationTitles.randomElement()!,
+ MotivationalCoachTitles.notificationBodyYesterday.randomElement()!)
+ case (.ZenMaster, .Today):
+ return (ZenMasterTitles.notificationTitles.randomElement()!,
+ ZenMasterTitles.notificationBodyToday.randomElement()!)
+ case (.ZenMaster, .Previous):
+ return (ZenMasterTitles.notificationTitles.randomElement()!,
+ ZenMasterTitles.notificationBodyYesterday.randomElement()!)
+ case (.BestFriend, .Today):
+ return (BestFriendTitles.notificationTitles.randomElement()!,
+ BestFriendTitles.notificationBodyToday.randomElement()!)
+ case (.BestFriend, .Previous):
+ return (BestFriendTitles.notificationTitles.randomElement()!,
+ BestFriendTitles.notificationBodyYesterday.randomElement()!)
+ case (.DataAnalyst, .Today):
+ return (DataAnalystTitles.notificationTitles.randomElement()!,
+ DataAnalystTitles.notificationBodyToday.randomElement()!)
+ case (.DataAnalyst, .Previous):
+ return (DataAnalystTitles.notificationTitles.randomElement()!,
+ DataAnalystTitles.notificationBodyYesterday.randomElement()!)
}
}
-
+
func title() -> String {
switch self {
case .Default:
return DefaultTitles.title
case .Rude:
return RudeTitles.title
+ case .MotivationalCoach:
+ return MotivationalCoachTitles.title
+ case .ZenMaster:
+ return ZenMasterTitles.title
+ case .BestFriend:
+ return BestFriendTitles.title
+ case .DataAnalyst:
+ return DataAnalystTitles.title
+ }
+ }
+
+ var icon: String {
+ switch self {
+ case .Default: return "face.smiling"
+ case .Rude: return "flame"
+ case .MotivationalCoach: return "figure.run"
+ case .ZenMaster: return "leaf"
+ case .BestFriend: return "heart"
+ case .DataAnalyst: return "chart.bar"
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .Default: return "Friendly and supportive"
+ case .Rude: return "Snarky with attitude"
+ case .MotivationalCoach: return "High energy pump-up vibes"
+ case .ZenMaster: return "Calm and mindful"
+ case .BestFriend: return "Casual and supportive"
+ case .DataAnalyst: return "Stats-focused and objective"
}
}
}
@@ -80,7 +138,7 @@ final class DefaultTitles: PersonalityPackable {
final class RudeTitles: PersonalityPackable {
static var title = String(localized: "rude")
-
+
static var notificationTitles: [String] {
[
String(localized: "rude_notif_title_one"),
@@ -89,7 +147,7 @@ final class RudeTitles: PersonalityPackable {
String(localized: "rude_notif_title_four")
]
}
-
+
static var notificationBodyToday: [String] {
[
String(localized: "rude_notif_body_today_one"),
@@ -97,7 +155,7 @@ final class RudeTitles: PersonalityPackable {
String(localized: "rude_notif_body_today_three")
]
}
-
+
static var notificationBodyYesterday: [String] {
[
String(localized: "rude_notif_body_yesterday_one"),
@@ -106,3 +164,135 @@ final class RudeTitles: PersonalityPackable {
]
}
}
+
+// MARK: - Motivational Coach
+
+final class MotivationalCoachTitles: PersonalityPackable {
+ static var title = "Coach"
+
+ static var notificationTitles: [String] {
+ [
+ "LET'S GO!",
+ "Champion Check-In",
+ "Time to Shine!",
+ "You've Got This!"
+ ]
+ }
+
+ static var notificationBodyToday: [String] {
+ [
+ "Every day is a chance to be AMAZING! How are you feeling today?",
+ "Winners track their progress! Log your mood and keep crushing it!",
+ "Your mental game matters! Take 10 seconds to check in with yourself.",
+ "Champions know their emotions! How's your energy right now?"
+ ]
+ }
+
+ static var notificationBodyYesterday: [String] {
+ [
+ "Yesterday's reflection builds tomorrow's success! How did you feel?",
+ "Great athletes review their game tape! Log yesterday's mood!",
+ "No day is wasted when you learn from it! How was yesterday?",
+ "Every experience counts! Tell me about yesterday!"
+ ]
+ }
+}
+
+// MARK: - Zen Master
+
+final class ZenMasterTitles: PersonalityPackable {
+ static var title = "Zen"
+
+ static var notificationTitles: [String] {
+ [
+ "A Gentle Reminder",
+ "Mindful Moment",
+ "Inner Peace",
+ "Present Awareness"
+ ]
+ }
+
+ static var notificationBodyToday: [String] {
+ [
+ "Breathe. Notice. How does this moment find you?",
+ "The river of feelings flows through us all. What flows through you now?",
+ "Like clouds passing, emotions come and go. What passes through you today?",
+ "In stillness, we find clarity. Take a moment to notice your inner weather."
+ ]
+ }
+
+ static var notificationBodyYesterday: [String] {
+ [
+ "Yesterday has passed like a leaf on the stream. How did it feel?",
+ "Reflecting on the past with compassion... How was yesterday's journey?",
+ "Each day is a teacher. What did yesterday's emotions teach you?",
+ "With gentle awareness, recall yesterday. What arose within you?"
+ ]
+ }
+}
+
+// MARK: - Best Friend
+
+final class BestFriendTitles: PersonalityPackable {
+ static var title = "Bestie"
+
+ static var notificationTitles: [String] {
+ [
+ "Hey you!",
+ "Quick check-in!",
+ "Thinking of you!",
+ "Got a sec?"
+ ]
+ }
+
+ static var notificationBodyToday: [String] {
+ [
+ "Just checking in on my favorite person! How's it going today?",
+ "Hey! Tell me everything - how are you feeling right now?",
+ "You know I always want to hear about your day! What's the vibe?",
+ "Sending good vibes your way! How are you doing today?"
+ ]
+ }
+
+ static var notificationBodyYesterday: [String] {
+ [
+ "Wait, we didn't catch up yesterday! How did it go?",
+ "I realized I didn't hear about yesterday - fill me in!",
+ "Oops, missed you yesterday! How was your day?",
+ "Tell me about yesterday! I want to hear all about it!"
+ ]
+ }
+}
+
+// MARK: - Data Analyst
+
+final class DataAnalystTitles: PersonalityPackable {
+ static var title = "Analyst"
+
+ static var notificationTitles: [String] {
+ [
+ "Data Point Required",
+ "Daily Metric Input",
+ "Mood Tracking Alert",
+ "Status Update Needed"
+ ]
+ }
+
+ static var notificationBodyToday: [String] {
+ [
+ "Today's emotional data point is pending. Please log your current mood state.",
+ "Incomplete dataset detected. Input today's mood to maintain tracking accuracy.",
+ "Daily mood metric collection: What is your current emotional status (1-5)?",
+ "Recording today's baseline. Please submit your mood data for analysis."
+ ]
+ }
+
+ static var notificationBodyYesterday: [String] {
+ [
+ "Gap in historical data: Yesterday's mood entry is missing. Please backfill.",
+ "Data integrity alert: Yesterday's emotional metric was not captured.",
+ "Historical data request: Submit yesterday's mood for trend analysis.",
+ "Missing data point from previous day. Please log for complete dataset."
+ ]
+ }
+}
diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift
index 896d560..242bcb1 100644
--- a/Shared/Models/UserDefaultsStore.swift
+++ b/Shared/Models/UserDefaultsStore.swift
@@ -68,6 +68,8 @@ class UserDefaultsStore {
case lastVotedDate
case votingLayoutStyle
case dayViewStyle
+ case privacyLockEnabled
+ case healthKitEnabled
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag
diff --git a/Shared/Persisence/DataControllerUPDATE.swift b/Shared/Persisence/DataControllerUPDATE.swift
index f6443a2..a9f48a5 100644
--- a/Shared/Persisence/DataControllerUPDATE.swift
+++ b/Shared/Persisence/DataControllerUPDATE.swift
@@ -21,4 +21,34 @@ extension DataController {
EventLogger.log(event: "update_entry")
return true
}
+
+ // MARK: - Notes
+
+ @discardableResult
+ func updateNotes(forDate date: Date, notes: String?) -> Bool {
+ guard let entry = getEntry(byDate: date) else {
+ return false
+ }
+
+ entry.notes = notes
+ saveAndRunDataListeners()
+
+ EventLogger.log(event: "update_notes")
+ return true
+ }
+
+ // MARK: - Photo
+
+ @discardableResult
+ func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool {
+ guard let entry = getEntry(byDate: date) else {
+ return false
+ }
+
+ entry.photoID = photoID
+ saveAndRunDataListeners()
+
+ EventLogger.log(event: "update_photo")
+ return true
+ }
}
diff --git a/Shared/Random.swift b/Shared/Random.swift
index 2dff367..5740caa 100644
--- a/Shared/Random.swift
+++ b/Shared/Random.swift
@@ -12,7 +12,15 @@ import SwiftData
struct Constants {
static let groupShareId = "group.com.tt.ifeel"
static let groupShareIdDebug = "group.com.tt.ifeelDebug"
-
+
+ static var currentGroupShareId: String {
+ #if DEBUG
+ return groupShareIdDebug
+ #else
+ return groupShareId
+ #endif
+ }
+
static let viewsCornerRaidus: CGFloat = 10
}
diff --git a/Shared/Services/BiometricAuthManager.swift b/Shared/Services/BiometricAuthManager.swift
new file mode 100644
index 0000000..91804ab
--- /dev/null
+++ b/Shared/Services/BiometricAuthManager.swift
@@ -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")
+ }
+}
diff --git a/Shared/Services/ExportService.swift b/Shared/Services/ExportService.swift
new file mode 100644
index 0000000..2d4716f
--- /dev/null
+++ b/Shared/Services/ExportService.swift
@@ -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..= 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
+ }
+}
diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift
index c6c1c76..25253c1 100644
--- a/Shared/Services/FoundationModelsInsightService.swift
+++ b/Shared/Services/FoundationModelsInsightService.swift
@@ -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 {
diff --git a/Shared/Services/HealthService.swift b/Shared/Services/HealthService.swift
new file mode 100644
index 0000000..624e004
--- /dev/null
+++ b/Shared/Services/HealthService.swift
@@ -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 {
+ [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
+ }
+}
diff --git a/Shared/Services/MoodDataSummarizer.swift b/Shared/Services/MoodDataSummarizer.swift
index 85723c2..940c7e7 100644
--- a/Shared/Services/MoodDataSummarizer.swift
+++ b/Shared/Services/MoodDataSummarizer.swift
@@ -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")
}
}
diff --git a/Shared/Services/PhotoManager.swift b/Shared/Services/PhotoManager.swift
new file mode 100644
index 0000000..e9e8c3d
--- /dev/null
+++ b/Shared/Services/PhotoManager.swift
@@ -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)
+ }
+}
diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift
index fdbb7ab..1062974 100644
--- a/Shared/Views/CustomizeView/CustomizeView.swift
+++ b/Shared/Views/CustomizeView/CustomizeView.swift
@@ -8,8 +8,89 @@
import SwiftUI
import StoreKit
+// MARK: - Customize Content View (for use in SettingsTabView)
+struct CustomizeContentView: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 24) {
+ // APPEARANCE
+ SettingsSection(title: "Appearance") {
+ VStack(spacing: 16) {
+ // Theme
+ SettingsRow(title: "Theme") {
+ ThemePickerCompact()
+ }
+
+ Divider()
+
+ // Text Color
+ SettingsRow(title: "Text Color") {
+ TextColorPickerCompact()
+ }
+ }
+ }
+
+ // MOOD STYLE
+ SettingsSection(title: "Mood Style") {
+ VStack(spacing: 16) {
+ // Icon Style
+ SettingsRow(title: "Icons") {
+ ImagePackPickerCompact()
+ }
+
+ Divider()
+
+ // Mood Colors
+ SettingsRow(title: "Colors") {
+ TintPickerCompact()
+ }
+
+ Divider()
+
+ // Day View Style
+ SettingsRow(title: "Entry Style") {
+ DayViewStylePickerCompact()
+ }
+
+ Divider()
+
+ // Voting Layout
+ SettingsRow(title: "Voting Layout") {
+ VotingLayoutPickerCompact()
+ }
+ }
+ }
+
+ // WIDGETS
+ SettingsSection(title: "Widgets") {
+ CustomWidgetSection()
+ }
+
+ // NOTIFICATIONS
+ SettingsSection(title: "Notifications") {
+ PersonalityPackPickerCompact()
+ }
+
+ // FILTERS
+ SettingsSection(title: "Day Filter") {
+ DayFilterPickerCompact()
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 32)
+ }
+ .onAppear(perform: {
+ EventLogger.log(event: "show_customize_view")
+ })
+ }
+}
+
+// MARK: - Legacy CustomizeView (kept for backwards compatibility)
struct CustomizeView: View {
- @State private var showSettings = false
@State private var showSubscriptionStore = false
@EnvironmentObject var iapManager: IAPManager
@Environment(\.colorScheme) private var colorScheme
@@ -27,9 +108,6 @@ struct CustomizeView: View {
SubscriptionBannerView(showSubscriptionStore: $showSubscriptionStore)
.environmentObject(iapManager)
- // Preview showing current style
-// SampleEntryView()
-
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
@@ -99,9 +177,6 @@ struct CustomizeView: View {
.onAppear(perform: {
EventLogger.log(event: "show_customize_view")
})
- .sheet(isPresented: $showSettings) {
- SettingsView()
- }
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
}
@@ -118,14 +193,6 @@ struct CustomizeView: View {
.foregroundColor(textColor)
Spacer()
-
- Button(action: {
- showSettings.toggle()
- }) {
- Image(systemName: "gear")
- .font(.system(size: 20))
- .foregroundColor(textColor.opacity(0.6))
- }
}
.padding(.top, 8)
}
diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift
index 49f0b36..6861ba2 100644
--- a/Shared/Views/DayView/DayView.swift
+++ b/Shared/Views/DayView/DayView.swift
@@ -30,11 +30,11 @@ struct DayView: View {
// MARK: edit row properties
@State private var showingSheet = false
@State private var selectedEntry: MoodEntryModel?
+ @State private var showEntryDetail = false
//
// MARK: ?? properties
@State private var showTodayInput = true
- @State private var showUpdateEntryAlert = false
@StateObject private var onboardingData = OnboardingDataDataManager.shared
@StateObject private var filteredDays = DaysFilterClass.shared
@EnvironmentObject var iapManager: IAPManager
@@ -53,31 +53,25 @@ struct DayView: View {
.sheet(isPresented: $showingSheet) {
SettingsView()
}
- .alert(DayViewViewModel.updateTitleHeader(forEntry: selectedEntry),
- isPresented: $showUpdateEntryAlert) {
- ForEach(Mood.allValues) { mood in
- Button(mood.strValue, action: {
- if let selectedEntry = selectedEntry {
- viewModel.update(entry: selectedEntry, toMood: mood)
+ .onChange(of: selectedEntry) { _, newEntry in
+ if newEntry != nil {
+ showEntryDetail = true
+ }
+ }
+ .sheet(isPresented: $showEntryDetail, onDismiss: {
+ selectedEntry = nil
+ }) {
+ if let entry = selectedEntry {
+ EntryDetailView(
+ entry: entry,
+ onMoodUpdate: { newMood in
+ viewModel.update(entry: entry, toMood: newMood)
+ },
+ onDelete: {
+ viewModel.update(entry: entry, toMood: .missing)
}
- showUpdateEntryAlert = false
- selectedEntry = nil
- })
+ )
}
-
- if let selectedEntry = selectedEntry,
- deleteEnabled,
- selectedEntry.mood != .missing {
- Button(String(localized: "content_view_delete_entry"), action: {
- viewModel.update(entry: selectedEntry, toMood: Mood.missing)
- showUpdateEntryAlert = false
- })
- }
-
- Button(String(localized: "content_view_fill_in_missing_entry_cancel"), role: .cancel, action: {
- selectedEntry = nil
- showUpdateEntryAlert = false
- })
}
}
.padding([.top])
@@ -182,10 +176,9 @@ extension DayView {
ForEach(filteredEntries, id: \.self) { entry in
EntryListView(entry: entry)
.contentShape(Rectangle())
- .onTapGesture(perform: {
+ .onTapGesture {
selectedEntry = entry
- showUpdateEntryAlert = true
- })
+ }
}
}
.padding(.horizontal, 12)
@@ -196,10 +189,9 @@ extension DayView {
ForEach(filteredEntries, id: \.self) { entry in
EntryListView(entry: entry)
.contentShape(Rectangle())
- .onTapGesture(perform: {
+ .onTapGesture {
selectedEntry = entry
- showUpdateEntryAlert = true
- })
+ }
}
}
.padding(.horizontal, 12)
diff --git a/Shared/Views/ExportView.swift b/Shared/Views/ExportView.swift
new file mode 100644
index 0000000..bc9ba4f
--- /dev/null
+++ b/Shared/Views/ExportView.swift
@@ -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) {}
+}
diff --git a/Shared/Views/FeelsSubscriptionStoreView.swift b/Shared/Views/FeelsSubscriptionStoreView.swift
index 447d876..f4996bc 100644
--- a/Shared/Views/FeelsSubscriptionStoreView.swift
+++ b/Shared/Views/FeelsSubscriptionStoreView.swift
@@ -14,25 +14,56 @@ struct FeelsSubscriptionStoreView: View {
var body: some View {
SubscriptionStoreView(groupID: IAPManager.subscriptionGroupID) {
- VStack(spacing: 16) {
- Image(systemName: "heart.fill")
- .font(.system(size: 60))
- .foregroundStyle(.pink)
+ VStack(spacing: 20) {
+ // App icon or logo
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [.pink.opacity(0.3), .orange.opacity(0.2)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 100, height: 100)
- Text(String(localized: "subscription_store_title"))
- .font(.title)
- .bold()
+ Image(systemName: "heart.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(
+ LinearGradient(
+ colors: [.pink, .red],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ )
+ }
- Text(String(localized: "subscription_store_subtitle"))
- .font(.body)
- .foregroundStyle(.secondary)
- .multilineTextAlignment(.center)
+ VStack(spacing: 8) {
+ Text("Unlock Premium")
+ .font(.system(size: 28, weight: .bold, design: .rounded))
+
+ Text("Get unlimited access to all features")
+ .font(.system(size: 16))
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+
+ // Feature highlights
+ VStack(alignment: .leading, spacing: 12) {
+ FeatureHighlight(icon: "calendar", text: "Month & Year Views")
+ FeatureHighlight(icon: "lightbulb.fill", text: "AI-Powered Insights")
+ FeatureHighlight(icon: "photo.fill", text: "Photos & Journal Notes")
+ FeatureHighlight(icon: "heart.fill", text: "Health Data Correlation")
+ }
+ .padding(.top, 8)
}
- .padding()
+ .padding(.horizontal, 20)
+ .padding(.vertical, 10)
}
.subscriptionStoreControlStyle(.prominentPicker)
.storeButton(.visible, for: .restorePurchases)
.subscriptionStoreButtonLabel(.multiline)
+ .tint(.pink)
.onInAppPurchaseCompletion { _, result in
if case .success(.success(_)) = result {
dismiss()
@@ -41,6 +72,26 @@ struct FeelsSubscriptionStoreView: View {
}
}
+// MARK: - Feature Highlight Row
+struct FeatureHighlight: View {
+ let icon: String
+ let text: String
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18))
+ .foregroundColor(.green)
+
+ Text(text)
+ .font(.system(size: 15, weight: .medium))
+ .foregroundColor(.primary)
+
+ Spacer()
+ }
+ }
+}
+
#Preview {
FeelsSubscriptionStoreView()
.environmentObject(IAPManager())
diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift
index 8531370..8252ae5 100644
--- a/Shared/Views/InsightsView/InsightsViewModel.swift
+++ b/Shared/Views/InsightsView/InsightsViewModel.swift
@@ -44,6 +44,7 @@ class InsightsViewModel: ObservableObject {
// MARK: - Dependencies
private let insightService = FoundationModelsInsightService()
+ private let healthService = HealthService.shared
private let calendar = Calendar.current
// MARK: - Initialization
@@ -148,11 +149,28 @@ class InsightsViewModel: ObservableObject {
updateState(.loading)
+ // Fetch health data if enabled
+ var healthCorrelations: [HealthCorrelation] = []
+ if healthService.isEnabled && healthService.isAuthorized {
+ let healthData = await healthService.fetchHealthData(for: validEntries)
+ let correlations = healthService.analyzeCorrelations(entries: validEntries, healthData: healthData)
+
+ // Convert to HealthCorrelation format
+ healthCorrelations = correlations.map {
+ HealthCorrelation(
+ metric: $0.metric,
+ insight: $0.insight,
+ correlation: $0.correlation
+ )
+ }
+ }
+
do {
let insights = try await insightService.generateInsights(
for: validEntries,
periodName: periodName,
- count: 5
+ count: 5,
+ healthCorrelations: healthCorrelations
)
updateInsights(insights)
updateState(.loaded)
diff --git a/Shared/Views/LockScreenView.swift b/Shared/Views/LockScreenView.swift
new file mode 100644
index 0000000..58ecffd
--- /dev/null
+++ b/Shared/Views/LockScreenView.swift
@@ -0,0 +1,105 @@
+//
+// LockScreenView.swift
+// Feels
+//
+// Lock screen shown when privacy lock is enabled and app needs authentication.
+//
+
+import SwiftUI
+
+struct LockScreenView: View {
+
+ @ObservedObject var authManager: BiometricAuthManager
+ @State private var showError = false
+
+ var body: some View {
+ ZStack {
+ // Background gradient
+ LinearGradient(
+ colors: [
+ Color(.systemBackground),
+ Color(.systemGray6)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .ignoresSafeArea()
+
+ VStack(spacing: 40) {
+ Spacer()
+
+ // App icon / lock icon
+ VStack(spacing: 20) {
+ Image(systemName: "lock.fill")
+ .font(.system(size: 60))
+ .foregroundStyle(.secondary)
+
+ Text("Feels is Locked")
+ .font(.title2)
+ .fontWeight(.semibold)
+
+ Text("Authenticate to access your mood data")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+
+ Spacer()
+
+ // Unlock button
+ Button {
+ Task {
+ let success = await authManager.authenticate()
+ if !success {
+ showError = true
+ }
+ }
+ } label: {
+ HStack(spacing: 12) {
+ Image(systemName: authManager.biometricIcon)
+ .font(.title2)
+
+ Text("Unlock with \(authManager.biometricName)")
+ .fontWeight(.medium)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 16)
+ .background(Color.accentColor)
+ .foregroundColor(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 14))
+ }
+ .disabled(authManager.isAuthenticating)
+ .padding(.horizontal, 40)
+
+ // Passcode fallback hint
+ if authManager.canUseDevicePasscode {
+ Text("Or use your device passcode")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ Spacer()
+ .frame(height: 60)
+ }
+ .padding()
+ }
+ .alert("Authentication Failed", isPresented: $showError) {
+ Button("Try Again") {
+ Task {
+ await authManager.authenticate()
+ }
+ }
+ Button("Cancel", role: .cancel) { }
+ } message: {
+ Text("Unable to verify your identity. Please try again.")
+ }
+ .onAppear {
+ // Auto-trigger authentication on appear
+ if !authManager.isUnlocked && !authManager.isAuthenticating {
+ Task {
+ await authManager.authenticate()
+ }
+ }
+ }
+ }
+}
diff --git a/Shared/Views/MainTabView.swift b/Shared/Views/MainTabView.swift
index 6c5df85..418252a 100644
--- a/Shared/Views/MainTabView.swift
+++ b/Shared/Views/MainTabView.swift
@@ -20,7 +20,6 @@ struct MainTabView: View {
let monthView: MonthView
let yearView: YearView
let insightsView: InsightsView
- let customizeView: CustomizeView
var body: some View {
return TabView {
@@ -44,9 +43,9 @@ struct MainTabView: View {
Label(String(localized: "content_view_tab_insights"), systemImage: "lightbulb.fill")
}
- customizeView
+ SettingsTabView()
.tabItem {
- Label(String(localized: "content_view_tab_customize"), systemImage: "pencil")
+ Label("Settings", systemImage: "gear")
}
}
.accentColor(textColor)
@@ -87,7 +86,6 @@ struct MainTabView_Previews: PreviewProvider {
MainTabView(dayView: DayView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: false)),
monthView: MonthView(viewModel: DayViewViewModel(addMonthStartWeekdayPadding: true)),
yearView: YearView(viewModel: YearViewModel()),
- insightsView: InsightsView(),
- customizeView: CustomizeView())
+ insightsView: InsightsView())
}
}
diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift
new file mode 100644
index 0000000..8d1d4af
--- /dev/null
+++ b/Shared/Views/NoteEditorView.swift
@@ -0,0 +1,513 @@
+//
+// NoteEditorView.swift
+// Feels
+//
+// Editor for adding/editing journal notes on mood entries.
+//
+
+import SwiftUI
+import PhotosUI
+
+struct NoteEditorView: View {
+
+ @Environment(\.dismiss) private var dismiss
+ @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
+
+ let entry: MoodEntryModel
+ @State private var noteText: String
+ @State private var isSaving = false
+
+ @FocusState private var isTextFieldFocused: Bool
+
+ private let maxCharacters = 2000
+
+ init(entry: MoodEntryModel) {
+ self.entry = entry
+ self._noteText = State(initialValue: entry.notes ?? "")
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 0) {
+ // Entry header
+ entryHeader
+ .padding()
+ .background(Color(.systemGray6))
+
+ Divider()
+
+ // Notes editor
+ VStack(alignment: .leading, spacing: 8) {
+ TextEditor(text: $noteText)
+ .focused($isTextFieldFocused)
+ .frame(maxHeight: .infinity)
+ .scrollContentBackground(.hidden)
+ .padding(.horizontal, 4)
+
+ // Character count
+ HStack {
+ Spacer()
+ Text("\(noteText.count)/\(maxCharacters)")
+ .font(.caption)
+ .foregroundStyle(noteText.count > maxCharacters ? .red : .secondary)
+ }
+ }
+ .padding()
+ }
+ .navigationTitle("Journal Note")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ saveNote()
+ }
+ .disabled(isSaving || noteText.count > maxCharacters)
+ .fontWeight(.semibold)
+ }
+
+ ToolbarItemGroup(placement: .keyboard) {
+ Spacer()
+ Button("Done") {
+ isTextFieldFocused = false
+ }
+ }
+ }
+ .onAppear {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ isTextFieldFocused = true
+ }
+ }
+ }
+ }
+
+ private var entryHeader: some View {
+ HStack(spacing: 12) {
+ // Mood icon
+ Circle()
+ .fill(entry.mood.color.opacity(0.2))
+ .frame(width: 50, height: 50)
+ .overlay(
+ entry.mood.icon
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 28, height: 28)
+ .foregroundColor(entry.mood.color)
+ )
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.forDate, format: .dateTime.weekday(.wide).month().day().year())
+ .font(.headline)
+ .foregroundColor(textColor)
+
+ Text(entry.moodString)
+ .font(.subheadline)
+ .foregroundColor(entry.mood.color)
+ }
+
+ Spacer()
+ }
+ }
+
+ private func saveNote() {
+ isSaving = true
+
+ let trimmedNote = noteText.trimmingCharacters(in: .whitespacesAndNewlines)
+ let noteToSave: String? = trimmedNote.isEmpty ? nil : trimmedNote
+
+ let success = DataController.shared.updateNotes(forDate: entry.forDate, notes: noteToSave)
+
+ if success {
+ dismiss()
+ } else {
+ isSaving = false
+ }
+ }
+}
+
+// MARK: - Entry Detail View (combines mood edit, notes, photos)
+
+struct EntryDetailView: View {
+
+ @Environment(\.dismiss) private var dismiss
+ @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
+ @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
+ @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
+ @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
+
+ let entry: MoodEntryModel
+ let onMoodUpdate: (Mood) -> Void
+ let onDelete: () -> Void
+
+ @State private var showNoteEditor = false
+ @State private var showPhotoOptions = false
+ @State private var showPhotoPicker = false
+ @State private var showCamera = false
+ @State private var showDeleteConfirmation = false
+ @State private var showFullScreenPhoto = false
+ @State private var selectedPhotoItem: PhotosPickerItem?
+
+ private var moodColor: Color {
+ moodTint.color(forMood: entry.mood)
+ }
+
+ private func savePhoto(_ image: UIImage) {
+ if let photoID = PhotoManager.shared.savePhoto(image) {
+ _ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: photoID)
+ }
+ }
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ VStack(spacing: 24) {
+ // Date header
+ dateHeader
+
+ // Mood section
+ moodSection
+
+ // Notes section
+ notesSection
+
+ // Photo section
+ photoSection
+
+ // Delete button
+ if deleteEnabled && entry.mood != .missing {
+ deleteSection
+ }
+ }
+ .padding()
+ }
+ .background(Color(.systemGroupedBackground))
+ .navigationTitle("Entry Details")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ .sheet(isPresented: $showNoteEditor) {
+ NoteEditorView(entry: entry)
+ }
+ .alert("Delete Entry", isPresented: $showDeleteConfirmation) {
+ Button("Delete", role: .destructive) {
+ onDelete()
+ dismiss()
+ }
+ Button("Cancel", role: .cancel) { }
+ } message: {
+ Text("Are you sure you want to delete this mood entry? This cannot be undone.")
+ }
+ .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
+ .onChange(of: selectedPhotoItem) { _, newItem in
+ guard let newItem else { return }
+ Task {
+ if let data = try? await newItem.loadTransferable(type: Data.self),
+ let image = UIImage(data: data) {
+ savePhoto(image)
+ }
+ selectedPhotoItem = nil
+ }
+ }
+ .fullScreenCover(isPresented: $showCamera) {
+ CameraView { image in
+ savePhoto(image)
+ }
+ }
+ .fullScreenCover(isPresented: $showFullScreenPhoto) {
+ FullScreenPhotoView(photoID: entry.photoID)
+ }
+ }
+ }
+
+ private var dateHeader: some View {
+ VStack(spacing: 8) {
+ Text(entry.forDate, format: .dateTime.weekday(.wide))
+ .font(.title2)
+ .fontWeight(.semibold)
+ .foregroundColor(textColor)
+
+ Text(entry.forDate, format: .dateTime.month(.wide).day().year())
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 20)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+
+ private var moodSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Mood")
+ .font(.headline)
+ .foregroundColor(textColor)
+
+ // Current mood display
+ HStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(
+ LinearGradient(
+ colors: [moodColor.opacity(0.8), moodColor.opacity(0.4)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+ .frame(width: 60, height: 60)
+
+ imagePack.icon(forMood: entry.mood)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 34, height: 34)
+ .foregroundColor(.white)
+ }
+ .shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.moodString)
+ .font(.title3)
+ .fontWeight(.semibold)
+ .foregroundColor(moodColor)
+
+ Text("Tap to change")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+
+ // Mood selection grid
+ LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
+ ForEach(Mood.allValues) { mood in
+ Button {
+ onMoodUpdate(mood)
+ } label: {
+ VStack(spacing: 6) {
+ Circle()
+ .fill(entry.mood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
+ .frame(width: 50, height: 50)
+ .overlay(
+ imagePack.icon(forMood: mood)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 28, height: 28)
+ .foregroundColor(entry.mood == mood ? .white : .gray)
+ )
+
+ Text(mood.strValue)
+ .font(.caption2)
+ .foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+ }
+
+ private var notesSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Text("Journal Note")
+ .font(.headline)
+ .foregroundColor(textColor)
+
+ Spacer()
+
+ Button {
+ showNoteEditor = true
+ } label: {
+ Text(entry.notes == nil ? "Add" : "Edit")
+ .font(.subheadline)
+ .fontWeight(.medium)
+ }
+ }
+
+ Button {
+ showNoteEditor = true
+ } label: {
+ HStack {
+ if let notes = entry.notes, !notes.isEmpty {
+ Text(notes)
+ .font(.body)
+ .foregroundColor(textColor)
+ .multilineTextAlignment(.leading)
+ .lineLimit(5)
+ } else {
+ HStack(spacing: 8) {
+ Image(systemName: "note.text")
+ .foregroundStyle(.secondary)
+ Text("Add a note about how you're feeling...")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+
+ private var photoSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Text("Photo")
+ .font(.headline)
+ .foregroundColor(textColor)
+
+ Spacer()
+
+ Button {
+ showPhotoOptions = true
+ } label: {
+ Text(entry.photoID == nil ? "Add" : "Change")
+ .font(.subheadline)
+ .fontWeight(.medium)
+ }
+ }
+ .zIndex(1)
+
+ if let photoID = entry.photoID,
+ let image = PhotoManager.shared.thumbnail(for: photoID) {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(height: 200)
+ .frame(maxWidth: .infinity)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .clipped()
+ .contentShape(Rectangle())
+ .onTapGesture {
+ showFullScreenPhoto = true
+ }
+ } else {
+ Button {
+ showPhotoOptions = true
+ } label: {
+ VStack(spacing: 12) {
+ Image(systemName: "photo.badge.plus")
+ .font(.largeTitle)
+ .foregroundStyle(.secondary)
+
+ Text("Add a photo")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 120)
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ .overlay(
+ RoundedRectangle(cornerRadius: 16)
+ .strokeBorder(style: StrokeStyle(lineWidth: 1, dash: [8]))
+ .foregroundStyle(.tertiary)
+ )
+ )
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .confirmationDialog("Photo", isPresented: $showPhotoOptions, titleVisibility: .visible) {
+ Button("Take Photo") {
+ showCamera = true
+ }
+ Button("Choose from Library") {
+ showPhotoPicker = true
+ }
+ if entry.photoID != nil {
+ Button("Remove Photo", role: .destructive) {
+ PhotoManager.shared.deletePhoto(id: entry.photoID!)
+ _ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: nil)
+ }
+ }
+ Button("Cancel", role: .cancel) { }
+ }
+ }
+
+ private var deleteSection: some View {
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ HStack {
+ Image(systemName: "trash")
+ Text("Delete Entry")
+ }
+ .frame(maxWidth: .infinity)
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+ .padding(.top, 8)
+ }
+}
+
+// MARK: - Full Screen Photo View
+
+struct FullScreenPhotoView: View {
+ @Environment(\.dismiss) private var dismiss
+ let photoID: UUID?
+
+ var body: some View {
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ if let photoID = photoID,
+ let image = PhotoManager.shared.loadPhoto(id: photoID) {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .ignoresSafeArea()
+ }
+ }
+ .overlay(alignment: .topTrailing) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title)
+ .foregroundStyle(.white.opacity(0.8))
+ .padding()
+ }
+ }
+ .onTapGesture {
+ dismiss()
+ }
+ }
+}
diff --git a/Shared/Views/PhotoPickerView.swift b/Shared/Views/PhotoPickerView.swift
new file mode 100644
index 0000000..8ffd3fe
--- /dev/null
+++ b/Shared/Views/PhotoPickerView.swift
@@ -0,0 +1,378 @@
+//
+// PhotoPickerView.swift
+// Feels
+//
+// Photo picker and gallery for mood entry attachments.
+//
+
+import SwiftUI
+import PhotosUI
+
+// MARK: - Photo Picker View
+
+struct PhotoPickerView: View {
+
+ @Environment(\.dismiss) private var dismiss
+
+ let entry: MoodEntryModel
+ let onPhotoSelected: (UIImage) -> Void
+
+ @State private var selectedItem: PhotosPickerItem?
+ @State private var showCamera = false
+ @State private var isProcessing = false
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 24) {
+ // Header
+ VStack(spacing: 8) {
+ Image(systemName: "photo.on.rectangle.angled")
+ .font(.system(size: 50))
+ .foregroundStyle(.secondary)
+
+ Text("Add a Photo")
+ .font(.title2)
+ .fontWeight(.semibold)
+
+ Text("Capture how you're feeling today")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.top, 40)
+
+ Spacer()
+
+ // Options
+ VStack(spacing: 16) {
+ // Photo Library
+ PhotosPicker(selection: $selectedItem, matching: .images) {
+ HStack(spacing: 16) {
+ Image(systemName: "photo.stack")
+ .font(.title2)
+ .frame(width: 44, height: 44)
+ .background(Color.blue.opacity(0.15))
+ .foregroundColor(.blue)
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Photo Library")
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ Text("Choose from your photos")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+ .disabled(isProcessing)
+
+ // Camera
+ Button {
+ showCamera = true
+ } label: {
+ HStack(spacing: 16) {
+ Image(systemName: "camera")
+ .font(.title2)
+ .frame(width: 44, height: 44)
+ .background(Color.green.opacity(0.15))
+ .foregroundColor(.green)
+ .clipShape(Circle())
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Take Photo")
+ .font(.headline)
+ .foregroundColor(.primary)
+
+ Text("Use your camera")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ .background(
+ RoundedRectangle(cornerRadius: 16)
+ .fill(Color(.systemBackground))
+ )
+ }
+ .disabled(isProcessing)
+ }
+ .padding(.horizontal)
+
+ Spacer()
+
+ // Loading indicator
+ if isProcessing {
+ ProgressView("Processing...")
+ .padding()
+ }
+ }
+ .background(Color(.systemGroupedBackground))
+ .navigationTitle("Add Photo")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ }
+ .onChange(of: selectedItem) { _, newItem in
+ Task {
+ await loadImage(from: newItem)
+ }
+ }
+ .fullScreenCover(isPresented: $showCamera) {
+ CameraView { image in
+ handleSelectedImage(image)
+ }
+ }
+ }
+ }
+
+ private func loadImage(from item: PhotosPickerItem?) async {
+ guard let item = item else { return }
+
+ isProcessing = true
+ defer { isProcessing = false }
+
+ do {
+ if let data = try await item.loadTransferable(type: Data.self),
+ let image = UIImage(data: data) {
+ handleSelectedImage(image)
+ }
+ } catch {
+ print("PhotoPickerView: Failed to load image: \(error)")
+ }
+ }
+
+ private func handleSelectedImage(_ image: UIImage) {
+ onPhotoSelected(image)
+ dismiss()
+ }
+}
+
+// MARK: - Camera View
+
+struct CameraView: UIViewControllerRepresentable {
+
+ @Environment(\.dismiss) private var dismiss
+ let onImageCaptured: (UIImage) -> Void
+
+ func makeUIViewController(context: Context) -> UIImagePickerController {
+ let picker = UIImagePickerController()
+ picker.sourceType = .camera
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self)
+ }
+
+ class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
+ let parent: CameraView
+
+ init(_ parent: CameraView) {
+ self.parent = parent
+ }
+
+ func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
+ if let image = info[.originalImage] as? UIImage {
+ parent.onImageCaptured(image)
+ }
+ parent.dismiss()
+ }
+
+ func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
+ parent.dismiss()
+ }
+ }
+}
+
+// MARK: - Photo Gallery View (Full screen photo viewer)
+
+struct PhotoGalleryView: View {
+
+ @Environment(\.dismiss) private var dismiss
+
+ let photoID: UUID
+ let onDelete: () -> Void
+
+ @State private var showDeleteConfirmation = false
+ @State private var scale: CGFloat = 1.0
+ @State private var lastScale: CGFloat = 1.0
+ @State private var offset: CGSize = .zero
+ @State private var lastOffset: CGSize = .zero
+
+ var body: some View {
+ NavigationStack {
+ GeometryReader { geometry in
+ ZStack {
+ Color.black.ignoresSafeArea()
+
+ if let image = PhotoManager.shared.image(for: photoID) {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .scaleEffect(scale)
+ .offset(offset)
+ .gesture(
+ MagnificationGesture()
+ .onChanged { value in
+ scale = lastScale * value
+ }
+ .onEnded { _ in
+ lastScale = scale
+ if scale < 1 {
+ withAnimation {
+ scale = 1
+ lastScale = 1
+ offset = .zero
+ lastOffset = .zero
+ }
+ }
+ }
+ )
+ .simultaneousGesture(
+ DragGesture()
+ .onChanged { value in
+ if scale > 1 {
+ offset = CGSize(
+ width: lastOffset.width + value.translation.width,
+ height: lastOffset.height + value.translation.height
+ )
+ }
+ }
+ .onEnded { _ in
+ lastOffset = offset
+ }
+ )
+ .onTapGesture(count: 2) {
+ withAnimation {
+ if scale > 1 {
+ scale = 1
+ lastScale = 1
+ offset = .zero
+ lastOffset = .zero
+ } else {
+ scale = 2
+ lastScale = 2
+ }
+ }
+ }
+ } else {
+ VStack(spacing: 16) {
+ Image(systemName: "photo.badge.exclamationmark")
+ .font(.system(size: 50))
+ .foregroundColor(.gray)
+
+ Text("Photo not found")
+ .foregroundColor(.gray)
+ }
+ }
+ }
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbarBackground(.hidden, for: .navigationBar)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button {
+ dismiss()
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ }
+
+ ToolbarItem(placement: .primaryAction) {
+ Menu {
+ Button {
+ sharePhoto()
+ } label: {
+ Label("Share", systemImage: "square.and.arrow.up")
+ }
+
+ Button(role: .destructive) {
+ showDeleteConfirmation = true
+ } label: {
+ Label("Delete", systemImage: "trash")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle.fill")
+ .font(.title2)
+ .foregroundStyle(.white.opacity(0.7))
+ }
+ }
+ }
+ .confirmationDialog("Delete Photo", isPresented: $showDeleteConfirmation, titleVisibility: .visible) {
+ Button("Delete", role: .destructive) {
+ onDelete()
+ dismiss()
+ }
+ Button("Cancel", role: .cancel) { }
+ } message: {
+ Text("Are you sure you want to delete this photo?")
+ }
+ }
+ }
+
+ private func sharePhoto() {
+ guard let uiImage = PhotoManager.shared.loadPhoto(id: photoID) else { return }
+
+ let activityVC = UIActivityViewController(
+ activityItems: [uiImage],
+ applicationActivities: nil
+ )
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootVC = windowScene.windows.first?.rootViewController {
+ rootVC.present(activityVC, animated: true)
+ }
+ }
+}
+
+// MARK: - Photo Thumbnail View (for list display)
+
+struct PhotoThumbnailView: View {
+
+ let photoID: UUID?
+ let size: CGFloat
+
+ var body: some View {
+ if let photoID = photoID,
+ let image = PhotoManager.shared.thumbnail(for: photoID) {
+ image
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: size, height: size)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ } else {
+ RoundedRectangle(cornerRadius: 8)
+ .fill(Color(.systemGray5))
+ .frame(width: size, height: size)
+ .overlay(
+ Image(systemName: "photo")
+ .foregroundStyle(.tertiary)
+ )
+ }
+ }
+}
diff --git a/Shared/Views/SettingsView/SettingsTabView.swift b/Shared/Views/SettingsView/SettingsTabView.swift
new file mode 100644
index 0000000..6b7fba2
--- /dev/null
+++ b/Shared/Views/SettingsView/SettingsTabView.swift
@@ -0,0 +1,304 @@
+//
+// SettingsTabView.swift
+// Feels (iOS)
+//
+// Created by Trey Tartt on 12/13/25.
+//
+
+import SwiftUI
+import StoreKit
+
+enum SettingsTab: String, CaseIterable {
+ case customize = "Customize"
+ case settings = "Settings"
+}
+
+struct SettingsTabView: View {
+ @State private var selectedTab: SettingsTab = .customize
+ @State private var showWhyUpgrade = false
+ @State private var showSubscriptionStore = false
+
+ @EnvironmentObject var authManager: BiometricAuthManager
+ @EnvironmentObject var iapManager: IAPManager
+
+ @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
+ @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ Text("Settings")
+ .font(.system(size: 28, weight: .bold, design: .rounded))
+ .foregroundColor(textColor)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.top, 8)
+
+ // Upgrade Banner (only show if not subscribed)
+ if !iapManager.isSubscribed {
+ UpgradeBannerView(
+ showWhyUpgrade: $showWhyUpgrade,
+ showSubscriptionStore: $showSubscriptionStore,
+ trialExpirationDate: iapManager.trialExpirationDate
+ )
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ }
+
+ // Segmented control
+ Picker("", selection: $selectedTab) {
+ ForEach(SettingsTab.allCases, id: \.self) { tab in
+ Text(tab.rawValue).tag(tab)
+ }
+ }
+ .pickerStyle(.segmented)
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ .padding(.bottom, 16)
+
+ // Content based on selected tab
+ if selectedTab == .customize {
+ CustomizeContentView()
+ } else {
+ SettingsContentView()
+ .environmentObject(authManager)
+ }
+ }
+ .background(
+ theme.currentTheme.bg
+ .edgesIgnoringSafeArea(.all)
+ )
+ .sheet(isPresented: $showWhyUpgrade) {
+ WhyUpgradeView()
+ }
+ .sheet(isPresented: $showSubscriptionStore) {
+ FeelsSubscriptionStoreView()
+ .environmentObject(iapManager)
+ }
+ }
+}
+
+// MARK: - Upgrade Banner View
+struct UpgradeBannerView: View {
+ @Binding var showWhyUpgrade: Bool
+ @Binding var showSubscriptionStore: Bool
+ let trialExpirationDate: Date?
+
+ @Environment(\.colorScheme) private var colorScheme
+ @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
+
+ var body: some View {
+ VStack(spacing: 12) {
+ // Countdown timer
+ HStack(spacing: 6) {
+ Image(systemName: "clock")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(.orange)
+
+ if let expirationDate = trialExpirationDate {
+ Text("Trial expires in ")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(textColor.opacity(0.8))
+ +
+ Text(expirationDate, style: .relative)
+ .font(.system(size: 14, weight: .bold))
+ .foregroundColor(.orange)
+ } else {
+ Text("Trial expired")
+ .font(.system(size: 14, weight: .medium))
+ .foregroundColor(.orange)
+ }
+ }
+
+ // Buttons in HStack
+ HStack(spacing: 12) {
+ // Why Upgrade button
+ Button {
+ showWhyUpgrade = true
+ } label: {
+ Text("Why Upgrade?")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.accentColor)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .stroke(Color.accentColor, lineWidth: 1.5)
+ )
+ }
+
+ // Subscribe button
+ Button {
+ showSubscriptionStore = true
+ } label: {
+ Text("Subscribe")
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundColor(.white)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 10)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.pink)
+ )
+ }
+ }
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 14)
+ .fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemGray6).opacity(0.5))
+ )
+ }
+}
+
+// MARK: - Why Upgrade View
+struct WhyUpgradeView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ NavigationView {
+ ScrollView {
+ VStack(spacing: 24) {
+ // Header
+ VStack(spacing: 12) {
+ Image(systemName: "star.fill")
+ .font(.system(size: 50))
+ .foregroundStyle(
+ LinearGradient(
+ colors: [.orange, .pink],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ )
+ )
+
+ Text("Unlock Premium")
+ .font(.system(size: 28, weight: .bold))
+
+ Text("Get the most out of your mood tracking journey")
+ .font(.body)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.top, 20)
+
+ // Benefits list
+ VStack(spacing: 16) {
+ PremiumBenefitRow(
+ icon: "calendar",
+ iconColor: .blue,
+ title: "Month View",
+ description: "See your mood patterns across entire months at a glance"
+ )
+
+ PremiumBenefitRow(
+ icon: "chart.bar.fill",
+ iconColor: .green,
+ title: "Year View",
+ description: "Track long-term trends and see how your mood evolves over time"
+ )
+
+ PremiumBenefitRow(
+ icon: "lightbulb.fill",
+ iconColor: .yellow,
+ title: "AI Insights",
+ description: "Get personalized insights and patterns discovered by AI"
+ )
+
+ PremiumBenefitRow(
+ icon: "note.text",
+ iconColor: .purple,
+ title: "Journal Notes",
+ description: "Add notes and context to your mood entries"
+ )
+
+ PremiumBenefitRow(
+ icon: "photo.fill",
+ iconColor: .pink,
+ title: "Photo Attachments",
+ description: "Capture moments with photos attached to entries"
+ )
+
+ PremiumBenefitRow(
+ icon: "heart.fill",
+ iconColor: .red,
+ title: "Health Integration",
+ description: "Correlate mood with steps, sleep, and exercise data"
+ )
+
+ PremiumBenefitRow(
+ icon: "square.and.arrow.up",
+ iconColor: .orange,
+ title: "Export Data",
+ description: "Export your data as CSV or beautiful PDF reports"
+ )
+
+ PremiumBenefitRow(
+ icon: "faceid",
+ iconColor: .gray,
+ title: "Privacy Lock",
+ description: "Protect your data with Face ID or Touch ID"
+ )
+ }
+ .padding(.horizontal, 20)
+ }
+ .padding(.bottom, 40)
+ }
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .navigationBarTrailing) {
+ Button("Done") {
+ dismiss()
+ }
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Premium Benefit Row
+struct PremiumBenefitRow: View {
+ let icon: String
+ let iconColor: Color
+ let title: String
+ let description: String
+
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 14) {
+ Image(systemName: icon)
+ .font(.system(size: 22))
+ .foregroundColor(iconColor)
+ .frame(width: 44, height: 44)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(iconColor.opacity(0.15))
+ )
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 16, weight: .semibold))
+
+ Text(description)
+ .font(.system(size: 14))
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+ }
+ .padding(14)
+ .background(
+ RoundedRectangle(cornerRadius: 12)
+ .fill(colorScheme == .dark ? Color(.systemGray6) : .white)
+ )
+ }
+}
+
+struct SettingsTabView_Previews: PreviewProvider {
+ static var previews: some View {
+ SettingsTabView()
+ .environmentObject(BiometricAuthManager())
+ .environmentObject(IAPManager())
+ }
+}
diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift
index 28d9f42..17fbaf2 100644
--- a/Shared/Views/SettingsView/SettingsView.swift
+++ b/Shared/Views/SettingsView/SettingsView.swift
@@ -10,26 +10,332 @@ import CloudKitSyncMonitor
import UniformTypeIdentifiers
import StoreKit
+// MARK: - Settings Content View (for use in SettingsTabView)
+struct SettingsContentView: View {
+ @EnvironmentObject var authManager: BiometricAuthManager
+
+ @State private var showOnboarding = false
+ @State private var showExportView = false
+ @StateObject private var healthService = HealthService.shared
+
+ @AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
+ @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
+ @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
+
+ var body: some View {
+ ScrollView {
+ VStack {
+ // Features section
+ featuresSectionHeader
+ privacyLockToggle
+ healthKitToggle
+ exportDataButton
+
+ // Settings section
+ settingsSectionHeader
+ canDelete
+ showOnboardingButton
+
+ // Legal section
+ legalSectionHeader
+ eulaButton
+ privacyButton
+
+ Spacer()
+ .frame(height: 20)
+
+ Text("\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))")
+ .font(.body)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ }
+ .sheet(isPresented: $showOnboarding) {
+ OnboardingMain(onboardingData: UserDefaultsStore.getOnboarding(),
+ updateBoardingDataClosure: { onboardingData in
+ OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
+ showOnboarding = false
+ })
+ }
+ .sheet(isPresented: $showExportView) {
+ ExportView(entries: DataController.shared.getData(
+ startDate: Date(timeIntervalSince1970: 0),
+ endDate: Date(),
+ includedDays: []
+ ))
+ }
+ .onAppear(perform: {
+ EventLogger.log(event: "show_settings_view")
+ })
+ }
+
+ // MARK: - Section Headers
+
+ private var featuresSectionHeader: some View {
+ HStack {
+ Text("Features")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ private var settingsSectionHeader: some View {
+ HStack {
+ Text("Settings")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ private var legalSectionHeader: some View {
+ HStack {
+ Text("Legal")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ // MARK: - Privacy Lock Toggle
+
+ @ViewBuilder
+ private var privacyLockToggle: some View {
+ if authManager.canUseBiometrics {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ HStack(spacing: 12) {
+ Image(systemName: authManager.biometricIcon)
+ .font(.title2)
+ .foregroundColor(.accentColor)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Privacy Lock")
+ .foregroundColor(textColor)
+
+ Text("Require \(authManager.biometricName) to open app")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Toggle("", isOn: Binding(
+ get: { authManager.isLockEnabled },
+ set: { newValue in
+ Task {
+ if newValue {
+ let success = await authManager.enableLock()
+ if !success {
+ EventLogger.log(event: "privacy_lock_enable_failed")
+ }
+ } else {
+ authManager.disableLock()
+ }
+ }
+ }
+ ))
+ .labelsHidden()
+ }
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+ }
+
+ // MARK: - Health Kit Toggle
+
+ private var healthKitToggle: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ HStack(spacing: 12) {
+ Image(systemName: "heart.fill")
+ .font(.title2)
+ .foregroundColor(.red)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Apple Health")
+ .foregroundColor(textColor)
+
+ Text("Correlate mood with health data")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if healthService.isAvailable {
+ Toggle("", isOn: Binding(
+ get: { healthService.isEnabled },
+ set: { newValue in
+ if newValue {
+ Task {
+ let success = await healthService.requestAuthorization()
+ if !success {
+ EventLogger.log(event: "healthkit_enable_failed")
+ }
+ }
+ } else {
+ healthService.isEnabled = false
+ EventLogger.log(event: "healthkit_disabled")
+ }
+ }
+ ))
+ .labelsHidden()
+ } else {
+ Text("Not Available")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ // MARK: - Export Data Button
+
+ private var exportDataButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "tap_export_data")
+ showExportView = true
+ }, label: {
+ HStack(spacing: 12) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.title2)
+ .foregroundColor(.accentColor)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Export Data")
+ .foregroundColor(textColor)
+
+ Text("CSV or PDF report")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ })
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ private var showOnboardingButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "tap_show_onboarding")
+ showOnboarding.toggle()
+ }, label: {
+ Text(String(localized: "settings_view_show_onboarding"))
+ .foregroundColor(textColor)
+ })
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ private var canDelete: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ VStack {
+ Toggle(String(localized: "settings_use_delete_enable"),
+ isOn: $deleteEnabled)
+ .onChange(of: deleteEnabled) { _, newValue in
+ EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
+ }
+ .foregroundColor(textColor)
+ .padding()
+ }
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ private var eulaButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "show_eula")
+ if let url = URL(string: "https://ifeels.app/eula.html") {
+ UIApplication.shared.open(url)
+ }
+ }, label: {
+ Text(String(localized: "settings_view_show_eula"))
+ .foregroundColor(textColor)
+ })
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ private var privacyButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "show_privacy")
+ if let url = URL(string: "https://ifeels.app/privacy.html") {
+ UIApplication.shared.open(url)
+ }
+ }, label: {
+ Text(String(localized: "settings_view_show_privacy"))
+ .foregroundColor(textColor)
+ })
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+}
+
+// MARK: - Legacy SettingsView (sheet presentation with close button)
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) var openURL
@EnvironmentObject var iapManager: IAPManager
-
+ @EnvironmentObject var authManager: BiometricAuthManager
+
@State private var showingExporter = false
@State private var showingImporter = false
@State private var importContent = ""
-
+
@State private var showOnboarding = false
-
+ @State private var showExportView = false
+
@State private var showSpecialThanks = false
@State private var showWhyBGMode = false
@ObservedObject var syncMonitor = SyncMonitor.shared
-
+ @StateObject private var healthService = HealthService.shared
+
@AppStorage(UserDefaultsStore.Keys.useCloudKit.rawValue, store: GroupUserDefaults.groupDefaults) private var useCloudKit = false
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
+ @AppStorage(UserDefaultsStore.Keys.privacyLockEnabled.rawValue, store: GroupUserDefaults.groupDefaults) private var privacyLockEnabled = false
var body: some View {
ScrollView {
@@ -37,11 +343,23 @@ struct SettingsView: View {
Group {
closeButtonView
.padding()
-
+
// cloudKitEnable
subscriptionInfoView
+
+ // Features section
+ featuresSectionHeader
+ privacyLockToggle
+ healthKitToggle
+ exportDataButton
+
+ // Settings section
+ settingsSectionHeader
canDelete
showOnboardingButton
+
+ // Legal section
+ legalSectionHeader
eulaButton
privacyButton
// specialThanksCell
@@ -78,6 +396,13 @@ struct SettingsView: View {
showOnboarding = false
})
}
+ .sheet(isPresented: $showExportView) {
+ ExportView(entries: DataController.shared.getData(
+ startDate: Date(timeIntervalSince1970: 0),
+ endDate: Date(),
+ includedDays: []
+ ))
+ }
.onAppear(perform: {
EventLogger.log(event: "show_settings_view")
})
@@ -146,6 +471,178 @@ struct SettingsView: View {
private var subscriptionInfoView: some View {
PurchaseButtonView(iapManager: iapManager)
}
+
+ // MARK: - Section Headers
+
+ private var featuresSectionHeader: some View {
+ HStack {
+ Text("Features")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ private var settingsSectionHeader: some View {
+ HStack {
+ Text("Settings")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ private var legalSectionHeader: some View {
+ HStack {
+ Text("Legal")
+ .font(.headline)
+ .foregroundColor(textColor)
+ Spacer()
+ }
+ .padding(.top, 20)
+ .padding(.horizontal, 4)
+ }
+
+ // MARK: - Privacy Lock Toggle
+
+ @ViewBuilder
+ private var privacyLockToggle: some View {
+ if authManager.canUseBiometrics {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ HStack(spacing: 12) {
+ Image(systemName: authManager.biometricIcon)
+ .font(.title2)
+ .foregroundColor(.accentColor)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Privacy Lock")
+ .foregroundColor(textColor)
+
+ Text("Require \(authManager.biometricName) to open app")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Toggle("", isOn: Binding(
+ get: { authManager.isLockEnabled },
+ set: { newValue in
+ Task {
+ if newValue {
+ let success = await authManager.enableLock()
+ if !success {
+ EventLogger.log(event: "privacy_lock_enable_failed")
+ }
+ } else {
+ authManager.disableLock()
+ }
+ }
+ }
+ ))
+ .labelsHidden()
+ }
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+ }
+
+ // MARK: - Health Kit Toggle
+
+ private var healthKitToggle: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ HStack(spacing: 12) {
+ Image(systemName: "heart.fill")
+ .font(.title2)
+ .foregroundColor(.red)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Apple Health")
+ .foregroundColor(textColor)
+
+ Text("Correlate mood with health data")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ if healthService.isAvailable {
+ Toggle("", isOn: Binding(
+ get: { healthService.isEnabled },
+ set: { newValue in
+ if newValue {
+ Task {
+ let success = await healthService.requestAuthorization()
+ if !success {
+ EventLogger.log(event: "healthkit_enable_failed")
+ }
+ }
+ } else {
+ healthService.isEnabled = false
+ EventLogger.log(event: "healthkit_disabled")
+ }
+ }
+ ))
+ .labelsHidden()
+ } else {
+ Text("Not Available")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding()
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
+
+ // MARK: - Export Data Button
+
+ private var exportDataButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "tap_export_data")
+ showExportView = true
+ }, label: {
+ HStack(spacing: 12) {
+ Image(systemName: "square.and.arrow.up")
+ .font(.title2)
+ .foregroundColor(.accentColor)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Export Data")
+ .foregroundColor(textColor)
+
+ Text("CSV or PDF report")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .padding()
+ })
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ }
private var closeButtonView: some View {
HStack{