From d179c4c6249870d4f6bd8314c132dd6f1cbe9d25 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Mar 2026 11:28:13 -0700 Subject: [PATCH] feat(watch,mac): complete watchOS + macOS companion targets - watchOS: add WatchSessionManager (WCSession bridge), WatchNotificationHandler (snooze/dismiss actions), recommendations() for AppIntentTimelineProvider - watchOS: WatchTimerStore tick loop with cascade haptics, App Group sync - macOS: CreateTimerSheet (countdown/alarm/pomodoro), launch-at-login toggle - macOS: MenuBarState showCreateSheet, MacSettingsView data tab - Xcode project updated with new file references --- ios/ChronoMind.xcodeproj/project.pbxproj | 12 ++ ios/ChronoMindMac/ChronoMindMacApp.swift | 4 + ios/ChronoMindMac/MenuBarState.swift | 1 + .../Views/CreateTimerSheet.swift | 166 ++++++++++++++++++ ios/ChronoMindMac/Views/MacSettingsView.swift | 23 ++- .../Complications/WatchComplications.swift | 4 + .../Views/WatchTimerDetailView.swift | 86 +++++++++ .../WatchNotificationHandler.swift | 131 ++++++++++++++ ios/ChronoMindWatch/WatchSessionManager.swift | 166 ++++++++++++++++++ ios/ChronoMindWatch/WatchTimerStore.swift | 128 +++++++++++++- 10 files changed, 713 insertions(+), 8 deletions(-) create mode 100644 ios/ChronoMindMac/Views/CreateTimerSheet.swift create mode 100644 ios/ChronoMindWatch/WatchNotificationHandler.swift create mode 100644 ios/ChronoMindWatch/WatchSessionManager.swift diff --git a/ios/ChronoMind.xcodeproj/project.pbxproj b/ios/ChronoMind.xcodeproj/project.pbxproj index a85fc3e..48b3534 100644 --- a/ios/ChronoMind.xcodeproj/project.pbxproj +++ b/ios/ChronoMind.xcodeproj/project.pbxproj @@ -37,6 +37,7 @@ 32665033E2BC63790F7AC21A /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A574A113648729BDB180DC6 /* Config.swift */; }; 327CE7DB4C7C15AF8DA2B62A /* FeatureFlagService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDB8E3C2B7FD90E1FA218B01 /* FeatureFlagService.swift */; }; 33FE05A7CC65C5A251D5C686 /* CascadeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEC3C28A0A106D3A02642F48 /* CascadeTests.swift */; }; + 34490943BF5BF5F1AC51CBB4 /* CreateTimerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */; }; 383A8D81DE756B7B927380C7 /* Urgency.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8775EEA5055E7416149B8384 /* Urgency.swift */; }; 3A536FC42B293811D9C2FFBE /* ByteLystPlatformSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 421BDB267D3349C06FADAA28 /* ByteLystPlatformSDK */; }; 3BC2A4D25FCF4BDA4E815A5D /* MacSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A54834F3BE0671164B5552F /* MacSettingsView.swift */; }; @@ -77,6 +78,7 @@ 880CCDF13CFBAC1A05A9900D /* TelemetryService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 355EAC56ED412303126019DA /* TelemetryService.swift */; }; 8A0C48D4161E6AAAA2A07E1A /* ChronoMindMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C51DECA2DE40C002338930E /* ChronoMindMacApp.swift */; }; 8AC85822AA4E433E857DA4A5 /* Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CC158E525F3F703034E5542 /* Format.swift */; }; + 8EA608B60672AC6A8056AA71 /* WatchNotificationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */; }; 8EF5C79BDF55D1F04851C3C0 /* TimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C2F36FE2E4FEAD385B6860 /* TimelineView.swift */; }; 9018CB5B25A5B9609510CFF8 /* SharedTimerData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84C298B338633A7D75102171 /* SharedTimerData.swift */; }; 95B261AD4DFE1681A86BAD90 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A574A113648729BDB180DC6 /* Config.swift */; }; @@ -100,6 +102,7 @@ AC65C4971CDEF04A0BBCBCA6 /* TimerEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93579D521BE5D6682B129A5C /* TimerEngine.swift */; }; AE00522D3CE642B0C608E8A2 /* CountdownRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE814566D06D5ED5DE214765 /* CountdownRing.swift */; }; B586A949950B02D3499C828E /* TimeBlindness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D899FC687E51E9606B813E4F /* TimeBlindness.swift */; }; + B73F1243A121C80414DE0AF4 /* WatchSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */; }; B7924EB8DA38D8A3CD30DE8F /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD29B4D4514DB0134359A13E /* KeychainHelper.swift */; }; B79A0BFA53743E32ADC391A2 /* TimeBlindness.swift in Sources */ = {isa = PBXBuildFile; fileRef = D899FC687E51E9606B813E4F /* TimeBlindness.swift */; }; B8AB20C0CB248C63CB3492A5 /* Routines.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880334677DB319D5189434D0 /* Routines.swift */; }; @@ -229,8 +232,10 @@ 38EB42DB96BAF7C05809A7D9 /* TimerAppIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerAppIntents.swift; sourceTree = ""; }; 3979E2F91CF5CD721AB63632 /* ChronoMindMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ChronoMindMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; 3A54834F3BE0671164B5552F /* MacSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacSettingsView.swift; sourceTree = ""; }; + 3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = ""; }; 439857A424E7419653DBC47B /* WatchContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContentView.swift; sourceTree = ""; }; 4438C3D29B6F307E453E9EC1 /* SleepManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepManager.swift; sourceTree = ""; }; + 450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTimerSheet.swift; sourceTree = ""; }; 453FAEB975BAA8307C8AED26 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 479A9078C8BF70F342BE7A1C /* BadgeGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeGridView.swift; sourceTree = ""; }; 492793555CC220F85136F2B4 /* TimerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerCard.swift; sourceTree = ""; }; @@ -272,6 +277,7 @@ B3A5D547D31EEFB16F3491EA /* ContextMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMessages.swift; sourceTree = ""; }; B769E9E88032FB471820D110 /* RoutineRunnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRunnerView.swift; sourceTree = ""; }; B7AC8BE54BDA744FCCA8C5A1 /* LocationTriggerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTriggerManager.swift; sourceTree = ""; }; + BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = ""; }; BD88A07063BD00F2DC6BB753 /* ChronoMindApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChronoMindApp.swift; sourceTree = ""; }; BE3B45641C99D54A016AD38E /* NLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLParser.swift; sourceTree = ""; }; BEE32B08F25A4438A6B69FB6 /* ChronoMindTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = ChronoMindTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -504,6 +510,7 @@ 6A8791D6748EAE1B7BA105B5 /* Views */ = { isa = PBXGroup; children = ( + 450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */, 3A54834F3BE0671164B5552F /* MacSettingsView.swift */, CD4D2E4380D018CAF3C8B82F /* MenuBarPopover.swift */, ); @@ -679,6 +686,8 @@ FBC821ED7751399B034F12DF /* Views */, 1E2C2086B4B2E599F4EE7D9D /* ChronoMindWatch.entitlements */, 162EA6A89620EC9ED71707E1 /* ChronoMindWatchApp.swift */, + BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */, + 3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */, 0625840216F70EFAB4A21985 /* WatchTimerStore.swift */, ); path = ChronoMindWatch; @@ -1032,6 +1041,7 @@ 3E6BD90AD48861E7FB3293D9 /* CloudKitSyncManager.swift in Sources */, 32665033E2BC63790F7AC21A /* Config.swift in Sources */, E0A6D4C09DE2F6269FFCC4CB /* ContextMessages.swift in Sources */, + 34490943BF5BF5F1AC51CBB4 /* CreateTimerSheet.swift in Sources */, 59AF6F8E533757C8ED232857 /* FeatureFlagService.swift in Sources */, 0BD15496ADEDDF47400ACFAE /* Format.swift in Sources */, 4E897C117548C3E0895782F3 /* GamificationEngine.swift in Sources */, @@ -1108,7 +1118,9 @@ AC65C4971CDEF04A0BBCBCA6 /* TimerEngine.swift in Sources */, C7B2C7C46C8929AC4C8F09D4 /* Urgency.swift in Sources */, F68B39CF4045DA16BD3FF38A /* WatchContentView.swift in Sources */, + 8EA608B60672AC6A8056AA71 /* WatchNotificationHandler.swift in Sources */, 7574D49CE352EFD54B38ED54 /* WatchQuickTimerView.swift in Sources */, + B73F1243A121C80414DE0AF4 /* WatchSessionManager.swift in Sources */, 96B75E94FDFB11235CD67F49 /* WatchTimerDetailView.swift in Sources */, 2006E7190D828C4F92FF0A5E /* WatchTimerStore.swift in Sources */, ); diff --git a/ios/ChronoMindMac/ChronoMindMacApp.swift b/ios/ChronoMindMac/ChronoMindMacApp.swift index 1121ce4..ce60d9a 100644 --- a/ios/ChronoMindMac/ChronoMindMacApp.swift +++ b/ios/ChronoMindMac/ChronoMindMacApp.swift @@ -14,6 +14,10 @@ struct ChronoMindMacApp: App { MenuBarPopover() .environmentObject(timerStore) .environmentObject(menuBarState) + .sheet(isPresented: $menuBarState.showCreateSheet) { + CreateTimerSheet() + .environmentObject(timerStore) + } } label: { menuBarLabel } diff --git a/ios/ChronoMindMac/MenuBarState.swift b/ios/ChronoMindMac/MenuBarState.swift index 75f7390..b12c9dc 100644 --- a/ios/ChronoMindMac/MenuBarState.swift +++ b/ios/ChronoMindMac/MenuBarState.swift @@ -10,6 +10,7 @@ final class MenuBarState: ObservableObject { @Published var isExpanded = false @Published var showQuickTimer = false + @Published var showCreateSheet = false @Published var quickTimerLabel = "" @Published var quickTimerMinutes: Double = 25 diff --git a/ios/ChronoMindMac/Views/CreateTimerSheet.swift b/ios/ChronoMindMac/Views/CreateTimerSheet.swift new file mode 100644 index 0000000..3f36f88 --- /dev/null +++ b/ios/ChronoMindMac/Views/CreateTimerSheet.swift @@ -0,0 +1,166 @@ +// Sheet for creating a new timer from the macOS menu bar popover + +import SwiftUI + +struct CreateTimerSheet: View { + @EnvironmentObject var store: MacTimerStore + @Environment(\.dismiss) private var dismiss + + @State private var label = "" + @State private var timerType: CMTimerType = .countdown + @State private var hours: Int = 0 + @State private var minutes: Int = 25 + @State private var alarmTime = Date().addingTimeInterval(3600) + @State private var pomodoroRounds: Int = 4 + @State private var workMinutes: Int = 25 + @State private var breakMinutes: Int = 5 + @State private var urgency: UrgencyLevel = .standard + @State private var cascadePreset: CascadePreset = .standard + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("New Timer") + .font(CMFonts.display(size: 16)) + .foregroundStyle(CMColors.text) + Spacer() + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(CMColors.textMuted) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider().background(CMColors.border) + + // Form + Form { + TextField("Timer Label", text: $label) + .textFieldStyle(.roundedBorder) + + Picker("Type", selection: $timerType) { + Text("Countdown").tag(CMTimerType.countdown) + Text("Alarm").tag(CMTimerType.alarm) + Text("Pomodoro").tag(CMTimerType.pomodoro) + } + + switch timerType { + case .countdown: + countdownFields + case .alarm: + alarmFields + case .pomodoro: + pomodoroFields + default: + EmptyView() + } + + Picker("Urgency", selection: $urgency) { + ForEach(UrgencyLevel.allCases) { level in + HStack { + Circle() + .fill(CMColors.urgencyColor(level)) + .frame(width: 8, height: 8) + Text(getUrgencyConfig(level).label) + } + .tag(level) + } + } + + if timerType != .pomodoro { + Picker("Cascade", selection: $cascadePreset) { + ForEach(CascadePreset.allCases.filter { $0 != .custom }) { preset in + Text(preset.label).tag(preset) + } + } + } + } + .formStyle(.grouped) + .scrollContentBackground(.hidden) + .background(CMColors.bg) + + Divider().background(CMColors.border) + + // Actions + HStack { + Button("Cancel") { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Create") { + createTimer() + dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(label.isEmpty && timerType != .pomodoro) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + .frame(width: 360, height: 420) + .background(CMColors.bg) + } + + // MARK: - Type-Specific Fields + + private var countdownFields: some View { + HStack { + Stepper("Hours: \(hours)", value: $hours, in: 0...23) + Stepper("Minutes: \(minutes)", value: $minutes, in: 0...59) + } + } + + private var alarmFields: some View { + DatePicker("Target Time", selection: $alarmTime, displayedComponents: [.date, .hourAndMinute]) + } + + private var pomodoroFields: some View { + Group { + Stepper("Rounds: \(pomodoroRounds)", value: $pomodoroRounds, in: 1...12) + Stepper("Work: \(workMinutes)m", value: $workMinutes, in: 5...60, step: 5) + Stepper("Break: \(breakMinutes)m", value: $breakMinutes, in: 1...30) + } + } + + // MARK: - Create + + private func createTimer() { + let timerLabel = label.isEmpty ? "Focus Session" : label + + switch timerType { + case .countdown: + let totalSeconds = TimeInterval((hours * 3600) + (minutes * 60)) + guard totalSeconds > 0 else { return } + store.addCountdown(label: timerLabel, durationSeconds: totalSeconds) + + case .alarm: + store.addAlarm(label: timerLabel, targetTime: alarmTime, urgency: urgency) + + case .pomodoro: + let config = PomodoroConfig( + workMinutes: workMinutes, + breakMinutes: breakMinutes, + longBreakMinutes: breakMinutes * 3, + rounds: pomodoroRounds + ) + let timer = createPomodoro(CreatePomodoroParams( + label: timerLabel, + config: config, + urgency: urgency + )) + store.timers.append(timer) + + default: + break + } + } +} diff --git a/ios/ChronoMindMac/Views/MacSettingsView.swift b/ios/ChronoMindMac/Views/MacSettingsView.swift index 702fd8e..7274ede 100644 --- a/ios/ChronoMindMac/Views/MacSettingsView.swift +++ b/ios/ChronoMindMac/Views/MacSettingsView.swift @@ -1,6 +1,8 @@ // ── macOS Settings View ─────────────────────────────────────── import SwiftUI +import ServiceManagement +import os struct MacSettingsView: View { @EnvironmentObject var store: MacTimerStore @@ -8,6 +10,8 @@ struct MacSettingsView: View { @AppStorage("cm_defaultCascade") private var defaultCascade = "standard" @AppStorage("cm_launchAtLogin") private var launchAtLogin = false + private let logger = Logger(subsystem: "com.chronomind.mac", category: "Settings") + var body: some View { TabView { generalTab @@ -26,7 +30,24 @@ struct MacSettingsView: View { private var generalTab: some View { Form { - Toggle("Launch at Login", isOn: $launchAtLogin) + Toggle("Launch at Login", isOn: Binding( + get: { launchAtLogin }, + set: { newValue in + launchAtLogin = newValue + do { + if newValue { + try SMAppService.mainApp.register() + logger.info("Registered for launch at login") + } else { + try SMAppService.mainApp.unregister() + logger.info("Unregistered from launch at login") + } + } catch { + logger.error("Launch at login toggle failed: \(error.localizedDescription)") + launchAtLogin = !newValue + } + } + )) Picker("Default Urgency", selection: $defaultUrgency) { ForEach(UrgencyLevel.allCases) { level in diff --git a/ios/ChronoMindWatch/Complications/WatchComplications.swift b/ios/ChronoMindWatch/Complications/WatchComplications.swift index 7954deb..3e888d2 100644 --- a/ios/ChronoMindWatch/Complications/WatchComplications.swift +++ b/ios/ChronoMindWatch/Complications/WatchComplications.swift @@ -50,6 +50,10 @@ struct WatchComplicationProvider: AppIntentTimelineProvider { return Timeline(entries: entries, policy: .after(reloadDate)) } + func recommendations() -> [AppIntentRecommendation] { + [AppIntentRecommendation(intent: WatchComplicationIntent(), description: "Next Timer")] + } + private func entryFromSharedData() -> WatchComplicationEntry { if let timer = SharedTimerDataManager.shared.readNextFiringTimer() { return WatchComplicationEntry( diff --git a/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift b/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift index 6edd7df..c84d900 100644 --- a/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift +++ b/ios/ChronoMindWatch/Views/WatchTimerDetailView.swift @@ -6,6 +6,7 @@ import WatchKit struct WatchTimerDetailView: View { let timer: TimerSnapshot + @EnvironmentObject var store: WatchTimerStore var body: some View { ScrollView { @@ -79,6 +80,11 @@ struct WatchTimerDetailView: View { } .foregroundStyle(.orange) } + + // Action buttons + Divider().padding(.vertical, 4) + + actionButtons } .padding() } @@ -86,6 +92,86 @@ struct WatchTimerDetailView: View { .navigationBarTitleDisplayMode(.inline) } + // MARK: - Action Buttons + + @ViewBuilder + private var actionButtons: some View { + switch timer.state { + case .firing: + HStack(spacing: 8) { + Button { + WKInterfaceDevice.current().play(.click) + store.sendCommand(.snooze(id: timer.id, minutes: 5)) + } label: { + Label("Snooze 5m", systemImage: "moon.zzz") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.borderedProminent) + .tint(.orange) + + Button { + WKInterfaceDevice.current().play(.success) + store.sendCommand(.dismiss(id: timer.id)) + } label: { + Label("Dismiss", systemImage: "xmark.circle") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.bordered) + .tint(.red) + } + + case .active, .warning: + Button { + WKInterfaceDevice.current().play(.click) + store.sendCommand(.pause(id: timer.id)) + } label: { + Label("Pause", systemImage: "pause.fill") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + + case .paused: + Button { + WKInterfaceDevice.current().play(.start) + store.sendCommand(.resume(id: timer.id)) + } label: { + Label("Resume", systemImage: "play.fill") + .font(.system(size: 14, weight: .medium)) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.green) + + case .snoozed: + HStack(spacing: 8) { + Button { + WKInterfaceDevice.current().play(.click) + store.sendCommand(.snooze(id: timer.id, minutes: 5)) + } label: { + Label("Snooze", systemImage: "moon.zzz") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.bordered) + .tint(.orange) + + Button { + WKInterfaceDevice.current().play(.success) + store.sendCommand(.dismiss(id: timer.id)) + } label: { + Label("Dismiss", systemImage: "xmark.circle") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.bordered) + .tint(.red) + } + + default: + EmptyView() + } + } + private func urgencyColor(_ urgency: UrgencyLevel) -> Color { switch urgency { case .critical: return .red diff --git a/ios/ChronoMindWatch/WatchNotificationHandler.swift b/ios/ChronoMindWatch/WatchNotificationHandler.swift new file mode 100644 index 0000000..9e803c3 --- /dev/null +++ b/ios/ChronoMindWatch/WatchNotificationHandler.swift @@ -0,0 +1,131 @@ +// Local notification handler for watchOS timer alerts + +import Foundation +import UserNotifications +import os + +@MainActor +final class WatchNotificationHandler: NSObject, ObservableObject { + static let shared = WatchNotificationHandler() + + private let logger = Logger(subsystem: "com.chronomind.watch", category: "Notifications") + private let center = UNUserNotificationCenter.current() + + static let timerFiredCategory = "CHRONOMIND_TIMER_FIRED" + static let snoozeAction = "SNOOZE_ACTION" + static let dismissAction = "DISMISS_ACTION" + + private override init() { + super.init() + } + + // MARK: - Setup + + func requestAuthorization() { + center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + Task { @MainActor in + if let error = error { + self.logger.error("Notification auth error: \(error.localizedDescription)") + return + } + self.logger.info("Notification auth granted: \(granted)") + self.registerCategories() + } + } + } + + private func registerCategories() { + let snooze = UNNotificationAction( + identifier: Self.snoozeAction, + title: "Snooze 5m", + options: [] + ) + let dismiss = UNNotificationAction( + identifier: Self.dismissAction, + title: "Dismiss", + options: [.destructive] + ) + + let category = UNNotificationCategory( + identifier: Self.timerFiredCategory, + actions: [snooze, dismiss], + intentIdentifiers: [], + options: [] + ) + + center.setNotificationCategories([category]) + center.delegate = self + logger.info("Notification categories registered") + } + + // MARK: - Schedule + + func scheduleTimerNotification(id: String, label: String, targetTime: Date, urgency: UrgencyLevel) { + let interval = targetTime.timeIntervalSinceNow + guard interval > 0 else { return } + + let content = UNMutableNotificationContent() + content.title = "Timer Fired" + content.body = label + content.sound = urgency == .critical ? .defaultCritical : .default + content.categoryIdentifier = Self.timerFiredCategory + content.userInfo = ["timerId": id, "urgency": urgency.rawValue] + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + let request = UNNotificationRequest(identifier: "timer-\(id)", content: content, trigger: trigger) + + center.add(request) { error in + Task { @MainActor in + if let error = error { + self.logger.error("Failed to schedule notification: \(error.localizedDescription)") + } else { + self.logger.info("Scheduled notification for timer \(id) in \(Int(interval))s") + } + } + } + } + + func cancelNotification(for timerId: String) { + center.removePendingNotificationRequests(withIdentifiers: ["timer-\(timerId)"]) + } +} + +// MARK: - UNUserNotificationCenterDelegate + +extension WatchNotificationHandler: UNUserNotificationCenterDelegate { + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let userInfo = response.notification.request.content.userInfo + guard let timerId = userInfo["timerId"] as? String else { + completionHandler() + return + } + + Task { @MainActor in + let sessionManager = WatchSessionManager.shared + switch response.actionIdentifier { + case Self.snoozeAction: + sessionManager.sendCommand(.snooze(id: timerId, minutes: 5)) + logger.info("Snooze action for timer \(timerId)") + case Self.dismissAction: + sessionManager.sendCommand(.dismiss(id: timerId)) + logger.info("Dismiss action for timer \(timerId)") + default: + break + } + } + + completionHandler() + } + + nonisolated func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) + } +} diff --git a/ios/ChronoMindWatch/WatchSessionManager.swift b/ios/ChronoMindWatch/WatchSessionManager.swift new file mode 100644 index 0000000..3b52228 --- /dev/null +++ b/ios/ChronoMindWatch/WatchSessionManager.swift @@ -0,0 +1,166 @@ +// WCSession manager for bidirectional iPhone ↔ Watch sync + +import Foundation +import WatchConnectivity +import os + +// MARK: - Watch Timer Command + +enum WatchTimerCommand: Codable { + case snooze(id: String, minutes: Int) + case dismiss(id: String) + case pause(id: String) + case resume(id: String) + case complete(id: String) + + var messageDict: [String: Any] { + switch self { + case .snooze(let id, let minutes): + return ["command": "snooze", "id": id, "minutes": minutes] + case .dismiss(let id): + return ["command": "dismiss", "id": id] + case .pause(let id): + return ["command": "pause", "id": id] + case .resume(let id): + return ["command": "resume", "id": id] + case .complete(let id): + return ["command": "complete", "id": id] + } + } +} + +// MARK: - Watch Session Manager + +@MainActor +final class WatchSessionManager: NSObject, ObservableObject { + static let shared = WatchSessionManager() + + @Published var isReachable = false + + private let session: WCSession + private let logger = Logger(subsystem: "com.chronomind.watch", category: "WatchSessionManager") + private var pendingCommands: [[String: Any]] = [] + + private override init() { + session = WCSession.default + super.init() + } + + // MARK: - Activation + + func activate() { + guard WCSession.isSupported() else { + logger.warning("WCSession not supported on this device") + return + } + session.delegate = self + session.activate() + logger.info("WCSession activation requested") + } + + // MARK: - Send Command + + func sendCommand(_ command: WatchTimerCommand) { + let message = command.messageDict + guard session.activationState == .activated else { + logger.warning("Session not activated, queuing command") + pendingCommands.append(message) + return + } + + if session.isReachable { + session.sendMessage(message, replyHandler: { reply in + Task { @MainActor in + self.logger.info("Command acknowledged: \(reply.description)") + } + }, errorHandler: { error in + Task { @MainActor in + self.logger.error("Failed to send command: \(error.localizedDescription)") + self.pendingCommands.append(message) + } + }) + } else { + // Fallback: use application context for non-urgent delivery + do { + try session.updateApplicationContext(["pendingCommand": message]) + logger.info("Command sent via application context") + } catch { + logger.error("Failed to update application context: \(error.localizedDescription)") + pendingCommands.append(message) + } + } + } + + // MARK: - Flush Pending + + private func flushPendingCommands() { + guard session.isReachable, !pendingCommands.isEmpty else { return } + let commands = pendingCommands + pendingCommands.removeAll() + + for command in commands { + session.sendMessage(command, replyHandler: nil) { [weak self] error in + Task { @MainActor in + self?.logger.error("Failed to flush command: \(error.localizedDescription)") + } + } + } + logger.info("Flushed \(commands.count) pending commands") + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + Task { @MainActor in + if let error = error { + logger.error("WCSession activation failed: \(error.localizedDescription)") + return + } + logger.info("WCSession activated: \(activationState.rawValue)") + isReachable = session.isReachable + flushPendingCommands() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor in + isReachable = session.isReachable + logger.info("Reachability changed: \(session.isReachable)") + if session.isReachable { + flushPendingCommands() + } + } + } + + nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + Task { @MainActor in + logger.info("Received application context update") + // iPhone pushed updated timer data — refresh from App Group + NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil) + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + Task { @MainActor in + logger.info("Received message: \(message.keys.joined(separator: ", "))") + // iPhone pushed a real-time update — refresh from App Group + NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil) + } + } + + nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + Task { @MainActor in + logger.info("Received message with reply: \(message.keys.joined(separator: ", "))") + NotificationCenter.default.post(name: .watchTimerDataUpdated, object: nil) + replyHandler(["status": "received"]) + } + } +} + +// MARK: - Notification Name + +extension Notification.Name { + static let watchTimerDataUpdated = Notification.Name("watchTimerDataUpdated") +} diff --git a/ios/ChronoMindWatch/WatchTimerStore.swift b/ios/ChronoMindWatch/WatchTimerStore.swift index 802af6e..7e1dcd2 100644 --- a/ios/ChronoMindWatch/WatchTimerStore.swift +++ b/ios/ChronoMindWatch/WatchTimerStore.swift @@ -5,6 +5,7 @@ import Foundation import Combine import WatchKit +import os @MainActor final class WatchTimerStore: ObservableObject { @@ -16,23 +17,59 @@ final class WatchTimerStore: ObservableObject { // MARK: - Private private var tickTimer: Timer? + private var updateObserver: Any? private let sharedData = SharedTimerDataManager.shared + private let notificationHandler = WatchNotificationHandler.shared + private let logger = Logger(subsystem: "com.chronomind.watch", category: "WatchTimerStore") + + // Track which warnings we already fired haptics for + private var firedWarningKeys: Set = [] // MARK: - Init init() { loadFromSharedData() startTicking() + notificationHandler.requestAuthorization() + + // Activate WCSession + WatchSessionManager.shared.activate() + + // Listen for real-time updates from iPhone + updateObserver = NotificationCenter.default.addObserver( + forName: .watchTimerDataUpdated, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.loadFromSharedData() + } + } } deinit { tickTimer?.invalidate() + if let observer = updateObserver { + NotificationCenter.default.removeObserver(observer) + } } // MARK: - Data Loading func loadFromSharedData() { - timers = sharedData.readActiveSnapshots() + let newTimers = sharedData.readActiveSnapshots() + + // Schedule notifications for any new timers + for timer in newTimers where !timers.contains(where: { $0.id == timer.id }) { + notificationHandler.scheduleTimerNotification( + id: timer.id, + label: timer.label, + targetTime: timer.targetTime, + urgency: timer.urgency + ) + } + + timers = newTimers } // MARK: - Queries @@ -48,6 +85,18 @@ final class WatchTimerStore: ObservableObject { timers.filter { [.active, .warning, .snoozed, .firing].contains($0.state) } } + // MARK: - Commands (via WCSession) + + func sendCommand(_ command: WatchTimerCommand) { + WatchSessionManager.shared.sendCommand(command) + + // Refresh after a short delay to pick up state changes + Task { + try? await Task.sleep(for: .milliseconds(500)) + loadFromSharedData() + } + } + // MARK: - Quick Timer Creation (writes to App Group) func createQuickTimer(minutes: Int, label: String) { @@ -82,8 +131,17 @@ final class WatchTimerStore: ObservableObject { // Write back to shared data so iOS app picks it up sharedData.writeSnapshots(timers) + // Schedule notification + notificationHandler.scheduleTimerNotification( + id: snapshot.id, + label: label, + targetTime: targetTime, + urgency: .standard + ) + // Haptic confirmation WKInterfaceDevice.current().play(.success) + logger.info("Created quick timer: \(label) for \(minutes)m") } // MARK: - Tick @@ -105,20 +163,76 @@ final class WatchTimerStore: ObservableObject { loadFromSharedData() } - // Check for fired timers + // Check for warnings and fired timers var changed = false for i in timers.indices { - if timers[i].state == .active || timers[i].state == .warning { - if now >= timers[i].targetTime { - // Timer has fired — haptic alert - WKInterfaceDevice.current().play(.notification) + let timer = timers[i] + + // Check cascade warnings + if let nextWarning = timer.nextWarningTime, + now >= nextWarning, + timer.state == .active || timer.state == .warning { + let warningKey = "\(timer.id)-\(Int(nextWarning.timeIntervalSince1970))" + if !firedWarningKeys.contains(warningKey) { + firedWarningKeys.insert(warningKey) + playWarningHaptic(urgency: timer.urgency) + logger.info("Warning fired for timer \(timer.label)") + changed = true + } + } + + // Check for fired timers + if timer.state == .active || timer.state == .warning { + if now >= timer.targetTime { + playFiredHaptic(urgency: timer.urgency) + logger.info("Timer fired: \(timer.label)") changed = true } } } if changed { - loadFromSharedData() // Refresh to get updated states + loadFromSharedData() + } + } + + // MARK: - Haptics + + private func playWarningHaptic(urgency: UrgencyLevel) { + switch urgency { + case .critical: + // Triple haptic for critical + WKInterfaceDevice.current().play(.notification) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + WKInterfaceDevice.current().play(.notification) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + WKInterfaceDevice.current().play(.notification) + } + case .important: + WKInterfaceDevice.current().play(.notification) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + WKInterfaceDevice.current().play(.notification) + } + case .standard, .gentle: + WKInterfaceDevice.current().play(.notification) + case .passive: + WKInterfaceDevice.current().play(.click) + } + } + + private func playFiredHaptic(urgency: UrgencyLevel) { + switch urgency { + case .critical: + WKInterfaceDevice.current().play(.notification) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + WKInterfaceDevice.current().play(.notification) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + WKInterfaceDevice.current().play(.notification) + } + default: + WKInterfaceDevice.current().play(.notification) } } }