Atoll/DynamicIsland/managers/ReminderLiveActivityManager.swift
Hariharan Mudaliar 12db48bc25 Fix: Memory Leak
2025-11-10 23:20:38 +05:30

368 lines
12 KiB
Swift

//
// ReminderLiveActivityManager.swift
// DynamicIsland
//
// Created by GitHub Copilot on 2025-11-12.
//
import Combine
import Defaults
import EventKit
import Foundation
@MainActor
final class ReminderLiveActivityManager: ObservableObject {
struct ReminderEntry: Equatable {
let event: EventModel
let triggerDate: Date
let leadTime: TimeInterval
}
static let shared = ReminderLiveActivityManager()
@Published private(set) var activeReminder: ReminderEntry?
@Published private(set) var currentDate: Date = Date()
private var nextReminder: ReminderEntry?
private var cancellables = Set<AnyCancellable>()
private var tickerTask: Task<Void, Never>? { didSet { oldValue?.cancel() } }
private var evaluationTask: Task<Void, Never>?
private var fallbackRefreshTask: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
private var pendingRefreshTask: Task<Void, Never>?
private var pendingRefreshForce = false
private var pendingRefreshToken = UUID()
private var lastRefreshDate: Date?
private let minimumRefreshInterval: TimeInterval = 10
private let refreshDebounceInterval: TimeInterval = 0.3
private var refreshTaskToken = UUID()
private let calendarService: CalendarServiceProviding
private let calendarManager = CalendarManager.shared
var isActive: Bool { activeReminder != nil }
private init(calendarService: CalendarServiceProviding = CalendarService()) {
self.calendarService = calendarService
setupObservers()
scheduleRefresh(force: true)
}
private func setupObservers() {
Defaults.publisher(.enableReminderLiveActivity, options: [])
.sink { [weak self] change in
guard let self else { return }
if change.newValue {
self.scheduleRefresh(force: true)
} else {
self.deactivateReminder()
}
}
.store(in: &cancellables)
Defaults.publisher(.reminderLeadTime, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true)
}
.store(in: &cancellables)
Defaults.publisher(.reminderPresentationStyle, options: [])
.sink { [weak self] _ in
guard let self else { return }
// Presentation change does not alter scheduling, but ensure state publishes for UI updates.
if let reminder = self.activeReminder {
self.activeReminder = reminder
}
}
.store(in: &cancellables)
calendarManager.$allCalendars
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true)
}
.store(in: &cancellables)
calendarManager.$events
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: false)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .EKEventStoreChanged)
.sink { [weak self] _ in
guard let self else { return }
self.scheduleRefresh(force: true)
}
.store(in: &cancellables)
}
private func cancelAllTimers() {
tickerTask = nil
evaluationTask?.cancel()
evaluationTask = nil
fallbackRefreshTask?.cancel()
fallbackRefreshTask = nil
pendingRefreshTask?.cancel()
pendingRefreshTask = nil
pendingRefreshForce = false
refreshTask?.cancel()
refreshTask = nil
}
private func deactivateReminder() {
nextReminder = nil
activeReminder = nil
cancelAllTimers()
}
private func selectedCalendarIDs() -> [String] {
calendarManager.allCalendars
.filter { calendarManager.getCalendarSelected($0) }
.map { $0.id }
}
private func shouldHide(_ event: EventModel) -> Bool {
if event.isAllDay && Defaults[.hideAllDayEvents] {
return true
}
if case let .reminder(completed) = event.type,
completed && Defaults[.hideCompletedReminders] {
return true
}
return false
}
private func makeEntry(from event: EventModel, leadMinutes: Int, referenceDate: Date) -> ReminderEntry? {
guard event.start > referenceDate else { return nil }
let leadSeconds = max(1, leadMinutes) * 60
let trigger = event.start.addingTimeInterval(TimeInterval(-leadSeconds))
return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))
}
private func scheduleEvaluation(at date: Date) {
evaluationTask?.cancel()
let delay = date.timeIntervalSinceNow
guard delay > 0 else {
Task { await self.evaluateCurrentState(at: Date()) }
return
}
evaluationTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
await self.evaluateCurrentState(at: Date())
}
}
private func scheduleFallbackRefresh() {
fallbackRefreshTask?.cancel()
let delay: TimeInterval = 15 * 60
fallbackRefreshTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
await self.scheduleRefresh(force: true)
}
}
private func scheduleRefresh(force: Bool) {
pendingRefreshForce = pendingRefreshForce || force
pendingRefreshToken = UUID()
let token = pendingRefreshToken
refreshTask?.cancel()
pendingRefreshTask?.cancel()
pendingRefreshTask = Task { [weak self] in
guard let self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(self.refreshDebounceInterval * 1_000_000_000))
} catch {
return
}
await self.executeScheduledRefresh(token: token)
}
}
private func executeScheduledRefresh(token: UUID) async {
guard pendingRefreshToken == token else { return }
pendingRefreshTask = nil
if Task.isCancelled {
return
}
let now = Date()
let force = pendingRefreshForce
if !force, let last = lastRefreshDate {
let elapsed = now.timeIntervalSince(last)
if elapsed < minimumRefreshInterval {
let remaining = max(minimumRefreshInterval - elapsed, refreshDebounceInterval)
pendingRefreshToken = UUID()
let nextToken = pendingRefreshToken
pendingRefreshTask = Task { [weak self] in
guard let self else { return }
do {
try await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000))
} catch {
return
}
await self.executeScheduledRefresh(token: nextToken)
}
return
}
}
pendingRefreshForce = false
lastRefreshDate = now
refreshTask?.cancel()
let taskToken = UUID()
refreshTaskToken = taskToken
let task = Task { [weak self] in
guard let self else { return }
await self.refreshUpcomingReminder(force: force)
}
refreshTask = task
defer {
if refreshTaskToken == taskToken {
refreshTask = nil
}
}
_ = try? await task.value
}
private func startTickerIfNeeded() {
guard tickerTask == nil else { return }
tickerTask = Task { [weak self] in
while let self, !Task.isCancelled {
await self.handleTick()
do {
try await Task.sleep(for: .seconds(1))
} catch {
break
}
}
}
}
private func stopTicker() {
tickerTask = nil
}
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
fallbackRefreshTask?.cancel()
nextReminder = entry
Task { await self.evaluateCurrentState(at: referenceDate) }
}
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date) {
let leadMinutes = Defaults[.reminderLeadTime]
let upcoming = events
.filter { !shouldHide($0) }
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
.sorted { $0.triggerDate < $1.triggerDate }
guard let first = upcoming.first else {
deactivateReminder()
scheduleFallbackRefresh()
return
}
handleEntrySelection(first, referenceDate: referenceDate)
}
func refreshUpcomingReminder(force: Bool = false) async {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
return
}
let now = Date()
if !force, let entry = nextReminder, entry.event.start > now {
await evaluateCurrentState(at: now)
return
}
let calendars = selectedCalendarIDs()
guard !calendars.isEmpty else {
deactivateReminder()
return
}
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60)
let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars)
await MainActor.run {
self.refreshFromEvents(events, referenceDate: now)
}
}
func evaluateCurrentState(at date: Date) async {
guard Defaults[.enableReminderLiveActivity] else {
deactivateReminder()
return
}
currentDate = date
guard var entry = nextReminder else {
activeReminder = nil
stopTicker()
return
}
if entry.event.start <= date {
activeReminder = nil
nextReminder = nil
stopTicker()
scheduleRefresh(force: true)
return
}
if entry.triggerDate <= date {
if entry.triggerDate > entry.event.start {
entry = ReminderEntry(event: entry.event, triggerDate: entry.event.start, leadTime: entry.leadTime)
nextReminder = entry
}
if activeReminder != entry {
activeReminder = entry
DynamicIslandViewCoordinator.shared.toggleSneakPeek(
status: true,
type: .reminder,
duration: Defaults[.reminderSneakPeekDuration],
value: 0,
icon: glyphName(for: entry.event)
)
}
startTickerIfNeeded()
} else {
if activeReminder != nil {
activeReminder = nil
}
stopTicker()
scheduleEvaluation(at: entry.triggerDate)
}
}
@MainActor
private func handleTick() async {
let now = Date()
if abs(currentDate.timeIntervalSince(now)) >= 0.5 {
currentDate = now
}
await evaluateCurrentState(at: now)
}
private func glyphName(for event: EventModel) -> String {
switch event.type {
case .birthday:
return "gift.fill"
case .reminder(let completed):
return completed ? "checkmark.circle" : "bell.fill"
case .event:
return event.isMeeting ? "person.2.fill" : "calendar"
}
}
}