From 7ae8f0bc7c516ba977b47ec241cde4ab0ac2b475 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 19 Oct 2025 22:55:59 +0530 Subject: [PATCH 1/3] Working Weather widget --- .gitignore | 2 +- DynamicIsland/DynamicIslandApp.swift | 6 + DynamicIsland/Localizable.xcstrings | 12 ++ .../LockScreen/LockScreenWeatherWidget.swift | 23 +++ .../managers/LockScreenManager.swift | 2 + .../managers/LockScreenWeatherManager.swift | 170 ++++++++++++++++++ .../LockScreenWeatherPanelManager.swift | 86 +++++++++ DynamicIsland/models/Constants.swift | 2 + 8 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift create mode 100644 DynamicIsland/managers/LockScreenWeatherManager.swift create mode 100644 DynamicIsland/managers/LockScreenWeatherPanelManager.swift 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) From 3533a2674685798e63d97ca020a2a0f35ce8638d Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 19 Oct 2025 23:18:33 +0530 Subject: [PATCH 2/3] Working with settings --- DynamicIsland/Localizable.xcstrings | 6 +----- .../LockScreen/LockScreenWeatherWidget.swift | 2 +- DynamicIsland/components/Settings/SettingsView.swift | 10 +++------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 206d80a..35230ae 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -6680,10 +6680,6 @@ } } }, - "Enable lock screen media widget" : { - "comment" : "A toggle that enables or disables the lock screen media widget.", - "isCommentAutoGenerated" : true - }, "Enable music live activity" : { "localizations" : { "ar" : { @@ -15199,7 +15195,7 @@ } } }, - "Shows a lock icon in the Dynamic Island when the screen is locked. The lock screen music panel can be toggled separately in Media settings." : { + "Shows a lock icon in the Dynamic Island when the screen is locked. Use the toggles above to control the lock screen media panel and weather capsule." : { }, "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs." : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index e538697..da047bd 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -12,7 +12,7 @@ struct LockScreenWeatherWidget: View { .font(.system(size: 24, weight: .semibold, design: .rounded)) .kerning(-0.4) } - .foregroundStyle(.white) + .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.clear) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index e13635a..dc29bb0 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -322,6 +322,8 @@ struct GeneralSettings: View { Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon) Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder) Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) + Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) + Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) Button("Copy Latest Crash Report") { copyLatestCrashReport() @@ -329,7 +331,7 @@ struct GeneralSettings: View { } header: { Text("Lock Screen") } footer: { - Text("Shows a lock icon in the Dynamic Island when the screen is locked. The lock screen music panel can be toggled separately in Media settings.") + Text("Shows a lock icon in the Dynamic Island when the screen is locked. Use the toggles above to control the lock screen media panel and weather capsule.") } Section { @@ -782,12 +784,6 @@ struct Media: View { customBadge(text: "Beta") } } - // Enable/disable the lock screen media widget - Defaults.Toggle(key: .enableLockScreenMediaWidget) { - HStack { - Text("Enable lock screen media widget") - } - } } header: { Text("Media controls") } From b44d369994370ad068ed46bf4a3d54db930f4ee7 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 20 Oct 2025 00:53:16 +0530 Subject: [PATCH 3/3] Fully working --- DynamicIsland.xcodeproj/project.pbxproj | 4 +- DynamicIsland/DynamicIslandApp.swift | 1 + DynamicIsland/Info.plist | 4 + DynamicIsland/Localizable.xcstrings | 14 ++- .../LockScreen/LockScreenWeatherWidget.swift | 28 ++++- .../managers/LockScreenWeatherManager.swift | 108 +++++++++++++++++- 6 files changed, 149 insertions(+), 10 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index a587c08..84d3ad6 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -408,7 +408,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "1.0.0-beta"; + MARKETING_VERSION = "1.0.1-beta"; PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -456,7 +456,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = "1.0.0-beta"; + MARKETING_VERSION = "1.0.1-beta"; PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 7cc9a4f..384401b 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -658,6 +658,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { previousScreens = NSScreen.screens if Defaults[.enableLockScreenWeatherWidget] { + LockScreenWeatherManager.shared.prepareLocationAccess() Task { @MainActor in await LockScreenWeatherManager.shared.refresh(force: true) } diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index 65dc943..034e9ab 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -11,6 +11,10 @@ NSBluetoothAlwaysUsageDescription Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD. + NSLocationUsageDescription + Dynamic Island uses your location to display accurate lock screen weather. + NSLocationWhenInUseUsageDescription + Dynamic Island uses your location to display accurate lock screen weather. NSScreenCaptureUsageDescription Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature. SUEnableDownloaderService diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 35230ae..a9f0a7d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -17482,8 +17482,7 @@ }, "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, + "comment" : "Weather description and temperature", "localizations" : { "en" : { "stringUnit" : { @@ -17493,6 +17492,17 @@ } } }, + "Weather: %@ %@ in %@" : { + "comment" : "Weather description, temperature, and location", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Weather: %1$@ %2$@ in %3$@" + } + } + } + }, "Welcome" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index da047bd..9aa2bee 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -4,13 +4,20 @@ struct LockScreenWeatherWidget: View { let snapshot: LockScreenWeatherSnapshot var body: some View { - HStack(spacing: 6) { + HStack(spacing: 8) { 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) + if let locationName = snapshot.locationName, !locationName.isEmpty { + Text("•") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + Text(locationName) + .font(.system(size: 20, weight: .medium, design: .rounded)) + .foregroundStyle(Color.white.opacity(0.65)) + } } .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) @@ -18,6 +25,23 @@ struct LockScreenWeatherWidget: View { .background(Color.clear) .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) .accessibilityElement(children: .ignore) - .accessibilityLabel("Weather: \(snapshot.description) \(snapshot.temperatureText)") + .accessibilityLabel(accessibilityLabel) + } + + private var accessibilityLabel: String { + if let locationName = snapshot.locationName, !locationName.isEmpty { + return String( + format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), + snapshot.description, + snapshot.temperatureText, + locationName + ) + } + + return String( + format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), + snapshot.description, + snapshot.temperatureText + ) } } diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index 12c7634..cb0c272 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -1,5 +1,6 @@ import Foundation import Defaults +import CoreLocation @MainActor final class LockScreenWeatherManager: ObservableObject { @@ -8,16 +9,23 @@ final class LockScreenWeatherManager: ObservableObject { @Published private(set) var snapshot: LockScreenWeatherSnapshot? private let provider = LockScreenWeatherProvider() + private let locationProvider = LockScreenWeatherLocationProvider() private var lastFetchDate: Date? private init() {} + func prepareLocationAccess() { + locationProvider.prepareAuthorization() + } + func showWeatherWidget() { guard Defaults[.enableLockScreenWeatherWidget] else { LockScreenWeatherPanelManager.shared.hide() return } + locationProvider.prepareAuthorization() + Task { @MainActor [weak self] in guard let self else { return } await self.refresh(force: self.snapshot == nil) @@ -43,7 +51,8 @@ final class LockScreenWeatherManager: ObservableObject { } do { - let snapshot = try await provider.fetchSnapshot() + let location = await locationProvider.currentLocation() + let snapshot = try await provider.fetchSnapshot(location: location) self.snapshot = snapshot lastFetchDate = Date() LockScreenWeatherPanelManager.shared.update(with: snapshot) @@ -57,6 +66,7 @@ struct LockScreenWeatherSnapshot: Equatable { let temperatureText: String let symbolName: String let description: String + let locationName: String? } private actor LockScreenWeatherProvider { @@ -71,8 +81,19 @@ private actor LockScreenWeatherProvider { decoder = JSONDecoder() } - func fetchSnapshot() async throws -> LockScreenWeatherSnapshot { - guard let url = URL(string: "https://wttr.in/?format=j1") else { + func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot { + let locationSuffix: String + if let coordinate = location?.coordinate { + let lat = String(format: "%.4f", coordinate.latitude) + let lon = String(format: "%.4f", coordinate.longitude) + locationSuffix = "\(lat),\(lon)" + } else { + locationSuffix = "" + } + + let urlString = locationSuffix.isEmpty ? "https://wttr.in/?format=j1" : "https://wttr.in/\(locationSuffix)?format=j1" + + guard let url = URL(string: urlString) else { throw WeatherProviderError.invalidURL } @@ -94,10 +115,14 @@ private actor LockScreenWeatherProvider { let symbol = WeatherSymbolMapper.symbol(for: code) let description = condition.localizedDescription + let nearest = payload.nearestArea.first + let locationName = nearest?.preferredName + return LockScreenWeatherSnapshot( temperatureText: temperatureText, symbolName: symbol, - description: description + description: description, + locationName: locationName ) } } @@ -110,8 +135,10 @@ enum WeatherProviderError: Error { private struct WTTRResponse: Decodable { let current_condition: [WTTRCurrentCondition] + let nearest_area: [WTTRNearestArea]? var currentCondition: [WTTRCurrentCondition] { current_condition } + var nearestArea: [WTTRNearestArea] { nearest_area ?? [] } } private struct WTTRCurrentCondition: Decodable { @@ -144,6 +171,31 @@ private struct WTTRTextValue: Decodable { let value: String } +private struct WTTRNearestArea: Decodable { + let areaName: [WTTRTextValue]? + let region: [WTTRTextValue]? + let country: [WTTRTextValue]? + + private enum CodingKeys: String, CodingKey { + case areaName = "areaName" + case region + case country + } + + var preferredName: String? { + if let name = areaName?.first?.value, !name.isEmpty { + return name + } + if let regionName = region?.first?.value, !regionName.isEmpty { + return regionName + } + if let countryName = country?.first?.value, !countryName.isEmpty { + return countryName + } + return nil + } +} + private enum WeatherSymbolMapper { static func symbol(for code: Int) -> String { switch code { @@ -168,3 +220,51 @@ private enum WeatherSymbolMapper { } } } + +@MainActor +private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate { + private let manager: CLLocationManager + private var continuation: CheckedContinuation? + private var lastLocation: CLLocation? + + override init() { + manager = CLLocationManager() + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyHundredMeters + } + + func prepareAuthorization() { + let status = CLLocationManager.authorizationStatus() + if status == .notDetermined { + manager.requestWhenInUseAuthorization() + } + } + + func currentLocation() async -> CLLocation? { + let status = CLLocationManager.authorizationStatus() + switch status { + case .authorizedAlways, .authorizedWhenInUse: + if let lastLocation, abs(lastLocation.timestamp.timeIntervalSinceNow) < 1800 { + return lastLocation + } + manager.requestLocation() + return await withCheckedContinuation { continuation in + self.continuation = continuation + } + default: + return nil + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + lastLocation = locations.last + continuation?.resume(returning: lastLocation) + continuation = nil + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + continuation?.resume(returning: nil) + continuation = nil + } +}