Files
Reflect/Shared/MoodStreakActivity.swift
Trey t bea2d3bbc9 Update Neon colors and show color circles in theme picker
- Update NeonMoodTint to use synthwave colors matching Neon voting style
  (cyan, lime, yellow, orange, magenta)
- Replace text label with 5 color circles in theme preview Colors row
- Remove unused textColor customization code and picker views
- Add .id(moodTint) to Month/Year views for color refresh
- Clean up various unused color-related code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 00:08:01 -06:00

371 lines
13 KiB
Swift

//
// 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<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 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)
}
/// 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
}
}