Merge pull request #95 from Ebullioscopic/feature/lock-screen

Reminder Live Activity and Lock Screen Widget
This commit is contained in:
Hariharan Mudaliar 2025-11-11 01:39:29 +05:30 • committed by GitHub
commit 4b7a54f46f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1245 additions and 4 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 != .reminder) && (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 {
@ -465,6 +469,25 @@ struct ContentView: View {
.padding(.bottom, 10)
}
}
else if coordinator.sneakPeek.type == .reminder {
if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard, let reminder = reminderManager.activeReminder {
GeometryReader { geo in
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
HStack(spacing: 6) {
RoundedRectangle(cornerRadius: 2)
.fill(chipColor)
.frame(width: 8, height: 12)
MarqueeText(
.constant(reminderSneakPeekText(for: reminder, now: reminderManager.currentDate)),
textColor: reminderColor(for: reminder, now: reminderManager.currentDate),
minDuration: 1,
frameWidth: max(0, geo.size.width - 14)
)
}
}
.padding(.bottom, 10)
}
}
}
}
}
@ -505,6 +528,43 @@ struct ContentView: View {
}
}
private func reminderColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color {
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
let remaining = reminder.event.start.timeIntervalSince(now)
if window > 0 && remaining > 0 && remaining <= window {
return .red
}
return Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
}
private func reminderSneakPeekText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title
let remaining = max(entry.event.start.timeIntervalSince(now), 0)
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
if window > 0 && remaining <= window {
return "\(title) • now"
}
let minutes = Int(ceil(remaining / 60))
let timeString = reminderTimeFormatter.string(from: entry.event.start)
if minutes <= 0 {
return "\(title) • now • \(timeString)"
} else if minutes == 1 {
return "\(title) • in 1 min • \(timeString)"
} else {
return "\(title) • in \(minutes) min • \(timeString)"
}
}
private let reminderTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
@ViewBuilder
func DynamicIslandFaceAnimation() -> some View {
HStack {

View file

@ -19,6 +19,7 @@ enum SneakContentType {
case battery
case download
case timer
case reminder
case recording
case doNotDisturb
case bluetoothAudio
@ -143,8 +144,17 @@ class DynamicIslandViewCoordinator: ObservableObject {
}
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
sneakPeekDuration = duration
if type != .music && type != .timer {
let resolvedDuration: TimeInterval
switch type {
case .timer:
resolvedDuration = 10
case .reminder:
resolvedDuration = Defaults[.reminderSneakPeekDuration]
default:
resolvedDuration = duration
}
sneakPeekDuration = resolvedDuration
if type != .music && type != .timer && type != .reminder {
// close()
if !hudReplacement {
return

View file

@ -868,6 +868,14 @@
}
}
},
"%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 s" : {
"comment" : "A text view displaying the duration of the \"sneak peek\" feature in the reminder live activity section of the calendar settings view. The text is followed by a unit of measurement (\"s\").",
"isCommentAutoGenerated" : true
},
"%lld%%" : {
"localizations" : {
"ar" : {
@ -4725,6 +4733,10 @@
}
}
},
"Chip color" : {
"comment" : "A label for the color of the chip in the lock screen reminder widget.",
"isCommentAutoGenerated" : true
},
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
"extractionState" : "stale",
"localizations" : {
@ -5634,6 +5646,10 @@
}
}
},
"Countdown style" : {
"comment" : "A label for the style of countdown to use when reminders are live.",
"isCommentAutoGenerated" : true
},
"CPU Usage" : {
"localizations" : {
"fr" : {
@ -10543,6 +10559,10 @@
},
"Lock screen material" : {
},
"Lock Screen Reminder Widget" : {
"comment" : "A section header for the lock screen reminder widget settings.",
"isCommentAutoGenerated" : true
},
"Loop Mode" : {
@ -14238,6 +14258,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 +15353,10 @@
}
}
},
"Reminder Live Activity" : {
"comment" : "A section header that lists the options related to live activity notifications for reminders.",
"isCommentAutoGenerated" : true
},
"Reminders" : {
"localizations" : {
"fr" : {
@ -17161,6 +17189,10 @@
"comment" : "A description of how the color-coding feature works.",
"isCommentAutoGenerated" : true
},
"Sneak peek duration" : {
"comment" : "A label describing the duration of the \"sneak peek\" feature in the calendar settings.",
"isCommentAutoGenerated" : true
},
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
"localizations" : {
"ar" : {

View file

@ -0,0 +1,222 @@
//
// 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 = 16
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, now: now)
.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, now: Date) -> some View {
let diameter = iconDiameter
let accent = accentColor(for: reminder, now: now)
return Image(systemName: iconName(for: reminder, now: now))
.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, now: now)
switch presentationStyle {
case .ringCountdown:
ringCountdownSection(for: reminder, now: now, accent: accent)
case .digital:
digitalSection(for: reminder, now: now, accent: accent)
case .minutes:
minutesSection(for: reminder, now: now, accent: accent)
}
}
private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
let progressValue = progress(for: reminder, now: now)
let diameter = ringDiameter
return ZStack {
Circle()
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
Circle()
.trim(from: 0, to: progressValue)
.stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.smooth(duration: 0.25), value: progressValue)
}
.frame(width: diameter, height: diameter)
.frame(height: notchContentHeight, alignment: .center)
}
private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
let countdown = digitalCountdown(for: reminder, now: now)
return Text(countdown)
.font(.system(size: 16, weight: .semibold, design: .monospaced))
.foregroundColor(accent)
.contentTransition(.numericText())
.animation(.smooth(duration: 0.25), value: countdown)
.frame(height: notchContentHeight, alignment: .center)
}
private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
VStack(alignment: .trailing, spacing: 2) {
Text(minutesCountdown(for: reminder, now: now))
.font(.system(size: 13, weight: .semibold))
.foregroundColor(accent)
}
.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 accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Color {
if isCritical(for: reminder, now: now) {
return .red
}
return 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 - 8, 26)
}
private var ringDiameter: CGFloat {
max(min(notchContentHeight - 12, 22), 16)
}
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
}
private func iconName(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
isCritical(for: reminder, now: now) ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName
}
private func isCritical(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool {
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
guard window > 0 else { return false }
let remaining = reminder.event.start.timeIntervalSince(now)
return remaining > 0 && remaining <= window
}
}

View file

@ -0,0 +1,106 @@
import SwiftUI
struct LockScreenReminderWidget: View {
let snapshot: LockScreenReminderWidgetSnapshot
private let primaryFont = Font.system(size: 19, weight: .semibold, design: .rounded)
private let iconSize: CGFloat = 20
private let chipWidth: CGFloat = 9
var body: some View {
HStack(alignment: .center, spacing: 8) {
Image(systemName: snapshot.iconName)
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(Color.white)
RoundedRectangle(cornerRadius: 2)
.fill(chipColor)
.frame(width: chipWidth, height: iconSize)
Text(snapshot.title)
.font(primaryFont)
.foregroundStyle(primaryTextColor)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
.layoutPriority(1)
.frame(height: iconSize, alignment: .center)
if let relative = snapshot.relativeDescription {
Text("•")
.font(primaryFont)
.foregroundStyle(separatorColor)
.frame(height: iconSize, alignment: .center)
Text(relative)
.font(primaryFont)
.foregroundStyle(secondaryTextColor)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
.layoutPriority(0.75)
.frame(height: iconSize, alignment: .center)
}
Text("•")
.font(primaryFont)
.foregroundStyle(separatorColor)
.frame(height: iconSize, alignment: .center)
Text(snapshot.eventTimeText)
.font(primaryFont)
.foregroundStyle(secondaryTextColor)
.lineLimit(1)
.minimumScaleFactor(0.85)
.allowsTightening(true)
.layoutPriority(0.75)
.frame(height: iconSize, alignment: .center)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.frame(minWidth: 240, idealWidth: 360, maxWidth: 520, alignment: .leading)
.foregroundStyle(Color.white)
.background(Color.clear)
.shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
private var accentColor: Color {
snapshot.isCritical ? .red : snapshot.accent.color
}
private var chipColor: Color {
if snapshot.isCritical {
return .red
}
switch snapshot.chipStyle {
case .eventColor:
return accentColor.ensureMinimumBrightness(factor: 0.7)
case .monochrome:
return Color.white.opacity(0.85)
}
}
private var primaryTextColor: Color {
Color.white.opacity(0.92)
}
private var secondaryTextColor: Color {
snapshot.isCritical ? Color.white : Color.white.opacity(0.78)
}
private var separatorColor: Color {
Color.white.opacity(0.65)
}
private var accessibilityLabel: String {
var components: [String] = [snapshot.title]
if let relative = snapshot.relativeDescription {
components.append(relative)
}
components.append(snapshot.eventTimeText)
return components.joined(separator: ", ")
}
}

View file

@ -953,6 +953,12 @@ 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
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
var body: some View {
Form {
@ -997,6 +1003,59 @@ 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)
}
HStack {
Text("Sneak peek duration")
Slider(
value: $reminderSneakPeekDuration,
in: 3...20,
step: 1
)
.disabled(!enableReminderLiveActivity)
Text("\(Int(reminderSneakPeekDuration)) s")
.foregroundStyle(.secondary)
.frame(width: 60, alignment: .trailing)
}
}
Section(header: Text("Lock Screen Reminder Widget")) {
Defaults.Toggle("Show lock screen reminder", key: .enableLockScreenReminderWidget)
Picker("Chip color", selection: $lockScreenReminderChipStyle) {
ForEach(LockScreenReminderChipStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenReminderWidget || !enableReminderLiveActivity)
}
Section(header: Text("Select Calendars")) {
List {
ForEach(calendarManager.allCalendars, id: \.id) { calendar in

View file

@ -125,3 +125,10 @@ enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializab
}
}
}
enum LockScreenReminderChipStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
case eventColor = "Event color"
case monochrome = "White"
var id: String { rawValue }
}

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

@ -105,6 +105,7 @@ class LockScreenManager: ObservableObject {
LockScreenPanelManager.shared.showPanel()
LockScreenLiveActivityWindowManager.shared.showLocked()
LockScreenWeatherManager.shared.showWeatherWidget()
LockScreenReminderWidgetManager.shared.showReminderWidget()
// THEN trigger lock icon in Dynamic Island (only if enabled in settings)
if Defaults[.enableLockScreenLiveActivity] {
@ -125,6 +126,7 @@ class LockScreenManager: ObservableObject {
LockScreenPanelManager.shared.hidePanel()
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenReminderWidgetManager.shared.hideReminderWidget()
// Update state immediately
if Defaults[.enableLockScreenLiveActivity] {

View file

@ -20,6 +20,7 @@ class LockScreenPanelManager {
private var collapsedFrame: NSRect?
private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52
private(set) var latestFrame: NSRect?
private init() {
print("[\(timestamp())] LockScreenPanelManager: initialized")
@ -79,6 +80,7 @@ class LockScreenPanelManager {
}
window.setFrame(targetFrame, display: true)
latestFrame = targetFrame
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
window.contentView = hosting
@ -123,6 +125,8 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true)
}
latestFrame = targetFrame
// Update corner radius to match the SwiftUI panel's style
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
if animated {
@ -146,6 +150,8 @@ class LockScreenPanelManager {
window.orderOut(nil)
window.contentView = nil
latestFrame = nil
print("[\(timestamp())] LockScreenPanelManager: panel hidden")
}
}

View file

@ -0,0 +1,209 @@
import Foundation
import Combine
import Defaults
import SwiftUI
import AppKit
@MainActor
final class LockScreenReminderWidgetManager: ObservableObject {
static let shared = LockScreenReminderWidgetManager()
@Published private(set) var snapshot: LockScreenReminderWidgetSnapshot?
private let reminderManager = ReminderLiveActivityManager.shared
private var cancellables = Set<AnyCancellable>()
private let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
private init() {
observeReminderUpdates()
observeDefaults()
}
func showReminderWidget() {
guard Defaults[.enableLockScreenReminderWidget] else {
LockScreenReminderWidgetPanelManager.shared.hide()
return
}
if let snapshot {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot)
} else {
recomputeSnapshot(now: reminderManager.currentDate)
if let snapshot {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot)
} else {
LockScreenReminderWidgetPanelManager.shared.hide()
}
}
}
func hideReminderWidget() {
LockScreenReminderWidgetPanelManager.shared.hide()
}
private func observeReminderUpdates() {
reminderManager.$activeReminder
.receive(on: RunLoop.main)
.sink { [weak self] _ in
guard let self else { return }
self.recomputeSnapshot(now: self.reminderManager.currentDate)
}
.store(in: &cancellables)
reminderManager.$currentDate
.receive(on: RunLoop.main)
.sink { [weak self] now in
guard let self else { return }
self.recomputeSnapshot(now: now)
}
.store(in: &cancellables)
}
private func observeDefaults() {
Defaults.publisher(.enableLockScreenReminderWidget, options: [])
.sink { [weak self] change in
guard let self else { return }
if change.newValue {
self.recomputeSnapshot(now: self.reminderManager.currentDate)
self.showReminderWidgetIfPossible()
} else {
self.snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide()
}
}
.store(in: &cancellables)
Defaults.publisher(.lockScreenReminderChipStyle, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.recomputeSnapshot(now: self.reminderManager.currentDate)
}
.store(in: &cancellables)
}
private func showReminderWidgetIfPossible() {
guard LockScreenManager.shared.currentLockStatus else { return }
guard Defaults[.enableLockScreenReminderWidget] else { return }
if let snapshot {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot)
} else {
LockScreenReminderWidgetPanelManager.shared.hide()
}
}
private func recomputeSnapshot(now: Date) {
guard Defaults[.enableLockScreenReminderWidget] else {
snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide()
return
}
guard let entry = reminderManager.activeReminder else {
snapshot = nil
LockScreenReminderWidgetPanelManager.shared.hide()
return
}
let snapshot = makeSnapshot(from: entry, now: now)
if self.snapshot != snapshot {
self.snapshot = snapshot
}
deliver(snapshot)
}
private func deliver(_ snapshot: LockScreenReminderWidgetSnapshot) {
guard LockScreenManager.shared.currentLockStatus else { return }
if LockScreenReminderWidgetPanelManager.shared.isVisible {
LockScreenReminderWidgetPanelManager.shared.update(with: snapshot)
} else {
LockScreenReminderWidgetPanelManager.shared.show(with: snapshot)
}
}
private func makeSnapshot(from entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> LockScreenReminderWidgetSnapshot {
let title = entry.event.title.isEmpty ? "Upcoming Reminder" : entry.event.title
let timeText = timeFormatter.string(from: entry.event.start)
let isCritical = criticalWindowContains(entry: entry, now: now)
let relative = relativeDescription(for: entry, now: now)
let accent = accentColor(for: entry, isCritical: isCritical)
let iconName = isCritical ? ReminderLiveActivityManager.criticalIconName : ReminderLiveActivityManager.standardIconName
return LockScreenReminderWidgetSnapshot(
title: title,
eventTimeText: timeText,
relativeDescription: relative,
accent: accent,
chipStyle: Defaults[.lockScreenReminderChipStyle],
isCritical: isCritical,
iconName: iconName
)
}
private func accentColor(for entry: ReminderLiveActivityManager.ReminderEntry, isCritical: Bool) -> LockScreenReminderWidgetSnapshot.RGBAColor {
if isCritical {
return LockScreenReminderWidgetSnapshot.RGBAColor(nsColor: .systemRed)
}
let boosted = Color(nsColor: entry.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
return LockScreenReminderWidgetSnapshot.RGBAColor(nsColor: NSColor(boosted))
}
private func relativeDescription(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String? {
let remaining = entry.event.start.timeIntervalSince(now)
if remaining <= 0 {
return "now"
}
let minutes = Int(ceil(remaining / 60))
if minutes <= 0 {
return "now"
}
if minutes == 1 {
return "in 1 min"
}
return "in \(minutes) min"
}
private func criticalWindowContains(entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Bool {
let window = TimeInterval(Defaults[.reminderSneakPeekDuration])
guard window > 0 else { return false }
let remaining = entry.event.start.timeIntervalSince(now)
return remaining > 0 && remaining <= window
}
}
struct LockScreenReminderWidgetSnapshot: Equatable {
struct RGBAColor: Equatable {
let red: Double
let green: Double
let blue: Double
let alpha: Double
init(nsColor: NSColor) {
let color = nsColor.usingColorSpace(.sRGB) ?? nsColor
self.red = Double(color.redComponent)
self.green = Double(color.greenComponent)
self.blue = Double(color.blueComponent)
self.alpha = Double(color.alphaComponent)
}
var color: Color {
Color(red: red, green: green, blue: blue, opacity: alpha)
}
}
let title: String
let eventTimeText: String
let relativeDescription: String?
let accent: RGBAColor
let chipStyle: LockScreenReminderChipStyle
let isCritical: Bool
let iconName: String
}

View file

@ -0,0 +1,108 @@
import AppKit
import SwiftUI
import SkyLightWindow
@MainActor
final class LockScreenReminderWidgetPanelManager {
static let shared = LockScreenReminderWidgetPanelManager()
private var window: NSWindow?
private var hasDelegated = false
var isVisible: Bool {
window?.isVisible == true
}
private init() {}
func show(with snapshot: LockScreenReminderWidgetSnapshot) {
render(snapshot: snapshot, makeVisible: true)
}
func update(with snapshot: LockScreenReminderWidgetSnapshot) {
render(snapshot: snapshot, makeVisible: false)
}
func hide() {
guard let window else { return }
window.orderOut(nil)
window.contentView = nil
}
private func render(snapshot: LockScreenReminderWidgetSnapshot, makeVisible: Bool) {
guard let screen = NSScreen.main else { return }
if !makeVisible, window == nil {
return
}
let view = LockScreenReminderWidget(snapshot: snapshot)
let hostingView = NSHostingView(rootView: view)
let fittingSize = hostingView.fittingSize
hostingView.frame = NSRect(origin: .zero, size: fittingSize)
let window = ensureWindow()
window.setFrame(frame(for: fittingSize, on: screen), display: true)
window.contentView = hostingView
if makeVisible {
window.orderFrontRegardless()
}
}
private func ensureWindow() -> NSWindow {
if let window {
return window
}
let frame = NSRect(origin: .zero, size: CGSize(width: 220, height: 48))
let newWindow = NSWindow(
contentRect: frame,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
newWindow.isReleasedWhenClosed = false
newWindow.isOpaque = false
newWindow.backgroundColor = .clear
newWindow.hasShadow = false
newWindow.ignoresMouseEvents = true
newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()))
newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
window = newWindow
if !hasDelegated {
SkyLightOperator.shared.delegateWindow(newWindow)
hasDelegated = true
}
return newWindow
}
private func frame(for size: CGSize, on screen: NSScreen) -> NSRect {
let screenFrame = screen.frame
let originX = screenFrame.midX - (size.width / 2)
let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32)
let defaultWeatherBottom = screenFrame.midY + (screenFrame.height * 0.14)
let weatherBottom = LockScreenWeatherPanelManager.shared.latestFrame?.minY ?? min(screenFrame.maxY - 120, max(defaultWeatherBottom, musicTop + size.height + 80))
let marginAboveMusic: CGFloat = 16
let marginBelowWeather: CGFloat = 16
let constrainedWeatherBottom = max(weatherBottom, musicTop + size.height + marginAboveMusic + marginBelowWeather)
let centerY = (musicTop + constrainedWeatherBottom) / 2
let lowerBound = screenFrame.minY + 80
let upperBound = constrainedWeatherBottom - marginBelowWeather - size.height
var proposedY = centerY - (size.height / 2)
if proposedY < lowerBound {
proposedY = lowerBound
}
if upperBound > lowerBound {
proposedY = min(proposedY, upperBound)
}
return NSRect(x: originX, y: proposedY, width: size.width, height: size.height)
}
}

View file

@ -8,6 +8,7 @@ final class LockScreenWeatherPanelManager {
private var window: NSWindow?
private var hasDelegated = false
private(set) var latestFrame: NSRect?
private init() {}
@ -23,6 +24,7 @@ final class LockScreenWeatherPanelManager {
guard let window else { return }
window.orderOut(nil)
window.contentView = nil
latestFrame = nil
}
private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) {
@ -39,6 +41,7 @@ final class LockScreenWeatherPanelManager {
let targetFrame = frame(for: fittingSize, on: screen)
let window = ensureWindow()
window.setFrame(targetFrame, display: true)
latestFrame = targetFrame
window.contentView = hostingView
if makeVisible {

View file

@ -0,0 +1,388 @@
//
// 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()
static let standardIconName = "calendar.badge.clock"
static let criticalIconName = "calendar.badge.exclamationmark"
@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 var hasShownCriticalSneakPeek = false
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
hasShownCriticalSneakPeek = false
}
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
hasShownCriticalSneakPeek = false
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()
hasShownCriticalSneakPeek = false
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: ReminderLiveActivityManager.standardIconName
)
hasShownCriticalSneakPeek = false
}
let criticalWindow = TimeInterval(Defaults[.reminderSneakPeekDuration])
let timeRemaining = entry.event.start.timeIntervalSince(date)
if criticalWindow > 0 && timeRemaining > 0 {
if timeRemaining <= criticalWindow {
if !hasShownCriticalSneakPeek {
let displayDuration = min(criticalWindow, max(timeRemaining - 2, 0))
if displayDuration > 0 {
DynamicIslandViewCoordinator.shared.toggleSneakPeek(
status: true,
type: .reminder,
duration: displayDuration,
value: 0,
icon: ReminderLiveActivityManager.criticalIconName
)
hasShownCriticalSneakPeek = true
}
}
} else {
hasShownCriticalSneakPeek = false
}
}
startTickerIfNeeded()
} else {
if activeReminder != nil {
activeReminder = nil
}
stopTicker()
hasShownCriticalSneakPeek = false
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)
}
}

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"
@ -359,6 +375,7 @@ extension Defaults.Keys {
// Enable lock screen media widget (shows the standalone panel when screen is locked)
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
static let enableLockScreenReminderWidget = Key<Bool>("enableLockScreenReminderWidget", default: true)
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
@ -374,6 +391,7 @@ extension Defaults.Keys {
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
// MARK: Battery
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
@ -442,6 +460,12 @@ 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 reminderSneakPeekDuration = Key<Double>("reminderSneakPeekDuration", default: 10)
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
// MARK: ColorPicker Feature