- 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
239 lines
7.5 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|