Add ReminderLiveActivity

This commit is contained in:
Hariharan Mudaliar 2025-11-10 21:13:22 +05:30
commit 422b986721
8 changed files with 579 additions and 3 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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" : {

View file

@ -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
}
}

View file

@ -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

View file

@ -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(

View file

@ -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<AnyCancellable>()
private var tickerCancellable: AnyCancellable?
private var evaluationTask: Task<Void, Never>?
private var fallbackRefreshTask: Task<Void, Never>?
private var refreshTask: Task<Void, Never>?
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)
}
}
}

View file

@ -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<Bool>("timerShowsLabel", default: true)
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
// MARK: Reminder Live Activity
static let enableReminderLiveActivity = Key<Bool>("enableReminderLiveActivity", default: true)
static let reminderPresentationStyle = Key<ReminderPresentationStyle>("reminderPresentationStyle", default: .ringCountdown)
static let reminderLeadTime = Key<Int>("reminderLeadTime", default: 5)
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature