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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user