diff --git a/Feels Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/Feels Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
new file mode 100644
index 0000000..f564135
Binary files /dev/null and b/Feels Watch App/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ
diff --git a/Feels Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json b/Feels Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..74cc725
--- /dev/null
+++ b/Feels Watch App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon.png",
+ "idiom" : "universal",
+ "platform" : "watchos",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Feels Watch App/Assets.xcassets/Contents.json b/Feels Watch App/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/Feels Watch App/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Feels Watch App/ContentView.swift b/Feels Watch App/ContentView.swift
new file mode 100644
index 0000000..761b4e3
--- /dev/null
+++ b/Feels Watch App/ContentView.swift
@@ -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()
+}
diff --git a/Feels Watch App/Feels Watch App.entitlements b/Feels Watch App/Feels Watch App.entitlements
new file mode 100644
index 0000000..70b604d
--- /dev/null
+++ b/Feels Watch App/Feels Watch App.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.tt.ifeel
+
+
+
diff --git a/Feels Watch App/Feels Watch AppDebug.entitlements b/Feels Watch App/Feels Watch AppDebug.entitlements
new file mode 100644
index 0000000..282fd2c
--- /dev/null
+++ b/Feels Watch App/Feels Watch AppDebug.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.tt.ifeelDebug
+
+
+
diff --git a/Feels Watch App/FeelsComplication.swift b/Feels Watch App/FeelsComplication.swift
new file mode 100644
index 0000000..985b268
--- /dev/null
+++ b/Feels Watch App/FeelsComplication.swift
@@ -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) -> 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))
+}
diff --git a/Feels Watch App/FeelsWatchApp.swift b/Feels Watch App/FeelsWatchApp.swift
new file mode 100644
index 0000000..4cad729
--- /dev/null
+++ b/Feels Watch App/FeelsWatchApp.swift
@@ -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()
+ }
+ }
+}
diff --git a/Feels Watch App/WatchConnectivityManager.swift b/Feels Watch App/WatchConnectivityManager.swift
new file mode 100644
index 0000000..e763e97
--- /dev/null
+++ b/Feels Watch App/WatchConnectivityManager.swift
@@ -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()
+ }
+ }
+ }
+}
diff --git a/Feels Watch App/WatchDataProvider.swift b/Feels Watch App/WatchDataProvider.swift
new file mode 100644
index 0000000..3a37593
--- /dev/null
+++ b/Feels Watch App/WatchDataProvider.swift
@@ -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(
+ 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(
+ 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
+ }
+}
diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj
index 30caae4..f439ee2 100644
--- a/Feels.xcodeproj/project.pbxproj
+++ b/Feels.xcodeproj/project.pbxproj
@@ -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 = ""; };
+ 1C0DAB50279DB0FB003B1F21 /* Feels/Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Feels/Localizable.xcstrings; sourceTree = ""; };
1CB4D09E28787B3C00902A56 /* Configuration.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Configuration.storekit; sourceTree = ""; };
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 = ""; };
1CD90B6F278C8000001C4FEA /* FeelsWidgetExtensionDev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = FeelsWidgetExtensionDev.entitlements; sourceTree = ""; };
1CD90B70278C8000001C4FEA /* Feels (iOS)Dev.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = "Feels (iOS)Dev.entitlements"; sourceTree = ""; };
+ 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 = ""; };
+ B8AB4CD73C2B4DC89C6FE84D /* Feels Watch App/Feels Watch App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Feels Watch App/Feels Watch App.entitlements"; sourceTree = ""; };
/* 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 = ""; };
+ 1C00073D2EE9388A009C9ED5 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2166CE8AA7264FC2B4BFAAAC /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 1C000C162EE93AE3009C9ED5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; };
1C0009922EE938FC009C9ED5 /* FeelsWidget2 */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = FeelsWidget2; sourceTree = ""; };
+ 579031D619ED4B989145EEB1 /* Feels Watch App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feels Watch App"; sourceTree = ""; };
/* 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 = (
diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift
index 10a0d4a..e1a9f15 100644
--- a/FeelsWidget2/FeelsVoteWidget.swift
+++ b/FeelsWidget2/FeelsVoteWidget.swift
@@ -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
diff --git a/FeelsWidget2/WidgetDataProvider.swift b/FeelsWidget2/WidgetDataProvider.swift
index db77f3b..cdc889a 100644
--- a/FeelsWidget2/WidgetDataProvider.swift
+++ b/FeelsWidget2/WidgetDataProvider.swift
@@ -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
+ }
}
}
diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift
index 291a95d..0103122 100644
--- a/Shared/FeelsApp.swift
+++ b/Shared/FeelsApp.swift
@@ -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 {
diff --git a/Shared/Models/Mood.swift b/Shared/Models/Mood.swift
index bf34c2a..0122867 100644
--- a/Shared/Models/Mood.swift
+++ b/Shared/Models/Mood.swift
@@ -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 {
diff --git a/Shared/Models/MoodEntryModel.swift b/Shared/Models/MoodEntryModel.swift
index a2107ca..c585cc0 100644
--- a/Shared/Models/MoodEntryModel.swift
+++ b/Shared/Models/MoodEntryModel.swift
@@ -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?
diff --git a/Shared/MoodLogger.swift b/Shared/MoodLogger.swift
index 25e0929..43c9622 100644
--- a/Shared/MoodLogger.swift
+++ b/Shared/MoodLogger.swift
@@ -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
diff --git a/Shared/Random.swift b/Shared/Random.swift
index 5740caa..0e7b9b0 100644
--- a/Shared/Random.swift
+++ b/Shared/Random.swift
@@ -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 {
diff --git a/Shared/Services/ReviewRequestManager.swift b/Shared/Services/ReviewRequestManager.swift
index 508f40c..f77e1b7 100644
--- a/Shared/Services/ReviewRequestManager.swift
+++ b/Shared/Services/ReviewRequestManager.swift
@@ -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
diff --git a/Shared/Services/WatchConnectivityManager.swift b/Shared/Services/WatchConnectivityManager.swift
new file mode 100644
index 0000000..aad8d57
--- /dev/null
+++ b/Shared/Services/WatchConnectivityManager.swift
@@ -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
+}