Files
Reflect/Shared/MoodStreakActivity.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

372 lines
13 KiB
Swift

//
// 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<MoodStreakAttributes>?
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<MoodStreakAttributes>.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 {
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
}
}