diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 0e27a7b..1418272 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4733,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" : { @@ -10555,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" : { 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 807c757..af8d875 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -957,6 +957,8 @@ struct CalendarSettings: View { @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 { @@ -1042,6 +1044,18 @@ struct CalendarSettings: View { } } + 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/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/models/Constants.swift b/DynamicIsland/models/Constants.swift index c4e4692..f7372df 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -375,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) @@ -390,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)