learning_ai_clock/ios/ChronoMindWatch/WatchTimerStore.swift
saravanakumardb1 d179c4c624 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
2026-03-27 11:28:13 -07:00

239 lines
7.5 KiB
Swift

// 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<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() {
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)
}
}
}