From 440b04159ef82b13e656f455fd301050a3f3036d Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 19 Dec 2025 17:21:55 -0600 Subject: [PATCH] Add Apple platform features and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HealthKit State of Mind sync for mood entries - Add Live Activity with streak display and rating time window - Add App Shortcuts/Siri integration for voice mood logging - Add TipKit hints for feature discovery - Add centralized MoodLogger for consistent side effects - Add reminder time setting in Settings with time picker - Fix duplicate notifications when changing reminder time - Fix Live Activity streak showing 0 when not yet rated today - Fix slow tap response in entry detail mood selection - Update widget timeline to refresh at rating time - Sync widgets when reminder time changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Feels (iOS).entitlements | 4 +- Feels--iOS--Info.plist | 4 +- Feels.xcodeproj/project.pbxproj | 1 + FeelsWidget2/FeelsVoteWidget.swift | 64 +++- FeelsWidget2/FeelsWidget.swift | 156 +++++++- FeelsWidgetExtension-Info.plist | 2 + Shared/AppDelegate.swift | 17 +- Shared/AppShortcuts.swift | 217 +++++++++++ Shared/FeelsApp.swift | 11 + Shared/FeelsTips.swift | 259 +++++++++++++ Shared/HealthKitManager.swift | 184 +++++++++ Shared/Models/MoodEntryModel.swift | 3 + Shared/Models/OnboardingDataDataManager.swift | 16 +- Shared/Models/PersonalityPackable.swift | 30 +- Shared/Models/UserDefaultsStore.swift | 1 + Shared/MoodLogger.swift | 89 +++++ Shared/MoodStreakActivity.swift | 350 ++++++++++++++++++ Shared/Services/ExportService.swift | 3 + .../FoundationModelsInsightService.swift | 4 +- .../Views/CustomizeView/CustomizeView.swift | 17 +- .../SubViews/PersonalityPackPickerView.swift | 12 +- Shared/Views/DayView/DayView.swift | 32 +- Shared/Views/DayView/DayViewViewModel.swift | 19 +- Shared/Views/InsightsView/InsightsView.swift | 2 + Shared/Views/MonthView/MonthDetailView.swift | 4 +- Shared/Views/NoteEditorView.swift | 22 +- Shared/Views/SettingsView/SettingsView.swift | 135 ++++++- 27 files changed, 1577 insertions(+), 81 deletions(-) create mode 100644 Shared/AppShortcuts.swift create mode 100644 Shared/FeelsTips.swift create mode 100644 Shared/HealthKitManager.swift create mode 100644 Shared/MoodLogger.swift create mode 100644 Shared/MoodStreakActivity.swift 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") } }