Enable CloudKit sync for Watch app and fix WCSession handling

- Watch now uses CloudKit for data persistence (syncs automatically with iPhone)
- Added iCloud/CloudKit entitlements to Watch app (debug and release)
- Fixed WCSession delegate to handle messages with reply handler
- Watch UI now shows "Already Rated" screen after voting
- Invalidate LiveActivityScheduler cache when mood is logged
- WCSession now used only for immediate UI updates, not data persistence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-03 11:01:33 -06:00
parent 406d9ee4fd
commit 00cbd476d4
6 changed files with 147 additions and 66 deletions

View File

@@ -11,33 +11,44 @@ import WatchKit
struct ContentView: View { struct ContentView: View {
@State private var showConfirmation = false @State private var showConfirmation = false
@State private var selectedMood: Mood? @State private var selectedMood: Mood?
@State private var todaysMood: Mood?
var body: some View { var body: some View {
ZStack { ZStack {
VStack(spacing: 8) { if let mood = todaysMood ?? selectedMood, showConfirmation || todaysMood != nil {
Text("How do you feel?") // Show "already rated" view
.font(.system(size: 16, weight: .medium)) AlreadyRatedView(mood: mood)
.foregroundColor(.secondary) } else {
// Show voting UI
VStack(spacing: 8) {
Text("How do you feel?")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.secondary)
// Top row: Great, Good, Average // Top row: Great, Good, Average
HStack(spacing: 8) { HStack(spacing: 8) {
MoodButton(mood: .great, action: { logMood(.great) }) MoodButton(mood: .great, action: { logMood(.great) })
MoodButton(mood: .good, action: { logMood(.good) }) MoodButton(mood: .good, action: { logMood(.good) })
MoodButton(mood: .average, action: { logMood(.average) }) MoodButton(mood: .average, action: { logMood(.average) })
} }
// Bottom row: Bad, Horrible // Bottom row: Bad, Horrible
HStack(spacing: 8) { HStack(spacing: 8) {
MoodButton(mood: .bad, action: { logMood(.bad) }) MoodButton(mood: .bad, action: { logMood(.bad) })
MoodButton(mood: .horrible, action: { logMood(.horrible) }) MoodButton(mood: .horrible, action: { logMood(.horrible) })
}
} }
} }
.opacity(showConfirmation ? 0.3 : 1) }
.onAppear {
checkTodaysEntry()
}
}
// Confirmation overlay private func checkTodaysEntry() {
if showConfirmation { let entry = ExtensionDataProvider.shared.getTodayEntry()
ConfirmationView(mood: selectedMood) if let entry = entry, entry.mood != .missing && entry.mood != .placeholder {
} todaysMood = entry.mood
} }
} }
@@ -59,16 +70,27 @@ struct ContentView: View {
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date) _ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date)
} }
// Show confirmation // Show confirmation and keep it (user already rated)
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: 0.2)) {
showConfirmation = true showConfirmation = true
todaysMood = mood
} }
}
}
// Hide confirmation after delay // MARK: - Already Rated View
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeInOut(duration: 0.2)) { struct AlreadyRatedView: View {
showConfirmation = false let mood: Mood
}
var body: some View {
VStack(spacing: 12) {
Text(mood.watchEmoji)
.font(.system(size: 50))
Text("Logged!")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(.secondary)
} }
} }
} }
@@ -92,27 +114,6 @@ struct MoodButton: View {
} }
} }
// MARK: - Confirmation View
struct ConfirmationView: View {
let mood: Mood?
var body: some View {
VStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 40))
.foregroundColor(.green)
Text("Logged!")
.font(.system(size: 18, weight: .semibold))
if let mood = mood {
Text(mood.watchEmoji)
.font(.system(size: 24))
}
}
}
}
// MARK: - Watch Mood Image Provider // MARK: - Watch Mood Image Provider

View File

@@ -6,5 +6,13 @@
<array> <array>
<string>group.com.tt.feels</string> <string>group.com.tt.feels</string>
</array> </array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feels</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -6,5 +6,13 @@
<array> <array>
<string>group.com.tt.feelsDebug</string> <string>group.com.tt.feelsDebug</string>
</array> </array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
</array>
</dict> </dict>
</plist> </plist>

View File

@@ -80,6 +80,7 @@ final class MoodLogger {
// 3. Update Live Activity // 3. Update Live Activity
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood) LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
LiveActivityScheduler.shared.invalidateCache() // Clear stale hasRated cache
LiveActivityScheduler.shared.scheduleForNextDay() LiveActivityScheduler.shared.scheduleForNextDay()
// 4. Update tips parameters if requested // 4. Update tips parameters if requested

View File

@@ -3,7 +3,8 @@
// Feels // Feels
// //
// Unified data provider for Widget and Watch extensions. // Unified data provider for Widget and Watch extensions.
// Uses App Group container with CloudKit disabled for extension safety. // - Watch: Uses CloudKit for automatic sync with iPhone
// - Widget: Uses local App Group storage (widgets can't use CloudKit)
// //
// Add this file to: FeelsWidgetExtension, Feels Watch App // Add this file to: FeelsWidgetExtension, Feels Watch App
// //
@@ -14,7 +15,8 @@ import WidgetKit
import os.log import os.log
/// Unified data provider for Widget and Watch extensions /// Unified data provider for Widget and Watch extensions
/// Uses its own ModelContainer with App Group storage (no CloudKit) /// - Watch: Uses CloudKit for automatic sync with iPhone (no WCSession needed for data)
/// - Widget: Uses local App Group storage (widgets can't use CloudKit)
@MainActor @MainActor
final class ExtensionDataProvider { final class ExtensionDataProvider {
@@ -40,11 +42,31 @@ final class ExtensionDataProvider {
// Try to use shared app group container // Try to use shared app group container
do { do {
let storeURL = try getStoreURL() let storeURL = try getStoreURL()
#if os(watchOS)
// Watch uses CloudKit for automatic sync with iPhone
let cloudKitContainerID: String
#if DEBUG
cloudKitContainerID = "iCloud.com.tt.feelsDebug"
#else
cloudKitContainerID = "iCloud.com.tt.feels"
#endif
let configuration = ModelConfiguration( let configuration = ModelConfiguration(
schema: schema, schema: schema,
url: storeURL, url: storeURL,
cloudKitDatabase: .none // Extensions don't sync directly cloudKitDatabase: .private(cloudKitContainerID)
) )
Self.logger.info("Watch using CloudKit container: \(cloudKitContainerID)")
#else
// Widget uses local storage only (can't use CloudKit)
let configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none
)
#endif
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)")

View File

@@ -3,7 +3,8 @@
// Feels // Feels
// //
// Central coordinator for Watch Connectivity. // Central coordinator for Watch Connectivity.
// iOS app is the hub - all mood logging flows through here. // Used for immediate UI updates (Live Activity, widget refresh).
// Data persistence is handled by CloudKit sync.
// //
import Foundation import Foundation
@@ -12,7 +13,7 @@ import WidgetKit
import os.log import os.log
/// Manages Watch Connectivity between iOS and watchOS /// Manages Watch Connectivity between iOS and watchOS
/// iOS app acts as the central coordinator for all mood logging /// Used for immediate notifications, not data persistence (CloudKit handles that)
final class WatchConnectivityManager: NSObject, ObservableObject { final class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager() static let shared = WatchConnectivityManager()
@@ -21,6 +22,11 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
private var session: WCSession? private var session: WCSession?
#if os(watchOS)
/// Pending moods to send when session activates
private var pendingMoods: [(mood: Int, date: Date)] = []
#endif
/// Whether the paired device is currently reachable for immediate messaging /// Whether the paired device is currently reachable for immediate messaging
var isReachable: Bool { var isReachable: Bool {
session?.isReachable ?? false session?.isReachable ?? false
@@ -33,7 +39,7 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
session = WCSession.default session = WCSession.default
session?.delegate = self session?.delegate = self
session?.activate() session?.activate()
Self.logger.info("WCSession activated") Self.logger.info("WCSession activation requested")
} else { } else {
Self.logger.warning("WCSession not supported on this device") Self.logger.warning("WCSession not supported on this device")
} }
@@ -59,12 +65,20 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
// MARK: - Watch iOS // MARK: - Watch iOS
#if os(watchOS) #if os(watchOS)
/// Send mood to iOS app for centralized logging /// Send mood to iOS app for immediate side effects (Live Activity, etc.)
/// Returns true if message was sent, false if fallback to local storage is needed /// Data persistence is handled by CloudKit - this is just for immediate UI updates
func sendMoodToPhone(mood: Int, date: Date) -> Bool { func sendMoodToPhone(mood: Int, date: Date) -> Bool {
guard let session = session, guard let session = session else {
session.activationState == .activated else { Self.logger.warning("WCSession not ready - session is nil")
Self.logger.warning("WCSession not ready") return false
}
guard session.activationState == .activated else {
pendingMoods.append((mood: mood, date: date))
if session.activationState == .notActivated {
session.activate()
}
Self.logger.warning("WCSession not activated, queued mood for later")
return false return false
} }
@@ -74,9 +88,17 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
"date": date.timeIntervalSince1970 "date": date.timeIntervalSince1970
] ]
// Use transferUserInfo for guaranteed delivery // Try immediate message first if iPhone is reachable
session.transferUserInfo(message) if session.isReachable {
Self.logger.info("Sent mood \(mood) to iPhone for logging") session.sendMessage(message, replyHandler: nil) { error in
Self.logger.warning("sendMessage failed: \(error.localizedDescription), using transferUserInfo")
session.transferUserInfo(message)
}
Self.logger.info("Sent mood \(mood) to iPhone via sendMessage")
} else {
session.transferUserInfo(message)
Self.logger.info("Sent mood \(mood) to iPhone via transferUserInfo")
}
return true return true
} }
#endif #endif
@@ -91,6 +113,16 @@ extension WatchConnectivityManager: WCSessionDelegate {
Self.logger.error("WCSession activation failed: \(error.localizedDescription)") Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
} else { } else {
Self.logger.info("WCSession activation completed: \(activationState.rawValue)") Self.logger.info("WCSession activation completed: \(activationState.rawValue)")
#if os(watchOS)
if activationState == .activated && !pendingMoods.isEmpty {
Self.logger.info("Sending \(self.pendingMoods.count) pending mood(s)")
for pending in pendingMoods {
_ = sendMoodToPhone(mood: pending.mood, date: pending.date)
}
pendingMoods.removeAll()
}
#endif
} }
} }
@@ -104,17 +136,27 @@ extension WatchConnectivityManager: WCSessionDelegate {
session.activate() session.activate()
} }
// iOS receives mood from watch and logs it centrally
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
Self.logger.info("Received userInfo from watch")
handleReceivedMessage(userInfo) handleReceivedMessage(userInfo)
} }
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Self.logger.info("Received message from watch")
handleReceivedMessage(message) handleReceivedMessage(message)
} }
func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) {
Self.logger.info("Received message from watch (with reply)")
handleReceivedMessage(message)
replyHandler(["status": "received"])
}
private func handleReceivedMessage(_ message: [String: Any]) { private func handleReceivedMessage(_ message: [String: Any]) {
guard let action = message["action"] as? String else { return } guard let action = message["action"] as? String else {
Self.logger.error("No action in message")
return
}
switch action { switch action {
case "logMood": case "logMood":
@@ -126,26 +168,25 @@ extension WatchConnectivityManager: WCSessionDelegate {
} }
let date = Date(timeIntervalSince1970: timestamp) let date = Date(timeIntervalSince1970: timestamp)
Self.logger.info("Received mood \(moodRaw) from watch, logging centrally") Self.logger.info("Processing mood \(moodRaw) from watch for \(date)")
Task { @MainActor in Task { @MainActor in
// Use MoodLogger for centralized logging with all side effects
MoodLogger.shared.logMood(mood, for: date, entryType: .watch) MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
} }
case "reloadWidgets": case "reloadWidgets":
Self.logger.info("Received reloadWidgets from watch")
Task { @MainActor in Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
} }
default: default:
break Self.logger.warning("Unknown action: \(action)")
} }
} }
#endif #endif
#if os(watchOS) #if os(watchOS)
// Watch receives reload notification from iOS
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
if userInfo["action"] as? String == "reloadWidgets" { if userInfo["action"] as? String == "reloadWidgets" {
Self.logger.info("Received reload notification from iPhone") Self.logger.info("Received reload notification from iPhone")