diff --git a/Feels Watch App/ContentView.swift b/Feels Watch App/ContentView.swift index 6c52258..2021d95 100644 --- a/Feels Watch App/ContentView.swift +++ b/Feels Watch App/ContentView.swift @@ -53,7 +53,7 @@ struct ContentView: View { // Also save locally as fallback and for immediate complication updates Task { @MainActor in // Always save locally for immediate complication display - WatchDataProvider.shared.addMood(mood, forDate: date) + ExtensionDataProvider.shared.add(mood: mood, forDate: date, entryType: .watch) // Send to iPhone - it will handle HealthKit, Live Activity, etc. _ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date) diff --git a/Feels Watch App/FeelsComplication.swift b/Feels Watch App/FeelsComplication.swift index 985b268..01e0acd 100644 --- a/Feels Watch App/FeelsComplication.swift +++ b/Feels Watch App/FeelsComplication.swift @@ -38,8 +38,8 @@ struct FeelsTimelineProvider: TimelineProvider { @MainActor private func createEntry() -> FeelsEntry { - let todayEntry = WatchDataProvider.shared.getTodayEntry() - let streak = WatchDataProvider.shared.getCurrentStreak() + let todayEntry = ExtensionDataProvider.shared.getTodayEntry() + let streak = ExtensionDataProvider.shared.getCurrentStreak() return FeelsEntry( date: Date(), diff --git a/Feels Watch App/WatchConnectivityManager.swift b/Feels Watch App/WatchConnectivityManager.swift deleted file mode 100644 index e763e97..0000000 --- a/Feels Watch App/WatchConnectivityManager.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// WatchConnectivityManager.swift -// Feels Watch App -// -// Watch-side connectivity - sends mood to iPhone for centralized logging. -// - -import Foundation -import WatchConnectivity -import WidgetKit -import os.log - -/// Watch-side connectivity manager -/// Sends mood votes to iPhone for centralized logging -final class WatchConnectivityManager: NSObject, ObservableObject { - - static let shared = WatchConnectivityManager() - - private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchConnectivity") - - private var session: WCSession? - - private override init() { - super.init() - - if WCSession.isSupported() { - session = WCSession.default - session?.delegate = self - session?.activate() - Self.logger.info("WCSession activated") - } else { - Self.logger.warning("WCSession not supported") - } - } - - // MARK: - Watch → iOS - - /// Send mood to iOS app for centralized logging - func sendMoodToPhone(mood: Int, date: Date) -> Bool { - guard let session = session, - session.activationState == .activated else { - Self.logger.warning("WCSession not ready") - return false - } - - let message: [String: Any] = [ - "action": "logMood", - "mood": mood, - "date": date.timeIntervalSince1970 - ] - - // Use transferUserInfo for guaranteed delivery - session.transferUserInfo(message) - Self.logger.info("Sent mood \(mood) to iPhone") - return true - } -} - -// MARK: - WCSessionDelegate - -extension WatchConnectivityManager: WCSessionDelegate { - - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - if let error = error { - Self.logger.error("WCSession activation failed: \(error.localizedDescription)") - } else { - Self.logger.info("WCSession activated: \(activationState.rawValue)") - } - } - - // Receive reload notification from iOS - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { - if userInfo["action"] as? String == "reloadWidgets" { - Self.logger.info("Received reload notification from iPhone") - Task { @MainActor in - WidgetCenter.shared.reloadAllTimelines() - } - } - } - - func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { - if message["action"] as? String == "reloadWidgets" { - Task { @MainActor in - WidgetCenter.shared.reloadAllTimelines() - } - } - } -} diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index f439ee2..e9d8f17 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -9,8 +9,8 @@ /* Begin PBXBuildFile section */ 1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; }; 1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; }; - 1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; }; - 1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; }; + 1C9566422EF8F5F70032E68F /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566412EF8F5F70032E68F /* Charts */; }; + 1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566432EF8F5F70032E68F /* Algorithms */; }; 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; 1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; }; 1CD90B09278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */; }; @@ -128,7 +128,9 @@ MoodStreakActivity.swift, Onboarding/OnboardingData.swift, Onboarding/views/OnboardingDay.swift, + Persisence/ExtensionDataProvider.swift, Random.swift, + SharedMoodIntent.swift, ShowBasedOnVoteLogics.swift, Views/BGView.swift, Views/CustomIcon/IconView.swift, @@ -143,7 +145,10 @@ membershipExceptions = ( Models/Mood.swift, Models/MoodEntryModel.swift, + Persisence/ExtensionDataProvider.swift, Random.swift, + Services/WatchConnectivityManager.swift, + SharedMoodIntent.swift, ); target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */; }; @@ -160,10 +165,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */, + 1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */, 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */, + 1C9566422EF8F5F70032E68F /* Charts in Frameworks */, 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */, - 1C2618FA2795E41D00FDC148 /* Charts in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -306,8 +311,8 @@ ); name = "Feels (iOS)"; packageProductDependencies = ( - 1C2618F92795E41D00FDC148 /* Charts */, - 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */, + 1C9566412EF8F5F70032E68F /* Charts */, + 1C9566432EF8F5F70032E68F /* Algorithms */, ); productName = "Feels (iOS)"; productReference = 1CD90AF5278C7DE0001C4FEA /* iFeels.app */; @@ -458,8 +463,8 @@ ); mainGroup = 1CD90AE5278C7DDF001C4FEA; packageReferences = ( - 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */, - 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */, + 1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */, + 1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */, ); productRefGroup = 1CD90AF6278C7DE0001C4FEA /* Products */; projectDirPath = ""; @@ -1119,7 +1124,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */ = { + 1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/akatreyt/ChartsPackage"; requirement = { @@ -1127,26 +1132,26 @@ kind = branch; }; }; - 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */ = { + 1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ggruen/CloudKitSyncMonitor"; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.0; + kind = exactVersion; + version = 0.2.1; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1C2618F92795E41D00FDC148 /* Charts */ = { + 1C9566412EF8F5F70032E68F /* Charts */ = { isa = XCSwiftPackageProductDependency; - package = 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */; + package = 1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */; productName = Charts; }; - 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */ = { + 1C9566432EF8F5F70032E68F /* Algorithms */ = { isa = XCSwiftPackageProductDependency; - package = 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */; - productName = CloudKitSyncMonitor; + package = 1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift index ecc99e5..29c6407 100644 --- a/FeelsWidget2/FeelsVoteWidget.swift +++ b/FeelsWidget2/FeelsVoteWidget.swift @@ -4,50 +4,13 @@ // // Interactive widget for mood voting (iOS 17+) // +// Note: VoteMoodIntent is defined in Shared/SharedMoodIntent.swift +// import WidgetKit import SwiftUI import AppIntents -// MARK: - App Intent for Mood Voting - -struct VoteMoodIntent: AppIntent { - static var title: LocalizedStringResource = "Vote Mood" - static var description = IntentDescription("Record your mood for today") - - // Run directly in widget extension for immediate feedback - 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 - let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - - // Save mood via WidgetDataProvider (uses shared App Group container) - WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget) - - // Store last voted date - let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate)) - GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue) - - // Reload all widget timelines to show updated state - WidgetCenter.shared.reloadAllTimelines() - - return .result() - } -} - // MARK: - Vote Widget Provider struct VoteWidgetProvider: TimelineProvider { diff --git a/FeelsWidget2/WidgetDataProvider.swift b/FeelsWidget2/WidgetDataProvider.swift index cdc889a..9e129ee 100644 --- a/FeelsWidget2/WidgetDataProvider.swift +++ b/FeelsWidget2/WidgetDataProvider.swift @@ -2,198 +2,10 @@ // WidgetDataProvider.swift // FeelsWidget // -// Lightweight read-only data provider for widgets. -// Uses its own ModelContainer to avoid conflicts with the main app. +// Typealias to ExtensionDataProvider for backward compatibility. // import Foundation -import SwiftData -import WidgetKit -import os.log -/// Lightweight read-only data provider for widgets -/// Uses its own ModelContainer to avoid SwiftData conflicts with main app -@MainActor -final class WidgetDataProvider { - - static let shared = WidgetDataProvider() - - private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "WidgetDataProvider") - - 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 widget 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 - ) - 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: "WidgetDataProvider", 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: - Data Access - - /// 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( - 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 entries within a date range, filtered by weekdays - func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] { - let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays - - let descriptor = FetchDescriptor( - 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( - 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( - 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 - } - - // MARK: - Write Operations - - /// Add a new mood entry (simplified version for widget use) - 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() - - // Refresh all widgets immediately - WidgetCenter.shared.reloadAllTimelines() - - // Note: WatchConnectivity is not available in widget extensions - // The watch will pick up the data on its next timeline refresh - } catch { - // Silently fail for widget context - } - } -} +/// Typealias for backward compatibility - use ExtensionDataProvider directly for new code +typealias WidgetDataProvider = ExtensionDataProvider diff --git a/Shared/BGTask.swift b/Shared/BGTask.swift index 88a4a19..a0e1695 100644 --- a/Shared/BGTask.swift +++ b/Shared/BGTask.swift @@ -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) } diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 0103122..0822e76 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -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() } } } diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index 43c9622..204aa9b 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -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 diff --git a/Shared/Persisence/DataControllerProtocol.swift b/Shared/Persisence/DataControllerProtocol.swift index bfa56ae..40c7e6a 100644 --- a/Shared/Persisence/DataControllerProtocol.swift +++ b/Shared/Persisence/DataControllerProtocol.swift @@ -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( - 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( - 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( - sortBy: [SortDescriptor(\.forDate, order: .forward)] - ) - descriptor.fetchLimit = 1 - return try? modelContext.fetch(descriptor).first - } - - var latestEntry: MoodEntryModel? { - var descriptor = FetchDescriptor( - 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 - } -} diff --git a/Feels Watch App/WatchDataProvider.swift b/Shared/Persisence/ExtensionDataProvider.swift similarity index 58% rename from Feels Watch App/WatchDataProvider.swift rename to Shared/Persisence/ExtensionDataProvider.swift index 3a37593..f9fe145 100644 --- a/Feels Watch App/WatchDataProvider.swift +++ b/Shared/Persisence/ExtensionDataProvider.swift @@ -1,9 +1,11 @@ // -// WatchDataProvider.swift -// Feels Watch App +// ExtensionDataProvider.swift +// Feels // -// Data provider for Apple Watch with read/write access. -// Uses App Group container shared with main iOS app. +// 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 @@ -11,14 +13,14 @@ import SwiftData import WidgetKit import os.log -/// Data provider for Apple Watch with read/write access -/// Uses its own ModelContainer to avoid SwiftData conflicts +/// Unified data provider for Widget and Watch extensions +/// Uses its own ModelContainer with App Group storage (no CloudKit) @MainActor -final class WatchDataProvider { +final class ExtensionDataProvider { - static let shared = WatchDataProvider() + static let shared = ExtensionDataProvider() - private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchDataProvider") + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "ExtensionDataProvider") private var _container: ModelContainer? @@ -31,7 +33,7 @@ final class WatchDataProvider { return newContainer } - /// Creates the ModelContainer for watch data access + /// Creates the ModelContainer for extension data access private func createContainer() -> ModelContainer { let schema = Schema([MoodEntryModel.self]) @@ -41,11 +43,12 @@ final class WatchDataProvider { let configuration = ModelConfiguration( schema: schema, url: storeURL, - cloudKitDatabase: .none // Watch doesn't sync directly + 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]) @@ -61,7 +64,7 @@ final class WatchDataProvider { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: appGroupID ) else { - throw NSError(domain: "WatchDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"]) + throw NSError(domain: "ExtensionDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"]) } #if DEBUG return containerURL.appendingPathComponent("Feels-Debug.store") @@ -99,22 +102,52 @@ final class WatchDataProvider { getEntry(byDate: Date()) } - /// Get entries within a date range - func getData(startDate: Date, endDate: Date) -> [MoodEntryModel] { + /// 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( predicate: #Predicate { entry in - entry.forDate >= startDate && entry.forDate <= endDate + entry.forDate >= startDate && + entry.forDate <= endDate && + weekDays.contains(entry.weekDay) }, - sortBy: [SortDescriptor(\.forDate, order: .reverse)] + sortBy: [SortDescriptor(\.forDate, order: .forward)] ) return (try? modelContext.fetch(descriptor)) ?? [] } + /// Get the earliest entry in the database + var earliestEntry: MoodEntryModel? { + var descriptor = FetchDescriptor( + 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( + sortBy: [SortDescriptor(\.forDate, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? modelContext.fetch(descriptor).first + } + /// Get the current streak count - func getCurrentStreak() -> Int { - let yearAgo = Calendar.current.date(byAdding: .day, value: -365, to: Date())! - let entries = getData(startDate: yearAgo, endDate: Date()) + /// - 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()) @@ -135,8 +168,12 @@ final class WatchDataProvider { // MARK: - Write Operations - /// Add a new mood entry from the watch - func addMood(_ mood: Mood, forDate date: Date) { + /// 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) @@ -146,7 +183,7 @@ final class WatchDataProvider { let entry = MoodEntryModel( forDate: date, mood: mood, - entryType: .watch + entryType: entryType ) modelContext.insert(entry) @@ -155,17 +192,14 @@ final class WatchDataProvider { try modelContext.save() Self.logger.info("Saved mood \(mood.rawValue) for \(date)") - // Refresh watch complications immediately + // Refresh all widgets/complications immediately WidgetCenter.shared.reloadAllTimelines() - - // Note: WCSession notification is handled by ContentView - // iOS app coordinates all side effects when it receives the mood } catch { Self.logger.error("Failed to save mood: \(error.localizedDescription)") } } - /// Invalidate cached container + /// Invalidate cached container (call when data might have changed) func invalidateCache() { _container = nil } diff --git a/Shared/SharedMoodIntent.swift b/Shared/SharedMoodIntent.swift new file mode 100644 index 0000000..fdf6056 --- /dev/null +++ b/Shared/SharedMoodIntent.swift @@ -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( + 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