From 00cbd476d42d8ec26a980b7249e822d8085051b8 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 3 Jan 2026 11:01:33 -0600 Subject: [PATCH] Enable CloudKit sync for Watch app and fix WCSession handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Feels Watch App/ContentView.swift | 93 ++++++++++--------- Feels Watch App/Feels Watch App.entitlements | 8 ++ .../Feels Watch AppDebug.entitlements | 8 ++ Shared/MoodLogger.swift | 1 + Shared/Persisence/ExtensionDataProvider.swift | 28 +++++- .../Services/WatchConnectivityManager.swift | 75 +++++++++++---- 6 files changed, 147 insertions(+), 66 deletions(-) diff --git a/Feels Watch App/ContentView.swift b/Feels Watch App/ContentView.swift index 2021d95..b9f5638 100644 --- a/Feels Watch App/ContentView.swift +++ b/Feels Watch App/ContentView.swift @@ -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 diff --git a/Feels Watch App/Feels Watch App.entitlements b/Feels Watch App/Feels Watch App.entitlements index f101a5a..f2beaa5 100644 --- a/Feels Watch App/Feels Watch App.entitlements +++ b/Feels Watch App/Feels Watch App.entitlements @@ -6,5 +6,13 @@ group.com.tt.feels + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-identifiers + + iCloud.com.tt.feels + diff --git a/Feels Watch App/Feels Watch AppDebug.entitlements b/Feels Watch App/Feels Watch AppDebug.entitlements index 59caafc..dc167dd 100644 --- a/Feels Watch App/Feels Watch AppDebug.entitlements +++ b/Feels Watch App/Feels Watch AppDebug.entitlements @@ -6,5 +6,13 @@ group.com.tt.feelsDebug + com.apple.developer.icloud-services + + CloudKit + + com.apple.developer.icloud-container-identifiers + + iCloud.com.tt.feelsDebug + diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift index d98f1c5..b47e3cb 100644 --- a/Shared/MoodLogger.swift +++ b/Shared/MoodLogger.swift @@ -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 diff --git a/Shared/Persisence/ExtensionDataProvider.swift b/Shared/Persisence/ExtensionDataProvider.swift index ee7638c..fb8900e 100644 --- a/Shared/Persisence/ExtensionDataProvider.swift +++ b/Shared/Persisence/ExtensionDataProvider.swift @@ -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)") diff --git a/Shared/Services/WatchConnectivityManager.swift b/Shared/Services/WatchConnectivityManager.swift index 0fe51d2..d7e5b67 100644 --- a/Shared/Services/WatchConnectivityManager.swift +++ b/Shared/Services/WatchConnectivityManager.swift @@ -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")