Files
Reflect/Shared/MoodLogger.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

275 lines
11 KiB
Swift

//
// MoodLogger.swift
// Reflect
//
// Centralized mood logging service that handles all side effects
//
import Foundation
import WidgetKit
import os.log
/// Centralized service for logging moods with all associated side effects.
/// All mood entry points should use this service to ensure consistent behavior.
@MainActor
final class MoodLogger {
static let shared = MoodLogger()
private static let logger = Logger(subsystem: "com.88oakapps.reflect", category: "MoodLogger")
/// Key for tracking the last date side effects were applied
private static let lastSideEffectsDateKey = "lastSideEffectsAppliedDate"
private static let sideEffectsDateFormatter = ISO8601DateFormatter()
private init() {}
/// Log a mood entry with all associated side effects.
/// This is the single source of truth for mood logging in the app.
///
/// - Parameters:
/// - mood: The mood to log
/// - date: The date for the mood entry
/// - entryType: The source of the mood entry (header, widget, siri, etc.)
/// - syncHealthKit: Whether to sync to HealthKit (default true, but widget can't access HealthKit)
/// - updateTips: Whether to update TipKit parameters (default true, but widget can't access TipKit)
func logMood(
_ mood: Mood,
for date: Date,
entryType: EntryType,
syncHealthKit: Bool = true,
updateTips: Bool = true
) {
// 1. Add mood entry to data store
DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
// Apply side effects and mark as complete
applySideEffects(mood: mood, for: date, syncHealthKit: syncHealthKit, updateTips: updateTips)
}
/// Apply side effects for a mood entry without saving the entry itself.
/// Used for catch-up when widget saved data but couldn't run side effects.
///
/// - Parameters:
/// - mood: The mood that was logged
/// - date: The date of the mood entry
/// - syncHealthKit: Whether to sync to HealthKit
/// - updateTips: Whether to update TipKit parameters
func applySideEffects(
mood: Mood,
for date: Date,
syncHealthKit: Bool = true,
updateTips: Bool = true
) {
// Skip side effects for placeholder/missing moods
guard mood != .missing && mood != .placeholder else { return }
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
// 1. Sync to HealthKit if enabled, requested, and user has full access
if syncHealthKit {
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: date)
}
}
}
// 2. Calculate current streak for Live Activity and TipKit
let streak = calculateCurrentStreak()
// 3. Update Live Activity
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
LiveActivityScheduler.shared.invalidateCache() // Clear stale hasRated cache
LiveActivityScheduler.shared.scheduleForNextDay()
// 4. Update tips parameters if requested
if updateTips {
ReflectTipsManager.shared.onMoodLogged()
ReflectTipsManager.shared.updateStreak(streak)
}
// 5. Request app review at moments of delight
ReviewRequestManager.shared.onMoodLogged(streak: streak)
// 6. Reload widgets
WidgetCenter.shared.reloadAllTimelines()
// 7. Notify watch to refresh complications
WatchConnectivityManager.shared.notifyWatchToReload()
// 8. Mark side effects as applied for this date
markSideEffectsApplied(for: date)
}
/// Delete a mood entry for a specific date with all associated cleanup.
/// Replaces the entry with a .missing placeholder and cleans up HealthKit/Live Activity state.
///
/// - Parameter date: The date of the entry to delete
func deleteMood(forDate date: Date) {
// 1. Delete all entries for this date and replace with missing placeholder
DataController.shared.deleteAllEntries(forDate: date)
DataController.shared.add(mood: .missing, forDate: date, entryType: .filledInMissing)
Self.logger.info("Deleted mood entry for \(date)")
// 2. Delete HealthKit entry if enabled and user has access
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.deleteMood(for: date)
}
}
// 3. Recalculate streak and update Live Activity
let streak = calculateCurrentStreak()
LiveActivityManager.shared.updateActivity(streak: streak, mood: .missing)
LiveActivityScheduler.shared.invalidateCache()
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
// 4. Reload widgets
WidgetCenter.shared.reloadAllTimelines()
// 5. Notify watch to refresh
WatchConnectivityManager.shared.notifyWatchToReload()
}
/// Delete all mood data with full cleanup of HealthKit and Live Activity state.
/// Used when user clears all data from settings.
func deleteAllData() {
// 1. Clear all entries from the database
DataController.shared.clearDB()
Self.logger.info("Cleared all mood data")
// 2. Delete all HealthKit entries if enabled and user has access
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.deleteAllMoods()
}
}
// 3. End all Live Activities
Task {
await LiveActivityManager.shared.endAllActivities()
}
LiveActivityScheduler.shared.invalidateCache()
// 4. Reload widgets
WidgetCenter.shared.reloadAllTimelines()
// 5. Notify watch to refresh
WatchConnectivityManager.shared.notifyWatchToReload()
}
/// Update an existing mood entry with all associated side effects.
/// Centralizes the update logic that was previously split between ViewModel and DataController.
///
/// - Parameters:
/// - entryDate: The date of the entry to update
/// - mood: The new mood value
/// - Returns: Whether the update was successful
@discardableResult
func updateMood(entryDate: Date, withMood mood: Mood) -> Bool {
let success = DataController.shared.update(entryDate: entryDate, withMood: mood)
guard success else {
Self.logger.error("Failed to update mood entry for \(entryDate)")
return false
}
// Skip side effects for placeholder/missing moods
guard mood != .missing && mood != .placeholder else { return true }
// Sync to HealthKit if enabled and user has full access
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
let hasAccess = !IAPManager.shared.shouldShowPaywall
if healthKitEnabled && hasAccess {
Task {
try? await HealthKitManager.shared.saveMood(mood, for: entryDate)
}
}
// Reload widgets
WidgetCenter.shared.reloadAllTimelines()
// Recalculate streak and update Live Activity
let streak = calculateCurrentStreak()
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
LiveActivityScheduler.shared.invalidateCache()
// Notify watch to refresh
WatchConnectivityManager.shared.notifyWatchToReload()
return true
}
/// Check for and process any pending side effects from widget/extension votes.
/// Call this when the app becomes active to ensure all side effects are applied.
func processPendingSideEffects() {
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dayStart = Calendar.current.startOfDay(for: votingDate)
let dayEnd = Calendar.current.date(byAdding: .day, value: 1, to: dayStart)!
// Check if there's an entry for the current voting date
guard let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first,
entry.mood != .missing && entry.mood != .placeholder else {
Self.logger.debug("No valid mood entry for today, skipping side effects catch-up")
return
}
// Check if side effects were already applied for this date
if sideEffectsApplied(for: votingDate) {
Self.logger.debug("Side effects already applied for \(votingDate)")
return
}
// Apply the missing side effects
Self.logger.info("Catching up side effects for widget/watch vote: \(entry.mood.rawValue)")
applySideEffects(mood: entry.mood, for: entry.forDate)
}
/// Import mood entries in batch with a single round of side effects.
/// Used for CSV import to avoid O(n) widget reloads, streak calcs, etc.
func importMoods(_ entries: [(mood: Mood, date: Date, entryType: EntryType)]) {
guard !entries.isEmpty else { return }
DataController.shared.addBatch(entries: entries)
// Single round of side effects
WidgetCenter.shared.reloadAllTimelines()
WatchConnectivityManager.shared.notifyWatchToReload()
LiveActivityScheduler.shared.invalidateCache()
}
// MARK: - Side Effects Tracking
/// Mark that side effects have been applied for a given date
private func markSideEffectsApplied(for date: Date) {
let dateString = Self.sideEffectsDateFormatter.string(from: Calendar.current.startOfDay(for: date))
GroupUserDefaults.groupDefaults.set(dateString, forKey: Self.lastSideEffectsDateKey)
}
/// Check if side effects have been applied for a given date
private func sideEffectsApplied(for date: Date) -> Bool {
guard let lastDateString = GroupUserDefaults.groupDefaults.string(forKey: Self.lastSideEffectsDateKey),
let lastDate = Self.sideEffectsDateFormatter.date(from: lastDateString) else {
return false
}
let targetDay = Calendar.current.startOfDay(for: date)
let lastDay = Calendar.current.startOfDay(for: lastDate)
return targetDay == lastDay
}
/// Calculate the current mood streak using optimized batch query
private func calculateCurrentStreak() -> Int {
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
return DataController.shared.calculateStreak(from: votingDate).streak
}
}