// ── Shared Timer Data (App Groups) ──────────────────────────── // Bridges timer data between iOS app, watchOS, and WidgetKit // Uses App Group UserDefaults suite for cross-process persistence import Foundation // MARK: - App Group Constants enum AppGroupConstants { static let suiteName = "group.com.chronomind.shared" static let timersKey = "chronomind-timers" static let activeTimerKey = "chronomind-active-timer" static let lastUpdateKey = "chronomind-last-update" } // MARK: - Shared Timer Snapshot (lightweight for widgets/watch) struct TimerSnapshot: Codable, Identifiable, Equatable { let id: String let label: String let type: CMTimerType let urgency: UrgencyLevel let state: CMTimerState let targetTime: Date let duration: TimeInterval? let startedAt: Date? let elapsedBeforePause: TimeInterval let snoozeCount: Int let category: String? // Pomodoro let pomodoroCurrentRound: Int? let pomodoroTotalRounds: Int? let pomodoroIsBreak: Bool? // Cascade let nextWarningTime: Date? let totalWarnings: Int let firedWarnings: Int static func == (lhs: TimerSnapshot, rhs: TimerSnapshot) -> Bool { lhs.id == rhs.id } } // MARK: - Snapshot Conversion extension CMTimer { /// Convert full timer to lightweight snapshot for widgets/watch func toSnapshot() -> TimerSnapshot { let nextWarning = warnings.first(where: { !$0.fired })?.scheduledTime let firedCount = warnings.filter { $0.fired }.count return TimerSnapshot( id: id, label: label, type: type, urgency: urgency, state: state, targetTime: targetTime, duration: duration, startedAt: startedAt, elapsedBeforePause: elapsedBeforePause, snoozeCount: snoozeCount, category: category, pomodoroCurrentRound: pomodoroState?.currentRound, pomodoroTotalRounds: pomodoroConfig?.rounds, pomodoroIsBreak: pomodoroState?.isBreak, nextWarningTime: nextWarning, totalWarnings: warnings.count, firedWarnings: firedCount ) } } // MARK: - Shared Data Manager final class SharedTimerDataManager { static let shared = SharedTimerDataManager() private let defaults: UserDefaults? private let encoder = JSONEncoder() private let decoder = JSONDecoder() private init() { defaults = UserDefaults(suiteName: AppGroupConstants.suiteName) } // MARK: - Write (from iOS app) /// Write full timer list as snapshots to App Group func writeTimers(_ timers: [CMTimer]) { let snapshots = timers.map { $0.toSnapshot() } writeSnapshots(snapshots) } /// Write snapshots directly func writeSnapshots(_ snapshots: [TimerSnapshot]) { guard let data = try? encoder.encode(snapshots) else { return } defaults?.set(data, forKey: AppGroupConstants.timersKey) defaults?.set(Date(), forKey: AppGroupConstants.lastUpdateKey) } /// Write the currently active/next-firing timer for quick widget access func writeActiveTimer(_ timer: CMTimer?) { if let timer = timer { let snapshot = timer.toSnapshot() guard let data = try? encoder.encode(snapshot) else { return } defaults?.set(data, forKey: AppGroupConstants.activeTimerKey) } else { defaults?.removeObject(forKey: AppGroupConstants.activeTimerKey) } } // MARK: - Read (from widgets/watch) /// Read all timer snapshots from App Group func readSnapshots() -> [TimerSnapshot] { guard let data = defaults?.data(forKey: AppGroupConstants.timersKey), let snapshots = try? decoder.decode([TimerSnapshot].self, from: data) else { return [] } return snapshots } /// Read the active/next timer snapshot func readActiveTimer() -> TimerSnapshot? { guard let data = defaults?.data(forKey: AppGroupConstants.activeTimerKey), let snapshot = try? decoder.decode(TimerSnapshot.self, from: data) else { return nil } return snapshot } /// Last time data was updated by the iOS app func lastUpdateTime() -> Date? { defaults?.object(forKey: AppGroupConstants.lastUpdateKey) as? Date } // MARK: - Active Timers (filtered) /// Get only active timer snapshots sorted by target time func readActiveSnapshots() -> [TimerSnapshot] { readSnapshots() .filter { [.active, .warning, .snoozed, .firing].contains($0.state) } .sorted { $0.targetTime < $1.targetTime } } /// Get the next firing timer (active or warning, earliest target) func readNextFiringTimer() -> TimerSnapshot? { readSnapshots() .filter { [.active, .warning].contains($0.state) } .sorted { $0.targetTime < $1.targetTime } .first } } // MARK: - TimerSnapshot Helpers extension TimerSnapshot { /// Remaining seconds from now func remainingSeconds(now: Date = Date()) -> TimeInterval { if state == .paused { return (duration ?? 0) - elapsedBeforePause } return max(0, targetTime.timeIntervalSince(now)) } /// Whether this timer has fired (past target time) func hasFired(now: Date = Date()) -> Bool { now >= targetTime } /// Urgency color hex string var urgencyColorHex: String { getUrgencyConfig(urgency).colorHex } /// Compact remaining time string func remainingCompact(now: Date = Date()) -> String { formatDurationCompact(remainingSeconds(now: now)) } }