Add premium features and reorganize Settings tab

Premium Features:
- Journal notes and photo attachments for mood entries
- Data export (CSV and PDF reports)
- Privacy lock with Face ID/Touch ID
- Apple Health integration for mood correlation
- 4 new personality packs (Motivational Coach, Zen Master, Best Friend, Data Analyst)

Settings Tab Reorganization:
- Combined Customize and Settings into single tab with segmented control
- Added upgrade banner with trial countdown above segment
- "Why Upgrade?" sheet showing all premium benefits
- Subscribe button opens improved StoreKit 2 subscription view

UI Improvements:
- Enhanced subscription store with feature highlights
- Entry detail view for viewing/editing notes and photos
- Removed duplicate subscription banners from tab content

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-13 12:22:06 -06:00
parent 6c92cf4ec3
commit 920aaee35c
26 changed files with 4295 additions and 99 deletions

View File

@@ -14,5 +14,9 @@
<array>
<string>group.com.tt.ifeelDebug</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View File

@@ -16,5 +16,9 @@
<array>
<string>group.com.tt.ifeelDebug</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View File

@@ -23,5 +23,15 @@
<string>processing</string>
<string>remote-notification</string>
</array>
<key>NSHealthShareUsageDescription</key>
<string>Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Feels does not write any health data.</string>
<key>NSCameraUsageDescription</key>
<string>Feels uses the camera to take photos for your mood journal entries.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Feels accesses your photo library to attach photos to your mood journal entries.</string>
<key>NSFaceIDUsageDescription</key>
<string>Feels uses Face ID to protect your private mood data.</string>
</dict>
</plist>

View File

@@ -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()
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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."
]
}
}

View File

@@ -68,6 +68,8 @@ class UserDefaultsStore {
case lastVotedDate
case votingLayoutStyle
case dayViewStyle
case privacyLockEnabled
case healthKitEnabled
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag

View File

@@ -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
}
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -0,0 +1,335 @@
//
// ExportView.swift
// Feels
//
// Export mood data to CSV or PDF formats.
//
import SwiftUI
struct ExportView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor
@State private var selectedFormat: ExportFormat = .csv
@State private var selectedRange: DateRange = .allTime
@State private var isExporting = false
@State private var exportedURL: URL?
@State private var showShareSheet = false
@State private var showError = false
@State private var errorMessage = ""
private let entries: [MoodEntryModel]
enum ExportFormat: String, CaseIterable {
case csv = "CSV"
case pdf = "PDF"
var icon: String {
switch self {
case .csv: return "tablecells"
case .pdf: return "doc.richtext"
}
}
var description: String {
switch self {
case .csv: return "Spreadsheet format for data analysis"
case .pdf: return "Formatted report with insights"
}
}
}
enum DateRange: String, CaseIterable {
case lastWeek = "Last 7 Days"
case lastMonth = "Last 30 Days"
case last3Months = "Last 3 Months"
case lastYear = "Last Year"
case allTime = "All Time"
var days: Int? {
switch self {
case .lastWeek: return 7
case .lastMonth: return 30
case .last3Months: return 90
case .lastYear: return 365
case .allTime: return nil
}
}
}
init(entries: [MoodEntryModel]) {
self.entries = entries
}
private var filteredEntries: [MoodEntryModel] {
guard let days = selectedRange.days else {
return entries
}
let cutoffDate = Calendar.current.date(byAdding: .day, value: -days, to: Date()) ?? Date()
return entries.filter { $0.forDate >= cutoffDate }
}
private var validEntries: [MoodEntryModel] {
filteredEntries.filter { ![.missing, .placeholder].contains($0.mood) }
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
// Preview stats
statsCard
// Format selection
formatSelection
// Date range selection
dateRangeSelection
// Export button
exportButton
}
.padding()
}
.background(Color(.systemGroupedBackground))
.navigationTitle("Export Data")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
.sheet(isPresented: $showShareSheet) {
if let url = exportedURL {
ExportShareSheet(items: [url])
}
}
.alert("Export Failed", isPresented: $showError) {
Button("OK", role: .cancel) { }
} message: {
Text(errorMessage)
}
}
}
private var statsCard: some View {
VStack(spacing: 16) {
HStack {
Image(systemName: "chart.bar.doc.horizontal")
.font(.title2)
.foregroundColor(.accentColor)
Text("Export Preview")
.font(.headline)
.foregroundColor(textColor)
Spacer()
}
Divider()
HStack(spacing: 32) {
VStack(spacing: 4) {
Text("\(validEntries.count)")
.font(.title)
.fontWeight(.bold)
.foregroundColor(textColor)
Text("Entries")
.font(.caption)
.foregroundStyle(.secondary)
}
VStack(spacing: 4) {
Text(dateRangeText)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(textColor)
Text("Date Range")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.systemBackground))
)
}
private var dateRangeText: String {
guard !validEntries.isEmpty else { return "No data" }
let sorted = validEntries.sorted { $0.forDate < $1.forDate }
guard let first = sorted.first, let last = sorted.last else { return "No data" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
if Calendar.current.isDate(first.forDate, equalTo: last.forDate, toGranularity: .day) {
return formatter.string(from: first.forDate)
}
return "\(formatter.string(from: first.forDate)) - \(formatter.string(from: last.forDate))"
}
private var formatSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Format")
.font(.headline)
.foregroundColor(textColor)
ForEach(ExportFormat.allCases, id: \.self) { format in
Button {
selectedFormat = format
} label: {
HStack(spacing: 16) {
Image(systemName: format.icon)
.font(.title2)
.frame(width: 44, height: 44)
.background(selectedFormat == format ? Color.accentColor.opacity(0.15) : Color(.systemGray5))
.foregroundColor(selectedFormat == format ? .accentColor : .gray)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 2) {
Text(format.rawValue)
.font(.headline)
.foregroundColor(textColor)
Text(format.description)
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if selectedFormat == format {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 14)
.fill(Color(.systemBackground))
.overlay(
RoundedRectangle(cornerRadius: 14)
.stroke(selectedFormat == format ? Color.accentColor : Color.clear, lineWidth: 2)
)
)
}
.buttonStyle(.plain)
}
}
}
private var dateRangeSelection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("Date Range")
.font(.headline)
.foregroundColor(textColor)
VStack(spacing: 0) {
ForEach(DateRange.allCases, id: \.self) { range in
Button {
selectedRange = range
} label: {
HStack {
Text(range.rawValue)
.foregroundColor(textColor)
Spacer()
if selectedRange == range {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
}
}
.padding()
.background(Color(.systemBackground))
}
.buttonStyle(.plain)
if range != DateRange.allCases.last {
Divider()
.padding(.leading)
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 14))
}
}
private var exportButton: some View {
Button {
performExport()
} label: {
HStack(spacing: 8) {
if isExporting {
ProgressView()
.tint(.white)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(isExporting ? "Exporting..." : "Export \(selectedFormat.rawValue)")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(validEntries.isEmpty ? Color.gray : Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(isExporting || validEntries.isEmpty)
.padding(.top, 8)
}
private func performExport() {
isExporting = true
Task {
let url: URL?
switch selectedFormat {
case .csv:
url = ExportService.shared.exportCSV(entries: validEntries)
case .pdf:
url = ExportService.shared.exportPDF(entries: validEntries, title: "Feels Mood Report")
}
await MainActor.run {
isExporting = false
if let url = url {
exportedURL = url
showShareSheet = true
} else {
errorMessage = "Failed to create export file. Please try again."
showError = true
}
}
}
}
}
// MARK: - Export Share Sheet
struct ExportShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}

View File

@@ -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())

View File

@@ -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)

View File

@@ -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()
}
}
}
}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
)
}
}
}

View File

@@ -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())
}
}

View File

@@ -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{