From b44d369994370ad068ed46bf4a3d54db930f4ee7 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 20 Oct 2025 00:53:16 +0530 Subject: [PATCH] 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 + } +}