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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ class UserDefaultsStore {
|
||||
case lastVotedDate
|
||||
case votingLayoutStyle
|
||||
case dayViewStyle
|
||||
case privacyLockEnabled
|
||||
case healthKitEnabled
|
||||
|
||||
case contentViewCurrentSelectedHeaderViewBackDays
|
||||
case contentViewHeaderTag
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
173
Shared/Services/BiometricAuthManager.swift
Normal file
173
Shared/Services/BiometricAuthManager.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
786
Shared/Services/ExportService.swift
Normal file
786
Shared/Services/ExportService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
378
Shared/Services/HealthService.swift
Normal file
378
Shared/Services/HealthService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
246
Shared/Services/PhotoManager.swift
Normal file
246
Shared/Services/PhotoManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
335
Shared/Views/ExportView.swift
Normal file
335
Shared/Views/ExportView.swift
Normal 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) {}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
105
Shared/Views/LockScreenView.swift
Normal file
105
Shared/Views/LockScreenView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
513
Shared/Views/NoteEditorView.swift
Normal file
513
Shared/Views/NoteEditorView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
378
Shared/Views/PhotoPickerView.swift
Normal file
378
Shared/Views/PhotoPickerView.swift
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
304
Shared/Views/SettingsView/SettingsTabView.swift
Normal file
304
Shared/Views/SettingsView/SettingsTabView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user