feat(shared): add App Groups data layer for iOS/Watch/Widget communication

This commit is contained in:
saravanakumardb1 2026-02-27 22:01:24 -08:00
parent 38bb2629e9
commit 46d9866253
3 changed files with 224 additions and 0 deletions

View File

@ -1,6 +1,7 @@
// ChronoMind App Entry Point
import SwiftUI
import WidgetKit
@main
struct ChronoMindApp: App {
@ -17,6 +18,9 @@ struct ChronoMindApp: App {
notificationManager.registerCategories()
await notificationManager.requestPermission()
}
.onReceive(NotificationCenter.default.publisher(for: .chronoMindTimersDidChange)) { _ in
WidgetCenter.shared.reloadAllTimelines()
}
}
}
}

View File

@ -0,0 +1,183 @@
// 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))
}
}

View File

@ -4,6 +4,7 @@
import Foundation
import Combine
import ActivityKit
@MainActor
final class TimerStore: ObservableObject {
@ -17,12 +18,15 @@ final class TimerStore: ObservableObject {
private var tickTimer: Timer?
private let persistenceKey = "chronomind-timers"
private let notifications = CMNotificationManager.shared
private let sharedData = SharedTimerDataManager.shared
private let liveActivity = LiveActivityManager.shared
// MARK: - Init
init() {
loadTimers()
startTicking()
syncToSharedData()
}
deinit {
@ -36,6 +40,7 @@ final class TimerStore: ObservableObject {
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
return timer
}
@ -44,6 +49,7 @@ final class TimerStore: ObservableObject {
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
return timer
}
@ -52,10 +58,12 @@ final class TimerStore: ObservableObject {
timers.append(timer)
notifications.scheduleNotifications(for: timer)
saveTimers()
liveActivity.startActivity(for: timer)
return timer
}
func removeTimer(_ id: String) {
liveActivity.endActivity(for: id)
timers.removeAll { $0.id == id }
notifications.removeNotifications(for: id)
saveTimers()
@ -88,10 +96,12 @@ final class TimerStore: ObservableObject {
}
func dismiss(_ id: String) {
liveActivity.endActivity(for: id)
updateTimer(id) { dismissTimer($0) }
}
func complete(_ id: String) {
liveActivity.endActivity(for: id)
updateTimer(id) { completeTimer($0) }
}
@ -138,6 +148,11 @@ final class TimerStore: ObservableObject {
if changed {
saveTimers()
}
// Update Live Activity for the next firing timer
if let next = nextFiringTimer {
liveActivity.updateActivity(for: next)
}
}
// MARK: - Queries
@ -178,6 +193,7 @@ final class TimerStore: ObservableObject {
private func saveTimers() {
guard let data = try? JSONEncoder().encode(timers) else { return }
UserDefaults.standard.set(data, forKey: persistenceKey)
syncToSharedData()
}
private func loadTimers() {
@ -185,4 +201,25 @@ final class TimerStore: ObservableObject {
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")
}