Add Apple Watch companion app with complications and WCSession sync
- 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>
This commit is contained in:
@@ -35,6 +35,9 @@ struct FeelsApp: App {
|
||||
|
||||
// Initialize Live Activity scheduler
|
||||
LiveActivityScheduler.shared.scheduleBasedOnCurrentTime()
|
||||
|
||||
// Initialize Watch Connectivity for cross-device widget updates
|
||||
_ = WatchConnectivityManager.shared
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
@@ -58,23 +58,24 @@ enum Mood: Int {
|
||||
}
|
||||
}
|
||||
|
||||
static var allValues: [Mood] {
|
||||
return [Mood.horrible, Mood.bad, Mood.average, Mood.good, Mood.great].reversed()
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
var color: Color {
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return moodTint.color(forMood: self)
|
||||
}
|
||||
|
||||
static var allValues: [Mood] {
|
||||
return [Mood.horrible, Mood.bad, Mood.average, Mood.good, Mood.great].reversed()
|
||||
}
|
||||
|
||||
|
||||
var icon: Image {
|
||||
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
||||
return moodImages.icon(forMood: self)
|
||||
}
|
||||
|
||||
|
||||
var graphic: Image {
|
||||
switch self {
|
||||
|
||||
|
||||
case .horrible:
|
||||
return Image("HorribleGraphic", bundle: .main)
|
||||
case .bad:
|
||||
@@ -91,6 +92,7 @@ enum Mood: Int {
|
||||
return Image("MissingGraphic", bundle: .main)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension Mood: Identifiable {
|
||||
|
||||
@@ -27,16 +27,16 @@ enum EntryType: Int, Codable {
|
||||
|
||||
@Model
|
||||
final class MoodEntryModel {
|
||||
// Primary attributes
|
||||
var forDate: Date
|
||||
var moodValue: Int
|
||||
var timestamp: Date
|
||||
var weekDay: Int
|
||||
var entryType: Int
|
||||
// Primary attributes - CloudKit requires default values
|
||||
var forDate: Date = Date()
|
||||
var moodValue: Int = 0
|
||||
var timestamp: Date = Date()
|
||||
var weekDay: Int = 1
|
||||
var entryType: Int = 0
|
||||
|
||||
// Metadata
|
||||
var canEdit: Bool
|
||||
var canDelete: Bool
|
||||
// Metadata - CloudKit requires default values
|
||||
var canEdit: Bool = true
|
||||
var canDelete: Bool = true
|
||||
|
||||
// Journal & Media (NEW)
|
||||
var notes: String?
|
||||
|
||||
@@ -66,6 +66,9 @@ final class MoodLogger {
|
||||
|
||||
// 7. Reload widgets
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
|
||||
// 8. Notify watch to refresh complications
|
||||
WatchConnectivityManager.shared.notifyWatchToReload()
|
||||
}
|
||||
|
||||
/// Calculate the current mood streak
|
||||
|
||||
@@ -79,6 +79,7 @@ class Random {
|
||||
return newValue
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
static func createTotalPerc(fromEntries entries: [MoodEntryModel]) -> [MoodMetrics] {
|
||||
let filteredEntries = entries.filter({
|
||||
return ![.missing, .placeholder].contains($0.mood)
|
||||
@@ -100,13 +101,15 @@ class Random {
|
||||
|
||||
return returnData
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(watchOS)
|
||||
struct RoundedCorner: Shape {
|
||||
|
||||
|
||||
var radius: CGFloat = .infinity
|
||||
var corners: UIRectCorner = .allCorners
|
||||
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
|
||||
return Path(path.cgPath)
|
||||
@@ -117,7 +120,7 @@ extension View {
|
||||
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
|
||||
clipShape( RoundedCorner(radius: radius, corners: corners) )
|
||||
}
|
||||
|
||||
|
||||
func snapshot() -> UIImage {
|
||||
let controller = UIHostingController(rootView: self)
|
||||
let view = controller.view
|
||||
@@ -129,7 +132,7 @@ extension View {
|
||||
view?.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func asImage(size: CGSize) -> UIImage {
|
||||
let controller = UIHostingController(rootView: self)
|
||||
controller.view.bounds = CGRect(origin: .zero, size: size)
|
||||
@@ -156,7 +159,7 @@ extension Color {
|
||||
blue: .random(in: 0...1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
public func lighter(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).lighter(by: amount)) }
|
||||
public func darker(by amount: CGFloat = 0.2) -> Self { Self(UIColor(self).darker(by: amount)) }
|
||||
}
|
||||
@@ -167,34 +170,34 @@ extension String {
|
||||
let font = UIFont.systemFont(ofSize: 100) // you can change your font size here
|
||||
let stringAttributes = [NSAttributedString.Key.font: font]
|
||||
let imageSize = nsString.size(withAttributes: stringAttributes)
|
||||
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) // begin image context
|
||||
UIColor.clear.set() // clear background
|
||||
UIRectFill(CGRect(origin: CGPoint(), size: imageSize)) // set rect size
|
||||
nsString.draw(at: CGPoint.zero, withAttributes: stringAttributes) // draw text within rect
|
||||
let image = UIGraphicsGetImageFromCurrentImageContext() // create image from context
|
||||
UIGraphicsEndImageContext() // end image context
|
||||
|
||||
|
||||
return image ?? UIImage()
|
||||
}
|
||||
}
|
||||
|
||||
extension UIColor {
|
||||
|
||||
|
||||
func lighter(by percentage: CGFloat = 10.0) -> UIColor {
|
||||
return self.adjust(by: abs(percentage))
|
||||
}
|
||||
|
||||
|
||||
func darker(by percentage: CGFloat = 10.0) -> UIColor {
|
||||
return self.adjust(by: -abs(percentage))
|
||||
}
|
||||
|
||||
|
||||
func adjust(by percentage: CGFloat) -> UIColor {
|
||||
var alpha, hue, saturation, brightness, red, green, blue, white : CGFloat
|
||||
(alpha, hue, saturation, brightness, red, green, blue, white) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)
|
||||
|
||||
|
||||
let multiplier = percentage / 100.0
|
||||
|
||||
|
||||
if self.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) {
|
||||
let newBrightness: CGFloat = max(min(brightness + multiplier*brightness, 1.0), 0.0)
|
||||
return UIColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: alpha)
|
||||
@@ -209,10 +212,11 @@ extension UIColor {
|
||||
let newWhite: CGFloat = (white + multiplier*white)
|
||||
return UIColor(white: newWhite, alpha: alpha)
|
||||
}
|
||||
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
extension Bundle {
|
||||
var appName: String {
|
||||
|
||||
@@ -125,7 +125,7 @@ final class ReviewRequestManager {
|
||||
}
|
||||
|
||||
// Request the review - iOS decides whether to actually show it
|
||||
SKStoreReviewController.requestReview(in: windowScene)
|
||||
AppStore.requestReview(in: windowScene)
|
||||
}
|
||||
|
||||
// MARK: - Debug / Testing
|
||||
|
||||
166
Shared/Services/WatchConnectivityManager.swift
Normal file
166
Shared/Services/WatchConnectivityManager.swift
Normal file
@@ -0,0 +1,166 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user