Files
Reflect/Shared/SharedMoodIntent.swift
Trey t f1cd81c395 Fix 7 data mutation layer risks identified in audit
- save()/saveAndRunDataListeners() now return @discardableResult Bool;
  listeners only fire on successful save
- MoodLogger.updateMood() now recalculates streak, updates Live Activity,
  and notifies watch (was missing these side effects)
- CSV import uses new addBatch()/importMoods() for O(1) side effects
  instead of O(n) per-row widget reloads and streak calcs
- Foreground task ordering: fillInMissingDates() now runs before
  removeDuplicates() so backfill-created duplicates are caught same cycle
- WidgetMoodSaver deletes ALL entries for date (was fetchLimit=1, leaving
  CloudKit sync duplicates behind)
- cleanupPhotoIfNeeded logs warning on failed photo deletion instead of
  silently orphaning files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:09:11 -06:00

168 lines
5.7 KiB
Swift

//
// SharedMoodIntent.swift
// Feels
//
// Single VoteMoodIntent for all targets.
// Main app uses ForegroundContinuableIntent to run widget intents in app process.
//
// Add this file to ALL targets: Feels (iOS), FeelsWidgetExtension, Feels Watch App
//
import AppIntents
import SwiftUI
import SwiftData
import WidgetKit
import os.log
// MARK: - Vote Mood Intent
struct VoteMoodIntent: AppIntent {
static var title: LocalizedStringResource = "Vote Mood"
static var description = IntentDescription("Record your mood for today")
static var openAppWhenRun: Bool = false
@Parameter(title: "Mood")
var moodValue: Int
init() {
self.moodValue = 2
}
init(mood: Mood) {
self.moodValue = mood.rawValue
}
@MainActor
func perform() async throws -> some IntentResult {
let mood = Mood(rawValue: moodValue) ?? .average
#if os(watchOS)
// Watch: Send to iPhone via WatchConnectivity
let date = Date()
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: moodValue, date: date)
WidgetCenter.shared.reloadAllTimelines()
#elseif WIDGET_EXTENSION
// Widget: Save to shared container, main app handles side effects
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
WidgetMoodSaver.save(mood: mood, date: votingDate)
WidgetCenter.shared.reloadAllTimelines()
#else
// Main app: Full logging with all side effects
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
MoodLogger.shared.logMood(mood, for: votingDate, entryType: .widget)
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
#endif
return .result()
}
}
// MARK: - Main App: Run widget intents in app process
#if !WIDGET_EXTENSION && !os(watchOS)
extension VoteMoodIntent: ForegroundContinuableIntent {}
#endif
// MARK: - Widget: Save to shared container
#if WIDGET_EXTENSION
enum WidgetMoodSaver {
private static let logger = Logger(subsystem: "com.tt.feels.widget", category: "WidgetMoodSaver")
private static var cachedContainer: ModelContainer?
@MainActor
static func save(mood: Mood, date: Date) {
do {
let container = try getOrCreateContainer()
try performSave(mood: mood, date: date, container: container)
} catch {
// Container may be stale or corrupted discard cache and retry once
logger.warning("First save attempt failed, retrying with fresh container: \(error.localizedDescription)")
cachedContainer = nil
do {
let container = try getOrCreateContainer()
try performSave(mood: mood, date: date, container: container)
} catch {
logger.error("Failed to save mood after retry: \(error.localizedDescription)")
}
}
}
private static func getOrCreateContainer() throws -> ModelContainer {
if let existing = cachedContainer {
return existing
}
let schema = Schema([MoodEntryModel.self])
let appGroupID = Constants.currentGroupShareId
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
logger.error("App Group not available")
throw WidgetMoodSaverError.appGroupUnavailable
}
#if DEBUG
let storeURL = containerURL.appendingPathComponent("Feels-Debug.store")
#else
let storeURL = containerURL.appendingPathComponent("Feels.store")
#endif
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
let container = try ModelContainer(for: schema, configurations: [config])
cachedContainer = container
return container
}
@MainActor
private static func performSave(mood: Mood, date: Date, container: ModelContainer) throws {
let context = container.mainContext
// Delete existing entry for this date
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate < endDate
}
)
let existing = try context.fetch(descriptor)
if !existing.isEmpty {
for entry in existing {
context.delete(entry)
}
try context.save()
}
// Create new entry
let entry = MoodEntryModel(forDate: date, mood: mood, entryType: .widget)
context.insert(entry)
try context.save()
logger.info("Saved mood \(mood.rawValue) from widget")
// Note: The widget cannot run full side effects (HealthKit, streaks, analytics, etc.)
// because it runs in a separate extension process without access to MoodLogger.
// When the main app returns to the foreground, it calls
// MoodLogger.shared.processPendingSideEffects() to catch up on any side effects
// that were missed from widget or watch entries.
}
enum WidgetMoodSaverError: LocalizedError {
case appGroupUnavailable
var errorDescription: String? {
switch self {
case .appGroupUnavailable:
return "App Group container is not available"
}
}
}
}
#endif