feat(shared): add App Groups data layer for iOS/Watch/Widget communication
This commit is contained in:
parent
38bb2629e9
commit
46d9866253
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
183
ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift
Normal file
183
ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user