// ── Watch Timer Store ───────────────────────────────────────── // Reads timer data from App Group shared by the iOS app // Lightweight store for watchOS — read-only from shared data + local quick timers import Foundation import Combine import WatchKit import os @MainActor final class WatchTimerStore: ObservableObject { // MARK: - Published State @Published var timers: [TimerSnapshot] = [] @Published var now: Date = Date() // 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() { 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 var nextFiringTimer: TimerSnapshot? { timers .filter { [.active, .warning].contains($0.state) } .sorted { $0.targetTime < $1.targetTime } .first } var activeTimers: [TimerSnapshot] { 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) { let now = Date() let targetTime = now.addingTimeInterval(TimeInterval(minutes * 60)) let intervals = CascadePreset.minimal.defaultIntervals let snapshot = TimerSnapshot( id: UUID().uuidString, label: label, type: .countdown, urgency: .standard, state: .active, targetTime: targetTime, duration: TimeInterval(minutes * 60), startedAt: now, elapsedBeforePause: 0, snoozeCount: 0, category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil, pomodoroIsBreak: nil, nextWarningTime: intervals.first.map { targetTime.addingTimeInterval(-Double($0) * 60) }, totalWarnings: intervals.count, firedWarnings: 0 ) // Add to local list and persist timers.append(snapshot) timers.sort { $0.targetTime < $1.targetTime } // 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 private func startTicking() { tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.tick() } } } private func tick() { now = Date() // Refresh from shared data every 30 seconds let interval = Int(now.timeIntervalSince1970) % 30 if interval == 0 { loadFromSharedData() } // Check for warnings and fired timers var changed = false for i in timers.indices { 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() } } // 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) } } }