From 2453ca89ffe61732a9786f06371dd3dcb1b83392 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Wed, 4 Feb 2026 23:48:48 +0530 Subject: [PATCH 1/4] Bluetooth HUD Animations --- .../BluetoothHUDAnimations/airpods.mov | 3 + .../BluetoothHUDAnimations/airpodsGen3.mov | 3 + .../BluetoothHUDAnimations/airpodsGen4.mov | 3 + .../BluetoothHUDAnimations/airpodsMax.mov | 3 + .../BluetoothHUDAnimations/airpodsPro.mov | 3 + .../BluetoothHUDAnimations/airpodsPro3.mov | 3 + .../BluetoothHUDAnimations/beatssolo.mov | 3 + .../BluetoothHUDAnimations/beatsstudio.mov | 3 + .../Live activities/InlineHUD.swift | 73 ++++- .../components/Settings/SettingsView.swift | 3 + .../managers/BluetoothAudioManager.swift | 271 +++++++++++++++++- DynamicIsland/models/Constants.swift | 1 + 12 files changed, 366 insertions(+), 6 deletions(-) create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpods.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov create mode 100755 DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/beatssolo.mov create mode 100644 DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov diff --git a/DynamicIsland/BluetoothHUDAnimations/airpods.mov b/DynamicIsland/BluetoothHUDAnimations/airpods.mov new file mode 100755 index 0000000..96e69a1 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpods.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:54332f65534c9af667a302e9c2df17e4420bffb94e93cecd0091b11a05e70efc +size 351501 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov new file mode 100755 index 0000000..37b635e --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:329be57b63f437e3329772b688440b825ae4b27ece16ac33c483d43b3725d0cf +size 391452 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov new file mode 100644 index 0000000..089fda0 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsGen4.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:04b9bce0be1bb910c10d7466b795e0909cbf70d6b62e5f227124d7c67bc9b53a +size 352641 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov new file mode 100755 index 0000000..1cab6ca --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsMax.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b587c84649e0f3986d59b46fab527a15b2c8146eb9c57a523a2e899afe41ac20 +size 476065 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov new file mode 100755 index 0000000..ee76359 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2be0bc5150dd21d85c9d64a18ec5a782279e30980996e726a6c8d3a8cecb775 +size 463126 diff --git a/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov new file mode 100644 index 0000000..e03ff48 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/airpodsPro3.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df3275c52a9599dbd5768aba279c8904037925bbed6a02a2166a0a01fb730875 +size 398759 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov new file mode 100644 index 0000000..5e1cbb7 --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatssolo.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c59f43e4f3446a163dde871035971eb1482475051dbc67e5f6a771b5fb04f13 +size 393932 diff --git a/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov new file mode 100644 index 0000000..2a772ba --- /dev/null +++ b/DynamicIsland/BluetoothHUDAnimations/beatsstudio.mov @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:835ba4373e8f3dd85e2ff77655eb9464ed07a58687b3b79b51a4535c5febd26a +size 443917 diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index e319b29..4f55720 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -21,8 +21,65 @@ */ import SwiftUI +import AppKit +import AVFoundation import Defaults +// MARK: - Inline HUD looping .mov icon + +private final class LoopingPlayerController { + let player: AVQueuePlayer + private var looper: AVPlayerLooper? + + init(url: URL) { + let item = AVPlayerItem(url: url) + self.player = AVQueuePlayer() + self.player.isMuted = true + self.player.actionAtItemEnd = .none + self.looper = AVPlayerLooper(player: self.player, templateItem: item) + self.player.play() + } + + deinit { + player.pause() + looper = nil + } +} + +private struct LoopingVideoIcon: NSViewRepresentable { + let url: URL + let size: CGSize + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: NSRect(origin: .zero, size: size)) + view.wantsLayer = true + + let layer = AVPlayerLayer() + layer.videoGravity = .resizeAspect + layer.frame = view.bounds + + view.layer?.addSublayer(layer) + + context.coordinator.attach(layer: layer, url: url) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + // No-op; the animation loops via AVPlayerLooper. + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + final class Coordinator { + private var controller: LoopingPlayerController? + + func attach(layer: AVPlayerLayer, url: URL) { + controller = LoopingPlayerController(url: url) + layer.player = controller?.player + } + } +} + struct InlineHUD: View { @EnvironmentObject var vm: DynamicIslandViewModel @Binding var type: SneakContentType @@ -39,6 +96,7 @@ struct InlineHUD: View { @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee + @Default(.useBluetoothHUD3DIcon) var useBluetoothHUD3DIcon @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.showCapsLockLabel) var showCapsLockLabel @Default(.capsLockIndicatorTintMode) var capsLockTintMode @@ -173,10 +231,17 @@ struct InlineHUD: View { .contentTransition(.interpolate) .frame(width: 20, height: 15, alignment: .center) case .bluetoothAudio: - Image(systemName: icon.isEmpty ? "bluetooth" : icon) - .symbolRenderingMode(.hierarchical) - .contentTransition(.interpolate) - .frame(width: 20, height: 15, alignment: .center) + if useBluetoothHUD3DIcon, + let deviceType = bluetoothManager.lastConnectedDevice?.deviceType, + let url = Bundle.main.url(forResource: deviceType.inlineHUDAnimationBaseName, withExtension: "mov") { + LoopingVideoIcon(url: url, size: CGSize(width: 20, height: 20)) + .frame(width: 20, height: 20, alignment: .center) + } else { + Image(systemName: icon.isEmpty ? "bluetooth" : icon) + .symbolRenderingMode(.hierarchical) + .contentTransition(.interpolate) + .frame(width: 20, height: 15, alignment: .center) + } case .capsLock: Image(systemName: "capslock.fill") .symbolRenderingMode(.hierarchical) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index bd1a815..19defb3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -631,6 +631,7 @@ struct SettingsView: View { SettingsSearchEntry(tab: .devices, title: "Use circular battery indicator", keywords: ["battery", "circular"], highlightID: SettingsTab.devices.highlightID(for: "Use circular battery indicator")), SettingsSearchEntry(tab: .devices, title: "Show battery percentage text in HUD", keywords: ["battery text"], highlightID: SettingsTab.devices.highlightID(for: "Show battery percentage text in HUD")), SettingsSearchEntry(tab: .devices, title: "Scroll device name in HUD", keywords: ["marquee", "device name"], highlightID: SettingsTab.devices.highlightID(for: "Scroll device name in HUD")), + SettingsSearchEntry(tab: .devices, title: "Use 3D Bluetooth HUD icon", keywords: ["bluetooth", "3d", "animation", "mov"], highlightID: SettingsTab.devices.highlightID(for: "Use 3D Bluetooth HUD icon")), SettingsSearchEntry(tab: .devices, title: "Color-coded battery display", keywords: ["color", "battery"], highlightID: SettingsTab.devices.highlightID(for: "Color-coded battery display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Color-coded volume display", keywords: ["volume", "color"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Color-coded volume display")), SettingsSearchEntry(tab: .hudAndOSD, title: "Smooth color transitions", keywords: ["gradient", "smooth"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Smooth color transitions")), @@ -1783,6 +1784,8 @@ private struct DevicesSettingsView: View { .settingsHighlight(id: highlightID("Show battery percentage text in HUD")) Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee) .settingsHighlight(id: highlightID("Scroll device name in HUD")) + Defaults.Toggle("Use 3D Bluetooth HUD icon", key: .useBluetoothHUD3DIcon) + .settingsHighlight(id: highlightID("Use 3D Bluetooth HUD icon")) } header: { Text("Bluetooth Audio Devices") } footer: { diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index a7d3d30..869abf6 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -43,6 +43,25 @@ class BluetoothAudioManager: ObservableObject { private let batteryReader = BluetoothLEBatteryReader() private var isLiveBatteryRefreshInFlight = false + private let appleVendorID: UInt16 = 0x05AC + private let devicePIDMap: [UInt16: BluetoothAudioDeviceType] = [ + 0x2002: .airpods, // Gen 1 AirPods + 0x200F: .airpods, // Gen 2 AirPods + 0x2013: .airpodsGen3, // Gen 3 AirPods + 0x2019: .airpodsGen4, // Gen 4 AirPods + 0x201B: .airpodsGen4, // Gen 4 AirPods ANC + 0x200A: .airpodsMax, // AirPods Max Lightning + 0x201F: .airpodsMax, // AirPods Max USB-C + 0x200E: .airpodsPro, // AirPods Pro Gen 1 + 0x2014: .airpodsPro, // AirPods Pro Gen 2 Lightning + 0x2024: .airpodsPro, // AirPods Pro Gen 2 USB-C + 0x2027: .airpodsPro3, // AirPods Pro Gen 3 + 0x2017: .beatsstudio, // Beats Studio Pro + 0x2009: .beatsstudio, // Beats Studio 3 + 0x2006: .beatssolo, // Beats Solo 3 + 0x200C: .beatssolo // Beats Solo Pro + ] + @Published private(set) var batteryStatus: [String: String] = [:] private var batteryStatusByAddress: [String: Int] = [:] @@ -396,24 +415,247 @@ class BluetoothAudioManager: ObservableObject { logMissingBatteryInfo(for: device) return nil } + + // MARK: - PID-based device detection + + /// Extract a UInt16 from common payload formats (Int/NSNumber/String including hex like "0x201B"). + private func extractUInt16(from payload: [String: Any], keys: [String]) -> UInt16? { + for key in keys { + guard let raw = payload[key] else { continue } + + if let number = raw as? NSNumber { + return UInt16(truncatingIfNeeded: number.uint16Value) + } + if let intValue = raw as? Int { + return UInt16(truncatingIfNeeded: intValue) + } + if let string = raw as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x") { + let hex = trimmed.dropFirst(2) + if let value = UInt16(hex, radix: 16) { return value } + } else if let value = UInt16(trimmed, radix: 10) { + return value + } + } + } + return nil + } + + /// Recursively search nested dictionaries/arrays for the first UInt16 value whose key matches `predicate`. + private func deepSearchUInt16(in value: Any, predicate: (String) -> Bool) -> UInt16? { + if let dict = value as? [String: Any] { + for (key, entry) in dict { + if predicate(key) { + if let found = extractUInt16(from: dict, keys: [key]) { + return found + } + if let number = entry as? NSNumber { return UInt16(truncatingIfNeeded: number.uint16Value) } + if let intValue = entry as? Int { return UInt16(truncatingIfNeeded: intValue) } + if let string = entry as? String { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value } + if let value = UInt16(trimmed, radix: 10) { return value } + } + } + } + + for entry in dict.values { + if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found } + } + return nil + } + + if let array = value as? [Any] { + for entry in array { + if let found = deepSearchUInt16(in: entry, predicate: predicate) { return found } + } + return nil + } + + return nil + } + + /// Fallback: attempt to get VendorID/ProductID from system_profiler SPBluetoothDataType JSON. + private func vendorProductIDsFromSystemProfiler(forNormalizedAddress target: String) -> (vendor: UInt16, product: UInt16)? { + guard !target.isEmpty else { return nil } + guard let root = systemProfilerBluetoothDictionary() else { return nil } + guard let deviceConnected = root["device_connected"] as? [Any] else { return nil } + + func pidFromPayload(_ payload: [String: Any]) -> UInt16? { + if let raw = payload["device_productID"] as? String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.hasPrefix("0x"), let value = UInt16(trimmed.dropFirst(2), radix: 16) { return value } + if let value = UInt16(trimmed, radix: 16) { return value } + } + let productKeys = ["device_productID", "ProductID", "product_id", "productID", "DeviceProductID", "ProductId", "Product ID"] + return extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + } + + func vidFromPayload(_ payload: [String: Any]) -> UInt16? { + let vendorKeys = ["device_vendorID", "VendorID", "vendor_id", "vendorID", "DeviceVendorID", "VendorId", "Vendor ID"] + return extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + } + + for item in deviceConnected { + guard let dict = item as? [String: Any], + let nameKey = dict.keys.first, + let infoAny = dict[nameKey], + let payload = infoAny as? [String: Any] else { + continue + } + + if let address = payload["device_address"] as? String { + if normalizeBluetoothIdentifier(address) != target { continue } + } else { + let candidates = profilerAddressCandidates(from: payload).map(normalizeBluetoothIdentifier) + if !candidates.contains(target) { continue } + } + + if let pid = pidFromPayload(payload) { + if let vid = vidFromPayload(payload) { + return (vendor: vid, product: pid) + } + if devicePIDMap[pid] != nil { + return (vendor: appleVendorID, product: pid) + } + } + } + + return nil + } + + /// Attempts to find VendorID/ProductID for a device using Bluetooth caches. + private func vendorProductIDs(for device: IOBluetoothDevice) -> (vendor: UInt16, product: UInt16)? { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { + return nil + } + + let target = normalizeBluetoothIdentifier(device.addressString ?? "") + guard !target.isEmpty else { return nil } + + let vendorKeys = [ + "VendorID", "vendor_id", "vendorID", + "device_vendorID", "DeviceVendorID", "device_vendor_id", + "device_vendorId", "DeviceVendorId", + "VendorId", "Vendor ID", + "VendorIDSource", "VendorIDSourceLocal", "VendorIDSourceRemote" + ] + let productKeys = [ + "ProductID", "product_id", "productID", + "device_productID", "DeviceProductID", "device_product_id", + "device_productId", "DeviceProductId", + "ProductId", "Product ID", + "ProductIDSource", "ProductIDSourceLocal", "ProductIDSourceRemote" + ] + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + if matchesBluetoothIdentifier(target, key: key, payload: payload) { + let vendor = extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + + let product = extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + + if let product { + if let vendor { + return (vendor: vendor, product: product) + } + if devicePIDMap[product] != nil { + return (vendor: appleVendorID, product: product) + } + } + } + } + + if let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] { + for payload in coreCache.values { + if let addressValue = payload["DeviceAddress"] + ?? payload["Address"] + ?? payload["BD_ADDR"] + ?? payload["device_address"], + let address = normalizeBluetoothIdentifier(from: addressValue), + address == target { + let vendor = extractUInt16(from: payload, keys: vendorKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("vendorid") } + + let product = extractUInt16(from: payload, keys: productKeys) + ?? deepSearchUInt16(in: payload) { $0.lowercased().contains("productid") } + + if let product { + if let vendor { + return (vendor: vendor, product: product) + } + if devicePIDMap[product] != nil { + return (vendor: appleVendorID, product: product) + } + } + } + } + } + + if let fromProfiler = vendorProductIDsFromSystemProfiler(forNormalizedAddress: target) { + return fromProfiler + } + + return nil + } + + /// Attempts to detect AirPods/Beats type using vendor/product IDs. + private func airPodsTypeFromPID(_ device: IOBluetoothDevice) -> BluetoothAudioDeviceType? { + if let ids = vendorProductIDs(for: device) { + return devicePIDMap[ids.product] + } + return nil + } /// Detects the type of audio device based on name and properties private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType { let lowercaseName = name.lowercased() + + if let pidBasedType = airPodsTypeFromPID(device) { + return pidBasedType + } // Check for specific AirPods models if lowercaseName.contains("airpods") { if lowercaseName.contains("max") { return .airpodsMax } else if lowercaseName.contains("pro") { + if lowercaseName.contains("3") || lowercaseName.contains("gen 3") || lowercaseName.contains("gen3") { + return .airpodsPro3 + } return .airpodsPro + } else if lowercaseName.contains("gen 4") + || lowercaseName.contains("gen4") + || lowercaseName.contains("4th") + || lowercaseName.contains("airpods 4") + || lowercaseName.contains("airpods4") { + return .airpodsGen4 + } else if lowercaseName.contains("gen 3") + || lowercaseName.contains("gen3") + || lowercaseName.contains("3rd") + || lowercaseName.contains("third") + || lowercaseName.contains("airpods 3") + || lowercaseName.contains("airpods3") { + return .airpodsGen3 } return .airpods } // Check for other brands if lowercaseName.contains("beats") { - return .beats + if lowercaseName.contains("studio") { + return .beatsstudio + } + if lowercaseName.contains("solo") { + return .beatssolo + } + return .beatssolo } else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") { return .speaker } else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") || @@ -1770,9 +2012,14 @@ extension BluetoothAudioDevice { enum BluetoothAudioDeviceType { case airpods + case airpodsGen3 + case airpodsGen4 case airpodsPro + case airpodsPro3 case airpodsMax case beats + case beatsstudio + case beatssolo case headphones case speaker case generic @@ -1781,12 +2028,22 @@ enum BluetoothAudioDeviceType { switch self { case .airpods: return "airpods" + case .airpodsGen3: + return "airpods.gen3" + case .airpodsGen4: + return "airpods.gen4" case .airpodsPro: - return "airpodspro" + return "airpods.pro" + case .airpodsPro3: + return "airpods.pro" case .airpodsMax: return "airpodsmax" case .beats: return "beats.headphones" + case .beatsstudio: + return "beats.headphones" + case .beatssolo: + return "beats.headphones" case .headphones: return "headphones" case .speaker: @@ -1799,14 +2056,24 @@ enum BluetoothAudioDeviceType { var displayName: String { switch self { case .airpods: return "AirPods" + case .airpodsGen3: return "AirPods (Gen 3)" + case .airpodsGen4: return "AirPods (Gen 4)" case .airpodsPro: return "AirPods Pro" + case .airpodsPro3: return "AirPods Pro 3" case .airpodsMax: return "AirPods Max" case .beats: return "Beats" + case .beatsstudio: return "Beats Studio" + case .beatssolo: return "Beats Solo" case .headphones: return "Headphones" case .speaker: return "Speaker" case .generic: return "Bluetooth Device" } } + + /// Inline HUD only: base filename (no extension) for a looping .mov animation. + var inlineHUDAnimationBaseName: String { + String(describing: self) + } } // MARK: - Notification Name Constants diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 246b05b..2ba4464 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -838,6 +838,7 @@ extension Defaults.Keys { static let useCircularBluetoothBatteryIndicator = Key("useCircularBluetoothBatteryIndicator", default: true) static let showBluetoothBatteryPercentageText = Key("showBluetoothBatteryPercentageText", default: false) static let showBluetoothDeviceNameMarquee = Key("showBluetoothDeviceNameMarquee", default: false) + static let useBluetoothHUD3DIcon = Key("useBluetoothHUD3DIcon", default: true) // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) From f411676d025280a2129f6f1fca172bcc002192e0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 7 Feb 2026 22:26:03 +0530 Subject: [PATCH 2/4] Partial fixes for lock screen calendar --- DynamicIsland/DynamicIslandApp.swift | 2 - DynamicIsland/Localizable.xcstrings | 57 ++ .../LockScreenCalendarEventRow.swift | 79 +++ .../LockScreen/LockScreenWeatherWidget.swift | 667 +++++++++++++++++- .../components/Settings/SettingsView.swift | 427 ++++++++++- DynamicIsland/managers/CalendarManager.swift | 92 +++ .../LockScreenReminderWidgetManager.swift | 12 + ...LockScreenReminderWidgetPanelManager.swift | 18 +- .../LockScreenWeatherPanelManager.swift | 8 +- DynamicIsland/models/Constants.swift | 13 + 10 files changed, 1348 insertions(+), 27 deletions(-) create mode 100644 DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index fd91921..9422ba7 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -636,8 +636,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { let timerWidgetManager = LockScreenTimerWidgetManager.shared timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus) - // Ensure the reminder widget mirrors live activity snapshots as soon as they exist. - _ = LockScreenReminderWidgetManager.shared } private func registerOptionalShortcutHandlers() { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b9a4d48..98d2d64 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1537,6 +1537,12 @@ "comment" : "A time displayed in the timer panel on a lock screen.", "isCommentAutoGenerated" : true, "shouldTranslate" : false + }, + "1 hour" : { + + }, + "1 min" : { + }, "1x" : { "extractionState" : "stale", @@ -1548,6 +1554,9 @@ } } } + }, + "2 hours" : { + }, "3 items" : { "localizations" : { @@ -1596,6 +1605,9 @@ } } } + }, + "5 mins" : { + }, "7 items" : { "localizations" : { @@ -1644,6 +1656,9 @@ } } } + }, + "10 mins" : { + }, "15 colors" : { "localizations" : { @@ -1660,6 +1675,9 @@ } } } + }, + "15 mins" : { + }, "20" : { "extractionState" : "stale", @@ -1698,6 +1716,12 @@ } } } + }, + "30 mins" : { + + }, + "45 mins" : { + }, "100" : { "comment" : "The text displayed below a 100% battery level in the lock screen.", @@ -3008,6 +3032,9 @@ } } } + }, + "Alignment" : { + }, "All" : { "localizations" : { @@ -5158,6 +5185,9 @@ } } } + }, + "Calendar Widget" : { + }, "Calendars" : { "localizations" : { @@ -7046,6 +7076,9 @@ } } } + }, + "Controls the lock screen reminder chip and its positioning." : { + }, "Controls the optional timer widget that floats above the media panel, including its classic, frosted, or liquid glass surface independent of the global material setting." : { "localizations" : { @@ -8268,6 +8301,9 @@ } } } + }, + "Deselect All" : { + }, "Detection Status" : { "comment" : "The header for a section that displays whether the screen recording detection feature is currently active or inactive.", @@ -8633,6 +8669,9 @@ } } } + }, + "Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter." : { + }, "Do Not Disturb" : { "localizations" : { @@ -18968,6 +19007,9 @@ } } } + }, + "Reminder Widget" : { + }, "Reminders" : { "localizations" : { @@ -20830,6 +20872,15 @@ } } } + }, + "Show event after it starts" : { + + }, + "Show events from all calendars" : { + + }, + "Show events within the next" : { + }, "Show floating pause/stop controls" : { "localizations" : { @@ -23345,6 +23396,9 @@ } } } + }, + "Turn off 'Show event for entire duration' to use the post-start duration option." : { + }, "Turn on customizable controls to rearrange media buttons." : { "comment" : "A description of the feature that allows users to rearrange the order of media buttons.", @@ -24389,6 +24443,9 @@ } } } + }, + "Vertical offset" : { + }, "Vertical:" : { "extractionState" : "stale", diff --git a/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift b/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift new file mode 100644 index 0000000..3f45be2 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenCalendarEventRow.swift @@ -0,0 +1,79 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI + +struct LockScreenCalendarEventRow: View { + let line: String + let isVisible: Bool + let renderToken: Int + let font: Font + let alignment: Alignment + let iconName: String + let iconRenderingMode: SymbolRenderingMode + let iconColors: [Color] + + var body: some View { + HStack(alignment: .center, spacing: 6) { + calendarIcon + .frame(width: 26, height: 26) + + Text(line) + .font(font) + .lineLimit(1) + .truncationMode(.tail) + .minimumScaleFactor(0.85) + .allowsTightening(true) + .layoutPriority(10) + } + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.horizontal, 2) + .id(renderToken) + .opacity(isVisible ? 1 : 0) + .accessibilityHidden(!isVisible) + .allowsHitTesting(false) + } + + @ViewBuilder + private var calendarIcon: some View { + let baseImage = Image(systemName: iconName) + .font(.system(size: 20, weight: .semibold)) + .id(iconRenderingModeIdentifier) + + if iconRenderingModeIdentifier.lowercased().contains("palette"), iconColors.count >= 2 { + baseImage + .symbolRenderingMode(.palette) + .foregroundStyle(iconColors[0], iconColors[1]) + } else if iconColors.count >= 2 { + baseImage + .symbolRenderingMode(iconRenderingMode) + .foregroundStyle(iconColors[0], iconColors[1]) + } else if iconColors.count == 1 { + baseImage + .symbolRenderingMode(iconRenderingMode) + .foregroundStyle(iconColors[0]) + } else { + baseImage + .symbolRenderingMode(iconRenderingMode) + } + } + + private var iconRenderingModeIdentifier: String { + String(describing: iconRenderingMode) + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index c335394..a0726b2 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -18,14 +18,40 @@ import SwiftUI import Defaults +import AppKit +import CoreGraphics struct LockScreenWeatherWidget: View { let snapshot: LockScreenWeatherSnapshot @ObservedObject private var focusManager = DoNotDisturbManager.shared + @ObservedObject private var calendarManager = CalendarManager.shared @Default(.enableDoNotDisturbDetection) private var focusDetectionEnabled @Default(.showDoNotDisturbIndicator) private var focusIndicatorEnabled @Default(.enableLockScreenFocusWidget) private var lockScreenFocusWidgetEnabled + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenWeatherWidgetRowOrder) private var lockScreenWeatherWidgetRowOrder + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled + @Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle + @Default(.reminderSneakPeekDuration) private var reminderSneakPeekDuration + + @State private var currentTime = Date() + @State private var calendarRowVisible: Bool = false + @State private var lastCalendarLine: String = "" + @State private var calendarRowRenderToken: Int = 0 + @State private var widgetWidthRemeasureToken: Int = 0 + @State private var lastBadgeLogKey: String = "" + private var currentCalendarEventID: String { + nextCalendarEvent?.id ?? "no_event" + } private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded) @@ -38,9 +64,407 @@ struct LockScreenWeatherWidget: View { return formatter }() + // MARK: - Refresh + /// Refreshes every 15 seconds (general refresh), and optionally also ticks exactly on minute boundaries + /// *only when the screen is locked* and the calendar widget is enabled. + private final class LockAwareTicker: ObservableObject { + @Published var now: Date = Date() + + private var refreshTimer: Timer? + private var minuteTimer: Timer? + + private var lockObserver: NSObjectProtocol? + private var unlockObserver: NSObjectProtocol? + + private var isLocked: Bool = false + private var minuteAlignedEnabled: Bool = false + + func start() { + stop() + installLockObservers() + refreshLockStateFromSystem() + startGeneralRefresh() + fireNow() + updateMinuteAlignedTimer() + } + + func stop() { + refreshTimer?.invalidate() + refreshTimer = nil + + minuteTimer?.invalidate() + minuteTimer = nil + + if let lockObserver { DistributedNotificationCenter.default().removeObserver(lockObserver) } + if let unlockObserver { DistributedNotificationCenter.default().removeObserver(unlockObserver) } + lockObserver = nil + unlockObserver = nil + } + + func fireNow() { + now = Date() + } + + /// Enable/disable the minute-aligned ticker (minute boundary ticks). + /// It will only actually run when the screen is locked. + func setMinuteAlignedEnabled(_ enabled: Bool) { + minuteAlignedEnabled = enabled + updateMinuteAlignedTimer() + } + + private func startGeneralRefresh() { + refreshTimer = Timer.scheduledTimer(withTimeInterval: 15.0, repeats: true) { [weak self] _ in + self?.fireNow() + } + refreshTimer?.tolerance = 0 + if let refreshTimer { + RunLoop.main.add(refreshTimer, forMode: .common) + } + } + + private func refreshLockStateFromSystem() { + if let dict = CGSessionCopyCurrentDictionary() as? [String: Any] { + if let locked = dict["CGSSessionScreenIsLocked"] as? Bool { + isLocked = locked + } + } + } + + private func installLockObservers() { + lockObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsLocked"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.isLocked = true + self.fireNow() + self.updateMinuteAlignedTimer() + } + + unlockObserver = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.apple.screenIsUnlocked"), + object: nil, + queue: .main + ) { [weak self] _ in + guard let self else { return } + self.isLocked = false + self.fireNow() + self.updateMinuteAlignedTimer() + } + } + + private func updateMinuteAlignedTimer() { + let shouldRun = isLocked && minuteAlignedEnabled + if !shouldRun { + minuteTimer?.invalidate() + minuteTimer = nil + return + } + + if minuteTimer != nil { + return + } + + scheduleNextBoundaryAndRepeat() + } + + private func scheduleNextBoundaryAndRepeat() { + let current = Date() + let calendar = Calendar.current + + var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: current) + components.second = 0 + let thisMinute = calendar.date(from: components) ?? current + let nextMinute = calendar.date(byAdding: .minute, value: 1, to: thisMinute) ?? current.addingTimeInterval(60) + + let initialDelay = max(0.0, nextMinute.timeIntervalSince(current)) + minuteTimer = Timer.scheduledTimer(withTimeInterval: initialDelay, repeats: false) { [weak self] _ in + guard let self else { return } + self.fireNow() + + self.minuteTimer?.invalidate() + self.minuteTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + self?.fireNow() + } + self.minuteTimer?.tolerance = 0 + if let minuteTimer { + RunLoop.main.add(minuteTimer, forMode: .common) + } + } + minuteTimer?.tolerance = 0 + if let minuteTimer { + RunLoop.main.add(minuteTimer, forMode: .common) + } + } + + deinit { + stop() + } + } + + @StateObject private var minuteTicker = LockAwareTicker() + + private enum CalendarLookaheadWindow: String { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var minutes: Int? { + switch self { + case .mins15: return 15 + case .mins30: return 30 + case .hour1: return 60 + case .hours3: return 180 + case .hours6: return 360 + case .hours12: return 720 + case .restOfDay, .allTime: return nil + } + } + } + + private enum CalendarAfterStartWindow: String { + case min1 = "1m" + case mins5 = "5m" + case mins10 = "10m" + case mins15 = "15m" + case mins30 = "30m" + case mins45 = "45m" + case hour1 = "1h" + case hours2 = "2h" + + var minutes: Int { + switch self { + case .min1: return 1 + case .mins5: return 5 + case .mins10: return 10 + case .mins15: return 15 + case .mins30: return 30 + case .mins45: return 45 + case .hour1: return 60 + case .hours2: return 120 + } + } + } + + private var selectedLookaheadWindow: CalendarLookaheadWindow { + let raw = lockScreenCalendarEventLookaheadWindow + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch raw { + case "all time", "all_time", "alltime": + return .allTime + case "rest of the day", "rest_of_day", "restofday": + return .restOfDay + case "15m", "15 mins", "15min", "15mins": + return .mins15 + case "30m", "30 mins", "30min", "30mins": + return .mins30 + case "1h", "1 hour", "1hour": + return .hour1 + case "3h", "3 hours", "3hours": + return .hours3 + case "6h", "6 hours", "6hours": + return .hours6 + case "12h", "12 hours", "12hours": + return .hours12 + default: + return CalendarLookaheadWindow(rawValue: lockScreenCalendarEventLookaheadWindow) ?? .hours3 + } + } + + private var selectedAfterStartWindow: CalendarAfterStartWindow { + let raw = lockScreenShowCalendarEventAfterStartWindow + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + switch raw { + case "1m", "1 min", "1 minute", "1min": + return .min1 + case "5m", "5 min", "5 mins", "5 minutes", "5min": + return .mins5 + case "10m", "10 min", "10 mins", "10 minutes", "10min": + return .mins10 + case "15m", "15 min", "15 mins", "15 minutes", "15min": + return .mins15 + case "30m", "30 min", "30 mins", "30 minutes", "30min": + return .mins30 + case "45m", "45 min", "45 mins", "45 minutes", "45min": + return .mins45 + case "1h", "1 hr", "1 hour", "1hour": + return .hour1 + case "2h", "2 hrs", "2 hours", "2hours", "2hour": + return .hours2 + default: + return CalendarAfterStartWindow(rawValue: lockScreenShowCalendarEventAfterStartWindow) ?? .mins5 + } + } + + private func lookaheadEndDate(from now: Date) -> Date? { + switch selectedLookaheadWindow { + case .allTime: + return nil + case .restOfDay: + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + return calendar.date(byAdding: .day, value: 1, to: startOfToday) + default: + if let minutes = selectedLookaheadWindow.minutes { + return Calendar.current.date(byAdding: .minute, value: minutes, to: now) + } + return nil + } + } + + private func isEventEligibleForLookahead(_ event: EventModel, now: Date) -> Bool { + if event.isAllDay { + return false + } + if lockScreenShowCalendarEventEntireDuration, event.start <= now, event.end >= now { + return true + } + guard event.start > now else { return false } + guard let endDate = lookaheadEndDate(from: now) else { + return true + } + return event.start <= endDate + } + + private var lockScreenAllowedCalendarIDs: Set? { + guard lockScreenCalendarSelectionMode != "all" else { return nil } + return lockScreenSelectedCalendarIDs + } + + private func passesLockScreenCalendarFilter(_ event: EventModel) -> Bool { + guard let allowed = lockScreenAllowedCalendarIDs else { return true } + guard !allowed.isEmpty else { return false } + return allowed.contains(event.calendar.id) + } + + private var nextCalendarEvent: EventModel? { + let now = currentTime + + if lockScreenShowCalendarEventEntireDuration { + let candidates = calendarManager.lockScreenEvents + .filter { $0.end >= now } + .filter(passesLockScreenCalendarFilter) + .filter { isEventEligibleForLookahead($0, now: now) } + .sorted { $0.start < $1.start } + return candidates.first + } + + let graceMinutes = lockScreenShowCalendarEventAfterStartEnabled + ? selectedAfterStartWindow.minutes + : 0 + + if graceMinutes > 0 { + let graceEndForEvent: (EventModel) -> Date = { event in + Calendar.current.date(byAdding: .minute, value: graceMinutes, to: event.start) ?? event.start + } + + let activeCandidates = calendarManager.lockScreenEvents + .filter { !$0.isAllDay } + .filter(passesLockScreenCalendarFilter) + .filter { $0.start <= now && $0.end >= now } + .filter { now <= graceEndForEvent($0) } + .sorted { $0.start < $1.start } + + if let active = activeCandidates.first { + return active + } + } + + let upcoming = calendarManager.lockScreenEvents + .filter { $0.start > now } + .filter(passesLockScreenCalendarFilter) + .filter { isEventEligibleForLookahead($0, now: now) } + .sorted { $0.start < $1.start } + + return upcoming.first + } + + private var shouldShowCalendarRow: Bool { + lockScreenShowCalendarEvent && nextCalendarEvent != nil + } + + private enum RowKind { + case weather + case focus + case calendar + } + + private var orderedRowKinds: [RowKind] { + switch lockScreenWeatherWidgetRowOrder { + case "weather_focus_calendar": + return [.weather, .focus, .calendar] + case "weather_calendar_focus": + return [.weather, .calendar, .focus] + case "focus_weather_calendar": + return [.focus, .weather, .calendar] + case "focus_calendar_weather": + return [.focus, .calendar, .weather] + case "calendar_weather_focus": + return [.calendar, .weather, .focus] + case "calendar_focus_weather": + return [.calendar, .focus, .weather] + default: + return [.weather, .calendar, .focus] + } + } + + private var enabledRowKinds: [RowKind] { + var enabled: Set = [.weather] + if lockScreenShowCalendarEvent { enabled.insert(.calendar) } + if shouldShowFocusWidget { enabled.insert(.focus) } + return orderedRowKinds.filter { enabled.contains($0) } + } + + private var fullRowOrder: [RowKind] { + orderedRowKinds + } + + private func isRowActive(_ kind: RowKind) -> Bool { + switch kind { + case .weather: + return true + case .focus: + return shouldShowFocusWidget + case .calendar: + return nextCalendarEvent != nil + } + } + + private var shouldCollapseGap: Bool { + fullRowOrder.count == 3 + } + + private var collapseDistance: CGFloat { + 26 + focusWidgetSpacing + } + + private var collapseOffsetForBottomRow: CGFloat { + guard shouldCollapseGap else { return 0 } + let middleKind = fullRowOrder[1] + let bottomKind = fullRowOrder[2] + + let middleActive = isRowActive(middleKind) + let bottomActive = isRowActive(bottomKind) + + return (!middleActive && bottomActive) ? -collapseDistance : 0 + } + private var isInline: Bool { snapshot.widgetStyle == .inline } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackSpacing: CGFloat { isInline ? 14 : 22 } + private var mainRowAlignment: Alignment { isInline ? .leading : .center } + private var secondaryRowAlignment: Alignment { isInline ? .leading : .center } + private var focusRowAlignment: Alignment { .center } private var gaugeDiameter: CGFloat { 64 } private var topPadding: CGFloat { isInline ? 6 : 22 } private var bottomPadding: CGFloat { isInline ? 6 : 10 } @@ -51,11 +475,13 @@ struct LockScreenWeatherWidget: View { var body: some View { VStack(alignment: .leading, spacing: focusWidgetSpacing) { - if shouldShowFocusWidget { - focusWidget + ForEach(Array(enabledRowKinds.enumerated()), id: \.offset) { index, kind in + rowView(for: kind) + .offset(y: offsetForRow(index: index)) + .animation(.easeInOut(duration: 0.3), value: collapseOffsetForBottomRow) } - mainWidgetRow } + .id(widgetWidthRemeasureToken) .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) @@ -63,10 +489,96 @@ struct LockScreenWeatherWidget: View { .padding(.bottom, bottomPadding) .background(Color.clear) .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) + .onReceive(minuteTicker.$now) { currentTime = $0 } + .onAppear { + minuteTicker.start() + minuteTicker.setMinuteAlignedEnabled(lockScreenShowCalendarEvent) + currentTime = Date() + calendarRowVisible = shouldShowCalendarRow + + Task { + await calendarManager.checkCalendarAuthorization() + await calendarManager.checkReminderAuthorization() + await calendarManager.updateLockScreenEvents(force: true) + } + + if let event = nextCalendarEvent { + lastCalendarLine = eventLineText(for: event) + logBadgeColor(event: event, reason: "on-appear") + } + } + .onChange(of: lockScreenShowCalendarEvent) { _, enabled in + minuteTicker.setMinuteAlignedEnabled(enabled) + } + .onChange(of: lockScreenCalendarEventLookaheadWindow) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onChange(of: lockScreenCalendarSelectionMode) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onChange(of: lockScreenSelectedCalendarIDs) { _, _ in + Task { await calendarManager.updateLockScreenEvents(force: true) } + } + .onDisappear { minuteTicker.stop() } + .onChange(of: currentCalendarEventID) { _, _ in + calendarRowRenderToken &+= 1 + if nextCalendarEvent != nil { + widgetWidthRemeasureToken &+= 1 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { + calendarRowRenderToken &+= 1 + if nextCalendarEvent != nil { + widgetWidthRemeasureToken &+= 1 + } + } + } + .onChange(of: currentCalendarEventID) { _, _ in + if let event = nextCalendarEvent { + let newLine = eventLineText(for: event) + if !newLine.isEmpty { + lastCalendarLine = newLine + } + logBadgeColor(event: event, reason: "event-change") + } + } + .onChange(of: lockScreenReminderChipStyle) { _, _ in + if let event = nextCalendarEvent { + logBadgeColor(event: event, reason: "chip-style-change") + } + } + .onChange(of: shouldShowCalendarRow) { _, newValue in + withAnimation(.easeInOut(duration: 0.25)) { + calendarRowVisible = newValue + } + } .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) } + private func offsetForRow(index: Int) -> CGFloat { + if enabledRowKinds.count != 3 { return 0 } + if index == 2 { + return collapseOffsetForBottomRow + } + return 0 + } + + @ViewBuilder + private func rowView(for kind: RowKind) -> some View { + switch kind { + case .weather: + mainWidgetRow + case .calendar: + nextEventRow + case .focus: + focusWidget + .opacity(shouldShowFocusWidget ? 1 : 0) + .accessibilityHidden(!shouldShowFocusWidget) + .allowsHitTesting(false) + } + } + private var mainWidgetRow: some View { HStack(alignment: stackAlignment, spacing: stackSpacing) { if let charging = snapshot.charging { @@ -95,6 +607,143 @@ struct LockScreenWeatherWidget: View { locationSegment } } + .frame(maxWidth: .infinity, alignment: mainRowAlignment) + } + + private var nextEventRow: some View { + let event = nextCalendarEvent + let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine + let iconConfig = calendarRowIconConfig(for: event) + + return LockScreenCalendarEventRow( + line: line, + isVisible: calendarRowVisible, + renderToken: calendarRowRenderToken, + font: inlinePrimaryFont, + alignment: secondaryRowAlignment, + iconName: iconConfig.name, + iconRenderingMode: iconConfig.renderingMode, + iconColors: iconConfig.colors + ) + } + + private func calendarRowIconConfig(for event: EventModel?) -> (name: String, renderingMode: SymbolRenderingMode, colors: [Color]) { + guard let event else { + return (name: "calendar.badge", renderingMode: .hierarchical, colors: [Color.white.opacity(0.9)]) + } + + let badgeColor = reminderChipColor(for: event) + logBadgeColor(event: event, reason: "icon-config") + + if event.type.isReminder || event.calendar.isReminder { + if isReminderCritical(event) { + return (name: "exclamationmark.triangle.fill", renderingMode: .multicolor, colors: []) + } + return ( + name: "calendar.badge", + renderingMode: .palette, + colors: [badgeColor, Color.white.opacity(0.6)] + ) + } + + let eventBadgeColor = eventAccentColor(for: event) + return ( + name: "calendar.badge", + renderingMode: .palette, + colors: [eventBadgeColor, Color.white.opacity(0.6)] + ) + } + + private func logBadgeColor(event: EventModel, reason: String) { + let key = "\(event.id)|\(lockScreenReminderChipStyle.rawValue)|\(isReminderCritical(event))|\(reason)" + lastBadgeLogKey = key + + let color = reminderChipColor(for: event) + let nsColor = NSColor(color).usingColorSpace(.deviceRGB) + if let nsColor { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + nsColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + let hex = String(format: "#%02X%02X%02X", Int(red * 255), Int(green * 255), Int(blue * 255)) + let alphaText = String(format: "%.2f", alpha) + NSLog("LockScreenCalendarRow badge color: %@ alpha=%@ style=%@ critical=%@ reason=%@", hex, alphaText, lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason) + } else { + NSLog("LockScreenCalendarRow badge color: style=%@ critical=%@ reason=%@", lockScreenReminderChipStyle.rawValue, String(isReminderCritical(event)), reason) + } + } + + private func reminderChipColor(for event: EventModel) -> Color { + if isReminderCritical(event) { return .red } + switch lockScreenReminderChipStyle { + case .monochrome: + return Color.white.opacity(0.85) + case .eventColor: + let color = reminderAccentColor(for: event) + return color.ensureMinimumBrightness(factor: 0.7) + } + } + + private func reminderAccentColor(for event: EventModel) -> Color { + Color(event.calendar.color) + } + + private func eventAccentColor(for event: EventModel) -> Color { + Color(event.calendar.color).ensureMinimumBrightness(factor: 0.7) + } + + private func isReminderCritical(_ event: EventModel) -> Bool { + guard event.type.isReminder || event.calendar.isReminder else { return false } + let window = TimeInterval(reminderSneakPeekDuration) + guard window > 0 else { return false } + let remaining = event.start.timeIntervalSince(currentTime) + return remaining > 0 && remaining <= window + } + + private func eventLineText(for event: EventModel?) -> String { + guard let event else { return "" } + let now = currentTime + + if event.isAllDay { + return "All day • \(event.title)" + } + + let timeString = event.start.formatted(date: .omitted, time: .shortened) + + if event.start <= now && event.end >= now { + let leading = lockScreenShowCalendarStartTimeAfterBegins ? "\(timeString) • " : "" + let suffix: String + if lockScreenShowCalendarTimeRemaining { + let secondsLeft = event.end.timeIntervalSince(now) + let minutesLeft = max(0, Int(ceil(secondsLeft / 60.0))) + suffix = " • \(countdownText(fromMinutes: minutesLeft)) left" + } else { + suffix = "" + } + return "\(leading)\(event.title)\(suffix)" + } + + guard lockScreenShowCalendarCountdown else { + return "\(timeString) • \(event.title)" + } + + let secondsUntilStart = event.start.timeIntervalSince(now) + let totalMinutes = max(0, Int(ceil(secondsUntilStart / 60.0))) + return "\(timeString) • \(event.title) • in \(countdownText(fromMinutes: totalMinutes))" + } + + private func countdownText(fromMinutes minutes: Int) -> String { + if minutes < 60 { + return "\(minutes)m" + } + + let hours = minutes / 60 + let mins = minutes % 60 + if mins == 0 { + return "\(hours)h" + } + return "\(hours)h \(mins)m" } private var sunriseTimeText: String? { @@ -399,7 +1048,7 @@ struct LockScreenWeatherWidget: View { } private var focusWidgetSpacing: CGFloat { - guard shouldShowFocusWidget else { return 0 } + guard lockScreenShowCalendarEvent || lockScreenFocusWidgetEnabled else { return 0 } return isInline ? 14 : 20 } @@ -447,8 +1096,7 @@ struct LockScreenWeatherWidget: View { .lineLimit(1) .minimumScaleFactor(0.85) } - .frame(maxWidth: .infinity, alignment: .center) - .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: focusRowAlignment) .padding(.horizontal, 2) .accessibilityLabel("Focus active: \(focusDisplayName)") } @@ -670,6 +1318,13 @@ struct LockScreenWeatherWidget: View { components.append("Focus active: \(focusDisplayName)") } + if lockScreenShowCalendarEvent, let event = nextCalendarEvent { + let line = eventLineText(for: event) + if !line.isEmpty { + components.append("Calendar: \(line)") + } + } + return components.joined(separator: ". ") } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 19defb3..bb30b13 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -663,6 +663,14 @@ struct SettingsView: View { SettingsSearchEntry(tab: .calendar, title: "Enable reminder live activity", keywords: ["reminder", "live activity"], highlightID: SettingsTab.calendar.highlightID(for: "Enable reminder live activity")), SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Countdown style")), SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.calendar.highlightID(for: "Show lock screen reminder")), + SettingsSearchEntry(tab: .calendar, title: "Show next calendar event", keywords: ["calendar widget", "lock screen", "next event"], highlightID: SettingsTab.calendar.highlightID(for: "Show next calendar event")), + SettingsSearchEntry(tab: .calendar, title: "Show events within the next", keywords: ["calendar widget", "lookahead"], highlightID: SettingsTab.calendar.highlightID(for: "Show events within the next")), + SettingsSearchEntry(tab: .calendar, title: "Show events from all calendars", keywords: ["calendar widget", "selection"], highlightID: SettingsTab.calendar.highlightID(for: "Show events from all calendars")), + SettingsSearchEntry(tab: .calendar, title: "Show countdown", keywords: ["calendar widget", "countdown"], highlightID: SettingsTab.calendar.highlightID(for: "Show countdown")), + SettingsSearchEntry(tab: .calendar, title: "Show event for entire duration", keywords: ["calendar widget", "duration"], highlightID: SettingsTab.calendar.highlightID(for: "Show event for entire duration")), + SettingsSearchEntry(tab: .calendar, title: "Hide active event and show next upcoming event", keywords: ["calendar widget", "after start"], highlightID: SettingsTab.calendar.highlightID(for: "Hide active event and show next upcoming event")), + SettingsSearchEntry(tab: .calendar, title: "Show time remaining", keywords: ["calendar widget", "remaining"], highlightID: SettingsTab.calendar.highlightID(for: "Show time remaining")), + SettingsSearchEntry(tab: .calendar, title: "Show start time after event begins", keywords: ["calendar widget", "start time"], highlightID: SettingsTab.calendar.highlightID(for: "Show start time after event begins")), SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.calendar.highlightID(for: "Chip color")), SettingsSearchEntry(tab: .calendar, title: "Hide all-day events", keywords: ["calendar", "all-day"], highlightID: SettingsTab.calendar.highlightID(for: "Hide all-day events")), SettingsSearchEntry(tab: .calendar, title: "Hide completed reminders", keywords: ["reminder", "completed"], highlightID: SettingsTab.calendar.highlightID(for: "Hide completed reminders")), @@ -717,6 +725,10 @@ struct SettingsView: View { SettingsSearchEntry(tab: .lockScreen, title: "Show AQI widget", keywords: ["air quality", "aqi"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show AQI widget")), SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"], highlightID: SettingsTab.lockScreen.highlightID(for: "Air quality scale")), SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"], highlightID: SettingsTab.lockScreen.highlightID(for: "Use colored gauges")), + SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"], highlightID: SettingsTab.lockScreen.highlightID(for: "Show lock screen reminder")), + SettingsSearchEntry(tab: .lockScreen, title: "Chip color", keywords: ["reminder chip", "color"], highlightID: SettingsTab.lockScreen.highlightID(for: "Chip color")), + SettingsSearchEntry(tab: .lockScreen, title: "Reminder alignment", keywords: ["reminder", "alignment", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder alignment")), + SettingsSearchEntry(tab: .lockScreen, title: "Reminder vertical offset", keywords: ["reminder", "offset", "position"], highlightID: SettingsTab.lockScreen.highlightID(for: "Reminder vertical offset")), // Extensions SettingsSearchEntry(tab: .extensions, title: "Enable third-party extensions", keywords: ["extensions", "authorization", "third party"], highlightID: SettingsTab.extensions.highlightID(for: "Enable third-party extensions")), @@ -1161,7 +1173,7 @@ struct Downloads: View { } var body: some View { - Form { + SwiftUI.Form { Section { Defaults.Toggle("Enable download detection", key: .enableDownloadListener) .settingsHighlight(id: highlightID("Enable download detection")) @@ -2226,11 +2238,47 @@ struct CalendarSettings: View { @Default(.hideCompletedReminders) var hideCompletedReminders @Default(.showFullEventTitles) var showFullEventTitles @Default(.autoScrollToNextEvent) var autoScrollToNextEvent + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled private func highlightID(_ title: String) -> String { SettingsTab.calendar.highlightID(for: title) } + private enum CalendarLookaheadOption: String, CaseIterable, Identifiable { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var id: String { rawValue } + + var title: String { + switch self { + case .mins15: return "15 mins" + case .mins30: return "30 mins" + case .hour1: return "1 hour" + case .hours3: return "3 hours" + case .hours6: return "6 hours" + case .hours12: return "12 hours" + case .restOfDay: return "Rest of the day" + case .allTime: return "All time" + } + } + } + var body: some View { Form { if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess { @@ -2256,7 +2304,7 @@ struct CalendarSettings: View { } } else { // Permissions status - Section { + Section(header: Text("Permissions")) { HStack { Text("Calendars") Spacer() @@ -2269,8 +2317,6 @@ struct CalendarSettings: View { Text(statusText(for: calendarManager.reminderAuthorizationStatus)) .foregroundColor(color(for: calendarManager.reminderAuthorizationStatus)) } - } header: { - Text("Permissions") } Defaults.Toggle("Show calendar", key: .showCalendar) @@ -2353,21 +2399,147 @@ struct CalendarSettings: View { .settingsHighlight(id: highlightID("Chip color")) } - Section(header: Text("Select Calendars")) { - List { - ForEach(calendarManager.allCalendars, id: \.id) { calendar in - Toggle(isOn: Binding( - get: { calendarManager.getCalendarSelected(calendar) }, - set: { isSelected in - Task { - await calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + Section( + header: Text("Calendar Widget"), + footer: Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.") + .foregroundStyle(.secondary) + .font(.caption) + ) { + Defaults.Toggle("Show next calendar event", key: .lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show next calendar event")) + + LabeledContent("Show events within the next") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenCalendarEventLookaheadWindow) { + ForEach(CalendarLookaheadOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events within the next")) + + Toggle("Show events from all calendars", isOn: Binding( + get: { lockScreenCalendarSelectionMode == "all" }, + set: { useAll in + if useAll { + lockScreenCalendarSelectionMode = "all" + } else { + lockScreenCalendarSelectionMode = "selected" + lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id }) + } + } + )) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events from all calendars")) + + if lockScreenCalendarSelectionMode != "all" { + HStack { + Spacer() + Button("Deselect All") { + lockScreenSelectedCalendarIDs = [] + } + .buttonStyle(.link) + } + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 8) { + ForEach(calendarManager.eventCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { lockScreenSelectedCalendarIDs.contains(calendar.id) }, + set: { isOn in + if isOn { + lockScreenSelectedCalendarIDs.insert(calendar.id) + } else { + lockScreenSelectedCalendarIDs.remove(calendar.id) + } + } + )) { + HStack(spacing: 8) { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + Text(calendar.title) } } - )) { - Text(calendar.title) } - .disabled(!showCalendar) } + .padding(.top, 4) + .padding(.leading, 2) + .disabled(!lockScreenShowCalendarEvent) + } + + Defaults.Toggle("Show countdown", key: .lockScreenShowCalendarCountdown) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show countdown")) + + Defaults.Toggle("Show event for entire duration", key: .lockScreenShowCalendarEventEntireDuration) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show event for entire duration")) + .onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in + if newValue { + Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false + } + } + + Defaults.Toggle( + "Hide active event and show next upcoming event", + key: .lockScreenShowCalendarEventAfterStartEnabled + ) + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) + .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) + + LabeledContent("Show event after it starts") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) { + Text("1 min").tag("1m") + Text("5 mins").tag("5m") + Text("10 mins").tag("10m") + Text("15 mins").tag("15m") + Text("30 mins").tag("30m") + Text("45 mins").tag("45m") + Text("1 hour").tag("1h") + Text("2 hours").tag("2h") + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled) + + Text("Turn off 'Show event for entire duration' to use the post-start duration option.") + .font(.caption) + .foregroundStyle(.secondary) + + Defaults.Toggle("Show time remaining", key: .lockScreenShowCalendarTimeRemaining) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show time remaining")) + + Defaults.Toggle("Show start time after event begins", key: .lockScreenShowCalendarStartTimeAfterBegins) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show start time after event begins")) + } + + Section(header: Text("Select Calendars")) { + ForEach(calendarManager.allCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + Task { + await calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + } + } + )) { + Text(calendar.title) + } + .disabled(!showCalendar) } } } @@ -3480,6 +3652,7 @@ struct Appearance: View { } struct LockScreenSettings: View { + @ObservedObject private var calendarManager = CalendarManager.shared @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenMusicLiquidGlassVariant) private var lockScreenMusicLiquidGlassVariant @@ -3499,7 +3672,21 @@ struct LockScreenSettings: View { @Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI @Default(.lockScreenWeatherShowsSunrise) private var lockScreenWeatherShowsSunrise @Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale + @Default(.enableLockScreenReminderWidget) private var enableLockScreenReminderWidget + @Default(.lockScreenReminderChipStyle) private var lockScreenReminderChipStyle + @Default(.lockScreenReminderWidgetHorizontalAlignment) private var lockScreenReminderWidgetHorizontalAlignment + @Default(.lockScreenReminderWidgetVerticalOffset) private var lockScreenReminderWidgetVerticalOffset @Default(.showStandardMediaControls) private var showStandardMediaControls + @Default(.lockScreenShowCalendarCountdown) private var lockScreenShowCalendarCountdown + @Default(.lockScreenShowCalendarEvent) private var lockScreenShowCalendarEvent + @Default(.lockScreenShowCalendarEventEntireDuration) private var lockScreenShowCalendarEventEntireDuration + @Default(.lockScreenShowCalendarEventAfterStartWindow) private var lockScreenShowCalendarEventAfterStartWindow + @Default(.lockScreenShowCalendarTimeRemaining) private var lockScreenShowCalendarTimeRemaining + @Default(.lockScreenShowCalendarStartTimeAfterBegins) private var lockScreenShowCalendarStartTimeAfterBegins + @Default(.lockScreenCalendarEventLookaheadWindow) private var lockScreenCalendarEventLookaheadWindow + @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode + @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs + @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled private func highlightID(_ title: String) -> String { SettingsTab.lockScreen.highlightID(for: title) @@ -3509,6 +3696,48 @@ struct LockScreenSettings: View { Double(LiquidGlassVariant.supportedRange.lowerBound)...Double(LiquidGlassVariant.supportedRange.upperBound) } + private enum CalendarLookaheadOption: String, CaseIterable, Identifiable { + case mins15 = "15m" + case mins30 = "30m" + case hour1 = "1h" + case hours3 = "3h" + case hours6 = "6h" + case hours12 = "12h" + case restOfDay = "rest_of_day" + case allTime = "all_time" + + var id: String { rawValue } + + var title: String { + switch self { + case .mins15: return "15 mins" + case .mins30: return "30 mins" + case .hour1: return "1 hour" + case .hours3: return "3 hours" + case .hours6: return "6 hours" + case .hours12: return "12 hours" + case .restOfDay: return "Rest of the day" + case .allTime: return "All time" + } + } + } + + private enum ReminderAlignmentOption: String, CaseIterable, Identifiable { + case leading + case center + case trailing + + var id: String { rawValue } + + var title: String { + switch self { + case .leading: return "Left" + case .center: return "Center" + case .trailing: return "Right" + } + } + } + private var musicVariantBinding: Binding { Binding( get: { Double(lockScreenMusicLiquidGlassVariant.rawValue) }, @@ -3763,6 +3992,47 @@ struct LockScreenSettings: View { } footer: { Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.") } + + Section { + Defaults.Toggle("Show lock screen reminder", key: .enableLockScreenReminderWidget) + .settingsHighlight(id: highlightID("Show lock screen reminder")) + + Picker("Chip color", selection: $lockScreenReminderChipStyle) { + ForEach(LockScreenReminderChipStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenReminderWidget) + .settingsHighlight(id: highlightID("Chip color")) + + Picker("Alignment", selection: $lockScreenReminderWidgetHorizontalAlignment) { + ForEach(ReminderAlignmentOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .pickerStyle(.segmented) + .disabled(!enableLockScreenReminderWidget) + .settingsHighlight(id: highlightID("Reminder alignment")) + + HStack { + Text("Vertical offset") + Slider( + value: $lockScreenReminderWidgetVerticalOffset, + in: -160...160, + step: 2 + ) + .disabled(!enableLockScreenReminderWidget) + Text("\(Int(lockScreenReminderWidgetVerticalOffset)) px") + .foregroundStyle(.secondary) + .frame(width: 70, alignment: .trailing) + } + .settingsHighlight(id: highlightID("Reminder vertical offset")) + } header: { + Text("Reminder Widget") + } footer: { + Text("Controls the lock screen reminder chip and its positioning.") + } if BatteryActivityManager.shared.hasBattery() { Section { @@ -3800,6 +4070,133 @@ struct LockScreenSettings: View { Text("Displays the current Focus state above the weather capsule whenever Focus detection is enabled.") } + Section { + Defaults.Toggle("Show next calendar event", key: .lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show next calendar event")) + + LabeledContent("Show events within the next") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenCalendarEventLookaheadWindow) { + ForEach(CalendarLookaheadOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events within the next")) + + Toggle("Show events from all calendars", isOn: Binding( + get: { lockScreenCalendarSelectionMode == "all" }, + set: { useAll in + if useAll { + lockScreenCalendarSelectionMode = "all" + } else { + lockScreenCalendarSelectionMode = "selected" + lockScreenSelectedCalendarIDs = Set(calendarManager.eventCalendars.map { $0.id }) + } + } + )) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show events from all calendars")) + + if lockScreenCalendarSelectionMode != "all" { + HStack { + Spacer() + Button("Deselect All") { + lockScreenSelectedCalendarIDs = [] + } + .buttonStyle(.link) + } + .padding(.top, 2) + + VStack(alignment: .leading, spacing: 8) { + ForEach(calendarManager.eventCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { lockScreenSelectedCalendarIDs.contains(calendar.id) }, + set: { isOn in + if isOn { + lockScreenSelectedCalendarIDs.insert(calendar.id) + } else { + lockScreenSelectedCalendarIDs.remove(calendar.id) + } + } + )) { + HStack(spacing: 8) { + Circle() + .fill(Color(calendar.color)) + .frame(width: 8, height: 8) + Text(calendar.title) + } + } + } + } + .padding(.top, 4) + .padding(.leading, 2) + .disabled(!lockScreenShowCalendarEvent) + } + + Defaults.Toggle("Show countdown", key: .lockScreenShowCalendarCountdown) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show countdown")) + + Defaults.Toggle("Show event for entire duration", key: .lockScreenShowCalendarEventEntireDuration) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show event for entire duration")) + .onChange(of: Defaults[.lockScreenShowCalendarEventEntireDuration]) { _, newValue in + if newValue { + Defaults[.lockScreenShowCalendarEventAfterStartEnabled] = false + } + } + + Defaults.Toggle( + "Hide active event and show next upcoming event", + key: .lockScreenShowCalendarEventAfterStartEnabled + ) + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) + .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) + + LabeledContent("Show event after it starts") { + HStack { + Spacer(minLength: 0) + Picker("", selection: $lockScreenShowCalendarEventAfterStartWindow) { + Text("1 min").tag("1m") + Text("5 mins").tag("5m") + Text("10 mins").tag("10m") + Text("15 mins").tag("15m") + Text("30 mins").tag("30m") + Text("45 mins").tag("45m") + Text("1 hour").tag("1h") + Text("2 hours").tag("2h") + } + .labelsHidden() + .pickerStyle(.menu) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration || !lockScreenShowCalendarEventAfterStartEnabled) + + Text("Turn off 'Show event for entire duration' to use the post-start duration option.") + .font(.caption) + .foregroundStyle(.secondary) + + Defaults.Toggle("Show time remaining", key: .lockScreenShowCalendarTimeRemaining) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show time remaining")) + + Defaults.Toggle("Show start time after event begins", key: .lockScreenShowCalendarStartTimeAfterBegins) + .disabled(!lockScreenShowCalendarEvent) + .settingsHighlight(id: highlightID("Show start time after event begins")) + } header: { + Text("Calendar Widget") + } footer: { + Text("Displays your next upcoming calendar event above or below the weather capsule. Calendar selection here is independent from the Dynamic Island calendar filter.") + } + LockScreenPositioningControls() Section { diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 5584461..80b47fd 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -38,6 +38,7 @@ class CalendarManager: ObservableObject { @Published var selectedCalendarIDs: Set = [] @Published var calendarAuthorizationStatus: EKAuthorizationStatus = .notDetermined @Published var reminderAuthorizationStatus: EKAuthorizationStatus = .notDetermined + @Published var lockScreenEvents: [EventModel] = [] private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() @@ -50,6 +51,9 @@ class CalendarManager: ObservableObject { private let eventStoreChangeThrottle: TimeInterval = 20 private let selfInducedChangeSuppression: TimeInterval = 6 private let eventFetchLimiter = EventFetchLimiter() + private var lastLockScreenEventsFetchDate: Date? + private let lockScreenRefreshInterval: TimeInterval = 15 + private var lockScreenRefreshTask: Task? var hasCalendarAccess: Bool { isAuthorized(calendarAuthorizationStatus) } var hasReminderAccess: Bool { isAuthorized(reminderAuthorizationStatus) } @@ -57,6 +61,7 @@ class CalendarManager: ObservableObject { private init() { currentWeekStartDate = CalendarManager.startOfDay(Date()) setupEventStoreChangedObserver() + startLockScreenRefreshLoop() Task { await reloadCalendarAndReminderLists() } @@ -67,6 +72,7 @@ class CalendarManager: ObservableObject { NotificationCenter.default.removeObserver(observer) } pendingEventStoreRefreshTask?.cancel() + lockScreenRefreshTask?.cancel() } private func setupEventStoreChangedObserver() { @@ -111,6 +117,7 @@ class CalendarManager: ObservableObject { pendingEventStoreRefreshTask = nil await reloadCalendarAndReminderLists() await maybeRefreshEventsAfterReload() + await updateLockScreenEvents(force: true) nextAllowedEventStoreRefresh = Date().addingTimeInterval(eventStoreChangeThrottle) ignoreEventStoreChangesUntil = Date().addingTimeInterval(selfInducedChangeSuppression) } @@ -154,12 +161,14 @@ class CalendarManager: ObservableObject { if granted { await reloadCalendarAndReminderLists() await updateEvents(force: true) + await updateLockScreenEvents(force: true) } case .restricted, .denied: NSLog("Calendar access denied or restricted") case .authorized, .fullAccess: await reloadCalendarAndReminderLists() await updateEvents(force: true) + await updateLockScreenEvents(force: true) case .writeOnly: NSLog("Calendar write only") @unknown default: @@ -230,6 +239,7 @@ class CalendarManager: ObservableObject { Defaults[.calendarSelectionState] = selectionState updateSelectedCalendars() await updateEvents(force: true) + await updateLockScreenEvents(force: true) } static func startOfDay(_ date: Date) -> Date { @@ -239,6 +249,88 @@ class CalendarManager: ObservableObject { func updateCurrentDate(_ date: Date) async { currentWeekStartDate = Calendar.current.startOfDay(for: date) await updateEvents(force: true) + await updateLockScreenEvents(force: true) + } + + func updateLockScreenEvents(force: Bool = false) async { + let now = Date() + + if !force, + let lastFetch = lastLockScreenEventsFetchDate, + now.timeIntervalSince(lastFetch) < lockScreenRefreshInterval { + return + } + + let lookaheadRaw = Defaults[.lockScreenCalendarEventLookaheadWindow] + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + let isAllTimeLookahead = + lookaheadRaw == "all_time" || + lookaheadRaw == "all time" || + lookaheadRaw == "alltime" + + let isRestOfDay = + lookaheadRaw == "rest_of_day" || + lookaheadRaw == "rest of the day" || + lookaheadRaw == "restofday" + + func lookaheadMinutes(from raw: String) -> Int? { + switch raw { + case "15m", "15 min", "15 mins", "15min", "15mins": return 15 + case "30m", "30 min", "30 mins", "30min", "30mins": return 30 + case "1h", "1 hr", "1 hour", "1hour": return 60 + case "3h", "3 hr", "3 hours", "3hours": return 180 + case "6h", "6 hr", "6 hours", "6hours": return 360 + case "12h", "12 hr", "12 hours", "12hours": return 720 + default: return nil + } + } + + let calendar = Calendar.current + let startOfToday = calendar.startOfDay(for: now) + let startDate = calendar.date(byAdding: .day, value: -1, to: startOfToday) ?? startOfToday + + let endDate: Date + if isAllTimeLookahead { + endDate = calendar.date(byAdding: .day, value: 365, to: now) ?? now.addingTimeInterval(365 * 24 * 3600) + } else if isRestOfDay { + endDate = calendar.date(byAdding: .day, value: 1, to: startOfToday) ?? startOfToday.addingTimeInterval(24 * 3600) + } else if let minutes = lookaheadMinutes(from: lookaheadRaw) { + endDate = calendar.date(byAdding: .minute, value: minutes, to: now) ?? now.addingTimeInterval(TimeInterval(minutes * 60)) + } else { + endDate = calendar.date(byAdding: .minute, value: 180, to: now) ?? now.addingTimeInterval(180 * 60) + } + + let calendarIDs = allCalendars.map { $0.id } + let service = calendarService + + let fetched = await eventFetchLimiter.run { + await service.events(from: startDate, to: endDate, calendars: calendarIDs) + } + + if lockScreenEvents == fetched { + lastLockScreenEventsFetchDate = Date() + return + } + + withAnimation(.smooth(duration: 0.25)) { + lockScreenEvents = fetched + } + lastLockScreenEventsFetchDate = Date() + } + + private func startLockScreenRefreshLoop() { + lockScreenRefreshTask?.cancel() + lockScreenRefreshTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 60 * 1_000_000_000) + if Task.isCancelled { break } + guard self.hasCalendarAccess else { continue } + await self.updateLockScreenEvents(force: false) + } + } } private func updateEvents(force: Bool = false) async { diff --git a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift index 464e413..04b58cb 100644 --- a/DynamicIsland/managers/LockScreenReminderWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenReminderWidgetManager.swift @@ -94,6 +94,18 @@ final class LockScreenReminderWidgetManager: ObservableObject { self.handleSnapshotUpdate(self.reminderManager.lockScreenSnapshot) } .store(in: &cancellables) + + Defaults.publisher(.lockScreenReminderWidgetHorizontalAlignment, options: []) + .sink { _ in + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) + + Defaults.publisher(.lockScreenReminderWidgetVerticalOffset, options: []) + .sink { _ in + LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) } private func handleSnapshotUpdate(_ newSnapshot: LockScreenReminderWidgetSnapshot?) { diff --git a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift index b258c73..32df8f2 100644 --- a/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift +++ b/DynamicIsland/managers/LockScreenReminderWidgetPanelManager.swift @@ -20,6 +20,7 @@ import AppKit import SwiftUI import SkyLightWindow import QuartzCore +import Defaults @MainActor final class LockScreenReminderWidgetPanelManager { @@ -119,7 +120,18 @@ final class LockScreenReminderWidgetPanelManager { private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { let screenFrame = screen.frame - let originX = screenFrame.midX - (size.width / 2) + let horizontalPadding: CGFloat = 24 + let alignment = Defaults[.lockScreenReminderWidgetHorizontalAlignment].lowercased() + let originX: CGFloat + + switch alignment { + case "leading", "left": + originX = screenFrame.minX + horizontalPadding + case "trailing", "right": + originX = screenFrame.maxX - size.width - horizontalPadding + default: + originX = screenFrame.midX - (size.width / 2) + } let musicTop = LockScreenPanelManager.shared.latestFrame?.maxY ?? (screenFrame.midY - 32) let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame @@ -148,7 +160,9 @@ final class LockScreenReminderWidgetPanelManager { proposedY = (lowerBound + clampedUpperBound) / 2 } - proposedY = max(lowerBound, min(proposedY, clampedUpperBound)) + let rawOffset = CGFloat(Defaults[.lockScreenReminderWidgetVerticalOffset]) + let clampedOffset = min(max(rawOffset, -160), 160) + proposedY = max(lowerBound, min(proposedY + clampedOffset, clampedUpperBound)) 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 78f019b..8d5cf8b 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -114,14 +114,18 @@ final class LockScreenWeatherPanelManager { let screenFrame = screen.frame let originX = screenFrame.midX - (size.width / 2) let verticalOffset = screenFrame.height * 0.15 - let maxY = screenFrame.maxY - size.height - 48 + let isCircular = snapshot.widgetStyle == .circular + let topMargin: CGFloat = isCircular ? 120 : 48 + let maxY = screenFrame.maxY - size.height - topMargin let baseY = min(maxY, screenFrame.midY + verticalOffset) let loweredY = baseY - 36 let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0 + let circularDrop: CGFloat = isCircular ? 28 : 0 + let sizeDrop = max(0, size.height - 80) * 0.35 let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) let clampedOffset = min(max(userOffset, -160), 160) - let adjustedY = loweredY + inlineLift + clampedOffset + let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop let upperClampedY = min(maxY, adjustedY) let clampedY = max(screenFrame.minY + 80, upperClampedY) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 2ba4464..d587f61 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -774,6 +774,19 @@ extension Defaults.Keys { static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) + static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") + static let lockScreenReminderWidgetVerticalOffset = Key("lockScreenReminderWidgetVerticalOffset", default: 0) + static let lockScreenShowCalendarEvent = Key("lockScreenShowCalendarEvent", default: true) + static let lockScreenCalendarEventLookaheadWindow = Key("lockScreenCalendarEventLookaheadWindow", default: "3h") + static let lockScreenCalendarSelectionMode = Key("lockScreenCalendarSelectionMode", default: "all") + static let lockScreenSelectedCalendarIDs = Key>("lockScreenSelectedCalendarIDs", default: []) + static let lockScreenShowCalendarCountdown = Key("lockScreenShowCalendarCountdown", default: true) + static let lockScreenShowCalendarEventEntireDuration = Key("lockScreenShowCalendarEventEntireDuration", default: true) + static let lockScreenShowCalendarEventAfterStartEnabled = Key("lockScreenShowCalendarEventAfterStartEnabled", default: false) + static let lockScreenShowCalendarEventAfterStartWindow = Key("lockScreenShowCalendarEventAfterStartWindow", default: "5m") + static let lockScreenShowCalendarTimeRemaining = Key("lockScreenShowCalendarTimeRemaining", default: true) + static let lockScreenShowCalendarStartTimeAfterBegins = Key("lockScreenShowCalendarStartTimeAfterBegins", default: true) + static let lockScreenWeatherWidgetRowOrder = Key("lockScreenWeatherWidgetRowOrder", default: "weather_calendar_focus") // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) From 40e805a785f257c2d5b21dd98ac7eccc327b1ca9 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 7 Feb 2026 23:42:06 +0530 Subject: [PATCH 3/4] Hopefully fixed the Positioning --- DynamicIsland/Localizable.xcstrings | 12 ++ .../LockScreen/LockScreenWeatherWidget.swift | 129 +++++++++++++++++- .../LockScreenWeatherPanelManager.swift | 13 +- 3 files changed, 145 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 98d2d64..43742e8 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -827,6 +827,18 @@ } } }, + "%@. Focus active: %@" : { + "comment" : "A row that combines a calendar event with a focus indicator. The first argument is the text of the calendar event. The second argument is the name of the focus.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@. Focus active: %2$@" + } + } + } + }, "%@°" : { "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index a0726b2..5a31718 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -393,6 +393,14 @@ struct LockScreenWeatherWidget: View { lockScreenShowCalendarEvent && nextCalendarEvent != nil } + private var shouldShowFocusInCalendarSlot: Bool { + shouldShowFocusWidget && !shouldShowCalendarRow + } + + private var shouldCombineFocusAndCalendar: Bool { + shouldShowFocusWidget && shouldShowCalendarRow + } + private enum RowKind { case weather case focus @@ -422,6 +430,8 @@ struct LockScreenWeatherWidget: View { var enabled: Set = [.weather] if lockScreenShowCalendarEvent { enabled.insert(.calendar) } if shouldShowFocusWidget { enabled.insert(.focus) } + if shouldShowFocusInCalendarSlot { enabled.insert(.calendar); enabled.remove(.focus) } + if shouldCombineFocusAndCalendar { enabled.remove(.focus) } return orderedRowKinds.filter { enabled.contains($0) } } @@ -462,8 +472,13 @@ struct LockScreenWeatherWidget: View { private var isInline: Bool { snapshot.widgetStyle == .inline } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackSpacing: CGFloat { isInline ? 14 : 22 } - private var mainRowAlignment: Alignment { isInline ? .leading : .center } - private var secondaryRowAlignment: Alignment { isInline ? .leading : .center } + private var mainRowAlignment: Alignment { + if isInline { + return shouldCombineFocusAndCalendar ? .center : .leading + } + return .center + } + private var secondaryRowAlignment: Alignment { .center } private var focusRowAlignment: Alignment { .center } private var gaugeDiameter: CGFloat { 64 } private var topPadding: CGFloat { isInline ? 6 : 22 } @@ -570,15 +585,44 @@ struct LockScreenWeatherWidget: View { case .weather: mainWidgetRow case .calendar: - nextEventRow + if shouldCombineFocusAndCalendar { + combinedFocusCalendarRow + } else if shouldShowFocusInCalendarSlot { + focusRowInCalendarSlot + } else { + nextEventRow + } case .focus: - focusWidget - .opacity(shouldShowFocusWidget ? 1 : 0) - .accessibilityHidden(!shouldShowFocusWidget) - .allowsHitTesting(false) + if shouldCombineFocusAndCalendar { + EmptyView() + } else { + focusWidget + .opacity(shouldShowFocusWidget ? 1 : 0) + .accessibilityHidden(!shouldShowFocusWidget) + .allowsHitTesting(false) + } } } + private var focusRowInCalendarSlot: some View { + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: secondaryRowAlignment) + .padding(.horizontal, 2) + .opacity(shouldShowFocusWidget ? 1 : 0) + .accessibilityLabel("Focus active: \(focusDisplayName)") + .allowsHitTesting(false) + } + private var mainWidgetRow: some View { HStack(alignment: stackAlignment, spacing: stackSpacing) { if let charging = snapshot.charging { @@ -627,6 +671,77 @@ struct LockScreenWeatherWidget: View { ) } + private var combinedFocusCalendarRow: some View { + let event = nextCalendarEvent + let line = shouldShowCalendarRow ? eventLineText(for: event) : lastCalendarLine + let iconConfig = calendarRowIconConfig(for: event) + + return HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 6) { + calendarIconView(iconConfig) + .frame(width: 26, height: 26) + + Text(line) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .layoutPriority(2) + + Text("•") + .font(inlinePrimaryFont) + .foregroundStyle(secondaryLabelColor) + .accessibilityHidden(true) + + HStack(alignment: .center, spacing: 8) { + focusIcon + .font(.system(size: 20, weight: .semibold)) + .frame(width: 26, height: 26) + + Text(focusDisplayName) + .font(inlinePrimaryFont) + .lineLimit(1) + .minimumScaleFactor(0.85) + .allowsTightening(true) + } + .layoutPriority(1) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.horizontal, 2) + .id(calendarRowRenderToken) + .opacity(shouldShowFocusWidget && shouldShowCalendarRow ? 1 : 0) + .accessibilityLabel("\(line). Focus active: \(focusDisplayName)") + .allowsHitTesting(false) + } + + @ViewBuilder + private func calendarIconView(_ iconConfig: (name: String, renderingMode: SymbolRenderingMode, colors: [Color])) -> some View { + let image = Image(systemName: iconConfig.name) + .font(.system(size: 20, weight: .semibold)) + + let isPalette = String(describing: iconConfig.renderingMode) + .lowercased() + .contains("palette") + + if isPalette, iconConfig.colors.count >= 2 { + image + .symbolRenderingMode(.palette) + .foregroundStyle(iconConfig.colors[0], iconConfig.colors[1]) + } else if iconConfig.colors.count >= 2 { + image + .symbolRenderingMode(iconConfig.renderingMode) + .foregroundStyle(iconConfig.colors[0], iconConfig.colors[1]) + } else if iconConfig.colors.count == 1 { + image + .symbolRenderingMode(iconConfig.renderingMode) + .foregroundStyle(iconConfig.colors[0]) + } else { + image + .symbolRenderingMode(iconConfig.renderingMode) + } + } + private func calendarRowIconConfig(for event: EventModel?) -> (name: String, renderingMode: SymbolRenderingMode, colors: [Color]) { guard let event else { return (name: "calendar.badge", renderingMode: .hierarchical, colors: [Color.white.opacity(0.9)]) diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 8d5cf8b..9b6b5b1 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -31,6 +31,7 @@ final class LockScreenWeatherPanelManager { private(set) var latestFrame: NSRect? private var lastSnapshot: LockScreenWeatherSnapshot? private var lastContentSize: CGSize? + private var lastInlineBaselineHeight: CGFloat = 0 private var screenChangeObserver: NSObjectProtocol? private var workspaceObservers: [NSObjectProtocol] = [] @@ -64,6 +65,9 @@ final class LockScreenWeatherPanelManager { let view = LockScreenWeatherWidget(snapshot: snapshot) let hostingView = NSHostingView(rootView: view) let fittingSize = hostingView.fittingSize + if snapshot.widgetStyle == .inline { + lastInlineBaselineHeight = max(lastInlineBaselineHeight, fittingSize.height) + } hostingView.frame = NSRect(origin: .zero, size: fittingSize) let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen) @@ -116,13 +120,18 @@ final class LockScreenWeatherPanelManager { let verticalOffset = screenFrame.height * 0.15 let isCircular = snapshot.widgetStyle == .circular let topMargin: CGFloat = isCircular ? 120 : 48 - let maxY = screenFrame.maxY - size.height - topMargin + let inlineBaselineHeight: CGFloat = max(lastInlineBaselineHeight, 80) + let positionHeight = snapshot.widgetStyle == .inline + ? max(size.height, inlineBaselineHeight) + : size.height + let maxY = screenFrame.maxY - positionHeight - topMargin let baseY = min(maxY, screenFrame.midY + verticalOffset) let loweredY = baseY - 36 let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0 let circularDrop: CGFloat = isCircular ? 28 : 0 - let sizeDrop = max(0, size.height - 80) * 0.35 + let sizeDropHeight = positionHeight + let sizeDrop = max(0, sizeDropHeight - 80) * 0.35 let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset]) let clampedOffset = min(max(userOffset, -160), 160) let adjustedY = loweredY + inlineLift + clampedOffset - circularDrop - sizeDrop From 166c053621342f26ac98df8009ee0a7350df8512 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 8 Feb 2026 00:13:15 +0530 Subject: [PATCH 4/4] Update Notch slider for volume/brightness feedback by kryoscopic --- DynamicIsland/ContentView.swift | 4 +- .../Notch/MinimalisticMusicPlayerView.swift | 128 +++++++++++++++++- .../components/Notch/NotchHomeView.swift | 126 ++++++++++++++++- 3 files changed, 253 insertions(+), 5 deletions(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index cc795e4..033e69a 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -583,7 +583,7 @@ 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 != .timer) && (coordinator.sneakPeek.type != .reminder) && !coordinator.sneakPeek.type.isExtensionPayload && (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.isExtensionPayload && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( coordinator.sneakPeek.type == .capsLock @@ -631,7 +631,7 @@ struct ContentView: View { } if coordinator.sneakPeek.show { - if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { + if (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && !Defaults[.inlineHUD] && ((coordinator.sneakPeek.type != .volume && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { SystemEventIndicatorModifier(eventType: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, sendEventBack: { _ in // }) diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index f5cf76a..6fa3009 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -35,6 +35,9 @@ struct MinimalisticMusicPlayerView: View { @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @ObservedObject private var timerManager = TimerManager.shared @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared + @State private var hudValue: Double = 0 + @State private var hudDragging: Bool = false + @State private var hudLastDragged: Date = .distantPast @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableLyrics) private var enableLyrics @Default(.timerPresets) private var timerPresets @@ -85,8 +88,13 @@ struct MinimalisticMusicPlayerView: View { .padding(.top, 6) // Compact playback controls - playbackControls - .padding(.top, 4) + if shouldShowControlHUDRow { + controlHUDRow + .padding(.top, 4) + } else { + playbackControls + .padding(.top, 4) + } if enableLyrics { lyricsView @@ -672,6 +680,122 @@ private struct MinimalisticReminderDetailsView: View { .frame(maxWidth: .infinity, alignment: .center) .padding(.top, 2) } + + private var shouldShowControlHUDRow: Bool { + guard vm.notchState == .open else { return false } + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.enableSystemHUD] else { return false } + guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false } + + switch coordinator.sneakPeek.type { + case .volume: + return Defaults[.enableVolumeHUD] + case .brightness: + return Defaults[.enableBrightnessHUD] + case .backlight: + return Defaults[.enableKeyboardBacklightHUD] + default: + return false + } + } + + private var controlHUDRow: some View { + HStack(alignment: .center, spacing: 10) { + if !controlLeftIconName.isEmpty { + Image(systemName: controlLeftIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + + controlHUDSlider + + if !controlRightIconName.isEmpty { + Image(systemName: controlRightIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .frame(height: 60, alignment: .center) + .onAppear { syncHUDValueIfNeeded(force: true) } + .onChange(of: coordinator.sneakPeek.value) { _, _ in + syncHUDValueIfNeeded(force: false) + } + } + + private var controlHUDSlider: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + CustomSlider( + value: Binding( + get: { hudValue }, + set: { newValue in + hudValue = newValue + updateControlHUDValue(newValue) + } + ), + range: 0...1, + color: .white, + dragging: $hudDragging, + lastDragged: $hudLastDragged, + onValueChange: { newValue in + updateControlHUDValue(newValue) + }, + thumbSize: 10, + restingTrackHeight: 4, + draggingTrackHeight: 7 + ) + .frame(height: 7) + Spacer(minLength: 0) + } + .frame(height: 22) + } + + private func syncHUDValueIfNeeded(force: Bool) { + guard shouldShowControlHUDRow else { return } + guard force || !hudDragging else { return } + hudValue = Double(coordinator.sneakPeek.value) + } + + private func updateControlHUDValue(_ newValue: Double) { + let clamped = max(0, min(1, newValue)) + switch coordinator.sneakPeek.type { + case .volume: + SystemVolumeController.shared.setVolume(Float(clamped)) + case .brightness: + SystemBrightnessController.shared.setBrightness(Float(clamped)) + case .backlight: + SystemKeyboardBacklightController.shared.setLevel(Float(clamped)) + default: + break + } + } + + private var controlLeftIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1" + case .brightness: + return "sun.min.fill" + case .backlight: + return "light.min" + default: + return "" + } + } + + private var controlRightIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3" + case .brightness: + return "sun.max.fill" + case .backlight: + return "light.max" + default: + return "" + } + } private var playPauseButton: some View { MinimalisticSquircircleButton( diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 9cc8f9b..7e68b84 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -129,10 +129,15 @@ struct AlbumArtView: View { } struct MusicControlsView: View { + @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var musicManager = MusicManager.shared + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast + @State private var hudValue: Double = 0 + @State private var hudDragging: Bool = false + @State private var hudLastDragged: Date = .distantPast @Default(.showShuffleAndRepeat) private var showCustomControls @Default(.musicControlSlots) private var slotConfig @Default(.showMediaOutputControl) private var showMediaOutputControl @@ -144,7 +149,11 @@ struct MusicControlsView: View { var body: some View { VStack(alignment: .leading) { songInfoAndSlider - playbackControls + if shouldShowControlHUDRow { + controlHUDRow + } else { + playbackControls + } } .buttonStyle(PlainButtonStyle()) .frame(maxWidth: .infinity, alignment: .leading) @@ -238,6 +247,121 @@ struct MusicControlsView: View { .frame(maxWidth: .infinity, alignment: .center) } + private var shouldShowControlHUDRow: Bool { + guard vm.notchState == .open else { return false } + guard coordinator.sneakPeek.show else { return false } + guard Defaults[.enableSystemHUD] else { return false } + guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false } + + switch coordinator.sneakPeek.type { + case .volume: + return Defaults[.enableVolumeHUD] + case .brightness: + return Defaults[.enableBrightnessHUD] + case .backlight: + return Defaults[.enableKeyboardBacklightHUD] + default: + return false + } + } + + private var controlHUDRow: some View { + HStack(alignment: .center, spacing: 10) { + if !controlLeftIconName.isEmpty { + Image(systemName: controlLeftIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + + controlHUDSlider + + if !controlRightIconName.isEmpty { + Image(systemName: controlRightIconName) + .font(.system(size: 16, weight: .semibold)) + .frame(width: 22, height: 22, alignment: .center) + } + } + .frame(maxWidth: .infinity, alignment: .center) + .onAppear { syncHUDValueIfNeeded(force: true) } + .onChange(of: coordinator.sneakPeek.value) { _, _ in + syncHUDValueIfNeeded(force: false) + } + } + + private var controlHUDSlider: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + CustomSlider( + value: Binding( + get: { hudValue }, + set: { newValue in + hudValue = newValue + updateControlHUDValue(newValue) + } + ), + range: 0...1, + color: .white, + dragging: $hudDragging, + lastDragged: $hudLastDragged, + onValueChange: { newValue in + updateControlHUDValue(newValue) + }, + thumbSize: 10, + restingTrackHeight: 4, + draggingTrackHeight: 7 + ) + .frame(height: 7) + Spacer(minLength: 0) + } + .frame(height: 22) + } + + private func syncHUDValueIfNeeded(force: Bool) { + guard shouldShowControlHUDRow else { return } + guard force || !hudDragging else { return } + hudValue = Double(coordinator.sneakPeek.value) + } + + private func updateControlHUDValue(_ newValue: Double) { + let clamped = max(0, min(1, newValue)) + switch coordinator.sneakPeek.type { + case .volume: + SystemVolumeController.shared.setVolume(Float(clamped)) + case .brightness: + SystemBrightnessController.shared.setBrightness(Float(clamped)) + case .backlight: + SystemKeyboardBacklightController.shared.setLevel(Float(clamped)) + default: + break + } + } + + private var controlLeftIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1" + case .brightness: + return "sun.min.fill" + case .backlight: + return "light.min" + default: + return "" + } + } + + private var controlRightIconName: String { + switch coordinator.sneakPeek.type { + case .volume: + return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3" + case .brightness: + return "sun.max.fill" + case .backlight: + return "light.max" + default: + return "" + } + } + private var brandAccentColor: Color { musicManager.brandAccentColor }