- 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>
371 lines
13 KiB
Swift
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
|
|
}
|
|
}
|
|
|