learning_ai_clock/ios/ChronoMind/Shared/Store/TimerStore.swift

303 lines
9.4 KiB
Swift

// Timer Store
// Observable store managing all timers equivalent of Zustand store
// Persists to UserDefaults (SwiftData migration in future)
import Foundation
import Combine
import ActivityKit
@MainActor
final class TimerStore: ObservableObject {
// MARK: - Published State
@Published var timers: [CMTimer] = []
@Published var now: Date = Date()
// MARK: - Private
private var tickTimer: Timer?
private let persistenceKey = "chronomind-timers"
private let notifications = CMNotificationManager.shared
private let sharedData = SharedTimerDataManager.shared
private let liveActivity = LiveActivityManager.shared
private let syncManager = PlatformSyncManager.shared
// MARK: - Init
init() {
loadTimers()
startTicking()
syncToSharedData()
}
deinit {
tickTimer?.invalidate()
}
// MARK: - CRUD
func addAlarm(_ params: CreateAlarmParams) -> CMTimer {
let timer = createAlarm(params)
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer
}
func addCountdown(_ params: CreateCountdownParams) -> CMTimer {
let timer = createCountdown(params)
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer
}
func addPomodoro(_ params: CreatePomodoroParams = CreatePomodoroParams()) -> CMTimer {
let timer = createPomodoro(params)
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer
}
func removeTimer(_ id: String) {
liveActivity.endActivity(for: id)
timers.removeAll { $0.id == id }
notifications.removeNotifications(for: id)
saveTimers()
syncManager.enqueueDelete(timerId: id)
}
// MARK: - State Transitions
func pause(_ id: String) {
updateTimer(id) { pauseTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
}
func resume(_ id: String) {
updateTimer(id) { t in
let resumed = resumeTimer(t)
self.notifications.scheduleNotifications(for: resumed)
return resumed
}
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
}
func fire(_ id: String) {
updateTimer(id) { fireTimer($0) }
}
func snooze(_ id: String, minutes: Int) {
updateTimer(id) { t in
let snoozed = snoozeTimer(t, snoozeMinutes: minutes)
self.notifications.scheduleNotifications(for: snoozed)
return snoozed
}
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
}
func dismiss(_ id: String) {
liveActivity.endActivity(for: id)
// Check for late dismissal before updating state
if let timer = getTimer(id) {
checkForRescheduleSuggestion(dismissedTimer: timer)
}
updateTimer(id) { dismissTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
}
func complete(_ id: String) {
liveActivity.endActivity(for: id)
updateTimer(id) { completeTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
// Record completion for streak tracking
GamificationStore.shared.recordCompletion()
}
func advancePom(_ id: String) {
guard let index = timers.firstIndex(where: { $0.id == id }) else { return }
if let next = advancePomodoro(timers[index]) {
timers[index] = next
notifications.scheduleNotifications(for: next)
saveTimers()
syncManager.enqueueChange(next, action: .update)
}
}
// MARK: - Tick
func tick() {
let currentTime = Date()
now = currentTime
var changed = false
for i in timers.indices {
// Check if timer should fire
if shouldTimerFire(timers[i], now: currentTime) {
timers[i] = fireTimer(timers[i])
changed = true
// Haptic feedback
HapticEngine.fire(urgency: timers[i].urgency)
continue
}
// Check cascade warnings
let newlyFired = checkWarnings(&timers[i].warnings, now: currentTime)
if !newlyFired.isEmpty {
changed = true
// Update state to warning if still active
if timers[i].state == .active {
timers[i].state = .warning
}
// Haptic feedback for warning
HapticEngine.warning(urgency: timers[i].urgency)
}
}
if changed {
saveTimers()
}
// Update Live Activity for the next firing timer
if let next = nextFiringTimer {
liveActivity.updateActivity(for: next)
}
}
// MARK: - Reschedule
@Published var lastReschedule: RescheduleResult?
@Published var rescheduleSuggestions: [RescheduleSuggestion] = []
/// Apply a reschedule action
func applyReschedule(_ action: RescheduleAction) {
let result = RescheduleEngine.apply(action: action, to: timers)
lastReschedule = result
timers = result.updatedTimers
// Reschedule notifications for affected timers
for id in result.affectedTimerIds {
if let timer = getTimer(id) {
notifications.scheduleNotifications(for: timer)
}
}
saveTimers()
// Auto-clear undo after 1 hour
DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in
if self?.lastReschedule?.timestamp == result.timestamp {
self?.lastReschedule = nil
}
}
}
/// Undo the last reschedule
func undoReschedule() {
guard let result = lastReschedule else { return }
timers = RescheduleEngine.undo(result: result, in: timers)
// Reschedule notifications for restored timers
for id in result.affectedTimerIds {
if let timer = getTimer(id) {
notifications.scheduleNotifications(for: timer)
}
}
lastReschedule = nil
saveTimers()
}
/// Check for late dismissal and generate suggestions
func checkForRescheduleSuggestion(dismissedTimer: CMTimer) {
let now = Date()
if let delay = RescheduleEngine.detectLateDismissal(timer: dismissedTimer, dismissedAt: now) {
rescheduleSuggestions = RescheduleSuggestion.suggestionsForLateDismissal(
timer: dismissedTimer,
dismissDelay: delay
)
}
}
/// Clear current suggestions
func clearRescheduleSuggestions() {
rescheduleSuggestions = []
}
// MARK: - Queries
func getTimer(_ id: String) -> CMTimer? {
timers.first { $0.id == id }
}
var activeTimers: [CMTimer] {
timers.filter { [.active, .warning, .snoozed, .paused, .firing].contains($0.state) }
}
var nextFiringTimer: CMTimer? {
timers
.filter { [.active, .warning].contains($0.state) }
.sorted { $0.targetTime < $1.targetTime }
.first
}
// MARK: - Private Helpers
private func updateTimer(_ id: String, updater: (CMTimer) -> CMTimer) {
guard let index = timers.firstIndex(where: { $0.id == id }) else { return }
timers[index] = updater(timers[index])
saveTimers()
}
private func startTicking() {
tickTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.tick()
}
}
}
// MARK: - Persistence (UserDefaults for now, SwiftData later)
private func saveTimers() {
guard let data = try? JSONEncoder().encode(timers) else { return }
UserDefaults.standard.set(data, forKey: persistenceKey)
syncToSharedData()
}
private func loadTimers() {
guard let data = UserDefaults.standard.data(forKey: persistenceKey),
let saved = try? JSONDecoder().decode([CMTimer].self, from: data) else { return }
timers = saved
}
// MARK: - App Group Sync
/// Sync timer data to App Group for widgets and watchOS
private func syncToSharedData() {
sharedData.writeTimers(timers)
sharedData.writeActiveTimer(nextFiringTimer)
reloadWidgets()
}
/// Reload all WidgetKit timelines when data changes
private func reloadWidgets() {
// Post notification that widget-aware code can observe
NotificationCenter.default.post(name: .chronoMindTimersDidChange, object: nil)
}
}
// MARK: - Notification Names
extension Notification.Name {
static let chronoMindTimersDidChange = Notification.Name("chronoMindTimersDidChange")
}