mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 23:54:32 +00:00
Merge pull request #95 from Ebullioscopic/feature/lock-screen
Reminder Live Activity and Lock Screen Widget
This commit is contained in:
commit
4b7a54f46f
15 changed files with 1245 additions and 4 deletions
|
|
@ -21,6 +21,7 @@ struct ContentView: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject var musicManager = MusicManager.shared
|
@ObservedObject var musicManager = MusicManager.shared
|
||||||
@ObservedObject var timerManager = TimerManager.shared
|
@ObservedObject var timerManager = TimerManager.shared
|
||||||
|
@ObservedObject var reminderManager = ReminderLiveActivityManager.shared
|
||||||
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
@ObservedObject var batteryModel = BatteryStatusViewModel.shared
|
||||||
@ObservedObject var statsManager = StatsManager.shared
|
@ObservedObject var statsManager = StatsManager.shared
|
||||||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||||||
|
|
@ -34,6 +35,7 @@ struct ContentView: View {
|
||||||
@Default(.showGpuGraph) var showGpuGraph
|
@Default(.showGpuGraph) var showGpuGraph
|
||||||
@Default(.showNetworkGraph) var showNetworkGraph
|
@Default(.showNetworkGraph) var showNetworkGraph
|
||||||
@Default(.showDiskGraph) var showDiskGraph
|
@Default(.showDiskGraph) var showDiskGraph
|
||||||
|
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
|
|
@ -404,13 +406,15 @@ struct ContentView: View {
|
||||||
.frame(width: 76, alignment: .trailing)
|
.frame(width: 76, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
.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)
|
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
||||||
MusicLiveActivity()
|
MusicLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
|
||||||
TimerLiveActivity()
|
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 {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed {
|
||||||
RecordingLiveActivity()
|
RecordingLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && doNotDisturbManager.isDoNotDisturbActive && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
} 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)
|
.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
|
@ViewBuilder
|
||||||
func DynamicIslandFaceAnimation() -> some View {
|
func DynamicIslandFaceAnimation() -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ enum SneakContentType {
|
||||||
case battery
|
case battery
|
||||||
case download
|
case download
|
||||||
case timer
|
case timer
|
||||||
|
case reminder
|
||||||
case recording
|
case recording
|
||||||
case doNotDisturb
|
case doNotDisturb
|
||||||
case bluetoothAudio
|
case bluetoothAudio
|
||||||
|
|
@ -143,8 +144,17 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
||||||
sneakPeekDuration = duration
|
let resolvedDuration: TimeInterval
|
||||||
if type != .music && type != .timer {
|
switch type {
|
||||||
|
case .timer:
|
||||||
|
resolvedDuration = 10
|
||||||
|
case .reminder:
|
||||||
|
resolvedDuration = Defaults[.reminderSneakPeekDuration]
|
||||||
|
default:
|
||||||
|
resolvedDuration = duration
|
||||||
|
}
|
||||||
|
sneakPeekDuration = resolvedDuration
|
||||||
|
if type != .music && type != .timer && type != .reminder {
|
||||||
// close()
|
// close()
|
||||||
if !hudReplacement {
|
if !hudReplacement {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -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%%" : {
|
"%lld%%" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"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." : {
|
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
|
||||||
"extractionState" : "stale",
|
"extractionState" : "stale",
|
||||||
"localizations" : {
|
"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" : {
|
"CPU Usage" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -10543,6 +10559,10 @@
|
||||||
},
|
},
|
||||||
"Lock screen material" : {
|
"Lock screen material" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Lock Screen Reminder Widget" : {
|
||||||
|
"comment" : "A section header for the lock screen reminder widget settings.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Loop Mode" : {
|
"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" : {
|
"OK" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"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" : {
|
"Reminders" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -17161,6 +17189,10 @@
|
||||||
"comment" : "A description of how the color-coding feature works.",
|
"comment" : "A description of how the color-coding feature works.",
|
||||||
"isCommentAutoGenerated" : true
|
"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." : {
|
"Sneak Peek shows the media title and artist under the notch for a few seconds." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
|
||||||
222
DynamicIsland/components/Calendar/ReminderLiveActivity.swift
Normal file
222
DynamicIsland/components/Calendar/ReminderLiveActivity.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: ", ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -953,6 +953,12 @@ struct Media: View {
|
||||||
struct CalendarSettings: View {
|
struct CalendarSettings: View {
|
||||||
@ObservedObject private var calendarManager = CalendarManager.shared
|
@ObservedObject private var calendarManager = CalendarManager.shared
|
||||||
@Default(.showCalendar) var showCalendar: Bool
|
@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 {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -997,6 +1003,59 @@ struct CalendarSettings: View {
|
||||||
|
|
||||||
Defaults.Toggle("Show calendar", key: .showCalendar)
|
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")) {
|
Section(header: Text("Select Calendars")) {
|
||||||
List {
|
List {
|
||||||
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
|
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,20 @@ class CalendarManager: ObservableObject {
|
||||||
|
|
||||||
func checkCalendarAuthorization() async {
|
func checkCalendarAuthorization() async {
|
||||||
let status = EKEventStore.authorizationStatus(for: .event)
|
let status = EKEventStore.authorizationStatus(for: .event)
|
||||||
|
let reminderStatus = EKEventStore.authorizationStatus(for: .reminder)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
print("📅 Current calendar authorization status: \(status)")
|
print("📅 Current calendar authorization status: \(status)")
|
||||||
self.calendarAuthorizationStatus = status
|
self.calendarAuthorizationStatus = status
|
||||||
|
self.reminderAuthorizationStatus = reminderStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
switch status {
|
switch status {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
let granted = await calendarService.requestAccess()
|
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 {
|
if granted {
|
||||||
await reloadCalendarAndReminderLists()
|
await reloadCalendarAndReminderLists()
|
||||||
events = await calendarService.events(
|
events = await calendarService.events(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ class LockScreenManager: ObservableObject {
|
||||||
LockScreenPanelManager.shared.showPanel()
|
LockScreenPanelManager.shared.showPanel()
|
||||||
LockScreenLiveActivityWindowManager.shared.showLocked()
|
LockScreenLiveActivityWindowManager.shared.showLocked()
|
||||||
LockScreenWeatherManager.shared.showWeatherWidget()
|
LockScreenWeatherManager.shared.showWeatherWidget()
|
||||||
|
LockScreenReminderWidgetManager.shared.showReminderWidget()
|
||||||
|
|
||||||
// THEN trigger lock icon in Dynamic Island (only if enabled in settings)
|
// THEN trigger lock icon in Dynamic Island (only if enabled in settings)
|
||||||
if Defaults[.enableLockScreenLiveActivity] {
|
if Defaults[.enableLockScreenLiveActivity] {
|
||||||
|
|
@ -125,6 +126,7 @@ class LockScreenManager: ObservableObject {
|
||||||
LockScreenPanelManager.shared.hidePanel()
|
LockScreenPanelManager.shared.hidePanel()
|
||||||
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
|
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
|
||||||
LockScreenWeatherManager.shared.hideWeatherWidget()
|
LockScreenWeatherManager.shared.hideWeatherWidget()
|
||||||
|
LockScreenReminderWidgetManager.shared.hideReminderWidget()
|
||||||
|
|
||||||
// Update state immediately
|
// Update state immediately
|
||||||
if Defaults[.enableLockScreenLiveActivity] {
|
if Defaults[.enableLockScreenLiveActivity] {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class LockScreenPanelManager {
|
||||||
private var collapsedFrame: NSRect?
|
private var collapsedFrame: NSRect?
|
||||||
private let collapsedPanelCornerRadius: CGFloat = 28
|
private let collapsedPanelCornerRadius: CGFloat = 28
|
||||||
private let expandedPanelCornerRadius: CGFloat = 52
|
private let expandedPanelCornerRadius: CGFloat = 52
|
||||||
|
private(set) var latestFrame: NSRect?
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
print("[\(timestamp())] LockScreenPanelManager: initialized")
|
||||||
|
|
@ -79,6 +80,7 @@ class LockScreenPanelManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
|
latestFrame = targetFrame
|
||||||
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
|
let hosting = NSHostingView(rootView: LockScreenMusicPanel())
|
||||||
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
|
||||||
window.contentView = hosting
|
window.contentView = hosting
|
||||||
|
|
@ -123,6 +125,8 @@ class LockScreenPanelManager {
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latestFrame = targetFrame
|
||||||
|
|
||||||
// Update corner radius to match the SwiftUI panel's style
|
// Update corner radius to match the SwiftUI panel's style
|
||||||
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
|
||||||
if animated {
|
if animated {
|
||||||
|
|
@ -146,6 +150,8 @@ class LockScreenPanelManager {
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
window.contentView = nil
|
window.contentView = nil
|
||||||
|
|
||||||
|
latestFrame = nil
|
||||||
|
|
||||||
print("[\(timestamp())] LockScreenPanelManager: panel hidden")
|
print("[\(timestamp())] LockScreenPanelManager: panel hidden")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
209
DynamicIsland/managers/LockScreenReminderWidgetManager.swift
Normal file
209
DynamicIsland/managers/LockScreenReminderWidgetManager.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -8,6 +8,7 @@ final class LockScreenWeatherPanelManager {
|
||||||
|
|
||||||
private var window: NSWindow?
|
private var window: NSWindow?
|
||||||
private var hasDelegated = false
|
private var hasDelegated = false
|
||||||
|
private(set) var latestFrame: NSRect?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ final class LockScreenWeatherPanelManager {
|
||||||
guard let window else { return }
|
guard let window else { return }
|
||||||
window.orderOut(nil)
|
window.orderOut(nil)
|
||||||
window.contentView = nil
|
window.contentView = nil
|
||||||
|
latestFrame = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) {
|
private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) {
|
||||||
|
|
@ -39,6 +41,7 @@ final class LockScreenWeatherPanelManager {
|
||||||
let targetFrame = frame(for: fittingSize, on: screen)
|
let targetFrame = frame(for: fittingSize, on: screen)
|
||||||
let window = ensureWindow()
|
let window = ensureWindow()
|
||||||
window.setFrame(targetFrame, display: true)
|
window.setFrame(targetFrame, display: true)
|
||||||
|
latestFrame = targetFrame
|
||||||
window.contentView = hostingView
|
window.contentView = hostingView
|
||||||
|
|
||||||
if makeVisible {
|
if makeVisible {
|
||||||
|
|
|
||||||
388
DynamicIsland/managers/ReminderLiveActivityManager.swift
Normal file
388
DynamicIsland/managers/ReminderLiveActivityManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -217,6 +217,22 @@ enum TimerProgressStyle: String, CaseIterable, Identifiable, Defaults.Serializab
|
||||||
var id: String { rawValue }
|
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
|
// AI Model types for screen assistant
|
||||||
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
|
enum AIModelProvider: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||||
case gemini = "Gemini"
|
case gemini = "Gemini"
|
||||||
|
|
@ -359,6 +375,7 @@ extension Defaults.Keys {
|
||||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", 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 lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||||
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", 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 lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
||||||
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
|
||||||
|
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
|
||||||
|
|
||||||
// MARK: Battery
|
// MARK: Battery
|
||||||
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
|
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 timerShowsLabel = Key<Bool>("timerShowsLabel", default: true)
|
||||||
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
static let timerShowsProgress = Key<Bool>("timerShowsProgress", default: true)
|
||||||
static let timerProgressStyle = Key<TimerProgressStyle>("timerProgressStyle", default: .bar)
|
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)
|
static let timerControlWindowEnabled = Key<Bool>("timerControlWindowEnabled", default: true)
|
||||||
|
|
||||||
// MARK: ColorPicker Feature
|
// MARK: ColorPicker Feature
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue