- 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 <noreply@anthropic.com>
351 lines
13 KiB
Swift
351 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)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|