// // MoodStreakActivity.swift // Reflect // // 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 { #if DEBUG print("Live Activities not enabled") #endif 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 { #if DEBUG print("Error starting Live Activity: \(error)") #endif } } // 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, let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date()) else { return } let midnight = Calendar.current.startOfDay(for: tomorrow) 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) } /// Cached streak data to avoid redundant calculations private var cachedStreakData: (streak: Int, todaysMood: Mood?, votingDate: Date)? /// Get streak data using efficient batch query (cached per voting date) private func getStreakData() -> (streak: Int, todaysMood: Mood?) { let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) // Return cached data if still valid for current voting date if let cached = cachedStreakData, Calendar.current.isDate(cached.votingDate, inSameDayAs: votingDate) { return (cached.streak, cached.todaysMood) } // Calculate and cache #if WIDGET_EXTENSION // Widget extension uses its own data provider let calendar = Calendar.current let dayStart = calendar.startOfDay(for: votingDate) guard let yearAgo = calendar.date(byAdding: .day, value: -365, to: dayStart) else { return (0, nil) } let entries = WidgetDataProvider.shared.getData(startDate: yearAgo, endDate: votingDate, includedDays: []) .filter { $0.mood != .missing && $0.mood != .placeholder } guard !entries.isEmpty else { return (0, nil) } let datesWithEntries = Set(entries.map { calendar.startOfDay(for: $0.forDate) }) let todaysEntry = entries.first { calendar.isDate($0.forDate, inSameDayAs: votingDate) } let todaysMood = todaysEntry?.mood var streak = 0 var checkDate = votingDate if !datesWithEntries.contains(dayStart) { checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate } while true { let checkDayStart = calendar.startOfDay(for: checkDate) if datesWithEntries.contains(checkDayStart) { streak += 1 checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate } else { break } } cachedStreakData = (streak, todaysMood, votingDate) return (streak, todaysMood) #else let result = DataController.shared.calculateStreak(from: votingDate) cachedStreakData = (result.streak, result.todaysMood, votingDate) return result #endif } /// Check if user has rated today func hasRatedToday() -> Bool { return getStreakData().todaysMood != nil } /// Calculate current streak func calculateStreak() -> Int { return getStreakData().streak } /// Get today's mood if logged func getTodaysMood() -> Mood? { return getStreakData().todaysMood } /// Invalidate cached streak data (call when mood is logged) func invalidateCache() { cachedStreakData = nil } /// Schedule Live Activity based on current time and rating time func scheduleBasedOnCurrentTime() { invalidateTimers() guard ActivityAuthorizationInfo().areActivitiesEnabled else { #if DEBUG print("[LiveActivity] Live Activities not enabled by user") #endif return } let now = Date() guard let startTime = getStartTime(), let endTime = getEndTime() else { #if DEBUG print("[LiveActivity] No rating time configured - skipping") #endif return } let hasRated = hasRatedToday() #if DEBUG print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)") #endif // If user has already rated today, don't show activity - schedule for next day if hasRated { #if DEBUG print("[LiveActivity] User already rated today - scheduling for next day") #endif scheduleForNextDay() return } // Check if we're within the activity window (rating time to 5 hrs after) if now >= startTime && now <= endTime { // Start activity immediately #if DEBUG print("[LiveActivity] Within window - starting activity now") #endif 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 #if DEBUG print("[LiveActivity] Before window - scheduling start for \(startTime)") #endif scheduleStart(at: startTime) scheduleEnd(at: endTime) } else { // Past the window for today, schedule for tomorrow #if DEBUG print("[LiveActivity] Past window - scheduling for tomorrow") #endif 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 } }