mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +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 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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
|
|
|||
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 {
|
||||
@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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 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 {
|
||||
|
|
|
|||
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 }
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue