mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add ReminderLiveActivity
This commit is contained in:
parent
b643f1d905
commit
422b986721
8 changed files with 579 additions and 3 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 != .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 {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +144,8 @@ 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 = type == .timer ? 10 : duration
|
||||||
|
sneakPeekDuration = resolvedDuration
|
||||||
if type != .music && type != .timer {
|
if type != .music && type != .timer {
|
||||||
// close()
|
// close()
|
||||||
if !hudReplacement {
|
if !hudReplacement {
|
||||||
|
|
|
||||||
|
|
@ -868,6 +868,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"%lld min" : {
|
||||||
|
"comment" : "A text label displaying the current value of the \"Notify before\" slider. The argument is the current value of the slider, expressed in minutes.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"%lld%%" : {
|
"%lld%%" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -5634,6 +5638,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Countdown style" : {
|
||||||
|
"comment" : "A label for the style of countdown to use when reminders are live.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"CPU Usage" : {
|
"CPU Usage" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -14238,6 +14246,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Notify before" : {
|
||||||
|
"comment" : "A label describing the slider that allows the user to set a reminder lead time.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -15329,6 +15341,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Reminder Live Activity" : {
|
||||||
|
"comment" : "A section header that lists the options related to live activity notifications for reminders.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reminders" : {
|
"Reminders" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
|
||||||
225
DynamicIsland/components/Calendar/ReminderLiveActivity.swift
Normal file
225
DynamicIsland/components/Calendar/ReminderLiveActivity.swift
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
//
|
||||||
|
// ReminderLiveActivity.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 2025-11-12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
private typealias ReminderFont = NSFont
|
||||||
|
#elseif canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
private typealias ReminderFont = UIFont
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct ReminderLiveActivity: View {
|
||||||
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||||
|
@ObservedObject var manager = ReminderLiveActivityManager.shared
|
||||||
|
|
||||||
|
@Default(.reminderPresentationStyle) private var presentationStyle
|
||||||
|
|
||||||
|
private let wingPadding: CGFloat = 22
|
||||||
|
private let ringStrokeWidth: CGFloat = 3
|
||||||
|
|
||||||
|
private var notchContentHeight: CGFloat {
|
||||||
|
max(0, vm.effectiveClosedNotchHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let reminder = manager.activeReminder {
|
||||||
|
content(for: reminder, now: manager.currentDate)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: leftWingWidth, height: notchContentHeight)
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
iconSection(for: reminder)
|
||||||
|
.padding(.leading, wingPadding / 2)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(.black)
|
||||||
|
.frame(width: vm.closedNotchSize.width, height: notchContentHeight)
|
||||||
|
|
||||||
|
Color.clear
|
||||||
|
.frame(width: rightWingWidth(for: reminder, now: now), height: notchContentHeight)
|
||||||
|
.background(alignment: .trailing) {
|
||||||
|
rightSection(for: reminder, now: now)
|
||||||
|
.padding(.trailing, wingPadding / 2)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: notchContentHeight, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func iconSection(for reminder: ReminderLiveActivityManager.ReminderEntry) -> some View {
|
||||||
|
let diameter = iconDiameter
|
||||||
|
let accent = accentColor(for: reminder)
|
||||||
|
|
||||||
|
return ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.12), lineWidth: 2)
|
||||||
|
Circle()
|
||||||
|
.fill(accent.opacity(0.12))
|
||||||
|
Image(systemName: glyphName(for: reminder.event))
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(accent)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.frame(width: iconDiameter, height: notchContentHeight, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func rightSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
|
||||||
|
let accent = accentColor(for: reminder)
|
||||||
|
switch presentationStyle {
|
||||||
|
case .ringCountdown:
|
||||||
|
ringCountdownSection(for: reminder, now: now, accent: accent)
|
||||||
|
case .digital:
|
||||||
|
digitalSection(for: reminder, now: now)
|
||||||
|
case .minutes:
|
||||||
|
minutesSection(for: reminder, now: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ringCountdownSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date, accent: Color) -> some View {
|
||||||
|
let progress = progress(for: reminder, now: now)
|
||||||
|
let diameter = ringDiameter
|
||||||
|
|
||||||
|
return ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Color.white.opacity(0.15), lineWidth: ringStrokeWidth)
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: progress)
|
||||||
|
.stroke(accent, style: StrokeStyle(lineWidth: ringStrokeWidth, lineCap: .round))
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.smooth(duration: 0.25), value: progress)
|
||||||
|
}
|
||||||
|
.frame(width: diameter, height: diameter)
|
||||||
|
.frame(height: notchContentHeight, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func digitalSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
|
||||||
|
let countdown = digitalCountdown(for: reminder, now: now)
|
||||||
|
return Text(countdown)
|
||||||
|
.font(.system(size: 16, weight: .semibold, design: .monospaced))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
.animation(.smooth(duration: 0.25), value: countdown)
|
||||||
|
.frame(height: notchContentHeight, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesSection(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 2) {
|
||||||
|
Text(minutesCountdown(for: reminder, now: now))
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
}
|
||||||
|
.frame(height: notchContentHeight, alignment: .center)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> Double {
|
||||||
|
guard reminder.leadTime > 0 else { return 1 }
|
||||||
|
let remaining = max(reminder.event.start.timeIntervalSince(now), 0)
|
||||||
|
let elapsed = reminder.leadTime - remaining
|
||||||
|
let ratio = elapsed / reminder.leadTime
|
||||||
|
return min(max(ratio, 0), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func digitalCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
|
||||||
|
let remaining = max(reminder.event.start.timeIntervalSince(now), 0)
|
||||||
|
let totalSeconds = Int(remaining.rounded(.down))
|
||||||
|
let minutes = totalSeconds / 60
|
||||||
|
let seconds = totalSeconds % 60
|
||||||
|
return String(format: "%02d:%02d", minutes, seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesCountdown(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String {
|
||||||
|
let remaining = max(reminder.event.start.timeIntervalSince(now), 0)
|
||||||
|
let minutes = max(1, Int(ceil(remaining / 60)))
|
||||||
|
return minutes == 1 ? "in 1 min" : "in \(minutes) min"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func glyphName(for event: EventModel) -> String {
|
||||||
|
switch event.type {
|
||||||
|
case .birthday:
|
||||||
|
return "gift.fill"
|
||||||
|
case .reminder(let completed):
|
||||||
|
return completed ? "checkmark.circle" : "bell.fill"
|
||||||
|
case .event:
|
||||||
|
return event.isMeeting ? "person.2.fill" : "calendar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accentColor(for reminder: ReminderLiveActivityManager.ReminderEntry) -> Color {
|
||||||
|
Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var leftWingWidth: CGFloat {
|
||||||
|
wingPadding + iconDiameter
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rightWingWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat {
|
||||||
|
var width = wingPadding
|
||||||
|
switch presentationStyle {
|
||||||
|
case .ringCountdown:
|
||||||
|
width += ringDiameter
|
||||||
|
case .digital:
|
||||||
|
width += countdownWidth(for: reminder, now: now)
|
||||||
|
case .minutes:
|
||||||
|
width += minutesWidth(for: reminder, now: now)
|
||||||
|
}
|
||||||
|
return width
|
||||||
|
}
|
||||||
|
|
||||||
|
private func countdownWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat {
|
||||||
|
let text = digitalCountdown(for: reminder, now: now)
|
||||||
|
let width = measureTextWidth(text, font: monospacedDigitFont(size: 16, weight: ReminderFont.Weight.semibold))
|
||||||
|
return max(width + 18, 76)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func minutesWidth(for reminder: ReminderLiveActivityManager.ReminderEntry, now: Date) -> CGFloat {
|
||||||
|
let text = minutesCountdown(for: reminder, now: now)
|
||||||
|
let width = measureTextWidth(text, font: systemFont(size: 13, weight: ReminderFont.Weight.semibold))
|
||||||
|
return max(width + 18, 88)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconDiameter: CGFloat {
|
||||||
|
max(notchContentHeight - 6, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var ringDiameter: CGFloat {
|
||||||
|
max(min(notchContentHeight - 10, 24), 18)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func measureTextWidth(_ text: String, font: ReminderFont) -> CGFloat {
|
||||||
|
let attributes: [NSAttributedString.Key: Any] = [.font: font]
|
||||||
|
return ceil(NSAttributedString(string: text, attributes: attributes).size().width)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func systemFont(size: CGFloat, weight: ReminderFont.Weight) -> ReminderFont {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
return NSFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
#else
|
||||||
|
return UIFont.systemFont(ofSize: size, weight: weight)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monospacedDigitFont(size: CGFloat, weight: ReminderFont.Weight) -> ReminderFont {
|
||||||
|
#if canImport(AppKit)
|
||||||
|
return NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||||
|
#else
|
||||||
|
return UIFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -953,6 +953,9 @@ 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
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
|
|
@ -997,6 +1000,34 @@ 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
272
DynamicIsland/managers/ReminderLiveActivityManager.swift
Normal file
272
DynamicIsland/managers/ReminderLiveActivityManager.swift
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
//
|
||||||
|
// ReminderLiveActivityManager.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by GitHub Copilot on 2025-11-12.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Defaults
|
||||||
|
import EventKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ReminderLiveActivityManager: ObservableObject {
|
||||||
|
struct ReminderEntry: Equatable {
|
||||||
|
let event: EventModel
|
||||||
|
let triggerDate: Date
|
||||||
|
let leadTime: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = ReminderLiveActivityManager()
|
||||||
|
|
||||||
|
@Published private(set) var activeReminder: ReminderEntry?
|
||||||
|
@Published private(set) var currentDate: Date = Date()
|
||||||
|
|
||||||
|
private var nextReminder: ReminderEntry?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var tickerCancellable: AnyCancellable?
|
||||||
|
private var evaluationTask: Task<Void, Never>?
|
||||||
|
private var fallbackRefreshTask: Task<Void, Never>?
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private let calendarService: CalendarServiceProviding
|
||||||
|
private let calendarManager = CalendarManager.shared
|
||||||
|
|
||||||
|
var isActive: Bool { activeReminder != nil }
|
||||||
|
|
||||||
|
private init(calendarService: CalendarServiceProviding = CalendarService()) {
|
||||||
|
self.calendarService = calendarService
|
||||||
|
setupObservers()
|
||||||
|
Task { await self.refreshUpcomingReminder() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupObservers() {
|
||||||
|
Defaults.publisher(.enableReminderLiveActivity, options: [])
|
||||||
|
.sink { [weak self] change in
|
||||||
|
guard let self else { return }
|
||||||
|
if change.newValue {
|
||||||
|
self.scheduleRefresh(force: true)
|
||||||
|
} else {
|
||||||
|
self.deactivateReminder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.reminderLeadTime, options: [])
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduleRefresh(force: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
Defaults.publisher(.reminderPresentationStyle, options: [])
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
// Presentation change does not alter scheduling, but ensure state publishes for UI updates.
|
||||||
|
if let reminder = self.activeReminder {
|
||||||
|
self.activeReminder = reminder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
calendarManager.$allCalendars
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduleRefresh(force: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
calendarManager.$events
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduleRefresh(force: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .EKEventStoreChanged)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scheduleRefresh(force: true)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelAllTimers() {
|
||||||
|
tickerCancellable?.cancel()
|
||||||
|
tickerCancellable = nil
|
||||||
|
evaluationTask?.cancel()
|
||||||
|
evaluationTask = nil
|
||||||
|
fallbackRefreshTask?.cancel()
|
||||||
|
fallbackRefreshTask = nil
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deactivateReminder() {
|
||||||
|
nextReminder = nil
|
||||||
|
activeReminder = nil
|
||||||
|
cancelAllTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectedCalendarIDs() -> [String] {
|
||||||
|
calendarManager.allCalendars
|
||||||
|
.filter { calendarManager.getCalendarSelected($0) }
|
||||||
|
.map { $0.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldHide(_ event: EventModel) -> Bool {
|
||||||
|
if event.isAllDay && Defaults[.hideAllDayEvents] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if case let .reminder(completed) = event.type,
|
||||||
|
completed && Defaults[.hideCompletedReminders] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEntry(from event: EventModel, leadMinutes: Int, referenceDate: Date) -> ReminderEntry? {
|
||||||
|
guard event.start > referenceDate else { return nil }
|
||||||
|
let leadSeconds = max(1, leadMinutes) * 60
|
||||||
|
let trigger = event.start.addingTimeInterval(TimeInterval(-leadSeconds))
|
||||||
|
return ReminderEntry(event: event, triggerDate: trigger, leadTime: TimeInterval(leadSeconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleEvaluation(at date: Date) {
|
||||||
|
evaluationTask?.cancel()
|
||||||
|
let delay = date.timeIntervalSinceNow
|
||||||
|
guard delay > 0 else {
|
||||||
|
Task { await self.evaluateCurrentState(at: Date()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evaluationTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
await self.evaluateCurrentState(at: Date())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleFallbackRefresh() {
|
||||||
|
fallbackRefreshTask?.cancel()
|
||||||
|
let delay: TimeInterval = 15 * 60
|
||||||
|
fallbackRefreshTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||||
|
await self.refreshUpcomingReminder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRefresh(force: Bool) {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.refreshUpcomingReminder(force: force)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startTickerIfNeeded() {
|
||||||
|
guard tickerCancellable == nil else { return }
|
||||||
|
tickerCancellable = Timer.publish(every: 1, tolerance: 0.2, on: .main, in: .common)
|
||||||
|
.autoconnect()
|
||||||
|
.sink { [weak self] date in
|
||||||
|
guard let self else { return }
|
||||||
|
self.currentDate = date
|
||||||
|
Task { await self.evaluateCurrentState(at: date) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTicker() {
|
||||||
|
tickerCancellable?.cancel()
|
||||||
|
tickerCancellable = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleEntrySelection(_ entry: ReminderEntry?, referenceDate: Date) {
|
||||||
|
fallbackRefreshTask?.cancel()
|
||||||
|
nextReminder = entry
|
||||||
|
Task { await self.evaluateCurrentState(at: referenceDate) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshFromEvents(_ events: [EventModel], referenceDate: Date) {
|
||||||
|
let leadMinutes = Defaults[.reminderLeadTime]
|
||||||
|
let upcoming = events
|
||||||
|
.filter { !shouldHide($0) }
|
||||||
|
.compactMap { makeEntry(from: $0, leadMinutes: leadMinutes, referenceDate: referenceDate) }
|
||||||
|
.sorted { $0.triggerDate < $1.triggerDate }
|
||||||
|
|
||||||
|
guard let first = upcoming.first else {
|
||||||
|
deactivateReminder()
|
||||||
|
scheduleFallbackRefresh()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEntrySelection(first, referenceDate: referenceDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshUpcomingReminder(force: Bool = false) async {
|
||||||
|
guard Defaults[.enableReminderLiveActivity] else {
|
||||||
|
deactivateReminder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Date()
|
||||||
|
|
||||||
|
if !force, let entry = nextReminder, entry.event.start > now {
|
||||||
|
await evaluateCurrentState(at: now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let calendars = selectedCalendarIDs()
|
||||||
|
guard !calendars.isEmpty else {
|
||||||
|
deactivateReminder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let windowEnd = Calendar.current.date(byAdding: .hour, value: 24, to: now) ?? now.addingTimeInterval(24 * 60 * 60)
|
||||||
|
let events = await calendarService.events(from: now, to: windowEnd, calendars: calendars)
|
||||||
|
await MainActor.run {
|
||||||
|
self.refreshFromEvents(events, referenceDate: now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func evaluateCurrentState(at date: Date) async {
|
||||||
|
guard Defaults[.enableReminderLiveActivity] else {
|
||||||
|
deactivateReminder()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = date
|
||||||
|
|
||||||
|
guard var entry = nextReminder else {
|
||||||
|
activeReminder = nil
|
||||||
|
stopTicker()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.event.start <= date {
|
||||||
|
activeReminder = nil
|
||||||
|
nextReminder = nil
|
||||||
|
stopTicker()
|
||||||
|
scheduleRefresh(force: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if entry.triggerDate <= date {
|
||||||
|
if entry.triggerDate > entry.event.start {
|
||||||
|
entry = ReminderEntry(event: entry.event, triggerDate: entry.event.start, leadTime: entry.leadTime)
|
||||||
|
nextReminder = entry
|
||||||
|
}
|
||||||
|
if activeReminder != entry {
|
||||||
|
activeReminder = entry
|
||||||
|
}
|
||||||
|
startTickerIfNeeded()
|
||||||
|
} else {
|
||||||
|
if activeReminder != nil {
|
||||||
|
activeReminder = nil
|
||||||
|
}
|
||||||
|
stopTicker()
|
||||||
|
scheduleEvaluation(at: entry.triggerDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -442,6 +458,11 @@ 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 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