Fetch and display weather data (temp, condition, hi/lo, humidity) when users log a mood. Weather is stored as JSON on MoodEntryModel and shown as a card in EntryDetailView. Premium-gated with location permission prompt. Includes BGTask retry for failed fetches and full analytics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
284 lines
11 KiB
Swift
284 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)")
|
|
|
|
let hasAccess = !IAPManager.shared.shouldShowPaywall
|
|
|
|
// 1. Sync to HealthKit if enabled, requested, and user has full access
|
|
if syncHealthKit {
|
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
|
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)
|
|
|
|
// 9. Fetch weather if enabled and user has full access
|
|
let weatherEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.weatherEnabled.rawValue)
|
|
if weatherEnabled && hasAccess {
|
|
Task {
|
|
await WeatherManager.shared.fetchAndSaveWeather(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
|
|
}
|
|
}
|