184 lines
5.7 KiB
Swift
184 lines
5.7 KiB
Swift
// ── 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))
|
|
}
|
|
}
|