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/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/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..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,
@@ -1537,6 +1549,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 +1566,9 @@
}
}
}
+ },
+ "2 hours" : {
+
},
"3 items" : {
"localizations" : {
@@ -1596,6 +1617,9 @@
}
}
}
+ },
+ "5 mins" : {
+
},
"7 items" : {
"localizations" : {
@@ -1644,6 +1668,9 @@
}
}
}
+ },
+ "10 mins" : {
+
},
"15 colors" : {
"localizations" : {
@@ -1660,6 +1687,9 @@
}
}
}
+ },
+ "15 mins" : {
+
},
"20" : {
"extractionState" : "stale",
@@ -1698,6 +1728,12 @@
}
}
}
+ },
+ "30 mins" : {
+
+ },
+ "45 mins" : {
+
},
"100" : {
"comment" : "The text displayed below a 100% battery level in the lock screen.",
@@ -3008,6 +3044,9 @@
}
}
}
+ },
+ "Alignment" : {
+
},
"All" : {
"localizations" : {
@@ -5158,6 +5197,9 @@
}
}
}
+ },
+ "Calendar Widget" : {
+
},
"Calendars" : {
"localizations" : {
@@ -7046,6 +7088,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 +8313,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 +8681,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 +19019,9 @@
}
}
}
+ },
+ "Reminder Widget" : {
+
},
"Reminders" : {
"localizations" : {
@@ -20830,6 +20884,15 @@
}
}
}
+ },
+ "Show event after it starts" : {
+
+ },
+ "Show events from all calendars" : {
+
+ },
+ "Show events within the next" : {
+
},
"Show floating pause/stop controls" : {
"localizations" : {
@@ -23345,6 +23408,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 +24455,9 @@
}
}
}
+ },
+ "Vertical offset" : {
+
},
"Vertical:" : {
"extractionState" : "stale",
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/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..5a31718 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,422 @@ 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 var shouldShowFocusInCalendarSlot: Bool {
+ shouldShowFocusWidget && !shouldShowCalendarRow
+ }
+
+ private var shouldCombineFocusAndCalendar: Bool {
+ shouldShowFocusWidget && shouldShowCalendarRow
+ }
+
+ 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) }
+ if shouldShowFocusInCalendarSlot { enabled.insert(.calendar); enabled.remove(.focus) }
+ if shouldCombineFocusAndCalendar { enabled.remove(.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 {
+ 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 }
private var bottomPadding: CGFloat { isInline ? 6 : 10 }
@@ -51,11 +490,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 +504,125 @@ 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:
+ if shouldCombineFocusAndCalendar {
+ combinedFocusCalendarRow
+ } else if shouldShowFocusInCalendarSlot {
+ focusRowInCalendarSlot
+ } else {
+ nextEventRow
+ }
+ case .focus:
+ 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 {
@@ -95,6 +651,214 @@ 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 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)])
+ }
+
+ 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 +1163,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 +1211,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 +1433,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/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
}
diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift
index bd1a815..bb30b13 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")),
@@ -662,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")),
@@ -716,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")),
@@ -1160,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"))
@@ -1783,6 +1796,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: {
@@ -2223,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 {
@@ -2253,7 +2304,7 @@ struct CalendarSettings: View {
}
} else {
// Permissions status
- Section {
+ Section(header: Text("Permissions")) {
HStack {
Text("Calendars")
Spacer()
@@ -2266,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)
@@ -2350,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)
}
}
}
@@ -3477,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
@@ -3496,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)
@@ -3506,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) },
@@ -3760,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 {
@@ -3797,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/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/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..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)
@@ -114,14 +118,23 @@ 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 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 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
+ 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 246b05b..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)
@@ -838,6 +851,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)