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:
Trey t
2025-12-21 17:19:17 -06:00
parent d902694cdd
commit 224c00423a
20 changed files with 1148 additions and 57 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 KiB

View File

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon.png",
"idiom" : "universal",
"platform" : "watchos",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,191 @@
//
// ContentView.swift
// Feels Watch App
//
// Main voting interface for logging moods on Apple Watch.
//
import SwiftUI
import WatchKit
struct ContentView: View {
@State private var showConfirmation = false
@State private var selectedMood: Mood?
var body: some View {
ZStack {
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) })
}
// Bottom row: Bad, Horrible
HStack(spacing: 8) {
MoodButton(mood: .bad, action: { logMood(.bad) })
MoodButton(mood: .horrible, action: { logMood(.horrible) })
}
}
.opacity(showConfirmation ? 0.3 : 1)
// Confirmation overlay
if showConfirmation {
ConfirmationView(mood: selectedMood)
}
}
}
private func logMood(_ mood: Mood) {
selectedMood = mood
// Haptic feedback
WKInterfaceDevice.current().play(.success)
let date = Date()
// Send to iPhone for centralized logging (iOS handles all side effects)
// Also save locally as fallback and for immediate complication updates
Task { @MainActor in
// Always save locally for immediate complication display
WatchDataProvider.shared.addMood(mood, forDate: date)
// Send to iPhone - it will handle HealthKit, Live Activity, etc.
_ = WatchConnectivityManager.shared.sendMoodToPhone(mood: mood.rawValue, date: date)
}
// Show confirmation
withAnimation(.easeInOut(duration: 0.2)) {
showConfirmation = true
}
// Hide confirmation after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
withAnimation(.easeInOut(duration: 0.2)) {
showConfirmation = false
}
}
}
}
// MARK: - Mood Button
struct MoodButton: View {
let mood: Mood
let action: () -> Void
var body: some View {
Button(action: action) {
Text(mood.watchEmoji)
.font(.system(size: 28))
.frame(maxWidth: .infinity)
.frame(height: 50)
.background(mood.watchColor.opacity(0.3))
.cornerRadius(12)
}
.buttonStyle(.plain)
}
}
// 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
/// Provides the appropriate emoji based on user's selected mood image style
enum WatchMoodImageStyle: Int {
case fontAwesome = 0
case emoji = 1
case handEmoji = 2
static var current: WatchMoodImageStyle {
// Use optional chaining for preview safety - App Group may not exist in canvas
guard let defaults = UserDefaults(suiteName: Constants.currentGroupShareId) else {
return .emoji
}
let rawValue = defaults.integer(forKey: "moodImages")
return WatchMoodImageStyle(rawValue: rawValue) ?? .emoji
}
func emoji(for mood: Mood) -> String {
switch self {
case .fontAwesome:
// FontAwesome uses face icons - map to similar emoji
switch mood {
case .great: return "😁"
case .good: return "🙂"
case .average: return "😐"
case .bad: return "🙁"
case .horrible: return "😫"
case .missing, .placeholder: return ""
}
case .emoji:
switch mood {
case .great: return "😀"
case .good: return "🙂"
case .average: return "😑"
case .bad: return "😕"
case .horrible: return "💩"
case .missing, .placeholder: return ""
}
case .handEmoji:
switch mood {
case .great: return "🙏"
case .good: return "👍"
case .average: return "🖖"
case .bad: return "👎"
case .horrible: return "🖕"
case .missing, .placeholder: return ""
}
}
}
}
// MARK: - Watch-Specific Mood Extensions
extension Mood {
/// Emoji representation for watch display based on user's selected style
var watchEmoji: String {
WatchMoodImageStyle.current.emoji(for: self)
}
/// Color for watch UI (simplified palette)
var watchColor: Color {
switch self {
case .great: return .green
case .good: return .mint
case .average: return .yellow
case .bad: return .orange
case .horrible: return .red
case .missing, .placeholder: return .gray
}
}
}
#Preview {
ContentView()
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.ifeel</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.ifeelDebug</string>
</array>
</dict>
</plist>

View File

@@ -0,0 +1,232 @@
//
// FeelsComplication.swift
// Feels Watch App
//
// WidgetKit complications for Apple Watch.
//
import WidgetKit
import SwiftUI
// MARK: - Timeline Provider
struct FeelsTimelineProvider: TimelineProvider {
func placeholder(in context: Context) -> FeelsEntry {
FeelsEntry(date: Date(), mood: nil, streak: 0)
}
func getSnapshot(in context: Context, completion: @escaping (FeelsEntry) -> Void) {
Task { @MainActor in
let entry = createEntry()
completion(entry)
}
}
func getTimeline(in context: Context, completion: @escaping (Timeline<FeelsEntry>) -> Void) {
Task { @MainActor in
let entry = createEntry()
// Refresh at midnight for the next day
let tomorrow = Calendar.current.startOfDay(
for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!
)
let timeline = Timeline(entries: [entry], policy: .after(tomorrow))
completion(timeline)
}
}
@MainActor
private func createEntry() -> FeelsEntry {
let todayEntry = WatchDataProvider.shared.getTodayEntry()
let streak = WatchDataProvider.shared.getCurrentStreak()
return FeelsEntry(
date: Date(),
mood: todayEntry?.mood,
streak: streak
)
}
}
// MARK: - Timeline Entry
struct FeelsEntry: TimelineEntry {
let date: Date
let mood: Mood?
let streak: Int
}
// MARK: - Complication Views
struct FeelsComplicationEntryView: View {
var entry: FeelsEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .accessoryCircular:
CircularView(entry: entry)
case .accessoryCorner:
CornerView(entry: entry)
case .accessoryInline:
InlineView(entry: entry)
case .accessoryRectangular:
RectangularView(entry: entry)
default:
CircularView(entry: entry)
}
}
}
// MARK: - Circular Complication
struct CircularView: View {
let entry: FeelsEntry
var body: some View {
ZStack {
AccessoryWidgetBackground()
if let mood = entry.mood {
Text(mood.watchEmoji)
.font(.system(size: 24))
} else {
VStack(spacing: 0) {
Image(systemName: "face.smiling")
.font(.system(size: 18))
Text("Log")
.font(.system(size: 10))
}
}
}
}
}
// MARK: - Corner Complication
struct CornerView: View {
let entry: FeelsEntry
var body: some View {
if let mood = entry.mood {
Text(mood.watchEmoji)
.font(.system(size: 20))
.widgetLabel {
Text(mood.widgetDisplayName)
}
} else {
Image(systemName: "face.smiling")
.font(.system(size: 20))
.widgetLabel {
Text("Log mood")
}
}
}
}
// MARK: - Inline Complication
struct InlineView: View {
let entry: FeelsEntry
var body: some View {
if entry.streak > 0 {
Label("\(entry.streak) day streak", systemImage: "flame.fill")
} else if let mood = entry.mood {
Text("\(mood.watchEmoji) \(mood.widgetDisplayName)")
} else {
Label("Log your mood", systemImage: "face.smiling")
}
}
}
// MARK: - Rectangular Complication
struct RectangularView: View {
let entry: FeelsEntry
var body: some View {
HStack {
if let mood = entry.mood {
Text(mood.watchEmoji)
.font(.system(size: 28))
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text(mood.widgetDisplayName)
.font(.system(size: 14, weight: .semibold))
if entry.streak > 1 {
Label("\(entry.streak) days", systemImage: "flame.fill")
.font(.system(size: 10))
.foregroundColor(.orange)
}
}
} else {
Image(systemName: "face.smiling")
.font(.system(size: 24))
VStack(alignment: .leading, spacing: 2) {
Text("Feels")
.font(.system(size: 14, weight: .semibold))
Text("Tap to log mood")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
Spacer()
}
}
}
// MARK: - Widget Configuration
struct FeelsComplication: Widget {
let kind: String = "FeelsComplication"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: FeelsTimelineProvider()) { entry in
FeelsComplicationEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Feels")
.description("See today's mood and streak.")
.supportedFamilies([
.accessoryCircular,
.accessoryCorner,
.accessoryInline,
.accessoryRectangular
])
}
}
// MARK: - Preview
#Preview("Circular - Mood") {
CircularView(entry: FeelsEntry(date: Date(), mood: .great, streak: 5))
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
}
#Preview("Circular - Empty") {
CircularView(entry: FeelsEntry(date: Date(), mood: nil, streak: 0))
.previewContext(WidgetPreviewContext(family: .accessoryCircular))
}
#Preview("Rectangular - Mood") {
RectangularView(entry: FeelsEntry(date: Date(), mood: .good, streak: 7))
.previewContext(WidgetPreviewContext(family: .accessoryRectangular))
}
#Preview("Inline - Streak") {
InlineView(entry: FeelsEntry(date: Date(), mood: .great, streak: 5))
.previewContext(WidgetPreviewContext(family: .accessoryInline))
}
#Preview("Corner - Mood") {
CornerView(entry: FeelsEntry(date: Date(), mood: .average, streak: 3))
.previewContext(WidgetPreviewContext(family: .accessoryCorner))
}

View File

@@ -0,0 +1,23 @@
//
// FeelsWatchApp.swift
// Feels Watch App
//
// Entry point for the Apple Watch companion app.
//
import SwiftUI
@main
struct FeelsWatchApp: App {
init() {
// Initialize Watch Connectivity for cross-device widget updates
_ = WatchConnectivityManager.shared
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

View File

@@ -0,0 +1,88 @@
//
// WatchConnectivityManager.swift
// Feels Watch App
//
// Watch-side connectivity - sends mood to iPhone for centralized logging.
//
import Foundation
import WatchConnectivity
import WidgetKit
import os.log
/// Watch-side connectivity manager
/// Sends mood votes to iPhone for centralized logging
final class WatchConnectivityManager: NSObject, ObservableObject {
static let shared = WatchConnectivityManager()
private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchConnectivity")
private var session: WCSession?
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")
}
}
// MARK: - Watch iOS
/// Send mood to iOS app for centralized logging
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")
return true
}
}
// 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 activated: \(activationState.rawValue)")
}
}
// Receive 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()
}
}
}
}

View File

@@ -0,0 +1,172 @@
//
// WatchDataProvider.swift
// Feels Watch App
//
// Data provider for Apple Watch with read/write access.
// Uses App Group container shared with main iOS app.
//
import Foundation
import SwiftData
import WidgetKit
import os.log
/// Data provider for Apple Watch with read/write access
/// Uses its own ModelContainer to avoid SwiftData conflicts
@MainActor
final class WatchDataProvider {
static let shared = WatchDataProvider()
private static let logger = Logger(subsystem: "com.tt.ifeel.watchkitapp", category: "WatchDataProvider")
private var _container: ModelContainer?
private var container: ModelContainer {
if let existing = _container {
return existing
}
let newContainer = createContainer()
_container = newContainer
return newContainer
}
/// Creates the ModelContainer for watch data access
private func createContainer() -> ModelContainer {
let schema = Schema([MoodEntryModel.self])
// Try to use shared app group container
do {
let storeURL = try getStoreURL()
let configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: .none // Watch doesn't sync directly
)
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)")
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
Self.logger.critical("Failed to create ModelContainer: \(error.localizedDescription)")
preconditionFailure("Unable to create ModelContainer: \(error)")
}
}
}
private func getStoreURL() throws -> URL {
let appGroupID = Constants.currentGroupShareId
guard let containerURL = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupID
) else {
throw NSError(domain: "WatchDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"])
}
#if DEBUG
return containerURL.appendingPathComponent("Feels-Debug.store")
#else
return containerURL.appendingPathComponent("Feels.store")
#endif
}
private var modelContext: ModelContext {
container.mainContext
}
private init() {}
// MARK: - Read Operations
/// Get a single entry for a specific date
func getEntry(byDate date: Date) -> MoodEntryModel? {
let startDate = Calendar.current.startOfDay(for: date)
let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)!
var descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
descriptor.fetchLimit = 1
return try? modelContext.fetch(descriptor).first
}
/// Get today's mood entry
func getTodayEntry() -> MoodEntryModel? {
getEntry(byDate: Date())
}
/// Get entries within a date range
func getData(startDate: Date, endDate: Date) -> [MoodEntryModel] {
let descriptor = FetchDescriptor<MoodEntryModel>(
predicate: #Predicate { entry in
entry.forDate >= startDate && entry.forDate <= endDate
},
sortBy: [SortDescriptor(\.forDate, order: .reverse)]
)
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Get the current streak count
func getCurrentStreak() -> Int {
let yearAgo = Calendar.current.date(byAdding: .day, value: -365, to: Date())!
let entries = getData(startDate: yearAgo, endDate: Date())
var streak = 0
var currentDate = Calendar.current.startOfDay(for: Date())
for entry in entries {
let entryDate = Calendar.current.startOfDay(for: entry.forDate)
if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder {
streak += 1
currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
} else if entryDate < currentDate {
break
}
}
return streak
}
// MARK: - Write Operations
/// Add a new mood entry from the watch
func addMood(_ mood: Mood, forDate date: Date) {
// Delete existing entry for this date if present
if let existing = getEntry(byDate: date) {
modelContext.delete(existing)
try? modelContext.save()
}
let entry = MoodEntryModel(
forDate: date,
mood: mood,
entryType: .watch
)
modelContext.insert(entry)
do {
try modelContext.save()
Self.logger.info("Saved mood \(mood.rawValue) for \(date)")
// Refresh watch complications immediately
WidgetCenter.shared.reloadAllTimelines()
// Note: WCSession notification is handled by ContentView
// iOS app coordinates all side effects when it receives the mood
} catch {
Self.logger.error("Failed to save mood: \(error.localizedDescription)")
}
}
/// Invalidate cached container
func invalidateCache() {
_container = nil
}
}

View File

@@ -7,8 +7,8 @@
objects = {
/* Begin PBXBuildFile section */
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */; };
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */; };
1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; };
1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; };
1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; };
@@ -21,6 +21,8 @@
1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; };
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; };
69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -45,6 +47,13 @@
remoteGlobalIDString = 1CD90B44278C7E7A001C4FEA;
remoteInfo = FeelsWidgetExtension;
};
51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 1CD90AE6278C7DDF001C4FEA /* Project object */;
proxyType = 1;
remoteGlobalIDString = B1DB9E6543DE4A009DB00916;
remoteInfo = "Feels Watch App";
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
@@ -59,10 +68,21 @@
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
87A714924E734CD8948F0CD0 /* Embed Watch Content */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "$(CONTENTS_FOLDER_PATH)/Watch";
dstSubfolderSpec = 16;
files = (
69674916178A409ABDEA4126 /* Feels Watch App.app in Embed Watch Content */,
);
name = "Embed Watch Content";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = "Feels/Localizable.xcstrings"; sourceTree = "<group>"; };
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = "<group>"; };
1CB4D09E28787B3C00902A56 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = "<group>"; };
1CB4D09F28787D8A00902A56 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.5.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
1CD90AF5278C7DE0001C4FEA /* iFeels.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iFeels.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -83,6 +103,9 @@
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FeelsWidgetExtension.entitlements; sourceTree = "<group>"; };
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = FeelsWidgetExtensionDev.entitlements; sourceTree = "<group>"; };
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Feels (iOS)Dev.entitlements"; sourceTree = "<group>"; };
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Feels Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch AppDebug.entitlements"; sourceTree = "<group>"; };
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -115,11 +138,21 @@
);
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
};
2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Models/Mood.swift,
Models/MoodEntryModel.swift,
Random.swift,
);
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = "<group>"; };
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = "<group>"; };
579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -165,19 +198,30 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
28189547ACED4EA2B5842F91 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1CD90AE5278C7DDF001C4FEA = {
isa = PBXGroup;
children = (
B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */,
B60015D02A064FF582E232FD /* Feels Watch App/Feels Watch AppDebug.entitlements */,
1CB4D09E28787B3C00902A56 /* Configuration.storekit */,
1C0DAB50279DB0FB003B1F21 /* Localizable.xcstrings */,
1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */,
1CD90B6A278C7F75001C4FEA /* Feels (iOS).entitlements */,
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */,
1CD90B6D278C7F89001C4FEA /* FeelsWidgetExtension.entitlements */,
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */,
1CD90B69278C7F65001C4FEA /* Feels--iOS--Info.plist */,
579031D619ED4B989145EEB1 /* Feels Watch App */,
1C00073D2EE9388A009C9ED5 /* Shared */,
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */,
1CD90AFC278C7DE0001C4FEA /* macOS */,
@@ -191,6 +235,7 @@
1CD90AF6278C7DE0001C4FEA /* Products */ = {
isa = PBXGroup;
children = (
1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */,
1CD90AF5278C7DE0001C4FEA /* iFeels.app */,
1CD90AFB278C7DE0001C4FEA /* Feels.app */,
1CD90B02278C7DE0001C4FEA /* Tests iOS.xctest */,
@@ -248,11 +293,13 @@
1CD90AF2278C7DE0001C4FEA /* Frameworks */,
1CD90AF3278C7DE0001C4FEA /* Resources */,
1CD90B5A278C7E7A001C4FEA /* Embed Foundation Extensions */,
87A714924E734CD8948F0CD0 /* Embed Watch Content */,
);
buildRules = (
);
dependencies = (
1CD90B55278C7E7A001C4FEA /* PBXTargetDependency */,
CB28ED3402234638800683C9 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
1C00073D2EE9388A009C9ED5 /* Shared */,
@@ -341,6 +388,28 @@
productReference = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
B1DB9E6543DE4A009DB00916 /* Feels Watch App */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1B7D3790BF564C5392D480B2 /* Build configuration list for PBXNativeTarget "Feels Watch App" */;
buildPhases = (
0C4FBA03AAF5412783DD72AF /* Sources */,
28189547ACED4EA2B5842F91 /* Frameworks */,
05596FBF3C384AC4A2DC09B9 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
579031D619ED4B989145EEB1 /* Feels Watch App */,
);
name = "Feels Watch App";
packageProductDependencies = (
);
productName = "Feels Watch App";
productReference = 1E594AEAB5F046E3B3ED7C47 /* Feels Watch App.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -368,6 +437,9 @@
1CD90B44278C7E7A001C4FEA = {
CreatedOnToolsVersion = 13.2.1;
};
B1DB9E6543DE4A009DB00916 = {
CreatedOnToolsVersion = 15.0;
};
};
};
buildConfigurationList = 1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */;
@@ -394,6 +466,7 @@
projectRoot = "";
targets = (
1CD90AF4278C7DE0001C4FEA /* Feels (iOS) */,
B1DB9E6543DE4A009DB00916 /* Feels Watch App */,
1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */,
1CD90AFA278C7DE0001C4FEA /* Feels (macOS) */,
1CD90B01278C7DE0001C4FEA /* Tests iOS */,
@@ -403,11 +476,18 @@
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
05596FBF3C384AC4A2DC09B9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1CD90AF3278C7DE0001C4FEA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1C0DAB51279DB0FB003B1F21 /* Localizable.xcstrings in Resources */,
1C0DAB51279DB0FB003B1F21 /* Feels/Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -436,13 +516,20 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1C0DAB52279DB0FB003B1F22 /* Localizable.xcstrings in Resources */,
1C0DAB52279DB0FB003B1F22 /* Feels/Localizable.xcstrings in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
0C4FBA03AAF5412783DD72AF /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
1CD90AF1278C7DE0001C4FEA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -500,11 +587,44 @@
target = 1CD90B44278C7E7A001C4FEA /* FeelsWidgetExtension */;
targetProxy = 1CD90B54278C7E7A001C4FEA /* PBXContainerItemProxy */;
};
CB28ED3402234638800683C9 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = B1DB9E6543DE4A009DB00916 /* Feels Watch App */;
targetProxy = 51F6DCE106234B68B4F88529 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
1AA0E790DCE44476924A23BB /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch AppDebug.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Feels;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.ifeelDebug;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Debug;
};
1CD90B20278C7DE0001C4FEA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -899,9 +1019,49 @@
};
name = Release;
};
67FBFEE92D1D4F8BBFBF7B1D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Feels;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.ifeel;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeel.watchkitapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 4;
VALIDATE_PRODUCT = YES;
WATCHOS_DEPLOYMENT_TARGET = 10.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1B7D3790BF564C5392D480B2 /* Build configuration list for PBXNativeTarget "Feels Watch App" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1AA0E790DCE44476924A23BB /* Debug */,
67FBFEE92D1D4F8BBFBF7B1D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1CD90AE9278C7DDF001C4FEA /* Build configuration list for PBXProject "Feels" */ = {
isa = XCConfigurationList;
buildConfigurations = (

View File

@@ -14,7 +14,9 @@ import AppIntents
struct VoteMoodIntent: AppIntent {
static var title: LocalizedStringResource = "Vote Mood"
static var description = IntentDescription("Record your mood for today")
static var openAppWhenRun: Bool { false }
// Run in main app process - enables full MoodLogger with watch sync
static var openAppWhenRun: Bool { true }
@Parameter(title: "Mood")
var moodValue: Int
@@ -32,30 +34,23 @@ struct VoteMoodIntent: AppIntent {
let mood = Mood(rawValue: moodValue) ?? .average
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
// Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
// Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
// This code runs in the main app process (openAppWhenRun = true)
// Use conditional compilation for widget extension to compile
#if !WIDGET_EXTENSION
// Main app: use MoodLogger for all side effects including watch sync
MoodLogger.shared.logMood(mood, for: votingDate, entryType: .widget)
#else
// Widget extension compilation path (never executed at runtime)
WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
WidgetCenter.shared.reloadAllTimelines()
#endif
// Store last voted date
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
// Update Live Activity
let streak = calculateCurrentStreak()
LiveActivityManager.shared.updateActivity(streak: streak, mood: mood)
LiveActivityScheduler.shared.scheduleForNextDay()
// Reload widget timeline
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
return .result()
}
@MainActor
private func calculateCurrentStreak() -> Int {
// Use WidgetDataProvider for read operations
return WidgetDataProvider.shared.getCurrentStreak()
}
}
// MARK: - Vote Widget Provider

View File

@@ -8,6 +8,7 @@
import Foundation
import SwiftData
import WidgetKit
import os.log
/// Lightweight read-only data provider for widgets
@@ -182,6 +183,17 @@ final class WidgetDataProvider {
)
modelContext.insert(entry)
try? modelContext.save()
do {
try modelContext.save()
// Refresh all widgets immediately
WidgetCenter.shared.reloadAllTimelines()
// Note: WatchConnectivity is not available in widget extensions
// The watch will pick up the data on its next timeline refresh
} catch {
// Silently fail for widget context
}
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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?

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View 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
}