diff --git a/Feels (iOS).entitlements b/Feels (iOS).entitlements
index ffaafb6..57a142a 100644
--- a/Feels (iOS).entitlements
+++ b/Feels (iOS).entitlements
@@ -17,6 +17,8 @@
com.apple.developer.healthkit
com.apple.developer.healthkit.access
-
+
+ health-records
+
diff --git a/Feels--iOS--Info.plist b/Feels--iOS--Info.plist
index 49629b7..b39a2ce 100644
--- a/Feels--iOS--Info.plist
+++ b/Feels--iOS--Info.plist
@@ -26,7 +26,9 @@
NSHealthShareUsageDescription
Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.
NSHealthUpdateUsageDescription
- Feels does not write any health data.
+ Feels syncs your mood data to Apple Health so you can see how your emotions correlate with sleep, exercise, and other health metrics.
+ NSSupportsLiveActivities
+
NSCameraUsageDescription
Feels uses the camera to take photos for your mood journal entries.
NSPhotoLibraryUsageDescription
diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj
index 9b2502a..7d2cded 100644
--- a/Feels.xcodeproj/project.pbxproj
+++ b/Feels.xcodeproj/project.pbxproj
@@ -103,6 +103,7 @@
Models/Shapes.swift,
Models/Theme.swift,
Models/UserDefaultsStore.swift,
+ MoodStreakActivity.swift,
Onboarding/OnboardingData.swift,
Onboarding/views/OnboardingDay.swift,
Persisence/DataController.swift,
diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift
index 40f15d8..a15334b 100644
--- a/FeelsWidget2/FeelsVoteWidget.swift
+++ b/FeelsWidget2/FeelsVoteWidget.swift
@@ -32,18 +32,46 @@ struct VoteMoodIntent: AppIntent {
let mood = Mood(rawValue: moodValue) ?? .average
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
- // Add mood entry
+ // Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
+ // Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
DataController.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
// Store last voted date
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
+ // Update Live Activity
+ let streak = calculateCurrentStreak()
+ LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
+ LiveActivityScheduler.shared.scheduleForNextDay()
+
// Reload widget timeline
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
return .result()
}
+
+ @MainActor
+ private func calculateCurrentStreak() -> Int {
+ var streak = 0
+ var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+
+ while true {
+ let dayStart = Calendar.current.startOfDay(for: checkDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+
+ if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
+ streak += 1
+ checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
}
// MARK: - Vote Widget Provider
@@ -75,13 +103,41 @@ struct VoteWidgetProvider: TimelineProvider {
Task { @MainActor in
let entry = createEntry()
- // Refresh at midnight
- let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
- let timeline = Timeline(entries: [entry], policy: .after(midnight))
+ // Calculate next refresh time
+ let nextRefresh = calculateNextRefreshDate()
+ let timeline = Timeline(entries: [entry], policy: .after(nextRefresh))
completion(timeline)
}
}
+ /// Calculate when the widget should next refresh
+ /// Refreshes at: rating time (to show voting view) and midnight (for new day)
+ private func calculateNextRefreshDate() -> Date {
+ let now = Date()
+ let calendar = Calendar.current
+
+ // Get the rating time from onboarding data
+ let onboardingData = UserDefaultsStore.getOnboarding()
+ let ratingTimeComponents = calendar.dateComponents([.hour, .minute], from: onboardingData.date)
+
+ // Create today's rating time
+ var todayRatingComponents = calendar.dateComponents([.year, .month, .day], from: now)
+ todayRatingComponents.hour = ratingTimeComponents.hour
+ todayRatingComponents.minute = ratingTimeComponents.minute
+ let todayRatingTime = calendar.date(from: todayRatingComponents) ?? now
+
+ // Tomorrow's midnight
+ let midnight = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!)
+
+ // If we haven't passed today's rating time, refresh at rating time
+ if now < todayRatingTime {
+ return todayRatingTime
+ }
+
+ // Otherwise refresh at midnight
+ return midnight
+ }
+
@MainActor
private func createEntry() -> VoteWidgetEntry {
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
diff --git a/FeelsWidget2/FeelsWidget.swift b/FeelsWidget2/FeelsWidget.swift
index af491d7..251bbf1 100644
--- a/FeelsWidget2/FeelsWidget.swift
+++ b/FeelsWidget2/FeelsWidget.swift
@@ -9,6 +9,127 @@ import WidgetKit
import SwiftUI
import Intents
import SwiftData
+import ActivityKit
+import AppIntents
+
+// MARK: - Live Activity Widget
+// Note: MoodStreakAttributes is defined in MoodStreakActivity.swift (Shared folder)
+
+struct MoodStreakLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: MoodStreakAttributes.self) { context in
+ // Lock Screen / StandBy view
+ MoodStreakLockScreenView(context: context)
+ } dynamicIsland: { context in
+ DynamicIsland {
+ // Expanded view
+ DynamicIslandExpandedRegion(.leading) {
+ HStack(spacing: 8) {
+ Image(systemName: "flame.fill")
+ .foregroundColor(.orange)
+ Text("\(context.state.currentStreak)")
+ .font(.title2.bold())
+ }
+ }
+
+ DynamicIslandExpandedRegion(.trailing) {
+ if context.state.hasLoggedToday {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ .font(.title2)
+ } else {
+ Text("Log now")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ DynamicIslandExpandedRegion(.center) {
+ Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : "Don't break your streak!")
+ .font(.headline)
+ }
+
+ DynamicIslandExpandedRegion(.bottom) {
+ if !context.state.hasLoggedToday {
+ Text("Voting closes at midnight")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ } else {
+ HStack {
+ Circle()
+ .fill(Color(hex: context.state.lastMoodColor))
+ .frame(width: 20, height: 20)
+ Text("Today: \(context.state.lastMoodLogged)")
+ .font(.subheadline)
+ }
+ }
+ }
+ } compactLeading: {
+ Image(systemName: "flame.fill")
+ .foregroundColor(.orange)
+ } compactTrailing: {
+ Text("\(context.state.currentStreak)")
+ .font(.caption.bold())
+ } minimal: {
+ Image(systemName: "flame.fill")
+ .foregroundColor(.orange)
+ }
+ }
+ }
+}
+
+struct MoodStreakLockScreenView: View {
+ let context: ActivityViewContext
+
+ var body: some View {
+ HStack(spacing: 16) {
+ // Streak indicator
+ VStack(spacing: 4) {
+ Image(systemName: "flame.fill")
+ .font(.title)
+ .foregroundColor(.orange)
+ Text("\(context.state.currentStreak)")
+ .font(.title.bold())
+ Text("day streak")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ Divider()
+ .frame(height: 50)
+
+ // Status
+ VStack(alignment: .leading, spacing: 8) {
+ if context.state.hasLoggedToday {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(Color(hex: context.state.lastMoodColor))
+ .frame(width: 24, height: 24)
+ VStack(alignment: .leading) {
+ Text("Today's mood")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Text(context.state.lastMoodLogged)
+ .font(.headline)
+ }
+ }
+ } else {
+ VStack(alignment: .leading) {
+ Text("Don't break your streak!")
+ .font(.headline)
+ Text("Tap to log your mood")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+
+ Spacer()
+ }
+ .padding()
+ .activityBackgroundTint(Color(.systemBackground).opacity(0.8))
+ }
+}
class WatchTimelineView: Identifiable {
let id = UUID()
@@ -355,12 +476,13 @@ struct LargeWidgetView: View {
struct FeelsGraphicWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
-
+
var entry: Provider.Entry
-
+
@ViewBuilder
var body: some View {
SmallGraphicWidgetView(entry: entry)
+ .containerBackground(.fill.tertiary, for: .widget)
}
}
@@ -395,12 +517,13 @@ struct SmallGraphicWidgetView: View {
struct FeelsIconWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
-
+
var entry: Provider.Entry
-
+
@ViewBuilder
var body: some View {
SmallIconView(entry: entry)
+ .containerBackground(.fill.tertiary, for: .widget)
}
}
@@ -526,6 +649,31 @@ struct FeelsBundle: WidgetBundle {
FeelsGraphicWidget()
FeelsIconWidget()
FeelsVoteWidget()
+ FeelsMoodControlWidget()
+ MoodStreakLiveActivity()
+ }
+}
+
+// MARK: - Control Center Widget
+struct FeelsMoodControlWidget: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ StaticControlConfiguration(kind: "FeelsMoodControl") {
+ ControlWidgetButton(action: OpenFeelsIntent()) {
+ Label("Log Mood", systemImage: "face.smiling")
+ }
+ }
+ .displayName("Log Mood")
+ .description("Open Feels to log your mood")
+ }
+}
+
+struct OpenFeelsIntent: AppIntent {
+ static var title: LocalizedStringResource = "Open Feels"
+ static var description = IntentDescription("Open the Feels app to log your mood")
+ static var openAppWhenRun: Bool = true
+
+ func perform() async throws -> some IntentResult {
+ return .result()
}
}
diff --git a/FeelsWidgetExtension-Info.plist b/FeelsWidgetExtension-Info.plist
index 0f118fb..464a4f8 100644
--- a/FeelsWidgetExtension-Info.plist
+++ b/FeelsWidgetExtension-Info.plist
@@ -7,5 +7,7 @@
NSExtensionPointIdentifier
com.apple.widgetkit-extension
+ NSSupportsLiveActivities
+
diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift
index c1b18ae..e0197f8 100644
--- a/Shared/AppDelegate.swift
+++ b/Shared/AppDelegate.swift
@@ -8,7 +8,6 @@
import Foundation
import UserNotifications
import UIKit
-import WidgetKit
import SwiftUI
class AppDelegate: NSObject, UIApplicationDelegate {
@@ -56,21 +55,25 @@ extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
if let action = LocalNotification.ActionType(rawValue: response.actionIdentifier) {
let date = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: savedOnboardingData)
+ let mood: Mood
switch action {
case .horrible:
- DataController.shared.add(mood: .horrible, forDate: date, entryType: .notification)
+ mood = .horrible
case .bad:
- DataController.shared.add(mood: .bad, forDate: date, entryType: .notification)
+ mood = .bad
case .average:
- DataController.shared.add(mood: .average, forDate: date, entryType: .notification)
+ mood = .average
case .good:
- DataController.shared.add(mood: .good, forDate: date, entryType: .notification)
+ mood = .good
case .great:
- DataController.shared.add(mood: .great, forDate: date, entryType: .notification)
+ mood = .great
}
+
+ // Use centralized mood logger
+ MoodLogger.shared.logMood(mood, for: date, entryType: .notification)
+
UNUserNotificationCenter.current().setBadgeCount(0)
}
- WidgetCenter.shared.reloadAllTimelines()
completionHandler()
}
}
diff --git a/Shared/AppShortcuts.swift b/Shared/AppShortcuts.swift
new file mode 100644
index 0000000..6d09e94
--- /dev/null
+++ b/Shared/AppShortcuts.swift
@@ -0,0 +1,217 @@
+//
+// AppShortcuts.swift
+// Feels
+//
+// App Intents and Siri Shortcuts for voice-activated mood logging
+//
+
+import AppIntents
+import SwiftUI
+
+// MARK: - Mood Entity for App Intents
+
+struct MoodEntity: AppEntity {
+ static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Mood")
+
+ static var defaultQuery = MoodEntityQuery()
+
+ var id: Int
+ var name: String
+ var mood: Mood
+
+ var displayRepresentation: DisplayRepresentation {
+ DisplayRepresentation(title: "\(name)")
+ }
+
+ static let allMoods: [MoodEntity] = [
+ MoodEntity(id: 0, name: "Horrible", mood: .horrible),
+ MoodEntity(id: 1, name: "Bad", mood: .bad),
+ MoodEntity(id: 2, name: "Average", mood: .average),
+ MoodEntity(id: 3, name: "Good", mood: .good),
+ MoodEntity(id: 4, name: "Great", mood: .great)
+ ]
+}
+
+struct MoodEntityQuery: EntityQuery {
+ func entities(for identifiers: [Int]) async throws -> [MoodEntity] {
+ MoodEntity.allMoods.filter { identifiers.contains($0.id) }
+ }
+
+ func suggestedEntities() async throws -> [MoodEntity] {
+ MoodEntity.allMoods
+ }
+
+ func defaultResult() async -> MoodEntity? {
+ MoodEntity.allMoods.first { $0.mood == .average }
+ }
+}
+
+// MARK: - Log Mood Intent
+
+struct LogMoodIntent: AppIntent {
+ static var title: LocalizedStringResource = "Log Mood"
+ static var description = IntentDescription("Record your mood for today in Feels")
+ static var openAppWhenRun: Bool = false
+
+ @Parameter(title: "Mood")
+ var moodEntity: MoodEntity
+
+ static var parameterSummary: some ParameterSummary {
+ Summary("Log mood as \(\.$moodEntity)")
+ }
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+
+ // Use centralized mood logger
+ MoodLogger.shared.logMood(moodEntity.mood, for: votingDate, entryType: .siri)
+
+ let moodTint = UserDefaultsStore.moodTintable()
+ let color = moodTint.color(forMood: moodEntity.mood)
+
+ return .result(
+ dialog: "Got it! Logged \(moodEntity.name) for today.",
+ view: MoodLoggedSnippetView(moodName: moodEntity.name, color: color)
+ )
+ }
+}
+
+// MARK: - Check Today's Mood Intent
+
+struct CheckTodaysMoodIntent: AppIntent {
+ static var title: LocalizedStringResource = "Check Today's Mood"
+ static var description = IntentDescription("See what mood you logged today in Feels")
+ static var openAppWhenRun: Bool = false
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ProvidesDialog {
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+ let dayStart = Calendar.current.startOfDay(for: votingDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+
+ if let entry = todayEntry, entry.mood != .missing && entry.mood != .placeholder {
+ return .result(dialog: "Today you logged feeling \(entry.mood.widgetDisplayName).")
+ } else {
+ return .result(dialog: "You haven't logged your mood today yet. Would you like to log it now?")
+ }
+ }
+}
+
+// MARK: - Get Mood Streak Intent
+
+struct GetMoodStreakIntent: AppIntent {
+ static var title: LocalizedStringResource = "Get Mood Streak"
+ static var description = IntentDescription("Check your current mood logging streak")
+ static var openAppWhenRun: Bool = false
+
+ @MainActor
+ func perform() async throws -> some IntentResult & ProvidesDialog {
+ let streak = calculateStreak()
+
+ if streak == 0 {
+ return .result(dialog: "You don't have a streak yet. Log your mood today to start one!")
+ } else if streak == 1 {
+ return .result(dialog: "You have a 1 day streak. Keep it going!")
+ } else {
+ return .result(dialog: "Amazing! You have a \(streak) day streak. Keep it up!")
+ }
+ }
+
+ @MainActor
+ private func calculateStreak() -> Int {
+ var streak = 0
+ var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+
+ while true {
+ let dayStart = Calendar.current.startOfDay(for: checkDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+
+ if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
+ streak += 1
+ checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
+}
+
+// MARK: - Snippet View for Mood Logged
+
+struct MoodLoggedSnippetView: View {
+ let moodName: String
+ let color: Color
+
+ var body: some View {
+ HStack(spacing: 12) {
+ Circle()
+ .fill(color)
+ .frame(width: 44, height: 44)
+ .overlay {
+ Image(systemName: "checkmark")
+ .font(.title2.bold())
+ .foregroundColor(.white)
+ }
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Mood Logged")
+ .font(.headline)
+ Text(moodName)
+ .font(.subheadline)
+ .foregroundColor(color)
+ }
+
+ Spacer()
+ }
+ .padding()
+ }
+}
+
+// MARK: - App Shortcuts Provider
+
+struct FeelsShortcuts: AppShortcutsProvider {
+ static var appShortcuts: [AppShortcut] {
+ AppShortcut(
+ intent: LogMoodIntent(),
+ phrases: [
+ "Log my mood in \(.applicationName)",
+ "Log mood as \(\.$moodEntity) in \(.applicationName)",
+ "Record my mood in \(.applicationName)",
+ "I'm feeling \(\.$moodEntity) in \(.applicationName)",
+ "Track my mood in \(.applicationName)"
+ ],
+ shortTitle: "Log Mood",
+ systemImageName: "face.smiling"
+ )
+
+ AppShortcut(
+ intent: CheckTodaysMoodIntent(),
+ phrases: [
+ "What's my mood today in \(.applicationName)",
+ "Check today's mood in \(.applicationName)",
+ "How am I feeling in \(.applicationName)"
+ ],
+ shortTitle: "Today's Mood",
+ systemImageName: "calendar"
+ )
+
+ AppShortcut(
+ intent: GetMoodStreakIntent(),
+ phrases: [
+ "What's my mood streak in \(.applicationName)",
+ "Check my streak in \(.applicationName)",
+ "How many days in a row in \(.applicationName)"
+ ],
+ shortTitle: "Mood Streak",
+ systemImageName: "flame"
+ )
+ }
+}
+
diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift
index 12e3d6e..291a95d 100644
--- a/Shared/FeelsApp.swift
+++ b/Shared/FeelsApp.swift
@@ -9,6 +9,7 @@ import SwiftUI
import SwiftData
import BackgroundTasks
import WidgetKit
+import TipKit
@main
struct FeelsApp: App {
@@ -18,6 +19,7 @@ struct FeelsApp: App {
let dataController = DataController.shared
@StateObject var iapManager = IAPManager()
@StateObject var authManager = BiometricAuthManager()
+ @StateObject var healthKitManager = HealthKitManager.shared
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults) private var firstLaunchDate = Date()
@State private var showSubscriptionFromWidget = false
@@ -27,6 +29,12 @@ struct FeelsApp: App {
BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask)
}
UNUserNotificationCenter.current().setBadgeCount(0)
+
+ // Configure TipKit
+ TipsManager.shared.configure()
+
+ // Initialize Live Activity scheduler
+ LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
}
var body: some Scene {
@@ -39,6 +47,7 @@ struct FeelsApp: App {
.modelContainer(dataController.container)
.environmentObject(iapManager)
.environmentObject(authManager)
+ .environmentObject(healthKitManager)
.sheet(isPresented: $showSubscriptionFromWidget) {
FeelsSubscriptionStoreView()
.environmentObject(iapManager)
@@ -75,6 +84,8 @@ struct FeelsApp: App {
await authManager.authenticate()
}
}
+ // Reschedule Live Activity when app becomes active
+ LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
}
}
}
diff --git a/Shared/FeelsTips.swift b/Shared/FeelsTips.swift
new file mode 100644
index 0000000..d476959
--- /dev/null
+++ b/Shared/FeelsTips.swift
@@ -0,0 +1,259 @@
+//
+// FeelsTips.swift
+// Feels
+//
+// TipKit implementation for feature discovery and onboarding
+//
+
+import TipKit
+import SwiftUI
+
+// MARK: - Tip Definitions
+
+/// Tip for customizing mood layouts
+struct CustomizeLayoutTip: Tip {
+ var title: Text {
+ Text("Personalize Your Experience")
+ }
+
+ var message: Text? {
+ Text("Tap here to customize mood icons, colors, and layouts.")
+ }
+
+ var image: Image? {
+ Image(systemName: "paintbrush")
+ }
+}
+
+/// Tip for AI Insights feature
+struct AIInsightsTip: Tip {
+ var title: Text {
+ Text("Discover AI Insights")
+ }
+
+ var message: Text? {
+ Text("Get personalized insights about your mood patterns powered by Apple Intelligence.")
+ }
+
+ var image: Image? {
+ Image(systemName: "brain")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$hasLoggedMoods) { $0 >= 7 }
+ }
+
+ @Parameter
+ static var hasLoggedMoods: Int = 0
+}
+
+/// Tip for Siri shortcuts
+struct SiriShortcutTip: Tip {
+ var title: Text {
+ Text("Use Siri to Log Moods")
+ }
+
+ var message: Text? {
+ Text("Say \"Hey Siri, log my mood as great in Feels\" for hands-free logging.")
+ }
+
+ var image: Image? {
+ Image(systemName: "mic.fill")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$moodLogCount) { $0 >= 3 }
+ }
+
+ @Parameter
+ static var moodLogCount: Int = 0
+}
+
+/// Tip for HealthKit sync
+struct HealthKitSyncTip: Tip {
+ var title: Text {
+ Text("Sync with Apple Health")
+ }
+
+ var message: Text? {
+ Text("Connect to Apple Health to see your mood data alongside sleep, exercise, and more.")
+ }
+
+ var image: Image? {
+ Image(systemName: "heart.fill")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$hasSeenSettings) { $0 == true }
+ }
+
+ @Parameter
+ static var hasSeenSettings: Bool = false
+}
+
+/// Tip for widget voting
+struct WidgetVotingTip: Tip {
+ var title: Text {
+ Text("Vote from Your Home Screen")
+ }
+
+ var message: Text? {
+ Text("Add the Mood Vote widget to quickly log your mood without opening the app.")
+ }
+
+ var image: Image? {
+ Image(systemName: "square.grid.2x2")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$daysUsingApp) { $0 >= 2 }
+ }
+
+ @Parameter
+ static var daysUsingApp: Int = 0
+}
+
+/// Tip for viewing different time periods
+struct TimeViewTip: Tip {
+ var title: Text {
+ Text("View Your History")
+ }
+
+ var message: Text? {
+ Text("Switch between Day, Month, and Year views to see your mood patterns over time.")
+ }
+
+ var image: Image? {
+ Image(systemName: "calendar")
+ }
+}
+
+/// Tip for mood streaks
+struct MoodStreakTip: Tip {
+ var title: Text {
+ Text("Build Your Streak!")
+ }
+
+ var message: Text? {
+ Text("Log your mood daily to build a streak. Consistency helps you understand your patterns.")
+ }
+
+ var image: Image? {
+ Image(systemName: "flame.fill")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$currentStreak) { $0 >= 3 }
+ }
+
+ @Parameter
+ static var currentStreak: Int = 0
+}
+
+/// Tip for Control Center widget
+struct ControlCenterTip: Tip {
+ var title: Text {
+ Text("Quick Access from Control Center")
+ }
+
+ var message: Text? {
+ Text("Add Feels to Control Center for one-tap mood logging from anywhere.")
+ }
+
+ var image: Image? {
+ Image(systemName: "slider.horizontal.3")
+ }
+
+ var rules: [Rule] {
+ #Rule(Self.$daysUsingApp) { $0 >= 5 }
+ }
+
+ @Parameter
+ static var daysUsingApp: Int = 0
+}
+
+// MARK: - Tips Manager
+
+@MainActor
+class TipsManager {
+ static let shared = TipsManager()
+
+ private init() {}
+
+ func configure() {
+ try? Tips.configure([
+ .displayFrequency(.daily),
+ .datastoreLocation(.applicationDefault)
+ ])
+ }
+
+ func resetAllTips() {
+ try? Tips.resetDatastore()
+ }
+
+ // Update tip parameters based on user actions
+ func onMoodLogged() {
+ SiriShortcutTip.moodLogCount += 1
+ AIInsightsTip.hasLoggedMoods += 1
+ }
+
+ func onSettingsViewed() {
+ HealthKitSyncTip.hasSeenSettings = true
+ }
+
+ func updateDaysUsingApp(_ days: Int) {
+ WidgetVotingTip.daysUsingApp = days
+ ControlCenterTip.daysUsingApp = days
+ }
+
+ func updateStreak(_ streak: Int) {
+ MoodStreakTip.currentStreak = streak
+ }
+}
+
+// MARK: - Tip View Modifiers
+
+extension View {
+ func customizeLayoutTip() -> some View {
+ self.popoverTip(CustomizeLayoutTip())
+ }
+
+ func aiInsightsTip() -> some View {
+ self.popoverTip(AIInsightsTip())
+ }
+
+ func siriShortcutTip() -> some View {
+ self.popoverTip(SiriShortcutTip())
+ }
+
+ func healthKitSyncTip() -> some View {
+ self.popoverTip(HealthKitSyncTip())
+ }
+
+ func widgetVotingTip() -> some View {
+ self.popoverTip(WidgetVotingTip())
+ }
+
+ func timeViewTip() -> some View {
+ self.popoverTip(TimeViewTip())
+ }
+
+ func moodStreakTip() -> some View {
+ self.popoverTip(MoodStreakTip())
+ }
+
+ func controlCenterTip() -> some View {
+ self.popoverTip(ControlCenterTip())
+ }
+}
+
+// MARK: - Inline Tip View
+
+struct InlineTipView: View {
+ let tip: any Tip
+
+ var body: some View {
+ TipView(tip)
+ .tipBackground(Color(.secondarySystemBackground))
+ }
+}
diff --git a/Shared/HealthKitManager.swift b/Shared/HealthKitManager.swift
new file mode 100644
index 0000000..e605653
--- /dev/null
+++ b/Shared/HealthKitManager.swift
@@ -0,0 +1,184 @@
+//
+// HealthKitManager.swift
+// Feels
+//
+// HealthKit State of Mind API integration for syncing mood data with Apple Health
+//
+
+import Foundation
+import HealthKit
+
+@MainActor
+class HealthKitManager: ObservableObject {
+ static let shared = HealthKitManager()
+
+ private let healthStore = HKHealthStore()
+
+ @Published var isAuthorized = false
+ @Published var authorizationError: Error?
+
+ // State of Mind sample type
+ private var stateOfMindType: HKSampleType? {
+ HKSampleType.stateOfMindType()
+ }
+
+ // MARK: - Authorization
+
+ var isHealthKitAvailable: Bool {
+ HKHealthStore.isHealthDataAvailable()
+ }
+
+ func requestAuthorization() async throws {
+ guard isHealthKitAvailable else {
+ throw HealthKitError.notAvailable
+ }
+
+ guard let stateOfMindType = stateOfMindType else {
+ throw HealthKitError.typeNotAvailable
+ }
+
+ let typesToShare: Set = [stateOfMindType]
+ let typesToRead: Set = [stateOfMindType]
+
+ try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
+
+ // Check authorization status
+ let status = healthStore.authorizationStatus(for: stateOfMindType)
+ isAuthorized = status == .sharingAuthorized
+ }
+
+ func checkAuthorizationStatus() -> HKAuthorizationStatus {
+ guard let stateOfMindType = stateOfMindType else {
+ return .notDetermined
+ }
+ return healthStore.authorizationStatus(for: stateOfMindType)
+ }
+
+ // MARK: - Save Mood to HealthKit
+
+ func saveMood(_ mood: Mood, for date: Date, note: String? = nil) async throws {
+ guard isHealthKitAvailable else {
+ throw HealthKitError.notAvailable
+ }
+
+ guard checkAuthorizationStatus() == .sharingAuthorized else {
+ throw HealthKitError.notAuthorized
+ }
+
+ // Convert Feels mood to HealthKit valence (-1 to 1 scale)
+ let valence = moodToValence(mood)
+
+ // Create State of Mind sample
+ let stateOfMind = HKStateOfMind(
+ date: date,
+ kind: .dailyMood,
+ valence: valence,
+ labels: labelsForMood(mood),
+ associations: [.currentEvents]
+ )
+
+ try await healthStore.save(stateOfMind)
+ }
+
+ // MARK: - Read Mood from HealthKit
+
+ func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] {
+ guard isHealthKitAvailable else {
+ throw HealthKitError.notAvailable
+ }
+
+ guard let stateOfMindType = stateOfMindType else {
+ throw HealthKitError.typeNotAvailable
+ }
+
+ let predicate = HKQuery.predicateForSamples(
+ withStart: startDate,
+ end: endDate,
+ options: .strictStartDate
+ )
+
+ let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
+
+ return try await withCheckedThrowingContinuation { continuation in
+ let query = HKSampleQuery(
+ sampleType: stateOfMindType,
+ predicate: predicate,
+ limit: HKObjectQueryNoLimit,
+ sortDescriptors: [sortDescriptor]
+ ) { _, samples, error in
+ if let error = error {
+ continuation.resume(throwing: error)
+ return
+ }
+
+ let stateOfMindSamples = samples?.compactMap { $0 as? HKStateOfMind } ?? []
+ continuation.resume(returning: stateOfMindSamples)
+ }
+
+ healthStore.execute(query)
+ }
+ }
+
+ // MARK: - Conversion Helpers
+
+ /// Convert Feels Mood to HealthKit valence (-1 to 1)
+ private func moodToValence(_ mood: Mood) -> Double {
+ switch mood {
+ case .horrible: return -1.0
+ case .bad: return -0.5
+ case .average: return 0.0
+ case .good: return 0.5
+ case .great: return 1.0
+ case .missing, .placeholder: return 0.0
+ }
+ }
+
+ /// Convert HealthKit valence to Feels Mood
+ func valenceToMood(_ valence: Double) -> Mood {
+ switch valence {
+ case ..<(-0.75): return .horrible
+ case -0.75..<(-0.25): return .bad
+ case -0.25..<0.25: return .average
+ case 0.25..<0.75: return .good
+ default: return .great
+ }
+ }
+
+ /// Get HealthKit labels for a mood
+ private func labelsForMood(_ mood: Mood) -> [HKStateOfMind.Label] {
+ switch mood {
+ case .horrible:
+ return [.sad, .stressed, .anxious]
+ case .bad:
+ return [.sad, .stressed]
+ case .average:
+ return [.calm, .indifferent]
+ case .good:
+ return [.happy, .calm, .content]
+ case .great:
+ return [.happy, .excited, .joyful]
+ case .missing, .placeholder:
+ return []
+ }
+ }
+}
+
+// MARK: - Errors
+
+enum HealthKitError: LocalizedError {
+ case notAvailable
+ case notAuthorized
+ case typeNotAvailable
+
+ var errorDescription: String? {
+ switch self {
+ case .notAvailable:
+ return "HealthKit is not available on this device"
+ case .notAuthorized:
+ return "HealthKit access not authorized"
+ case .typeNotAvailable:
+ return "State of Mind type not available"
+ }
+ }
+}
+
diff --git a/Shared/Models/MoodEntryModel.swift b/Shared/Models/MoodEntryModel.swift
index 601477c..a2107ca 100644
--- a/Shared/Models/MoodEntryModel.swift
+++ b/Shared/Models/MoodEntryModel.swift
@@ -18,6 +18,9 @@ enum EntryType: Int, Codable {
case filledInMissing = 4
case notification = 5
case header = 6
+ case siri = 7
+ case controlCenter = 8
+ case liveActivity = 9
}
// MARK: - SwiftData Model
diff --git a/Shared/Models/OnboardingDataDataManager.swift b/Shared/Models/OnboardingDataDataManager.swift
index 94f07fb..31f0345 100644
--- a/Shared/Models/OnboardingDataDataManager.swift
+++ b/Shared/Models/OnboardingDataDataManager.swift
@@ -6,15 +6,27 @@
//
import Foundation
+import WidgetKit
final class OnboardingDataDataManager: ObservableObject {
static let shared = OnboardingDataDataManager()
-
+
@Published public private(set) var savedOnboardingData = UserDefaultsStore.getOnboarding()
-
+
public func updateOnboardingData(onboardingData: OnboardingData) {
let onboardingData = UserDefaultsStore.saveOnboarding(onboardingData: onboardingData)
savedOnboardingData = onboardingData
LocalNotification.scheduleReminder(atTime: onboardingData.date)
+
+ // Update Live Activity schedule when rating time changes
+ Task { @MainActor in
+ LiveActivityScheduler.shared.onRatingTimeUpdated()
+ }
+
+ // Force sync UserDefaults to app group before reloading widgets
+ GroupUserDefaults.groupDefaults.synchronize()
+
+ // Reload widgets so they show the correct view for new time
+ WidgetCenter.shared.reloadAllTimelines()
}
}
diff --git a/Shared/Models/PersonalityPackable.swift b/Shared/Models/PersonalityPackable.swift
index e4e915d..c5a7794 100644
--- a/Shared/Models/PersonalityPackable.swift
+++ b/Shared/Models/PersonalityPackable.swift
@@ -17,11 +17,11 @@ protocol PersonalityPackable {
enum PersonalityPack: Int, CaseIterable {
case Default = 0
- case Rude = 1
- case MotivationalCoach = 2
- case ZenMaster = 3
- case BestFriend = 4
- case DataAnalyst = 5
+// case Rude = 1
+ case MotivationalCoach = 1
+ case ZenMaster = 2
+ case BestFriend = 3
+ case DataAnalyst = 4
func randomPushNotificationStrings() -> (title: String, body: String) {
let onboarding = UserDefaultsStore.getOnboarding()
@@ -33,12 +33,12 @@ enum PersonalityPack: Int, CaseIterable {
case (.Default, .Previous):
return (DefaultTitles.notificationTitles.randomElement()!,
DefaultTitles.notificationBodyYesterday.randomElement()!)
- case (.Rude, .Today):
- return (RudeTitles.notificationTitles.randomElement()!,
- RudeTitles.notificationBodyToday.randomElement()!)
- case (.Rude, .Previous):
- return (RudeTitles.notificationTitles.randomElement()!,
- RudeTitles.notificationBodyYesterday.randomElement()!)
+// case (.Rude, .Today):
+// return (RudeTitles.notificationTitles.randomElement()!,
+// RudeTitles.notificationBodyToday.randomElement()!)
+// case (.Rude, .Previous):
+// return (RudeTitles.notificationTitles.randomElement()!,
+// RudeTitles.notificationBodyYesterday.randomElement()!)
case (.MotivationalCoach, .Today):
return (MotivationalCoachTitles.notificationTitles.randomElement()!,
MotivationalCoachTitles.notificationBodyToday.randomElement()!)
@@ -70,8 +70,8 @@ enum PersonalityPack: Int, CaseIterable {
switch self {
case .Default:
return DefaultTitles.title
- case .Rude:
- return RudeTitles.title
+// case .Rude:
+// return RudeTitles.title
case .MotivationalCoach:
return MotivationalCoachTitles.title
case .ZenMaster:
@@ -86,7 +86,7 @@ enum PersonalityPack: Int, CaseIterable {
var icon: String {
switch self {
case .Default: return "face.smiling"
- case .Rude: return "flame"
+// case .Rude: return "flame"
case .MotivationalCoach: return "figure.run"
case .ZenMaster: return "leaf"
case .BestFriend: return "heart"
@@ -97,7 +97,7 @@ enum PersonalityPack: Int, CaseIterable {
var description: String {
switch self {
case .Default: return "Friendly and supportive"
- case .Rude: return "Snarky with attitude"
+// 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"
diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift
index fae2725..69ca313 100644
--- a/Shared/Models/UserDefaultsStore.swift
+++ b/Shared/Models/UserDefaultsStore.swift
@@ -96,6 +96,7 @@ class UserDefaultsStore {
case dayViewStyle
case privacyLockEnabled
case healthKitEnabled
+ case healthKitSyncEnabled
case contentViewCurrentSelectedHeaderViewBackDays
case contentViewHeaderTag
diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift
new file mode 100644
index 0000000..a4621db
--- /dev/null
+++ b/Shared/MoodLogger.swift
@@ -0,0 +1,89 @@
+//
+// MoodLogger.swift
+// Feels
+//
+// Centralized mood logging service that handles all side effects
+//
+
+import Foundation
+import WidgetKit
+
+/// Centralized service for logging moods with all associated side effects.
+/// All mood entry points should use this service to ensure consistent behavior.
+@MainActor
+final class MoodLogger {
+ static let shared = MoodLogger()
+
+ private init() {}
+
+ /// Log a mood entry with all associated side effects.
+ /// This is the single source of truth for mood logging in the app.
+ ///
+ /// - Parameters:
+ /// - mood: The mood to log
+ /// - date: The date for the mood entry
+ /// - entryType: The source of the mood entry (header, widget, siri, etc.)
+ /// - syncHealthKit: Whether to sync to HealthKit (default true, but widget can't access HealthKit)
+ /// - updateTips: Whether to update TipKit parameters (default true, but widget can't access TipKit)
+ func logMood(
+ _ mood: Mood,
+ for date: Date,
+ entryType: EntryType,
+ syncHealthKit: Bool = true,
+ updateTips: Bool = true
+ ) {
+ // 1. Add mood entry to data store
+ DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
+
+ // Skip side effects for placeholder/missing moods
+ guard mood != .missing && mood != .placeholder else { return }
+
+ // 2. Sync to HealthKit if enabled and requested
+ if syncHealthKit {
+ let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
+ if healthKitEnabled {
+ Task {
+ try? await HealthKitManager.shared.saveMood(mood, for: date)
+ }
+ }
+ }
+
+ // 3. Calculate current streak for Live Activity and TipKit
+ let streak = calculateCurrentStreak()
+
+ // 4. Update Live Activity
+ LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
+ LiveActivityScheduler.shared.scheduleForNextDay()
+
+ // 5. Update TipKit parameters if requested
+ if updateTips {
+ TipsManager.shared.onMoodLogged()
+ TipsManager.shared.updateStreak(streak)
+ }
+
+ // 6. Reload widgets
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+
+ /// Calculate the current mood streak
+ private func calculateCurrentStreak() -> Int {
+ var streak = 0
+ var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+
+ while true {
+ let dayStart = Calendar.current.startOfDay(for: checkDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+
+ if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
+ streak += 1
+ checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
+}
diff --git a/Shared/MoodStreakActivity.swift b/Shared/MoodStreakActivity.swift
new file mode 100644
index 0000000..5e41485
--- /dev/null
+++ b/Shared/MoodStreakActivity.swift
@@ -0,0 +1,350 @@
+//
+// MoodStreakActivity.swift
+// Feels
+//
+// Live Activity for mood streak tracking on Lock Screen and Dynamic Island
+//
+
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+// MARK: - Activity Attributes
+// Note: This must be defined in both the main app and widget extension
+
+struct MoodStreakAttributes: ActivityAttributes {
+ public struct ContentState: Codable, Hashable {
+ var currentStreak: Int
+ var lastMoodLogged: String
+ var lastMoodColor: String // Hex color string
+ var hasLoggedToday: Bool
+ var votingWindowEnd: Date
+ }
+
+ var startDate: Date
+}
+
+// MARK: - Live Activity Manager
+
+@MainActor
+class LiveActivityManager: ObservableObject {
+ static let shared = LiveActivityManager()
+
+ @Published var currentActivity: Activity?
+
+ private init() {}
+
+ // Start a mood streak Live Activity
+ func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+ print("Live Activities not enabled")
+ return
+ }
+
+ Task {
+ // End any existing activity first
+ await endAllActivities()
+
+ // Now start the new activity
+ await startActivityInternal(streak: streak, lastMood: lastMood, hasLoggedToday: hasLoggedToday)
+ }
+ }
+
+ private func startActivityInternal(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) async {
+ let moodTint = UserDefaultsStore.moodTintable()
+ let moodName = lastMood?.widgetDisplayName ?? "None"
+ let moodColor = lastMood != nil ? (moodTint.color(forMood: lastMood!).toHex() ?? "#888888") : "#888888"
+
+ // Calculate voting window end (end of current day)
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+ let votingWindowEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: votingDate) ?? Date()
+
+ let attributes = MoodStreakAttributes(startDate: Date())
+ let initialState = MoodStreakAttributes.ContentState(
+ currentStreak: streak,
+ lastMoodLogged: moodName,
+ lastMoodColor: moodColor,
+ hasLoggedToday: hasLoggedToday,
+ votingWindowEnd: votingWindowEnd
+ )
+
+ do {
+ let activity = try Activity.request(
+ attributes: attributes,
+ content: .init(state: initialState, staleDate: nil),
+ pushType: nil
+ )
+ currentActivity = activity
+ } catch {
+ print("Error starting Live Activity: \(error)")
+ }
+ }
+
+ // Update the Live Activity after mood is logged
+ func updateActivity(streak: Int, mood: Mood) {
+ guard let activity = currentActivity else { return }
+
+ let moodTint = UserDefaultsStore.moodTintable()
+
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+ let votingWindowEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: votingDate) ?? Date()
+
+ let updatedState = MoodStreakAttributes.ContentState(
+ currentStreak: streak,
+ lastMoodLogged: mood.widgetDisplayName,
+ lastMoodColor: moodTint.color(forMood: mood).toHex() ?? "#888888",
+ hasLoggedToday: true,
+ votingWindowEnd: votingWindowEnd
+ )
+
+ Task {
+ await activity.update(.init(state: updatedState, staleDate: nil))
+ }
+ }
+
+ // End all Live Activities
+ func endAllActivities() async {
+ for activity in Activity.activities {
+ await activity.end(nil, dismissalPolicy: .immediate)
+ }
+ currentActivity = nil
+ }
+
+ // End activity at midnight
+ func scheduleActivityEnd() {
+ guard let activity = currentActivity else { return }
+
+ let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
+
+ Task {
+ await activity.end(nil, dismissalPolicy: .after(midnight))
+ }
+ }
+}
+
+// MARK: - Live Activity Scheduler
+
+/// Handles automatic scheduling of Live Activities based on user's rating time
+@MainActor
+class LiveActivityScheduler: ObservableObject {
+ static let shared = LiveActivityScheduler()
+
+ private var startTimer: Timer?
+ private var endTimer: Timer?
+
+ private init() {}
+
+ /// Get the user's configured rating time
+ func getUserRatingTime() -> Date? {
+ guard let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
+ let onboardingData = try? JSONDecoder().decode(OnboardingData.self, from: data) else {
+ return nil
+ }
+ return onboardingData.date
+ }
+
+ /// Calculate the start time (at rating time)
+ func getStartTime(for date: Date = Date()) -> Date? {
+ guard let ratingTime = getUserRatingTime() else { return nil }
+
+ let calendar = Calendar.current
+ let ratingComponents = calendar.dateComponents([.hour, .minute], from: ratingTime)
+
+ var startComponents = calendar.dateComponents([.year, .month, .day], from: date)
+ startComponents.hour = ratingComponents.hour
+ startComponents.minute = ratingComponents.minute
+
+ // Start at rating time
+ return calendar.date(from: startComponents)
+ }
+
+ /// Calculate the end time (5 hours after rating time)
+ func getEndTime(for date: Date = Date()) -> Date? {
+ guard let ratingTime = getUserRatingTime() else { return nil }
+
+ let calendar = Calendar.current
+ let ratingComponents = calendar.dateComponents([.hour, .minute], from: ratingTime)
+
+ var endComponents = calendar.dateComponents([.year, .month, .day], from: date)
+ endComponents.hour = ratingComponents.hour
+ endComponents.minute = ratingComponents.minute
+
+ guard let ratingDateTime = calendar.date(from: endComponents) else { return nil }
+
+ // End 5 hours after rating time
+ return calendar.date(byAdding: .hour, value: 5, to: ratingDateTime)
+ }
+
+ /// Check if user has rated today
+ func hasRatedToday() -> Bool {
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+ let dayStart = Calendar.current.startOfDay(for: votingDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+ return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder
+ }
+
+ /// Calculate current streak
+ func calculateStreak() -> Int {
+ var streak = 0
+ var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+
+ // Check if current voting date has an entry
+ let currentDayStart = Calendar.current.startOfDay(for: checkDate)
+ let currentDayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: currentDayStart)!
+ let currentEntry = DataController.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first
+
+ // If no entry for current voting date, start counting from previous day
+ // This ensures the streak shows correctly even if user hasn't rated today yet
+ if currentEntry == nil || currentEntry?.mood == .missing || currentEntry?.mood == .placeholder {
+ checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
+ }
+
+ while true {
+ let dayStart = Calendar.current.startOfDay(for: checkDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+
+ if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
+ streak += 1
+ checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)!
+ } else {
+ break
+ }
+ }
+
+ return streak
+ }
+
+ /// Get today's mood if logged
+ func getTodaysMood() -> Mood? {
+ let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
+ let dayStart = Calendar.current.startOfDay(for: votingDate)
+ let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
+
+ let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
+ if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
+ return entry.mood
+ }
+ return nil
+ }
+
+ /// Schedule Live Activity based on current time and rating time
+ func scheduleBasedOnCurrentTime() {
+ invalidateTimers()
+
+ guard ActivityAuthorizationInfo().areActivitiesEnabled else {
+ print("[LiveActivity] Live Activities not enabled by user")
+ return
+ }
+
+ let now = Date()
+ guard let startTime = getStartTime(),
+ let endTime = getEndTime() else {
+ print("[LiveActivity] No rating time configured - skipping")
+ return
+ }
+
+ let hasRated = hasRatedToday()
+ print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
+
+ // If user has already rated today, don't show activity - schedule for next day
+ if hasRated {
+ print("[LiveActivity] User already rated today - scheduling for next day")
+ scheduleForNextDay()
+ return
+ }
+
+ // Check if we're within the activity window (rating time to 5 hrs after)
+ if now >= startTime && now <= endTime {
+ // Start activity immediately
+ print("[LiveActivity] Within window - starting activity now")
+ let streak = calculateStreak()
+ LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
+
+ // Schedule end
+ scheduleEnd(at: endTime)
+ } else if now < startTime {
+ // Schedule start for later today
+ print("[LiveActivity] Before window - scheduling start for \(startTime)")
+ scheduleStart(at: startTime)
+ scheduleEnd(at: endTime)
+ } else {
+ // Past the window for today, schedule for tomorrow
+ print("[LiveActivity] Past window - scheduling for tomorrow")
+ scheduleForNextDay()
+ }
+ }
+
+ /// Schedule Live Activity to start at a specific time
+ private func scheduleStart(at date: Date) {
+ let interval = date.timeIntervalSince(Date())
+ guard interval > 0 else { return }
+
+ startTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ guard let self = self else { return }
+
+ // Check if user rated in the meantime
+ if !self.hasRatedToday() {
+ let streak = self.calculateStreak()
+ LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: self.getTodaysMood(), hasLoggedToday: false)
+ }
+ }
+ }
+ }
+
+ /// Schedule Live Activity to end at a specific time
+ private func scheduleEnd(at date: Date) {
+ let interval = date.timeIntervalSince(Date())
+ guard interval > 0 else { return }
+
+ endTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
+ Task { @MainActor in
+ await LiveActivityManager.shared.endAllActivities()
+ LiveActivityScheduler.shared.scheduleForNextDay()
+ }
+ }
+ }
+
+ /// Schedule for the next day (called after user rates or after window closes)
+ func scheduleForNextDay() {
+ invalidateTimers()
+
+ // End current activity if exists
+ Task {
+ await LiveActivityManager.shared.endAllActivities()
+ }
+
+ guard let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()),
+ let startTime = getStartTime(for: tomorrow) else {
+ return
+ }
+
+ let interval = startTime.timeIntervalSince(Date())
+ guard interval > 0 else { return }
+
+ startTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ self?.scheduleBasedOnCurrentTime()
+ }
+ }
+ }
+
+ /// Called when user updates their rating time in settings
+ func onRatingTimeUpdated() {
+ // Reschedule based on new time
+ scheduleBasedOnCurrentTime()
+ }
+
+ /// Invalidate all timers
+ private func invalidateTimers() {
+ startTimer?.invalidate()
+ startTimer = nil
+ endTimer?.invalidate()
+ endTimer = nil
+ }
+}
+
diff --git a/Shared/Services/ExportService.swift b/Shared/Services/ExportService.swift
index 2d4716f..ebe530b 100644
--- a/Shared/Services/ExportService.swift
+++ b/Shared/Services/ExportService.swift
@@ -773,6 +773,9 @@ extension EntryType: CustomStringConvertible {
case .filledInMissing: return "Auto-filled"
case .notification: return "Notification"
case .header: return "Header"
+ case .siri: return "Siri"
+ case .controlCenter: return "Control Center"
+ case .liveActivity: return "Live Activity"
}
}
}
diff --git a/Shared/Services/FoundationModelsInsightService.swift b/Shared/Services/FoundationModelsInsightService.swift
index 25253c1..e0cd503 100644
--- a/Shared/Services/FoundationModelsInsightService.swift
+++ b/Shared/Services/FoundationModelsInsightService.swift
@@ -95,8 +95,8 @@ class FoundationModelsInsightService: ObservableObject {
switch personalityPack {
case .Default:
return defaultSystemInstructions
- case .Rude:
- return rudeSystemInstructions
+// case .Rude:
+// return rudeSystemInstructions
case .MotivationalCoach:
return coachSystemInstructions
case .ZenMaster:
diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift
index 45e3115..f7c52d9 100644
--- a/Shared/Views/CustomizeView/CustomizeView.swift
+++ b/Shared/Views/CustomizeView/CustomizeView.swift
@@ -7,6 +7,7 @@
import SwiftUI
import StoreKit
+import TipKit
// MARK: - Customize Content View (for use in SettingsTabView)
struct CustomizeContentView: View {
@@ -17,6 +18,10 @@ struct CustomizeContentView: View {
var body: some View {
ScrollView {
VStack(spacing: 24) {
+ // Customize tip
+ TipView(CustomizeLayoutTip())
+ .tipBackground(Color(.secondarySystemBackground))
+
// APPEARANCE
SettingsSection(title: "Appearance") {
VStack(spacing: 16) {
@@ -640,16 +645,16 @@ struct PersonalityPackPickerCompact: View {
VStack(spacing: 8) {
ForEach(PersonalityPack.allCases, id: \.self) { aPack in
Button(action: {
- if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
- showOver18Alert = true
- EventLogger.log(event: "show_over_18_alert")
- } else {
+// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
+// showOver18Alert = true
+// EventLogger.log(event: "show_over_18_alert")
+// } else {
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
personalityPack = aPack
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
LocalNotification.rescheduleNotifiations()
- }
+// }
}) {
HStack {
VStack(alignment: .leading, spacing: 4) {
@@ -681,7 +686,7 @@ struct PersonalityPackPickerCompact: View {
)
}
.buttonStyle(.plain)
- .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
+// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 4 : 0)
}
}
.alert(isPresented: $showOver18Alert) {
diff --git a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift
index 652704c..a54e318 100644
--- a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift
+++ b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift
@@ -40,18 +40,18 @@ struct PersonalityPackPickerView: View {
.padding(5)
)
.onTapGesture {
- if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
- showOver18Alert = true
- EventLogger.log(event: "show_over_18_alert")
- } else {
+// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW {
+// showOver18Alert = true
+// EventLogger.log(event: "show_over_18_alert")
+// } else {
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
impactMed.impactOccurred()
personalityPack = aPack
EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()])
LocalNotification.rescheduleNotifiations()
- }
+// }
}
- .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
+// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
.alert(isPresented: $showOver18Alert) {
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
showNSFW = true
diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift
index a69b1a0..f50c346 100644
--- a/Shared/Views/DayView/DayView.swift
+++ b/Shared/Views/DayView/DayView.swift
@@ -8,6 +8,7 @@
import SwiftUI
import SwiftData
import Charts
+import TipKit
struct DayViewConstants {
static let maxHeaderHeight = 200.0
@@ -30,7 +31,6 @@ struct DayView: View {
// MARK: edit row properties
@State private var showingSheet = false
@State private var selectedEntry: MoodEntryModel?
- @State private var showEntryDetail = false
//
// MARK: ?? properties
@@ -53,25 +53,16 @@ struct DayView: View {
.sheet(isPresented: $showingSheet) {
SettingsView()
}
- .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)
- }
- )
- }
+ .sheet(item: $selectedEntry) { entry in
+ EntryDetailView(
+ entry: entry,
+ onMoodUpdate: { newMood in
+ viewModel.update(entry: entry, toMood: newMood)
+ },
+ onDelete: {
+ viewModel.update(entry: entry, toMood: .missing)
+ }
+ )
}
}
.padding([.top])
@@ -107,6 +98,7 @@ struct DayView: View {
AddMoodHeaderView(addItemHeaderClosure: { (mood, date) in
viewModel.add(mood: mood, forDate: date, entryType: .header)
})
+ .widgetVotingTip()
}
}
}
diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift
index 52169f8..afc14b8 100644
--- a/Shared/Views/DayView/DayViewViewModel.swift
+++ b/Shared/Views/DayView/DayViewViewModel.swift
@@ -7,6 +7,7 @@
import SwiftUI
import SwiftData
+import WidgetKit
@MainActor
class DayViewViewModel: ObservableObject {
@@ -60,12 +61,28 @@ class DayViewViewModel: ObservableObject {
}
public func add(mood: Mood, forDate date: Date, entryType: EntryType) {
- DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
+ MoodLogger.shared.logMood(mood, for: date, entryType: entryType)
}
public func update(entry: MoodEntryModel, toMood mood: Mood) {
if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) {
print("Failed to update mood entry")
+ return
+ }
+
+ // Sync to HealthKit for past day updates
+ guard mood != .missing && mood != .placeholder else { return }
+
+ let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
+ if healthKitEnabled {
+ Task {
+ try? await HealthKitManager.shared.saveMood(mood, for: entry.forDate)
+ }
+ }
+
+ // Reload widgets asynchronously to avoid UI delay
+ Task { @MainActor in
+ WidgetCenter.shared.reloadAllTimelines()
}
}
diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift
index c2a45b8..e39a06e 100644
--- a/Shared/Views/InsightsView/InsightsView.swift
+++ b/Shared/Views/InsightsView/InsightsView.swift
@@ -6,6 +6,7 @@
//
import SwiftUI
+import TipKit
struct InsightsView: View {
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -48,6 +49,7 @@ struct InsightsView: View {
)
)
.clipShape(Capsule())
+ .aiInsightsTip()
}
}
.padding(.horizontal)
diff --git a/Shared/Views/MonthView/MonthDetailView.swift b/Shared/Views/MonthView/MonthDetailView.swift
index df8130b..d410919 100644
--- a/Shared/Views/MonthView/MonthDetailView.swift
+++ b/Shared/Views/MonthView/MonthDetailView.swift
@@ -90,7 +90,7 @@ struct MonthDetailView: View {
ForEach(Mood.allValues) { mood in
Button(mood.strValue, action: {
if let selectedEntry = selectedEntry {
- DataController.shared.update(entryDate: selectedEntry.forDate, withMood: mood)
+ parentViewModel.update(entry: selectedEntry, toMood: mood)
}
updateEntries()
showUpdateEntryAlert = false
@@ -102,7 +102,7 @@ struct MonthDetailView: View {
deleteEnabled,
selectedEntry.mood != .missing {
Button(String(localized: "content_view_delete_entry"), action: {
- DataController.shared.update(entryDate: selectedEntry.forDate, withMood: .missing)
+ parentViewModel.update(entry: selectedEntry, toMood: .missing)
updateEntries()
showUpdateEntryAlert = false
})
diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift
index 8d1d4af..9c8de13 100644
--- a/Shared/Views/NoteEditorView.swift
+++ b/Shared/Views/NoteEditorView.swift
@@ -151,9 +151,14 @@ struct EntryDetailView: View {
@State private var showDeleteConfirmation = false
@State private var showFullScreenPhoto = false
@State private var selectedPhotoItem: PhotosPickerItem?
+ @State private var selectedMood: Mood?
+
+ private var currentMood: Mood {
+ selectedMood ?? entry.mood
+ }
private var moodColor: Color {
- moodTint.color(forMood: entry.mood)
+ moodTint.color(forMood: currentMood)
}
private func savePhoto(_ image: UIImage) {
@@ -267,7 +272,7 @@ struct EntryDetailView: View {
)
.frame(width: 60, height: 60)
- imagePack.icon(forMood: entry.mood)
+ imagePack.icon(forMood: currentMood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 34, height: 34)
@@ -276,7 +281,7 @@ struct EntryDetailView: View {
.shadow(color: moodColor.opacity(0.4), radius: 8, x: 0, y: 4)
VStack(alignment: .leading, spacing: 4) {
- Text(entry.moodString)
+ Text(currentMood.strValue)
.font(.title3)
.fontWeight(.semibold)
.foregroundColor(moodColor)
@@ -298,23 +303,28 @@ struct EntryDetailView: View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 5), spacing: 12) {
ForEach(Mood.allValues) { mood in
Button {
+ // Update local state immediately for instant feedback
+ withAnimation(.easeInOut(duration: 0.15)) {
+ selectedMood = mood
+ }
+ // Then persist the change
onMoodUpdate(mood)
} label: {
VStack(spacing: 6) {
Circle()
- .fill(entry.mood == mood ? moodTint.color(forMood: mood) : Color(.systemGray5))
+ .fill(currentMood == 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)
+ .foregroundColor(currentMood == mood ? .white : .gray)
)
Text(mood.strValue)
.font(.caption2)
- .foregroundColor(entry.mood == mood ? moodTint.color(forMood: mood) : .secondary)
+ .foregroundColor(currentMood == mood ? moodTint.color(forMood: mood) : .secondary)
}
}
.buttonStyle(.plain)
diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift
index 17fbaf2..01bd8ef 100644
--- a/Shared/Views/SettingsView/SettingsView.swift
+++ b/Shared/Views/SettingsView/SettingsView.swift
@@ -9,6 +9,7 @@ import SwiftUI
import CloudKitSyncMonitor
import UniformTypeIdentifiers
import StoreKit
+import TipKit
// MARK: - Settings Content View (for use in SettingsTabView)
struct SettingsContentView: View {
@@ -16,6 +17,7 @@ struct SettingsContentView: View {
@State private var showOnboarding = false
@State private var showExportView = false
+ @State private var showReminderTimePicker = false
@StateObject private var healthService = HealthService.shared
@AppStorage(UserDefaultsStore.Keys.deleteEnable.rawValue, store: GroupUserDefaults.groupDefaults) private var deleteEnabled = true
@@ -33,6 +35,7 @@ struct SettingsContentView: View {
// Settings section
settingsSectionHeader
+ reminderTimeButton
canDelete
showOnboardingButton
@@ -64,11 +67,59 @@ struct SettingsContentView: View {
includedDays: []
))
}
+ .sheet(isPresented: $showReminderTimePicker) {
+ ReminderTimePickerView()
+ }
.onAppear(perform: {
EventLogger.log(event: "show_settings_view")
+ TipsManager.shared.onSettingsViewed()
})
}
+ // MARK: - Reminder Time Button
+
+ private var reminderTimeButton: some View {
+ ZStack {
+ theme.currentTheme.secondaryBGColor
+ Button(action: {
+ EventLogger.log(event: "tap_reminder_time")
+ showReminderTimePicker = true
+ }, label: {
+ HStack(spacing: 12) {
+ Image(systemName: "clock.fill")
+ .font(.title2)
+ .foregroundColor(.orange)
+ .frame(width: 32)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Reminder Time")
+ .foregroundColor(textColor)
+
+ Text(formattedReminderTime)
+ .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 formattedReminderTime: String {
+ let onboardingData = UserDefaultsStore.getOnboarding()
+ let formatter = DateFormatter()
+ formatter.timeStyle = .short
+ return formatter.string(from: onboardingData.date)
+ }
+
// MARK: - Section Headers
private var featuresSectionHeader: some View {
@@ -80,6 +131,7 @@ struct SettingsContentView: View {
}
.padding(.top, 20)
.padding(.horizontal, 4)
+ .siriShortcutTip()
}
private var settingsSectionHeader: some View {
@@ -91,6 +143,7 @@ struct SettingsContentView: View {
}
.padding(.top, 20)
.padding(.horizontal, 4)
+ .controlCenterTip()
}
private var legalSectionHeader: some View {
@@ -180,8 +233,15 @@ struct SettingsContentView: View {
set: { newValue in
if newValue {
Task {
- let success = await healthService.requestAuthorization()
- if !success {
+ // Request read permissions for health insights
+ let readSuccess = await healthService.requestAuthorization()
+ // Request write permissions for State of Mind sync
+ do {
+ try await HealthKitManager.shared.requestAuthorization()
+ } catch {
+ print("HealthKit write authorization failed: \(error)")
+ }
+ if !readSuccess {
EventLogger.log(event: "healthkit_enable_failed")
}
}
@@ -202,6 +262,7 @@ struct SettingsContentView: View {
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
+ .healthKitSyncTip()
}
// MARK: - Export Data Button
@@ -311,6 +372,65 @@ struct SettingsContentView: View {
}
}
+// MARK: - Reminder Time Picker View
+
+struct ReminderTimePickerView: View {
+ @Environment(\.dismiss) private var dismiss
+ @State private var selectedTime: Date
+
+ init() {
+ let onboardingData = UserDefaultsStore.getOnboarding()
+ _selectedTime = State(initialValue: onboardingData.date)
+ }
+
+ var body: some View {
+ NavigationStack {
+ VStack(spacing: 24) {
+ Text("When would you like to be reminded to log your mood?")
+ .font(.headline)
+ .multilineTextAlignment(.center)
+ .padding(.top, 20)
+
+ DatePicker(
+ "Reminder Time",
+ selection: $selectedTime,
+ displayedComponents: .hourAndMinute
+ )
+ .datePickerStyle(.wheel)
+ .labelsHidden()
+
+ Spacer()
+ }
+ .padding()
+ .navigationTitle("Reminder Time")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") {
+ dismiss()
+ }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button("Save") {
+ saveReminderTime()
+ dismiss()
+ }
+ .fontWeight(.semibold)
+ }
+ }
+ }
+ }
+
+ private func saveReminderTime() {
+ let onboardingData = UserDefaultsStore.getOnboarding()
+ onboardingData.date = selectedTime
+ // This handles notification scheduling and Live Activity rescheduling
+ OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData)
+
+ EventLogger.log(event: "reminder_time_updated")
+ }
+}
+
// MARK: - Legacy SettingsView (sheet presentation with close button)
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@@ -583,8 +703,15 @@ struct SettingsView: View {
set: { newValue in
if newValue {
Task {
- let success = await healthService.requestAuthorization()
- if !success {
+ // Request read permissions for health insights
+ let readSuccess = await healthService.requestAuthorization()
+ // Request write permissions for State of Mind sync
+ do {
+ try await HealthKitManager.shared.requestAuthorization()
+ } catch {
+ print("HealthKit write authorization failed: \(error)")
+ }
+ if !readSuccess {
EventLogger.log(event: "healthkit_enable_failed")
}
}