diff --git a/.gitignore b/.gitignore index 3b22743..2bbae53 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ podfile.lock # Carthage Carthage/Build/ - +/temp/ # Swift Package Manager .swiftpm/ .build/ diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index a3adef5..7cc9a4f 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -656,6 +656,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { } previousScreens = NSScreen.screens + + if Defaults[.enableLockScreenWeatherWidget] { + Task { @MainActor in + await LockScreenWeatherManager.shared.refresh(force: true) + } + } } func playWelcomeSound() { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 8cb52c0..206d80a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -17484,6 +17484,18 @@ }, "Volume HUD" : { + }, + "Weather: %@ %@" : { + "comment" : "A label that describes the weather and temperature. The value inside the parentheses is a combination of the weather description and the temperature.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Weather: %1$@ %2$@" + } + } + } }, "Welcome" : { "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift new file mode 100644 index 0000000..e538697 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct LockScreenWeatherWidget: View { + let snapshot: LockScreenWeatherSnapshot + + var body: some View { + HStack(spacing: 6) { + Image(systemName: snapshot.symbolName) + .font(.system(size: 26, weight: .medium)) + .symbolRenderingMode(.hierarchical) + Text(snapshot.temperatureText) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .kerning(-0.4) + } + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.clear) + .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Weather: \(snapshot.description) \(snapshot.temperatureText)") + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 00c3b87..30a7680 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -96,6 +96,7 @@ class LockScreenManager: ObservableObject { print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") LockScreenPanelManager.shared.showPanel() LockScreenLiveActivityWindowManager.shared.showLocked() + LockScreenWeatherManager.shared.showWeatherWidget() // THEN trigger lock icon in Dynamic Island (only if enabled in settings) if Defaults[.enableLockScreenLiveActivity] { @@ -115,6 +116,7 @@ class LockScreenManager: ObservableObject { print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() + LockScreenWeatherManager.shared.hideWeatherWidget() // Update state immediately if Defaults[.enableLockScreenLiveActivity] { diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift new file mode 100644 index 0000000..12c7634 --- /dev/null +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -0,0 +1,170 @@ +import Foundation +import Defaults + +@MainActor +final class LockScreenWeatherManager: ObservableObject { + static let shared = LockScreenWeatherManager() + + @Published private(set) var snapshot: LockScreenWeatherSnapshot? + + private let provider = LockScreenWeatherProvider() + private var lastFetchDate: Date? + + private init() {} + + func showWeatherWidget() { + guard Defaults[.enableLockScreenWeatherWidget] else { + LockScreenWeatherPanelManager.shared.hide() + return + } + + Task { @MainActor [weak self] in + guard let self else { return } + await self.refresh(force: self.snapshot == nil) + if let snapshot = self.snapshot { + LockScreenWeatherPanelManager.shared.show(with: snapshot) + } else { + LockScreenWeatherPanelManager.shared.hide() + } + } + } + + func hideWeatherWidget() { + LockScreenWeatherPanelManager.shared.hide() + } + + func refresh(force: Bool = false) async { + let interval = Defaults[.lockScreenWeatherRefreshInterval] + if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval { + if let snapshot = snapshot { + LockScreenWeatherPanelManager.shared.update(with: snapshot) + } + return + } + + do { + let snapshot = try await provider.fetchSnapshot() + self.snapshot = snapshot + lastFetchDate = Date() + LockScreenWeatherPanelManager.shared.update(with: snapshot) + } catch { + NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") + } + } +} + +struct LockScreenWeatherSnapshot: Equatable { + let temperatureText: String + let symbolName: String + let description: String +} + +private actor LockScreenWeatherProvider { + private let session: URLSession + private let decoder: JSONDecoder + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10 + configuration.timeoutIntervalForResource = 10 + session = URLSession(configuration: configuration) + decoder = JSONDecoder() + } + + func fetchSnapshot() async throws -> LockScreenWeatherSnapshot { + guard let url = URL(string: "https://wttr.in/?format=j1") else { + throw WeatherProviderError.invalidURL + } + + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + throw WeatherProviderError.invalidResponse + } + + let payload = try decoder.decode(WTTRResponse.self, from: data) + guard let condition = payload.currentCondition.first else { + throw WeatherProviderError.noData + } + + let usesMetric = Locale.current.usesMetricSystem + let temperature = usesMetric ? condition.tempC : condition.tempF + let temperatureText = "\(temperature)°" + + let code = Int(condition.weatherCode) ?? 113 + let symbol = WeatherSymbolMapper.symbol(for: code) + let description = condition.localizedDescription + + return LockScreenWeatherSnapshot( + temperatureText: temperatureText, + symbolName: symbol, + description: description + ) + } +} + +enum WeatherProviderError: Error { + case invalidURL + case invalidResponse + case noData +} + +private struct WTTRResponse: Decodable { + let current_condition: [WTTRCurrentCondition] + + var currentCondition: [WTTRCurrentCondition] { current_condition } +} + +private struct WTTRCurrentCondition: Decodable { + private enum CodingKeys: String, CodingKey { + case tempC = "temp_C" + case tempF = "temp_F" + case weatherCode + case weatherDesc + case langEn = "lang_en" + } + + let tempC: String + let tempF: String + let weatherCode: String + let weatherDesc: [WTTRTextValue]? + let langEn: [WTTRTextValue]? + + var localizedDescription: String { + if let english = langEn?.first?.value, !english.isEmpty { + return english + } + if let desc = weatherDesc?.first?.value, !desc.isEmpty { + return desc + } + return "" + } +} + +private struct WTTRTextValue: Decodable { + let value: String +} + +private enum WeatherSymbolMapper { + static func symbol(for code: Int) -> String { + switch code { + case 113: + return "sun.max.fill" + case 116: + return "cloud.sun.fill" + case 119, 122: + return "cloud.fill" + case 143, 248, 260: + return "cloud.fog.fill" + case 176, 263, 266, 293, 296, 299, 302, 353, 356, 359: + return "cloud.rain.fill" + case 179, 182, 185, 311, 314, 317, 320, 362, 365: + return "cloud.sleet.fill" + case 227, 230, 281, 284, 317, 320, 323, 326, 329, 332, 335, 338, 368, 371, 374, 377: + return "cloud.snow.fill" + case 200, 386, 389, 392, 395: + return "cloud.bolt.rain.fill" + default: + return "cloud.sun.fill" + } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift new file mode 100644 index 0000000..76fef4f --- /dev/null +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -0,0 +1,86 @@ +import AppKit +import SwiftUI +import SkyLightWindow + +@MainActor +final class LockScreenWeatherPanelManager { + static let shared = LockScreenWeatherPanelManager() + + private var window: NSWindow? + private var hasDelegated = false + + private init() {} + + func show(with snapshot: LockScreenWeatherSnapshot) { + render(snapshot: snapshot, makeVisible: true) + } + + func update(with snapshot: LockScreenWeatherSnapshot) { + render(snapshot: snapshot, makeVisible: false) + } + + func hide() { + guard let window else { return } + window.orderOut(nil) + window.contentView = nil + } + + private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) { + guard let screen = NSScreen.main else { return } + if !makeVisible, window == nil { + return + } + + let view = LockScreenWeatherWidget(snapshot: snapshot) + let hostingView = NSHostingView(rootView: view) + let fittingSize = hostingView.fittingSize + hostingView.frame = NSRect(origin: .zero, size: fittingSize) + + let targetFrame = frame(for: fittingSize, on: screen) + let window = ensureWindow() + window.setFrame(targetFrame, display: true) + window.contentView = hostingView + + if makeVisible { + window.orderFrontRegardless() + } + } + + private func ensureWindow() -> NSWindow { + if let window { + return window + } + + let frame = NSRect(origin: .zero, size: CGSize(width: 110, height: 40)) + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = false + newWindow.ignoresMouseEvents = true + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + window = newWindow + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(newWindow) + hasDelegated = true + } + return newWindow + } + + private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let originX = screenFrame.midX - (size.width / 2) + let verticalOffset = screenFrame.height * 0.15 + let maxY = screenFrame.maxY - size.height - 48 + let originY = min(maxY, screenFrame.midY + verticalOffset) + return NSRect(x: originX, y: originY, width: size.width, height: size.height) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 40c17cd..503bf36 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -336,6 +336,8 @@ extension Defaults.Keys { static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) + static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) + static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)