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:
@@ -53,7 +53,7 @@ struct ContentView: View {
|
|||||||
// Also save locally as fallback and for immediate complication updates
|
// Also save locally as fallback and for immediate complication updates
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Always save locally for immediate complication display
|
// 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.
|
// Send to iPhone - it will handle HealthKit, Live Activity, etc.
|
||||||
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date)
|
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date)
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ struct FeelsTimelineProvider: TimelineProvider {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func createEntry() -> FeelsEntry {
|
private func createEntry() -> FeelsEntry {
|
||||||
let todayEntry = WatchDataProvider.shared.getTodayEntry()
|
let todayEntry = ExtensionDataProvider.shared.getTodayEntry()
|
||||||
let streak = WatchDataProvider.shared.getCurrentStreak()
|
let streak = ExtensionDataProvider.shared.getCurrentStreak()
|
||||||
|
|
||||||
return FeelsEntry(
|
return FeelsEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,8 @@
|
|||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
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 */; };
|
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
|
||||||
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; };
|
1C9566422EF8F5F70032E68F /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566412EF8F5F70032E68F /* Charts */; };
|
||||||
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; };
|
1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 1C9566432EF8F5F70032E68F /* Algorithms */; };
|
||||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
|
||||||
1CD90B07278C7DE0001C4FEA /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B06278C7DE0001C4FEA /* Tests_iOS.swift */; };
|
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 */; };
|
1CD90B09278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CD90B08278C7DE0001C4FEA /* Tests_iOSLaunchTests.swift */; };
|
||||||
@@ -128,7 +128,9 @@
|
|||||||
MoodStreakActivity.swift,
|
MoodStreakActivity.swift,
|
||||||
Onboarding/OnboardingData.swift,
|
Onboarding/OnboardingData.swift,
|
||||||
Onboarding/views/OnboardingDay.swift,
|
Onboarding/views/OnboardingDay.swift,
|
||||||
|
Persisence/ExtensionDataProvider.swift,
|
||||||
Random.swift,
|
Random.swift,
|
||||||
|
SharedMoodIntent.swift,
|
||||||
ShowBasedOnVoteLogics.swift,
|
ShowBasedOnVoteLogics.swift,
|
||||||
Views/BGView.swift,
|
Views/BGView.swift,
|
||||||
Views/CustomIcon/IconView.swift,
|
Views/CustomIcon/IconView.swift,
|
||||||
@@ -143,7 +145,10 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Models/Mood.swift,
|
Models/Mood.swift,
|
||||||
Models/MoodEntryModel.swift,
|
Models/MoodEntryModel.swift,
|
||||||
|
Persisence/ExtensionDataProvider.swift,
|
||||||
Random.swift,
|
Random.swift,
|
||||||
|
Services/WatchConnectivityManager.swift,
|
||||||
|
SharedMoodIntent.swift,
|
||||||
);
|
);
|
||||||
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
|
||||||
};
|
};
|
||||||
@@ -160,10 +165,10 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */,
|
1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */,
|
||||||
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */,
|
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */,
|
||||||
|
1C9566422EF8F5F70032E68F /* Charts in Frameworks */,
|
||||||
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */,
|
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */,
|
||||||
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -306,8 +311,8 @@
|
|||||||
);
|
);
|
||||||
name = "Feels (iOS)";
|
name = "Feels (iOS)";
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
1C2618F92795E41D00FDC148 /* Charts */,
|
1C9566412EF8F5F70032E68F /* Charts */,
|
||||||
1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */,
|
1C9566432EF8F5F70032E68F /* Algorithms */,
|
||||||
);
|
);
|
||||||
productName = "Feels (iOS)";
|
productName = "Feels (iOS)";
|
||||||
productReference = 1CD90AF5278C7DE0001C4FEA /* iFeels.app */;
|
productReference = 1CD90AF5278C7DE0001C4FEA /* iFeels.app */;
|
||||||
@@ -458,8 +463,8 @@
|
|||||||
);
|
);
|
||||||
mainGroup = 1CD90AE5278C7DDF001C4FEA;
|
mainGroup = 1CD90AE5278C7DDF001C4FEA;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */,
|
1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */,
|
||||||
1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */,
|
1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */,
|
||||||
);
|
);
|
||||||
productRefGroup = 1CD90AF6278C7DE0001C4FEA /* Products */;
|
productRefGroup = 1CD90AF6278C7DE0001C4FEA /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -1119,7 +1124,7 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */ = {
|
1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/akatreyt/ChartsPackage";
|
repositoryURL = "https://github.com/akatreyt/ChartsPackage";
|
||||||
requirement = {
|
requirement = {
|
||||||
@@ -1127,26 +1132,26 @@
|
|||||||
kind = branch;
|
kind = branch;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */ = {
|
1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/ggruen/CloudKitSyncMonitor";
|
repositoryURL = "https://github.com/apple/swift-algorithms.git";
|
||||||
requirement = {
|
requirement = {
|
||||||
kind = upToNextMajorVersion;
|
kind = exactVersion;
|
||||||
minimumVersion = 1.0.0;
|
version = 0.2.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
1C2618F92795E41D00FDC148 /* Charts */ = {
|
1C9566412EF8F5F70032E68F /* Charts */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 1C2618F82795E41D00FDC148 /* XCRemoteSwiftPackageReference "ChartsPackage" */;
|
package = 1C95663F2EF8F4A90032E68F /* XCRemoteSwiftPackageReference "ChartsPackage" */;
|
||||||
productName = Charts;
|
productName = Charts;
|
||||||
};
|
};
|
||||||
1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */ = {
|
1C9566432EF8F5F70032E68F /* Algorithms */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = 1C747CC7279F06EB00762CBD /* XCRemoteSwiftPackageReference "CloudKitSyncMonitor" */;
|
package = 1C9566402EF8F4D30032E68F /* XCRemoteSwiftPackageReference "swift-algorithms" */;
|
||||||
productName = CloudKitSyncMonitor;
|
productName = Algorithms;
|
||||||
};
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,50 +4,13 @@
|
|||||||
//
|
//
|
||||||
// Interactive widget for mood voting (iOS 17+)
|
// Interactive widget for mood voting (iOS 17+)
|
||||||
//
|
//
|
||||||
|
// Note: VoteMoodIntent is defined in Shared/SharedMoodIntent.swift
|
||||||
|
//
|
||||||
|
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppIntents
|
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
|
// MARK: - Vote Widget Provider
|
||||||
|
|
||||||
struct VoteWidgetProvider: TimelineProvider {
|
struct VoteWidgetProvider: TimelineProvider {
|
||||||
|
|||||||
@@ -2,198 +2,10 @@
|
|||||||
// WidgetDataProvider.swift
|
// WidgetDataProvider.swift
|
||||||
// FeelsWidget
|
// FeelsWidget
|
||||||
//
|
//
|
||||||
// Lightweight read-only data provider for widgets.
|
// Typealias to ExtensionDataProvider for backward compatibility.
|
||||||
// Uses its own ModelContainer to avoid conflicts with the main app.
|
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
|
||||||
import WidgetKit
|
|
||||||
import os.log
|
|
||||||
|
|
||||||
/// Lightweight read-only data provider for widgets
|
/// Typealias for backward compatibility - use ExtensionDataProvider directly for new code
|
||||||
/// Uses its own ModelContainer to avoid SwiftData conflicts with main app
|
typealias WidgetDataProvider = ExtensionDataProvider
|
||||||
@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<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 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<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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ class BGTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DataController.shared.fillInMissingDates()
|
DataController.shared.fillInMissingDates()
|
||||||
|
|
||||||
|
// Catch up on any side effects from widget/watch votes
|
||||||
|
MoodLogger.shared.processPendingSideEffects()
|
||||||
|
|
||||||
task.setTaskCompleted(success: true)
|
task.setTaskCompleted(success: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct FeelsApp: App {
|
|||||||
}
|
}
|
||||||
}.onChange(of: scenePhase) { _, newPhase in
|
}.onChange(of: scenePhase) { _, newPhase in
|
||||||
if newPhase == .background {
|
if newPhase == .background {
|
||||||
//BGTask.scheduleBackgroundProcessing()
|
BGTask.scheduleBackgroundProcessing()
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
// Lock the app when going to background
|
// Lock the app when going to background
|
||||||
authManager.lock()
|
authManager.lock()
|
||||||
@@ -89,6 +89,9 @@ struct FeelsApp: App {
|
|||||||
}
|
}
|
||||||
// Reschedule Live Activity when app becomes active
|
// Reschedule Live Activity when app becomes active
|
||||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||||
|
|
||||||
|
// Catch up on side effects from widget/watch votes
|
||||||
|
MoodLogger.shared.processPendingSideEffects()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
import os.log
|
||||||
|
|
||||||
/// Centralized service for logging moods with all associated side effects.
|
/// Centralized service for logging moods with all associated side effects.
|
||||||
/// All mood entry points should use this service to ensure consistent behavior.
|
/// All mood entry points should use this service to ensure consistent behavior.
|
||||||
@@ -14,6 +15,11 @@ import WidgetKit
|
|||||||
final class MoodLogger {
|
final class MoodLogger {
|
||||||
static let shared = 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() {}
|
private init() {}
|
||||||
|
|
||||||
/// Log a mood entry with all associated side effects.
|
/// Log a mood entry with all associated side effects.
|
||||||
@@ -35,10 +41,30 @@ final class MoodLogger {
|
|||||||
// 1. Add mood entry to data store
|
// 1. Add mood entry to data store
|
||||||
DataController.shared.add(mood: mood, forDate: date, entryType: entryType)
|
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
|
// Skip side effects for placeholder/missing moods
|
||||||
guard mood != .missing && mood != .placeholder else { return }
|
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 {
|
if syncHealthKit {
|
||||||
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
let healthKitEnabled = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue)
|
||||||
if healthKitEnabled {
|
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()
|
let streak = calculateCurrentStreak()
|
||||||
|
|
||||||
// 4. Update Live Activity
|
// 3. Update Live Activity
|
||||||
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
|
||||||
LiveActivityScheduler.shared.scheduleForNextDay()
|
LiveActivityScheduler.shared.scheduleForNextDay()
|
||||||
|
|
||||||
// 5. Update TipKit parameters if requested
|
// 4. Update TipKit parameters if requested
|
||||||
if updateTips {
|
if updateTips {
|
||||||
TipsManager.shared.onMoodLogged()
|
TipsManager.shared.onMoodLogged()
|
||||||
TipsManager.shared.updateStreak(streak)
|
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)
|
ReviewRequestManager.shared.onMoodLogged(streak: streak)
|
||||||
|
|
||||||
// 7. Reload widgets
|
// 6. Reload widgets
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
// 8. Notify watch to refresh complications
|
// 7. Notify watch to refresh complications
|
||||||
WatchConnectivityManager.shared.notifyWatchToReload()
|
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
|
/// Calculate the current mood streak
|
||||||
|
|||||||
@@ -86,159 +86,3 @@ protocol DataControlling: MoodDataReading, MoodDataWriting, MoodDataDeleting, Mo
|
|||||||
// MARK: - DataController Conformance
|
// MARK: - DataController Conformance
|
||||||
|
|
||||||
extension DataController: DataControlling {}
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
//
|
//
|
||||||
// WatchDataProvider.swift
|
// ExtensionDataProvider.swift
|
||||||
// Feels Watch App
|
// Feels
|
||||||
//
|
//
|
||||||
// Data provider for Apple Watch with read/write access.
|
// Unified data provider for Widget and Watch extensions.
|
||||||
// Uses App Group container shared with main iOS app.
|
// Uses App Group container with CloudKit disabled for extension safety.
|
||||||
|
//
|
||||||
|
// Add this file to: FeelsWidgetExtension, Feels Watch App
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -11,14 +13,14 @@ import SwiftData
|
|||||||
import WidgetKit
|
import WidgetKit
|
||||||
import os.log
|
import os.log
|
||||||
|
|
||||||
/// Data provider for Apple Watch with read/write access
|
/// Unified data provider for Widget and Watch extensions
|
||||||
/// Uses its own ModelContainer to avoid SwiftData conflicts
|
/// Uses its own ModelContainer with App Group storage (no CloudKit)
|
||||||
@MainActor
|
@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?
|
private var _container: ModelContainer?
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@ final class WatchDataProvider {
|
|||||||
return newContainer
|
return newContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates the ModelContainer for watch data access
|
/// Creates the ModelContainer for extension data access
|
||||||
private func createContainer() -> ModelContainer {
|
private func createContainer() -> ModelContainer {
|
||||||
let schema = Schema([MoodEntryModel.self])
|
let schema = Schema([MoodEntryModel.self])
|
||||||
|
|
||||||
@@ -41,11 +43,12 @@ final class WatchDataProvider {
|
|||||||
let configuration = ModelConfiguration(
|
let configuration = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
url: storeURL,
|
url: storeURL,
|
||||||
cloudKitDatabase: .none // Watch doesn't sync directly
|
cloudKitDatabase: .none // Extensions don't sync directly
|
||||||
)
|
)
|
||||||
return try ModelContainer(for: schema, configurations: [configuration])
|
return try ModelContainer(for: schema, configurations: [configuration])
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")
|
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")
|
||||||
|
// Fall back to in-memory storage
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [config])
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
@@ -61,7 +64,7 @@ final class WatchDataProvider {
|
|||||||
guard let containerURL = FileManager.default.containerURL(
|
guard let containerURL = FileManager.default.containerURL(
|
||||||
forSecurityApplicationGroupIdentifier: appGroupID
|
forSecurityApplicationGroupIdentifier: appGroupID
|
||||||
) else {
|
) 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
|
#if DEBUG
|
||||||
return containerURL.appendingPathComponent("Feels-Debug.store")
|
return containerURL.appendingPathComponent("Feels-Debug.store")
|
||||||
@@ -99,22 +102,52 @@ final class WatchDataProvider {
|
|||||||
getEntry(byDate: Date())
|
getEntry(byDate: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get entries within a date range
|
/// Get entries within a date range, optionally filtered by weekdays
|
||||||
func getData(startDate: Date, endDate: Date) -> [MoodEntryModel] {
|
/// - 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>(
|
let descriptor = FetchDescriptor<MoodEntryModel>(
|
||||||
predicate: #Predicate { entry in
|
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)) ?? []
|
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
|
/// Get the current streak count
|
||||||
func getCurrentStreak() -> Int {
|
/// - Parameter includedDays: Weekdays to include in streak calculation (empty = all days)
|
||||||
let yearAgo = Calendar.current.date(byAdding: .day, value: -365, to: Date())!
|
func getCurrentStreak(includedDays: [Int] = []) -> Int {
|
||||||
let entries = getData(startDate: yearAgo, endDate: Date())
|
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 streak = 0
|
||||||
var currentDate = Calendar.current.startOfDay(for: Date())
|
var currentDate = Calendar.current.startOfDay(for: Date())
|
||||||
@@ -135,8 +168,12 @@ final class WatchDataProvider {
|
|||||||
|
|
||||||
// MARK: - Write Operations
|
// MARK: - Write Operations
|
||||||
|
|
||||||
/// Add a new mood entry from the watch
|
/// Add a new mood entry
|
||||||
func addMood(_ mood: Mood, forDate date: Date) {
|
/// - 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
|
// Delete existing entry for this date if present
|
||||||
if let existing = getEntry(byDate: date) {
|
if let existing = getEntry(byDate: date) {
|
||||||
modelContext.delete(existing)
|
modelContext.delete(existing)
|
||||||
@@ -146,7 +183,7 @@ final class WatchDataProvider {
|
|||||||
let entry = MoodEntryModel(
|
let entry = MoodEntryModel(
|
||||||
forDate: date,
|
forDate: date,
|
||||||
mood: mood,
|
mood: mood,
|
||||||
entryType: .watch
|
entryType: entryType
|
||||||
)
|
)
|
||||||
|
|
||||||
modelContext.insert(entry)
|
modelContext.insert(entry)
|
||||||
@@ -155,17 +192,14 @@ final class WatchDataProvider {
|
|||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
Self.logger.info("Saved mood \(mood.rawValue) for \(date)")
|
Self.logger.info("Saved mood \(mood.rawValue) for \(date)")
|
||||||
|
|
||||||
// Refresh watch complications immediately
|
// Refresh all widgets/complications immediately
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
|
||||||
// Note: WCSession notification is handled by ContentView
|
|
||||||
// iOS app coordinates all side effects when it receives the mood
|
|
||||||
} catch {
|
} catch {
|
||||||
Self.logger.error("Failed to save mood: \(error.localizedDescription)")
|
Self.logger.error("Failed to save mood: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate cached container
|
/// Invalidate cached container (call when data might have changed)
|
||||||
func invalidateCache() {
|
func invalidateCache() {
|
||||||
_container = nil
|
_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