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
This commit is contained in:
saravanakumardb1 2026-03-27 11:28:13 -07:00
parent a865b9d655
commit d179c4c624
10 changed files with 713 additions and 8 deletions

View File

@ -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 = "<group>"; };
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 = "<group>"; };
3ECF62A4CD0EEE3EB8588FB0 /* WatchSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchSessionManager.swift; sourceTree = "<group>"; };
439857A424E7419653DBC47B /* WatchContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchContentView.swift; sourceTree = "<group>"; };
4438C3D29B6F307E453E9EC1 /* SleepManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SleepManager.swift; sourceTree = "<group>"; };
450F619387817B9E2A1C8142 /* CreateTimerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTimerSheet.swift; sourceTree = "<group>"; };
453FAEB975BAA8307C8AED26 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
479A9078C8BF70F342BE7A1C /* BadgeGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeGridView.swift; sourceTree = "<group>"; };
492793555CC220F85136F2B4 /* TimerCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerCard.swift; sourceTree = "<group>"; };
@ -272,6 +277,7 @@
B3A5D547D31EEFB16F3491EA /* ContextMessages.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMessages.swift; sourceTree = "<group>"; };
B769E9E88032FB471820D110 /* RoutineRunnerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoutineRunnerView.swift; sourceTree = "<group>"; };
B7AC8BE54BDA744FCCA8C5A1 /* LocationTriggerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationTriggerManager.swift; sourceTree = "<group>"; };
BD64A770691318BCD921DC49 /* WatchNotificationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchNotificationHandler.swift; sourceTree = "<group>"; };
BD88A07063BD00F2DC6BB753 /* ChronoMindApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChronoMindApp.swift; sourceTree = "<group>"; };
BE3B45641C99D54A016AD38E /* NLParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NLParser.swift; sourceTree = "<group>"; };
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 */,
);

View File

@ -14,6 +14,10 @@ struct ChronoMindMacApp: App {
MenuBarPopover()
.environmentObject(timerStore)
.environmentObject(menuBarState)
.sheet(isPresented: $menuBarState.showCreateSheet) {
CreateTimerSheet()
.environmentObject(timerStore)
}
} label: {
menuBarLabel
}

View File

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

View File

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

View File

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

View File

@ -50,6 +50,10 @@ struct WatchComplicationProvider: AppIntentTimelineProvider {
return Timeline(entries: entries, policy: .after(reloadDate))
}
func recommendations() -> [AppIntentRecommendation<WatchComplicationIntent>] {
[AppIntentRecommendation(intent: WatchComplicationIntent(), description: "Next Timer")]
}
private func entryFromSharedData() -> WatchComplicationEntry {
if let timer = SharedTimerDataManager.shared.readNextFiringTimer() {
return WatchComplicationEntry(

View File

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

View File

@ -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])
}
}

View File

@ -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")
}

View File

@ -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<String> = []
// 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)
}
}
}