diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 5a85727..e0a688c 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -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 { diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 2eba167..febe004 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -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 diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2d008e7..1418272 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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" : { diff --git a/DynamicIsland/components/Calendar/ReminderLiveActivity.swift b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift new file mode 100644 index 0000000..837a4b0 --- /dev/null +++ b/DynamicIsland/components/Calendar/ReminderLiveActivity.swift @@ -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 + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift b/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift new file mode 100644 index 0000000..0975e66 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenReminderWidget.swift @@ -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: ", ") + } +} + diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2245fdb..af8d875 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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 diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 1ee0b07..da5827a 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -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 } +} diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 0a85573..d0ef085 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -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( diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 2e0cca5..ca38307 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -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] { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 1ed694f..439d205 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -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") } } diff --git a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift new file mode 100644 index 0000000..ddbd2f1 --- /dev/null +++ b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift @@ -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() + + 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 +} + diff --git a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift new file mode 100644 index 0000000..5c66f75 --- /dev/null +++ b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift @@ -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) + } +} + diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 329e808..d5966d6 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -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 { diff --git a/DynamicIsland/managers/ReminderLiveActivityManager.swift b/DynamicIsland/managers/ReminderLiveActivityManager.swift new file mode 100644 index 0000000..3d631c0 --- /dev/null +++ b/DynamicIsland/managers/ReminderLiveActivityManager.swift @@ -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() + private var tickerTask: Task? { didSet { oldValue?.cancel() } } + private var evaluationTask: Task? + private var fallbackRefreshTask: Task? + private var refreshTask: Task? + private var pendingRefreshTask: Task? + 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) + } + +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index e936a74..f7372df 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -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("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) + static let enableLockScreenReminderWidget = Key("enableLockScreenReminderWidget", default: true) static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) @@ -374,6 +391,7 @@ extension Defaults.Keys { static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) + static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) @@ -442,6 +460,12 @@ extension Defaults.Keys { static let timerShowsLabel = Key("timerShowsLabel", default: true) static let timerShowsProgress = Key("timerShowsProgress", default: true) static let timerProgressStyle = Key("timerProgressStyle", default: .bar) + + // MARK: Reminder Live Activity + static let enableReminderLiveActivity = Key("enableReminderLiveActivity", default: true) + static let reminderPresentationStyle = Key("reminderPresentationStyle", default: .ringCountdown) + static let reminderLeadTime = Key("reminderLeadTime", default: 5) + static let reminderSneakPeekDuration = Key("reminderSneakPeekDuration", default: 10) static let timerControlWindowEnabled = Key("timerControlWindowEnabled", default: true) // MARK: ColorPicker Feature