- Add watchOS app target with mood voting UI (5 mood buttons) - Add WidgetKit complications (circular, corner, inline, rectangular) - Add WatchConnectivityManager for bidirectional sync between iOS and watch - iOS app acts as central coordinator - all mood logging flows through MoodLogger - Watch votes send to iPhone via WCSession, iPhone logs and notifies watch back - Widget votes use openAppWhenRun=true to run MoodLogger in main app process - Add #if !os(watchOS) guards to Mood.swift and Random.swift for compatibility - Update SKStoreReviewController to AppStore.requestReview (iOS 18 deprecation fix) - Watch reads user's moodImages preference from GroupUserDefaults for emoji style 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
167 lines
5.1 KiB
Swift
167 lines
5.1 KiB
Swift
//
|
|
// WatchConnectivityManager.swift
|
|
// Feels
|
|
//
|
|
// Central coordinator for Watch Connectivity.
|
|
// iOS app is the hub - all mood logging flows through here.
|
|
//
|
|
|
|
import Foundation
|
|
import WatchConnectivity
|
|
import WidgetKit
|
|
import os.log
|
|
|
|
/// Manages Watch Connectivity between iOS and watchOS
|
|
/// iOS app acts as the central coordinator for all mood logging
|
|
final class WatchConnectivityManager: NSObject, ObservableObject {
|
|
|
|
static let shared = WatchConnectivityManager()
|
|
|
|
private static let logger = Logger(subsystem: "com.tt.ifeel", category: "WatchConnectivity")
|
|
|
|
private var session: WCSession?
|
|
|
|
/// Whether the paired device is currently reachable for immediate messaging
|
|
var isReachable: Bool {
|
|
session?.isReachable ?? false
|
|
}
|
|
|
|
private override init() {
|
|
super.init()
|
|
|
|
if WCSession.isSupported() {
|
|
session = WCSession.default
|
|
session?.delegate = self
|
|
session?.activate()
|
|
Self.logger.info("WCSession activated")
|
|
} else {
|
|
Self.logger.warning("WCSession not supported on this device")
|
|
}
|
|
}
|
|
|
|
// MARK: - iOS → Watch
|
|
|
|
#if os(iOS)
|
|
/// Notify watch to reload its complications
|
|
func notifyWatchToReload() {
|
|
guard let session = session,
|
|
session.activationState == .activated,
|
|
session.isWatchAppInstalled else {
|
|
return
|
|
}
|
|
|
|
let message = ["action": "reloadWidgets"]
|
|
session.transferUserInfo(message)
|
|
Self.logger.info("Sent reload notification to watch")
|
|
}
|
|
#endif
|
|
|
|
// 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
|
|
func sendMoodToPhone(mood: Int, date: Date) -> Bool {
|
|
guard let session = session,
|
|
session.activationState == .activated else {
|
|
Self.logger.warning("WCSession not ready")
|
|
return false
|
|
}
|
|
|
|
let message: [String: Any] = [
|
|
"action": "logMood",
|
|
"mood": mood,
|
|
"date": date.timeIntervalSince1970
|
|
]
|
|
|
|
// Use transferUserInfo for guaranteed delivery
|
|
session.transferUserInfo(message)
|
|
Self.logger.info("Sent mood \(mood) to iPhone for logging")
|
|
return true
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - WCSessionDelegate
|
|
|
|
extension WatchConnectivityManager: WCSessionDelegate {
|
|
|
|
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
|
if let error = error {
|
|
Self.logger.error("WCSession activation failed: \(error.localizedDescription)")
|
|
} else {
|
|
Self.logger.info("WCSession activation completed: \(activationState.rawValue)")
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
func sessionDidBecomeInactive(_ session: WCSession) {
|
|
Self.logger.info("WCSession became inactive")
|
|
}
|
|
|
|
func sessionDidDeactivate(_ session: WCSession) {
|
|
Self.logger.info("WCSession deactivated, reactivating...")
|
|
session.activate()
|
|
}
|
|
|
|
// iOS receives mood from watch and logs it centrally
|
|
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) {
|
|
handleReceivedMessage(userInfo)
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
handleReceivedMessage(message)
|
|
}
|
|
|
|
private func handleReceivedMessage(_ message: [String: Any]) {
|
|
guard let action = message["action"] as? String else { return }
|
|
|
|
switch action {
|
|
case "logMood":
|
|
guard let moodRaw = message["mood"] as? Int,
|
|
let mood = Mood(rawValue: moodRaw),
|
|
let timestamp = message["date"] as? TimeInterval else {
|
|
Self.logger.error("Invalid mood message format")
|
|
return
|
|
}
|
|
|
|
let date = Date(timeIntervalSince1970: timestamp)
|
|
Self.logger.info("Received mood \(moodRaw) from watch, logging centrally")
|
|
|
|
Task { @MainActor in
|
|
// Use MoodLogger for centralized logging with all side effects
|
|
MoodLogger.shared.logMood(mood, for: date, entryType: .watch)
|
|
}
|
|
|
|
case "reloadWidgets":
|
|
Task { @MainActor in
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
#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")
|
|
Task { @MainActor in
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|
|
}
|
|
|
|
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
|
if message["action"] as? String == "reloadWidgets" {
|
|
Task { @MainActor in
|
|
WidgetCenter.shared.reloadAllTimelines()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|