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