- 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>
168 lines
5.7 KiB
Swift
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
|