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