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")