From 422b9867215d967768feb4853b464f4987000d21 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 10 Nov 2025 21:13:22 +0530 Subject: [PATCH] Add ReminderLiveActivity --- DynamicIsland/ContentView.swift | 6 +- .../DynamicIslandViewCoordinator.swift | 4 +- DynamicIsland/Localizable.xcstrings | 16 ++ .../Calendar/ReminderLiveActivity.swift | 225 +++++++++++++++ .../components/Settings/SettingsView.swift | 31 ++ DynamicIsland/managers/CalendarManager.swift | 7 +- .../ReminderLiveActivityManager.swift | 272 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 21 ++ 8 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 DynamicIsland/components/Calendar/ReminderLiveActivity.swift create mode 100644 DynamicIsland/managers/ReminderLiveActivityManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 5a85727..f9a7179 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -21,6 +21,7 @@ struct ContentView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var musicManager = MusicManager.shared @ObservedObject var timerManager = TimerManager.shared + @ObservedObject var reminderManager = ReminderLiveActivityManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @@ -34,6 +35,7 @@ struct ContentView: View { @Default(.showGpuGraph) var showGpuGraph @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph + @Default(.enableReminderLiveActivity) var enableReminderLiveActivity // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { @@ -404,13 +406,15 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) - } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { + } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { MusicLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { + ReminderLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked { diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 2eba167..0d4b7b8 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -19,6 +19,7 @@ enum SneakContentType { case battery case download case timer + case reminder case recording case doNotDisturb case bluetoothAudio @@ -143,7 +144,8 @@ class DynamicIslandViewCoordinator: ObservableObject { } func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { - sneakPeekDuration = duration + let resolvedDuration = type == .timer ? 10 : duration + sneakPeekDuration = resolvedDuration if type != .music && type != .timer { // close() if !hudReplacement { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2d008e7..7567aca 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -868,6 +868,10 @@ } } }, + "%lld min" : { + "comment" : "A text label displaying the current value of the \"Notify before\" slider. The argument is the current value of the slider, expressed in minutes.", + "isCommentAutoGenerated" : true + }, "%lld%%" : { "localizations" : { "ar" : { @@ -5634,6 +5638,10 @@ } } }, + "Countdown style" : { + "comment" : "A label for the style of countdown to use when reminders are live.", + "isCommentAutoGenerated" : true + }, "CPU Usage" : { "localizations" : { "fr" : { @@ -14238,6 +14246,10 @@ } } }, + "Notify before" : { + "comment" : "A label describing the slider that allows the user to set a reminder lead time.", + "isCommentAutoGenerated" : true + }, "OK" : { "localizations" : { "ar" : { @@ -15329,6 +15341,10 @@ } } }, + "Reminder Live Activity" : { + "comment" : "A section header that lists the options related to live activity notifications for reminders.", + "isCommentAutoGenerated" : true + }, "Reminders" : { "localizations" : { "fr" : { diff --git a/DynamicIsland/components/Calendar/ReminderLiveActivity.swift b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift new file mode 100644 index 0000000..7164f60 --- /dev/null +++ b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift @@ -0,0 +1,225 @@ +// +// ReminderLiveActivity.swift +// DynamicIsland +// +// Created by GitHub Copilot on 2025-11-12. +// + +import SwiftUI +import Defaults + +#if canImport(AppKit) +import AppKit +private typealias ReminderFont = NSFont +#elseif canImport(UIKit) +import UIKit +private typealias ReminderFont = UIFont +#endif + +struct ReminderLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var manager = ReminderLiveActivityManager.shared + + @Default(.reminderPresentationStyle) private var presentationStyle + + private let wingPadding: CGFloat = 22 + private let ringStrokeWidth: CGFloat = 3 + + private var notchContentHeight: CGFloat { + max(0, vm.effectiveClosedNotchHeight) + } + + var body: some View { + if let reminder = manager.activeReminder { + content(for: reminder, now: manager.currentDate) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + } + + @ViewBuilder + private func content(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + HStack(spacing: 0) { + Color.clear + .frame(width: leftWingWidth, height: notchContentHeight) + .background(alignment: .leading) { + iconSection(for: reminder) + .padding(.leading, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width, height: notchContentHeight) + + Color.clear + .frame(width: rightWingWidth(for: reminder, now: now), height: notchContentHeight) + .background(alignment: .trailing) { + rightSection(for: reminder, now: now) + .padding(.trailing, wingPadding / 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame(height: notchContentHeight, alignment: .center) + } + + private func iconSection(for reminder: ReminderLiveActivityManager.ReminderEntry) -> some View { + let diameter = iconDiameter + let accent = accentColor(for: reminder) + + return ZStack { + Circle() + .stroke(Color.white.opacity(0.12), lineWidth: 2) + Circle() + .fill(accent.opacity(0.12)) + Image(systemName: glyphName(for: reminder.event)) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(accent) + } + .frame(width: diameter, height: diameter) + .frame(width: iconDiameter, height: notchContentHeight, alignment: .center) + } + + @ViewBuilder + private func rightSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + let accent = accentColor(for: reminder) + switch presentationStyle { + case .ringCountdown: + ringCountdownSection(for: reminder, now: now, accent: accent) + case .digital: + digitalSection(for: reminder, now: now) + case .minutes: + minutesSection(for: reminder, now: now) + } + } + + private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View { + let progress = progress(for: reminder, now: now) + let diameter = ringDiameter + + return ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth) + Circle() + .trim(from: 0, to: progress) + .stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progress) + } + .frame(width: diameter, height: diameter) + .frame(height: notchContentHeight, alignment: .center) + } + + private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + let countdown = digitalCountdown(for: reminder, now: now) + return Text(countdown) + .font(.system(size: 16, weight: .semibold, design: .monospaced)) + .foregroundColor(.white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: countdown) + .frame(height: notchContentHeight, alignment: .center) + } + + private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View { + VStack(alignment: .trailing, spacing: 2) { + Text(minutesCountdown(for: reminder, now: now)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white) + } + .frame(height: notchContentHeight, alignment: .center) + } + + private func progress(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Double { + guard reminder.leadTime > 0 else { return 1 } + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let elapsed = reminder.leadTime - remaining + let ratio = elapsed / reminder.leadTime + return min(max(ratio, 0), 1) + } + + private func digitalCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + private func minutesCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(reminder.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + 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" + } + } + + private func accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry) -> Color { + Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private var leftWingWidth: CGFloat { + wingPadding + iconDiameter + } + + private func rightWingWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + var width = wingPadding + switch presentationStyle { + case .ringCountdown: + width += ringDiameter + case .digital: + width += countdownWidth(for: reminder, now: now) + case .minutes: + width += minutesWidth(for: reminder, now: now) + } + return width + } + + private func countdownWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + let text = digitalCountdown(for: reminder, now: now) + let width = measureTextWidth(text, font: monospacedDigitFont(size: 16, weight: ReminderFont.Weight.semibold)) + return max(width + 18, 76) + } + + private func minutesWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat { + let text = minutesCountdown(for: reminder, now: now) + let width = measureTextWidth(text, font: systemFont(size: 13, weight: ReminderFont.Weight.semibold)) + return max(width + 18, 88) + } + + private var iconDiameter: CGFloat { + max(notchContentHeight - 6, 28) + } + + private var ringDiameter: CGFloat { + max(min(notchContentHeight - 10, 24), 18) + } + + private func measureTextWidth(_ text: String, font: ReminderFont) -> CGFloat { + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return ceil(NSAttributedString(string: text, attributes: attributes).size().width) + } + + private func systemFont(size: CGFloat, weight: ReminderFont.Weight) -> ReminderFont { + #if canImport(AppKit) + return NSFont.systemFont(ofSize: size, weight: weight) + #else + return UIFont.systemFont(ofSize: size, weight: weight) + #endif + } + + private func monospacedDigitFont(size: CGFloat, weight: ReminderFont.Weight) -> ReminderFont { + #if canImport(AppKit) + return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight) + #else + return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight) + #endif + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2245fdb..c8e0346 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -953,6 +953,9 @@ struct Media: View { struct CalendarSettings: View { @ObservedObject private var calendarManager = CalendarManager.shared @Default(.showCalendar) var showCalendar: Bool + @Default(.enableReminderLiveActivity) var enableReminderLiveActivity + @Default(.reminderPresentationStyle) var reminderPresentationStyle + @Default(.reminderLeadTime) var reminderLeadTime var body: some View { Form { @@ -997,6 +1000,34 @@ struct CalendarSettings: View { Defaults.Toggle("Show calendar", key: .showCalendar) + Section(header: Text("Reminder Live Activity")) { + Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity) + + Picker("Countdown style", selection: $reminderPresentationStyle) { + ForEach(ReminderPresentationStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!enableReminderLiveActivity) + + HStack { + Text("Notify before") + Slider( + value: Binding( + get: { Double(reminderLeadTime) }, + set: { reminderLeadTime = Int($0) } + ), + in: 1...60, + step: 1 + ) + .disabled(!enableReminderLiveActivity) + Text("\(reminderLeadTime) min") + .foregroundStyle(.secondary) + .frame(width: 60, alignment: .trailing) + } + } + Section(header: Text("Select Calendars")) { List { ForEach(calendarManager.allCalendars, id: \.id) { calendar in diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 0a85573..d0ef085 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -64,15 +64,20 @@ class CalendarManager: ObservableObject { func checkCalendarAuthorization() async { let status = EKEventStore.authorizationStatus(for: .event) + let reminderStatus = EKEventStore.authorizationStatus(for: .reminder) DispatchQueue.main.async { print("📅 Current calendar authorization status: \(status)") self.calendarAuthorizationStatus = status + self.reminderAuthorizationStatus = reminderStatus } switch status { case .notDetermined: let granted = await calendarService.requestAccess() - self.calendarAuthorizationStatus = granted ? .fullAccess : .denied + let newEventStatus = EKEventStore.authorizationStatus(for: .event) + let newReminderStatus = EKEventStore.authorizationStatus(for: .reminder) + self.calendarAuthorizationStatus = newEventStatus + self.reminderAuthorizationStatus = newReminderStatus if granted { await reloadCalendarAndReminderLists() events = await calendarService.events( diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift new file mode 100644 index 0000000..07ed6a1 --- /dev/null +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -0,0 +1,272 @@ +// +// 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() + private var tickerCancellable: AnyCancellable? + private var evaluationTask: Task? + private var fallbackRefreshTask: Task? + private var refreshTask: Task? + + private let calendarService: CalendarServiceProviding + private let calendarManager = CalendarManager.shared + + var isActive: Bool { activeReminder != nil } + + private init(calendarService: CalendarServiceProviding = CalendarService()) { + self.calendarService = calendarService + setupObservers() + Task { await self.refreshUpcomingReminder() } + } + + 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: true) + } + .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() { + tickerCancellable?.cancel() + tickerCancellable = nil + evaluationTask?.cancel() + evaluationTask = nil + fallbackRefreshTask?.cancel() + fallbackRefreshTask = nil + 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.refreshUpcomingReminder() + } + } + + private func scheduleRefresh(force: Bool) { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + guard let self else { return } + await self.refreshUpcomingReminder(force: force) + } + } + + private func startTickerIfNeeded() { + guard tickerCancellable == nil else { return } + tickerCancellable = Timer.publish(every: 1, tolerance: 0.2, on: .main, in: .common) + .autoconnect() + .sink { [weak self] date in + guard let self else { return } + self.currentDate = date + Task { await self.evaluateCurrentState(at: date) } + } + } + + private func stopTicker() { + tickerCancellable?.cancel() + tickerCancellable = 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 + } + startTickerIfNeeded() + } else { + if activeReminder != nil { + activeReminder = nil + } + stopTicker() + scheduleEvaluation(at: entry.triggerDate) + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index e936a74..4ebbe16 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -217,6 +217,22 @@ enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializab var id: String { rawValue } } +enum ReminderPresentationStyle: String, CaseIterable, Identifiable, Defaults.Serializable { + case ringCountdown = "Ring" + case digital = "Digital" + case minutes = "Minutes" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .ringCountdown: return "Ring" + case .digital: return "Digital" + case .minutes: return "Minutes" + } + } +} + // AI Model types for screen assistant enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable { case gemini = "Gemini" @@ -442,6 +458,11 @@ extension Defaults.Keys { static let timerShowsLabel = Key("timerShowsLabel", default: true) static let timerShowsProgress = Key("timerShowsProgress", default: true) static let timerProgressStyle = Key("timerProgressStyle", default: .bar) + + // MARK: Reminder Live Activity + static let enableReminderLiveActivity = Key("enableReminderLiveActivity", default: true) + static let reminderPresentationStyle = Key("reminderPresentationStyle", default: .ringCountdown) + static let reminderLeadTime = Key("reminderLeadTime", default: 5) static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true) // MARK: ColorPicker Feature