/* * 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 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() private var startedDemoTimer = false private var previewWindow: NSWindow? private var previewWindowDelegate: PreviewWindowDelegate? private var previewHostingView: NSHostingView? 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(_ key: Defaults.Key) { 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( 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( 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 } } }