From 46d9866253650a92e4cff7f7c5e51d034d0893cd Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:01:24 -0800 Subject: [PATCH] feat(shared): add App Groups data layer for iOS/Watch/Widget communication --- ios/ChronoMind/App/ChronoMindApp.swift | 4 + .../Shared/AppGroup/SharedTimerData.swift | 183 ++++++++++++++++++ ios/ChronoMind/Shared/Store/TimerStore.swift | 37 ++++ 3 files changed, 224 insertions(+) create mode 100644 ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift diff --git a/ios/ChronoMind/App/ChronoMindApp.swift b/ios/ChronoMind/App/ChronoMindApp.swift index 0d74ee3..f2eaa08 100644 --- a/ios/ChronoMind/App/ChronoMindApp.swift +++ b/ios/ChronoMind/App/ChronoMindApp.swift @@ -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() + } } } } diff --git a/ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift b/ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift new file mode 100644 index 0000000..331a47e --- /dev/null +++ b/ios/ChronoMind/Shared/AppGroup/SharedTimerData.swift @@ -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)) + } +} diff --git a/ios/ChronoMind/Shared/Store/TimerStore.swift b/ios/ChronoMind/Shared/Store/TimerStore.swift index d212bf5..4645ab3 100644 --- a/ios/ChronoMind/Shared/Store/TimerStore.swift +++ b/ios/ChronoMind/Shared/Store/TimerStore.swift @@ -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") }