Atoll/DynamicIsland/managers/LockScreenWidgetPreviewManager.swift
Amir Zarrinkafsh d39c61c475
refactor(calendar): group calendars by accounts in settings
Calendars in the "Select Calendars" settings section are now grouped by their source account with color indicators, making it possible to distinguish calendars that share the same name across different accounts.

Each account group header includes a toggle to select or deselect all calendars in that group.
2026-03-22 13:19:20 +11:00

578 lines
20 KiB
Swift

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import AppKit
import Combine
import Defaults
import SwiftUI
@MainActor
final class LockScreenWidgetPreviewManager: ObservableObject {
static let shared = LockScreenWidgetPreviewManager()
@Published private(set) var isPreviewVisible = false
private var cancellables = Set<AnyCancellable>()
private var startedDemoTimer = false
private var previewWindow: NSWindow?
private var previewWindowDelegate: PreviewWindowDelegate?
private var previewHostingView: NSHostingView<LockScreenPreviewScene>?
private var cachedFocusState: FocusPreviewState?
private init() {
observeDefaults()
}
func togglePreview() {
if isPreviewVisible {
hidePreview()
} else {
showPreview()
}
}
func showPreview() {
guard !isPreviewVisible else { return }
isPreviewVisible = true
ensurePreviewWindow()
applyPreviewState()
renderPreview()
}
func hidePreview() {
guard isPreviewVisible else { return }
isPreviewVisible = false
hideAllWidgets()
restorePreviewState()
closePreviewWindow()
}
private func renderPreview() {
guard isPreviewVisible else { return }
ensurePreviewWindow()
if Defaults[.enableLockScreenTimerWidget] {
ensureDemoTimerIfNeeded()
} else {
stopDemoTimerIfNeeded()
}
updatePreviewContent()
}
private func hideAllWidgets() {
stopDemoTimerIfNeeded()
}
private func ensureDemoTimerIfNeeded() {
let timerManager = TimerManager.shared
guard !timerManager.isTimerActive else { return }
timerManager.startDemoTimer(duration: 1783)
startedDemoTimer = true
}
private func stopDemoTimerIfNeeded() {
guard startedDemoTimer else { return }
TimerManager.shared.forceStopTimer()
startedDemoTimer = false
}
private func observeDefaults() {
observeKey(.enableLockScreenMediaWidget)
observeKey(.enableLockScreenWeatherWidget)
observeKey(.enableLockScreenTimerWidget)
observeKey(.lockScreenWeatherWidgetStyle)
observeKey(.lockScreenWeatherProviderSource)
observeKey(.lockScreenWeatherTemperatureUnit)
observeKey(.lockScreenWeatherShowsLocation)
observeKey(.lockScreenWeatherShowsSunrise)
observeKey(.lockScreenWeatherShowsAQI)
observeKey(.lockScreenWeatherAQIScale)
observeKey(.lockScreenBatteryShowsCharging)
observeKey(.lockScreenBatteryShowsChargingPercentage)
observeKey(.lockScreenBatteryShowsBatteryGauge)
observeKey(.lockScreenBatteryShowsBluetooth)
observeKey(.lockScreenBatteryUsesLaptopSymbol)
observeKey(.lockScreenWeatherUsesGaugeTint)
observeKey(.lockScreenReminderChipStyle)
}
private func observeKey<T: Defaults.Serializable>(_ key: Defaults.Key<T>) {
Defaults.publisher(key, options: [])
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.renderPreview()
}
.store(in: &cancellables)
}
private func mockWeatherSnapshot() -> LockScreenWeatherSnapshot {
let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let temperatureValue: Double = unit == .celsius ? 21 : 72
let temperatureText = "\(Int(round(temperatureValue)))°"
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
minimum: temperatureValue - 5,
maximum: temperatureValue + 6,
unitSymbol: unit.symbol
)
let chargingInfo: LockScreenWeatherSnapshot.ChargingInfo? = Defaults[.lockScreenBatteryShowsCharging]
? LockScreenWeatherSnapshot.ChargingInfo(
minutesRemaining: 42,
isCharging: true,
isPluggedIn: true,
batteryLevel: 78
)
: nil
let bluetoothInfo: LockScreenWeatherSnapshot.BluetoothInfo? = Defaults[.lockScreenBatteryShowsBluetooth]
? LockScreenWeatherSnapshot.BluetoothInfo(
deviceName: "AirPods Pro",
batteryLevel: 74,
iconName: "airpodspro"
)
: nil
let batteryInfo: LockScreenWeatherSnapshot.BatteryInfo? = Defaults[.lockScreenBatteryShowsBatteryGauge]
? LockScreenWeatherSnapshot.BatteryInfo(
batteryLevel: 63,
usesLaptopSymbol: Defaults[.lockScreenBatteryUsesLaptopSymbol]
)
: nil
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let showLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation]
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let showsAQI = Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality
let scale = Defaults[.lockScreenWeatherAQIScale]
let airQualityIndex = scale == .us ? 42 : 28
let airQuality = showsAQI
? LockScreenWeatherSnapshot.AirQualityInfo(
index: airQualityIndex,
category: .init(index: airQualityIndex, scale: scale),
scale: scale
)
: nil
let showsSunrise = Defaults[.lockScreenWeatherShowsSunrise] && widgetStyle == .inline
let sunCycle = LockScreenWeatherSnapshot.SunCycleInfo(
sunrise: Date().addingTimeInterval(2 * 3600),
sunset: Date().addingTimeInterval(9 * 3600)
)
return LockScreenWeatherSnapshot(
temperatureText: temperatureText,
symbolName: "cloud.sun.fill",
description: "Partly Cloudy",
locationName: "Cupertino",
charging: chargingInfo,
bluetooth: bluetoothInfo,
battery: batteryInfo,
showsLocation: showLocation,
airQuality: airQuality,
widgetStyle: widgetStyle,
showsChargingPercentage: Defaults[.lockScreenBatteryShowsChargingPercentage],
temperatureInfo: temperatureInfo,
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint],
sunCycle: sunCycle,
showsSunrise: showsSunrise
)
}
private func ensurePreviewWindow() {
guard let screen = NSScreen.main else { return }
if let window = previewWindow {
window.makeKeyAndOrderFront(nil)
updatePreviewContent()
return
}
let frame = screen.frame
let window = NSWindow(
contentRect: frame,
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
window.title = "Lock Screen Preview"
window.isReleasedWhenClosed = false
window.backgroundColor = .clear
window.center()
let scene = LockScreenPreviewScene(
screenSize: screen.frame.size,
weatherSnapshot: mockWeatherSnapshot(),
showsWeather: Defaults[.enableLockScreenWeatherWidget],
showsTimer: Defaults[.enableLockScreenTimerWidget],
showsMediaPanel: Defaults[.enableLockScreenMediaWidget]
)
let hosting = NSHostingView(rootView: scene)
hosting.frame = NSRect(origin: .zero, size: frame.size)
hosting.autoresizingMask = [.width, .height]
previewHostingView = hosting
window.contentView = hosting
let delegate = PreviewWindowDelegate { [weak self] in
self?.hidePreview()
}
previewWindowDelegate = delegate
window.delegate = delegate
previewWindow = window
window.makeKeyAndOrderFront(nil)
}
private func updatePreviewContent() {
guard let screen = NSScreen.main else { return }
let scene = LockScreenPreviewScene(
screenSize: screen.frame.size,
weatherSnapshot: mockWeatherSnapshot(),
showsWeather: Defaults[.enableLockScreenWeatherWidget],
showsTimer: Defaults[.enableLockScreenTimerWidget],
showsMediaPanel: Defaults[.enableLockScreenMediaWidget]
)
if let hosting = previewHostingView {
hosting.rootView = scene
} else if let window = previewWindow {
let hosting = NSHostingView(rootView: scene)
hosting.frame = NSRect(origin: .zero, size: window.frame.size)
hosting.autoresizingMask = [.width, .height]
previewHostingView = hosting
window.contentView = hosting
}
}
private func closePreviewWindow() {
previewWindow?.orderOut(nil)
}
private func applyPreviewState() {
applyFocusPreviewState()
applyCalendarPreviewEvents()
}
private func restorePreviewState() {
restoreFocusPreviewState()
CalendarManager.shared.setLockScreenPreviewEvents(nil)
}
private func applyFocusPreviewState() {
let manager = DoNotDisturbManager.shared
if cachedFocusState == nil {
cachedFocusState = FocusPreviewState(
isActive: manager.isDoNotDisturbActive,
name: manager.currentFocusModeName,
identifier: manager.currentFocusModeIdentifier
)
}
manager.isDoNotDisturbActive = true
manager.currentFocusModeName = "Do Not Disturb"
manager.currentFocusModeIdentifier = "com.apple.donotdisturb"
}
private func restoreFocusPreviewState() {
guard let cachedFocusState else { return }
let manager = DoNotDisturbManager.shared
manager.isDoNotDisturbActive = cachedFocusState.isActive
manager.currentFocusModeName = cachedFocusState.name
manager.currentFocusModeIdentifier = cachedFocusState.identifier
self.cachedFocusState = nil
}
private func applyCalendarPreviewEvents() {
let now = Date()
let reminderCalendar = CalendarModel(
accountName: "Preview",
id: "preview.reminders",
title: "Reminders",
color: .systemBlue,
isSubscribed: false,
isReminder: true
)
let reminderEvent = EventModel(
id: "preview.reminder.pay-rent",
start: now.addingTimeInterval(12 * 60),
end: now.addingTimeInterval(42 * 60),
title: "Pay rent",
location: nil,
notes: nil,
url: nil,
isAllDay: false,
type: .reminder(completed: false),
calendar: reminderCalendar,
participants: [],
timeZone: TimeZone.current,
hasRecurrenceRules: false,
priority: .high,
conferenceURL: nil
)
let eventCalendar = CalendarModel(
accountName: "Preview",
id: "preview.calendar",
title: "Calendar",
color: .systemOrange,
isSubscribed: false,
isReminder: false
)
let event = EventModel(
id: "preview.event.standup",
start: now.addingTimeInterval(60 * 60),
end: now.addingTimeInterval(90 * 60),
title: "Design Review",
location: "Studio",
notes: nil,
url: nil,
isAllDay: false,
type: .event(.accepted),
calendar: eventCalendar,
participants: [],
timeZone: TimeZone.current,
hasRecurrenceRules: false,
priority: nil,
conferenceURL: nil
)
CalendarManager.shared.setLockScreenPreviewEvents([reminderEvent, event])
}
}
private struct FocusPreviewState {
let isActive: Bool
let name: String
let identifier: String
}
private final class PreviewWindowDelegate: NSObject, NSWindowDelegate {
private let onClose: () -> Void
init(onClose: @escaping () -> Void) {
self.onClose = onClose
}
func windowWillClose(_ notification: Notification) {
onClose()
}
}
private struct LockScreenPreviewScene: View {
let screenSize: CGSize
let weatherSnapshot: LockScreenWeatherSnapshot
let showsWeather: Bool
let showsTimer: Bool
let showsMediaPanel: Bool
@State private var weatherSize: CGSize = .zero
@State private var musicSize: CGSize = .zero
@State private var inlineBaselineHeight: CGFloat = 0
@StateObject private var panelAnimator = LockScreenPanelAnimator()
var body: some View {
GeometryReader { proxy in
let scale = min(proxy.size.width / screenSize.width, proxy.size.height / screenSize.height)
let contentSize = CGSize(width: screenSize.width * scale, height: screenSize.height * scale)
let origin = CGPoint(
x: (proxy.size.width - contentSize.width) / 2,
y: (proxy.size.height - contentSize.height) / 2
)
ZStack(alignment: .topLeading) {
WallpaperView()
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
ZStack(alignment: .topLeading) {
if showsMediaPanel {
LockScreenMusicPanel(animator: panelAnimator)
.fixedSize()
.background(PreviewSizeReader(size: $musicSize))
.position(centerPoint(for: swiftUIFrame(for: mediaFrame)))
.onAppear {
panelAnimator.isPresented = true
}
}
if showsTimer {
LockScreenTimerWidget()
.frame(width: timerFrame.width, height: timerFrame.height)
.position(centerPoint(for: swiftUIFrame(for: timerFrame)))
}
if showsWeather {
LockScreenWeatherWidget(snapshot: weatherSnapshot)
.fixedSize()
.background(PreviewSizeReader(size: $weatherSize))
.position(centerPoint(for: swiftUIFrame(for: weatherFrame)))
.onChange(of: weatherSize) { _, newSize in
if weatherSnapshot.widgetStyle == .inline {
inlineBaselineHeight = max(inlineBaselineHeight, newSize.height)
}
}
}
}
.frame(width: screenSize.width, height: screenSize.height, alignment: .topLeading)
.scaleEffect(scale, anchor: .topLeading)
.offset(x: origin.x, y: origin.y)
}
}
.ignoresSafeArea()
}
private var mediaFrame: CGRect {
let size = musicSize == .zero ? LockScreenMusicPanel.collapsedSize : musicSize
let screenFrame = CGRect(origin: .zero, size: screenSize)
let originX = screenFrame.midX - (size.width / 2)
let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - size.height - 32
let defaultLowering: CGFloat = -28
let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset])
let clampedOffset = min(max(userOffset, -160), 160)
var originY = baseOriginY + defaultLowering + clampedOffset
if showsTimer {
let maxAllowedTop = timerFrame.minY - 12
let maxOriginY = maxAllowedTop - size.height
originY = min(originY, maxOriginY)
}
return CGRect(x: originX, y: originY, width: size.width, height: size.height)
}
private var timerFrame: CGRect {
let size = LockScreenTimerWidget.preferredSize
let screenFrame = CGRect(origin: .zero, size: screenSize)
let originX = screenFrame.midX - (size.width / 2)
let defaultLowering: CGFloat = -18
let baseY = screenFrame.midY + 24 + defaultLowering
let offset = CGFloat(min(max(Defaults[.lockScreenTimerVerticalOffset], -160), 160))
var originY = baseY + offset
if showsWeather {
originY = min(originY, weatherFrame.minY - size.height - 20)
} else {
let topLimit = screenFrame.maxY - size.height - 72
originY = min(originY, topLimit)
}
let minY = screenFrame.minY + 100
originY = max(originY, minY)
return CGRect(x: originX, y: originY, width: size.width, height: size.height)
}
private var weatherFrame: CGRect {
let size = weatherSize == .zero ? CGSize(width: 220, height: 60) : weatherSize
let screenFrame = CGRect(origin: .zero, size: screenSize)
let originX = screenFrame.midX - (size.width / 2)
let verticalOffset = screenFrame.height * 0.15
let isCircular = weatherSnapshot.widgetStyle == .circular
let topMargin: CGFloat = isCircular ? 120 : 48
let inlineBaseline = max(inlineBaselineHeight, 80)
let positionHeight = weatherSnapshot.widgetStyle == .inline
? max(size.height, inlineBaseline)
: size.height
let maxY = screenFrame.maxY - positionHeight - topMargin
let baseY = min(maxY, screenFrame.midY + verticalOffset)
let loweredY = baseY - 36
let inlineLift: CGFloat = weatherSnapshot.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 - circularDrop - sizeDrop
let upperClampedY = min(maxY, adjustedY)
let clampedY = max(screenFrame.minY + 80, upperClampedY)
return CGRect(x: originX, y: clampedY, width: size.width, height: size.height)
}
private func swiftUIFrame(for appKitFrame: CGRect) -> CGRect {
let originY = screenSize.height - appKitFrame.maxY
return CGRect(x: appKitFrame.origin.x, y: originY, width: appKitFrame.width, height: appKitFrame.height)
}
private func centerPoint(for frame: CGRect) -> CGPoint {
CGPoint(x: frame.midX, y: frame.midY)
}
}
private struct PreviewSizeReader: View {
@Binding var size: CGSize
var body: some View {
GeometryReader { proxy in
Color.clear
.onAppear { size = proxy.size }
.onChange(of: proxy.size) { _, newSize in
size = newSize
}
}
}
}
struct WallpaperView: View {
@State private var wallpaperImage: NSImage?
var body: some View {
ZStack {
if let image = wallpaperImage {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
Color.black
Text("Loading...")
.foregroundStyle(.secondary)
}
}
.onAppear(perform: loadWallpaper)
}
func loadWallpaper() {
if let url = Bundle.main.url(forResource: "desktop", withExtension: "jpeg"),
let image = NSImage(contentsOf: url),
image.size.width > 0,
image.size.height > 0 {
self.wallpaperImage = image
return
}
if let image = NSImage(named: "desktop"),
image.size.width > 0,
image.size.height > 0 {
self.wallpaperImage = image
return
}
if let screen = NSScreen.main,
let url = NSWorkspace.shared.desktopImageURL(for: screen),
let image = NSImage(contentsOf: url),
image.size.width > 0,
image.size.height > 0 {
self.wallpaperImage = image
return
}
}
}