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{