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 {
@State private var showConfirmation = false
@State private var selectedMood: Mood?
@State private var todaysMood: Mood?
var body: some View {
ZStack {
VStack(spacing: 8) {
Text("How do you feel?")
.font(.system(size: 16, weight: .medium))
.foregroundColor(.secondary)
if let mood = todaysMood ?? selectedMood, showConfirmation || todaysMood != nil {
// Show "already rated" view
AlreadyRatedView(mood: mood)
} 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
HStack(spacing: 8) {
MoodButton(mood: .great, action: { logMood(.great) })
MoodButton(mood: .good, action: { logMood(.good) })
MoodButton(mood: .average, action: { logMood(.average) })
}
// Top row: Great, Good, Average
HStack(spacing: 8) {
MoodButton(mood: .great, action: { logMood(.great) })
MoodButton(mood: .good, action: { logMood(.good) })
MoodButton(mood: .average, action: { logMood(.average) })
}
// Bottom row: Bad, Horrible
HStack(spacing: 8) {
MoodButton(mood: .bad, action: { logMood(.bad) })
MoodButton(mood: .horrible, action: { logMood(.horrible) })
// Bottom row: Bad, Horrible
HStack(spacing: 8) {
MoodButton(mood: .bad, action: { logMood(.bad) })
MoodButton(mood: .horrible, action: { logMood(.horrible) })
}
}
}
.opacity(showConfirmation ? 0.3 : 1)
}
.onAppear {
checkTodaysEntry()
}
}
// Confirmation overlay
if showConfirmation {
ConfirmationView(mood: selectedMood)
}
private func checkTodaysEntry() {
let entry = ExtensionDataProvider.shared.getTodayEntry()
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)
}
// Show confirmation
// Show confirmation and keep it (user already rated)
withAnimation(.easeInOut(duration: 0.2)) {
showConfirmation = true
todaysMood = mood
}
}
}
// Hide confirmation after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeInOut(duration: 0.2)) {
showConfirmation = false
}
// MARK: - Already Rated View
struct AlreadyRatedView: View {
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

View File

@@ -6,5 +6,13 @@
<array>
<string>group.com.tt.feels</string>
</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>
</plist>

View File

@@ -6,5 +6,13 @@
<array>
<string>group.com.tt.feelsDebug</string>
</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>
</plist>

View File

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

View File

@@ -3,7 +3,8 @@
// Feels
//
// 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
//
@@ -14,7 +15,8 @@ import WidgetKit
import os.log
/// 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
final class ExtensionDataProvider {
@@ -40,11 +42,31 @@ final class ExtensionDataProvider {
// Try to use shared app group container
do {
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(
schema: schema,
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])
} catch {
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")

View File

@@ -3,7 +3,8 @@
// Feels
//
// 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
@@ -12,7 +13,7 @@ import WidgetKit
import os.log
/// 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 {
static let shared = WatchConnectivityManager()
@@ -21,6 +22,11 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
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
var isReachable: Bool {
session?.isReachable ?? false
@@ -33,7 +39,7 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
session = WCSession.default
session?.delegate = self
session?.activate()
Self.logger.info("WCSession activated")
Self.logger.info("WCSession activation requested")
} else {
Self.logger.warning("WCSession not supported on this device")
}
@@ -59,12 +65,20 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
// MARK: - Watch iOS
#if os(watchOS)
/// Send mood to iOS app for centralized logging
/// Returns true if message was sent, false if fallback to local storage is needed
/// Send mood to iOS app for immediate side effects (Live Activity, etc.)
/// Data persistence is handled by CloudKit - this is just for immediate UI updates
func sendMoodToPhone(mood: Int, date: Date) -> Bool {
guard let session = session,
session.activationState == .activated else {
Self.logger.warning("WCSession not ready")
guard let session = session else {
Self.logger.warning("WCSession not ready - session is nil")
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
}
@@ -74,9 +88,17 @@ final class WatchConnectivityManager: NSObject, ObservableObject {
"date": date.timeIntervalSince1970
]
// Use transferUserInfo for guaranteed delivery
session.transferUserInfo(message)
Self.logger.info("Sent mood \(mood) to iPhone for logging")
// Try immediate message first if iPhone is reachable
if session.isReachable {
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
}
#endif
@@ -91,6 +113,16 @@ extension WatchConnectivityManager: WCSessionDelegate {
Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
} else {
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()
}
// iOS receives mood from watch and logs it centrally
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
Self.logger.info("Received userInfo from watch")
handleReceivedMessage(userInfo)
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
Self.logger.info("Received message from watch")
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]) {
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 {
case "logMood":
@@ -126,26 +168,25 @@ extension WatchConnectivityManager: WCSessionDelegate {
}
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
// Use MoodLogger for centralized logging with all side effects
MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
}
case "reloadWidgets":
Self.logger.info("Received reloadWidgets from watch")
Task { @MainActor in
WidgetCenter.shared.reloadAllTimelines()
}
default:
break
Self.logger.warning("Unknown action: \(action)")
}
}
#endif
#if os(watchOS)
// Watch receives 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")