Consolidate extension data providers and add side effects catch-up
- Create unified ExtensionDataProvider for Widget and Watch targets - Remove duplicate WatchDataProvider and WatchConnectivityManager from Watch App - Add side effects catch-up mechanism in MoodLogger for widget votes - Process pending side effects on app launch and midnight background task - Reduce ~450 lines of duplicated code across targets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,10 @@ class BGTask {
|
||||
}
|
||||
|
||||
DataController.shared.fillInMissingDates()
|
||||
|
||||
// Catch up on any side effects from widget/watch votes
|
||||
MoodLogger.shared.processPendingSideEffects()
|
||||
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ struct FeelsApp: App {
|
||||
}
|
||||
}.onChange(of: scenePhase) { _, newPhase in
|
||||
if newPhase == .background {
|
||||
//BGTask.scheduleBackgroundProcessing()
|
||||
BGTask.scheduleBackgroundProcessing()
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
// Lock the app when going to background
|
||||
authManager.lock()
|
||||
@@ -89,6 +89,9 @@ struct FeelsApp: App {
|
||||
}
|
||||
// Reschedule Live Activity when app becomes active
|
||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||
|
||||
// Catch up on side effects from widget/watch votes
|
||||
MoodLogger.shared.processPendingSideEffects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
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.
|
||||
@@ -14,6 +15,11 @@ import WidgetKit
|
||||
final class MoodLogger {
|
||||
static let shared = MoodLogger()
|
||||
|
||||
private static let logger = Logger(subsystem: "com.tt.ifeel", category: "MoodLogger")
|
||||
|
||||
/// Key for tracking the last date side effects were applied
|
||||
private static let lastSideEffectsDateKey = "lastSideEffectsAppliedDate"
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Log a mood entry with all associated side effects.
|
||||
@@ -35,10 +41,30 @@ final class MoodLogger {
|
||||
// 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 }
|
||||
|
||||
// 2. Sync to HealthKit if enabled and requested
|
||||
Self.logger.info("Applying side effects for mood \(mood.rawValue) on \(date)")
|
||||
|
||||
// 1. Sync to HealthKit if enabled and requested
|
||||
if syncHealthKit {
|
||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||
if healthKitEnabled {
|
||||
@@ -48,27 +74,76 @@ final class MoodLogger {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Calculate current streak for Live Activity and TipKit
|
||||
// 2. Calculate current streak for Live Activity and TipKit
|
||||
let streak = calculateCurrentStreak()
|
||||
|
||||
// 4. Update Live Activity
|
||||
// 3. Update Live Activity
|
||||
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
||||
LiveActivityScheduler.shared.scheduleForNextDay()
|
||||
|
||||
// 5. Update TipKit parameters if requested
|
||||
// 4. Update TipKit parameters if requested
|
||||
if updateTips {
|
||||
TipsManager.shared.onMoodLogged()
|
||||
TipsManager.shared.updateStreak(streak)
|
||||
}
|
||||
|
||||
// 6. Request app review at moments of delight
|
||||
// 5. Request app review at moments of delight
|
||||
ReviewRequestManager.shared.onMoodLogged(streak: streak)
|
||||
|
||||
// 7. Reload widgets
|
||||
// 6. Reload widgets
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
// 8. Notify watch to refresh complications
|
||||
// 7. Notify watch to refresh complications
|
||||
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||
|
||||
// 8. Mark side effects as applied for this date
|
||||
markSideEffectsApplied(for: date)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
// MARK: - Side Effects Tracking
|
||||
|
||||
/// Mark that side effects have been applied for a given date
|
||||
private func markSideEffectsApplied(for date: Date) {
|
||||
let dateString = ISO8601DateFormatter().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 = ISO8601DateFormatter().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
|
||||
|
||||
@@ -86,159 +86,3 @@ protocol DataControlling: MoodDataReading, MoodDataWriting, MoodDataDeleting, Mo
|
||||
// MARK: - DataController Conformance
|
||||
|
||||
extension DataController: DataControlling {}
|
||||
|
||||
// MARK: - Widget Data Provider
|
||||
|
||||
/// Lightweight read-only data provider for widgets
|
||||
/// Uses UserDefaults-cached data when possible to avoid SwiftData overhead
|
||||
@MainActor
|
||||
final class WidgetDataProvider: MoodDataReading {
|
||||
|
||||
static let shared = WidgetDataProvider()
|
||||
|
||||
private var _container: ModelContainer?
|
||||
|
||||
private var container: ModelContainer {
|
||||
if let existing = _container {
|
||||
return existing
|
||||
}
|
||||
let newContainer = SharedModelContainer.createWithFallback(useCloudKit: true)
|
||||
_container = newContainer
|
||||
return newContainer
|
||||
}
|
||||
|
||||
private var modelContext: ModelContext {
|
||||
container.mainContext
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - MoodDataReading
|
||||
|
||||
func getEntry(byDate date: Date) -> MoodEntryModel? {
|
||||
let startDate = Calendar.current.startOfDay(for: date)
|
||||
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
||||
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate <= endDate
|
||||
},
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] {
|
||||
let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays
|
||||
|
||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate &&
|
||||
entry.forDate <= endDate &&
|
||||
weekDays.contains(entry.weekDay)
|
||||
},
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] {
|
||||
let data = getData(
|
||||
startDate: Date(timeIntervalSince1970: 0),
|
||||
endDate: Date(),
|
||||
includedDays: includedDays
|
||||
).sorted { $0.forDate < $1.forDate }
|
||||
|
||||
guard let earliest = data.first,
|
||||
let latest = data.last else { return [:] }
|
||||
|
||||
let calendar = Calendar.current
|
||||
let earliestYear = calendar.component(.year, from: earliest.forDate)
|
||||
let latestYear = calendar.component(.year, from: latest.forDate)
|
||||
|
||||
var result = [Int: [Int: [MoodEntryModel]]]()
|
||||
|
||||
for year in earliestYear...latestYear {
|
||||
var monthData = [Int: [MoodEntryModel]]()
|
||||
|
||||
for month in 1...12 {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = 1
|
||||
|
||||
guard let startOfMonth = calendar.date(from: components) else { continue }
|
||||
|
||||
let items = getData(
|
||||
startDate: startOfMonth,
|
||||
endDate: startOfMonth.endOfMonth,
|
||||
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
||||
)
|
||||
|
||||
if !items.isEmpty {
|
||||
monthData[month] = items
|
||||
}
|
||||
}
|
||||
|
||||
result[year] = monthData
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var earliestEntry: MoodEntryModel? {
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
var latestEntry: MoodEntryModel? {
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
// MARK: - Widget-Specific Helpers
|
||||
|
||||
/// Get today's mood entry
|
||||
func getTodayEntry() -> MoodEntryModel? {
|
||||
getEntry(byDate: Date())
|
||||
}
|
||||
|
||||
/// Get the current streak count
|
||||
func getCurrentStreak() -> Int {
|
||||
let entries = getData(
|
||||
startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!,
|
||||
endDate: Date(),
|
||||
includedDays: [1, 2, 3, 4, 5, 6, 7]
|
||||
).sorted { $0.forDate > $1.forDate }
|
||||
|
||||
var streak = 0
|
||||
var currentDate = Calendar.current.startOfDay(for: Date())
|
||||
|
||||
for entry in entries {
|
||||
let entryDate = Calendar.current.startOfDay(for: entry.forDate)
|
||||
|
||||
if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder {
|
||||
streak += 1
|
||||
currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
|
||||
} else if entryDate < currentDate {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
/// Invalidate cached container (call when data might have changed)
|
||||
func invalidateCache() {
|
||||
_container = nil
|
||||
}
|
||||
}
|
||||
|
||||
206
Shared/Persisence/ExtensionDataProvider.swift
Normal file
206
Shared/Persisence/ExtensionDataProvider.swift
Normal file
@@ -0,0 +1,206 @@
|
||||
//
|
||||
// ExtensionDataProvider.swift
|
||||
// Feels
|
||||
//
|
||||
// Unified data provider for Widget and Watch extensions.
|
||||
// Uses App Group container with CloudKit disabled for extension safety.
|
||||
//
|
||||
// Add this file to: FeelsWidgetExtension, Feels Watch App
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import WidgetKit
|
||||
import os.log
|
||||
|
||||
/// Unified data provider for Widget and Watch extensions
|
||||
/// Uses its own ModelContainer with App Group storage (no CloudKit)
|
||||
@MainActor
|
||||
final class ExtensionDataProvider {
|
||||
|
||||
static let shared = ExtensionDataProvider()
|
||||
|
||||
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "ExtensionDataProvider")
|
||||
|
||||
private var _container: ModelContainer?
|
||||
|
||||
private var container: ModelContainer {
|
||||
if let existing = _container {
|
||||
return existing
|
||||
}
|
||||
let newContainer = createContainer()
|
||||
_container = newContainer
|
||||
return newContainer
|
||||
}
|
||||
|
||||
/// Creates the ModelContainer for extension data access
|
||||
private func createContainer() -> ModelContainer {
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
|
||||
// Try to use shared app group container
|
||||
do {
|
||||
let storeURL = try getStoreURL()
|
||||
let configuration = ModelConfiguration(
|
||||
schema: schema,
|
||||
url: storeURL,
|
||||
cloudKitDatabase: .none // Extensions don't sync directly
|
||||
)
|
||||
return try ModelContainer(for: schema, configurations: [configuration])
|
||||
} catch {
|
||||
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")
|
||||
// Fall back to in-memory storage
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [config])
|
||||
} catch {
|
||||
Self.logger.critical("Failed to create ModelContainer: \(error.localizedDescription)")
|
||||
preconditionFailure("Unable to create ModelContainer: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func getStoreURL() throws -> URL {
|
||||
let appGroupID = Constants.currentGroupShareId
|
||||
guard let containerURL = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: appGroupID
|
||||
) else {
|
||||
throw NSError(domain: "ExtensionDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"])
|
||||
}
|
||||
#if DEBUG
|
||||
return containerURL.appendingPathComponent("Feels-Debug.store")
|
||||
#else
|
||||
return containerURL.appendingPathComponent("Feels.store")
|
||||
#endif
|
||||
}
|
||||
|
||||
private var modelContext: ModelContext {
|
||||
container.mainContext
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Read Operations
|
||||
|
||||
/// Get a single entry for a specific date
|
||||
func getEntry(byDate date: Date) -> MoodEntryModel? {
|
||||
let startDate = Calendar.current.startOfDay(for: date)
|
||||
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
|
||||
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate <= endDate
|
||||
},
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Get today's mood entry
|
||||
func getTodayEntry() -> MoodEntryModel? {
|
||||
getEntry(byDate: Date())
|
||||
}
|
||||
|
||||
/// Get entries within a date range, optionally filtered by weekdays
|
||||
/// - Parameters:
|
||||
/// - startDate: Start of the date range
|
||||
/// - endDate: End of the date range
|
||||
/// - includedDays: Weekdays to include (1=Sunday, 7=Saturday). Empty = all days.
|
||||
func getData(startDate: Date, endDate: Date, includedDays: [Int] = []) -> [MoodEntryModel] {
|
||||
let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays
|
||||
|
||||
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate &&
|
||||
entry.forDate <= endDate &&
|
||||
weekDays.contains(entry.weekDay)
|
||||
},
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
|
||||
return (try? modelContext.fetch(descriptor)) ?? []
|
||||
}
|
||||
|
||||
/// Get the earliest entry in the database
|
||||
var earliestEntry: MoodEntryModel? {
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
sortBy: [SortDescriptor(\.forDate, order: .forward)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Get the latest entry in the database
|
||||
var latestEntry: MoodEntryModel? {
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Get the current streak count
|
||||
/// - Parameter includedDays: Weekdays to include in streak calculation (empty = all days)
|
||||
func getCurrentStreak(includedDays: [Int] = []) -> Int {
|
||||
let entries = getData(
|
||||
startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!,
|
||||
endDate: Date(),
|
||||
includedDays: includedDays
|
||||
).sorted { $0.forDate > $1.forDate }
|
||||
|
||||
var streak = 0
|
||||
var currentDate = Calendar.current.startOfDay(for: Date())
|
||||
|
||||
for entry in entries {
|
||||
let entryDate = Calendar.current.startOfDay(for: entry.forDate)
|
||||
|
||||
if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder {
|
||||
streak += 1
|
||||
currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
|
||||
} else if entryDate < currentDate {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
// MARK: - Write Operations
|
||||
|
||||
/// Add a new mood entry
|
||||
/// - Parameters:
|
||||
/// - mood: The mood to record
|
||||
/// - date: The date for the entry
|
||||
/// - entryType: The source of the entry (widget, watch, etc.)
|
||||
func add(mood: Mood, forDate date: Date, entryType: EntryType) {
|
||||
// Delete existing entry for this date if present
|
||||
if let existing = getEntry(byDate: date) {
|
||||
modelContext.delete(existing)
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
let entry = MoodEntryModel(
|
||||
forDate: date,
|
||||
mood: mood,
|
||||
entryType: entryType
|
||||
)
|
||||
|
||||
modelContext.insert(entry)
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
Self.logger.info("Saved mood \(mood.rawValue) for \(date)")
|
||||
|
||||
// Refresh all widgets/complications immediately
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
} catch {
|
||||
Self.logger.error("Failed to save mood: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalidate cached container (call when data might have changed)
|
||||
func invalidateCache() {
|
||||
_container = nil
|
||||
}
|
||||
}
|
||||
124
Shared/SharedMoodIntent.swift
Normal file
124
Shared/SharedMoodIntent.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// 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.ifeel.widget", category: "WidgetMoodSaver")
|
||||
|
||||
@MainActor
|
||||
static func save(mood: Mood, date: Date) {
|
||||
let schema = Schema([MoodEntryModel.self])
|
||||
let appGroupID = Constants.currentGroupShareId
|
||||
|
||||
guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupID) else {
|
||||
logger.error("App Group not available")
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let storeURL = containerURL.appendingPathComponent("Feels-Debug.store")
|
||||
#else
|
||||
let storeURL = containerURL.appendingPathComponent("Feels.store")
|
||||
#endif
|
||||
|
||||
do {
|
||||
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
||||
let container = try ModelContainer(for: schema, configurations: [config])
|
||||
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)!
|
||||
|
||||
var descriptor = FetchDescriptor<MoodEntryModel>(
|
||||
predicate: #Predicate { entry in
|
||||
entry.forDate >= startDate && entry.forDate <= endDate
|
||||
}
|
||||
)
|
||||
descriptor.fetchLimit = 1
|
||||
|
||||
if let existing = try? context.fetch(descriptor).first {
|
||||
context.delete(existing)
|
||||
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")
|
||||
} catch {
|
||||
logger.error("Failed to save mood: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user