From 7e31286ae3964d197878f3f80458d351450814d1 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 14 Nov 2025 00:30:35 +0530 Subject: [PATCH] Added EAQI support --- DynamicIsland/Localizable.xcstrings | 19 ++- .../LockScreen/LockScreenWeatherWidget.swift | 27 +++-- .../components/Settings/SettingsView.swift | 9 ++ DynamicIsland/enums/generic.swift | 45 +++++++ .../managers/LockScreenWeatherManager.swift | 110 ++++++++++++++---- DynamicIsland/models/Constants.swift | 1 + 6 files changed, 175 insertions(+), 36 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 1069dba..e7e7c42 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -729,6 +729,18 @@ } } }, + "%@ · %@" : { + "comment" : "A secondary line of text that provides additional information about the air quality index, including its scale label and category name.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ · %2$@" + } + } + } + }, "%@ Models" : { "localizations" : { "fr" : { @@ -2342,6 +2354,9 @@ }, "Air quality requires the Open Meteo provider." : { + }, + "Air quality scale" : { + }, "AirDrop" : { "localizations" : { @@ -3185,10 +3200,6 @@ } } }, - "AQI · %@" : { - "comment" : "A label displaying the air quality index and its corresponding description. The argument is the air quality index.", - "isCommentAutoGenerated" : true - }, "Are you sure you want to delete \"%@\"?" : { "comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.", "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 8d0380d..4500058 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -246,7 +246,7 @@ struct LockScreenWeatherWidget: View { Image(systemName: "wind") .font(.system(size: 18, weight: .semibold)) .symbolRenderingMode(.hierarchical) - inlineComposite(primary: "AQI \(info.index)", secondary: info.category.displayName) + inlineComposite(primary: "\(info.scale.compactLabel) \(info.index)", secondary: info.category.displayName) .lineLimit(1) .minimumScaleFactor(0.85) } @@ -254,8 +254,11 @@ struct LockScreenWeatherWidget: View { } private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { - VStack(spacing: 6) { - Gauge(value: Double(info.index), in: 0...500) { + let range = info.scale.gaugeRange + let clampedValue = min(max(Double(info.index), range.lowerBound), range.upperBound) + + return VStack(spacing: 6) { + Gauge(value: clampedValue, in: range) { EmptyView() } currentValueLabel: { Text("\(info.index)") @@ -263,10 +266,10 @@ struct LockScreenWeatherWidget: View { .foregroundStyle(Color.white) } .gaugeStyle(.accessoryCircular) - .tint(aqiTint(for: info.category)) + .tint(aqiTint(for: info)) .frame(width: gaugeDiameter, height: gaugeDiameter) - Text("AQI · \(info.category.displayName)") + Text("\(info.scale.compactLabel) · \(info.category.displayName)") .font(inlineSecondaryFont) .foregroundStyle(secondaryLabelColor) .lineLimit(1) @@ -444,19 +447,27 @@ struct LockScreenWeatherWidget: View { } } - private func aqiTint(for category: LockScreenWeatherSnapshot.AirQualityInfo.Category) -> Color { + private func aqiTint(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> Color { guard snapshot.usesGaugeTint else { return monochromeGaugeTint } - switch category { + switch info.category { case .good: return Color(red: 0.20, green: 0.79, blue: 0.39) + case .fair: + return Color(red: 0.55, green: 0.85, blue: 0.32) case .moderate: return Color(red: 0.97, green: 0.82, blue: 0.30) case .unhealthyForSensitive: return Color(red: 0.98, green: 0.57, blue: 0.24) case .unhealthy: return Color(red: 0.91, green: 0.29, blue: 0.25) + case .poor: + return Color(red: 0.98, green: 0.57, blue: 0.24) + case .veryPoor: + return Color(red: 0.91, green: 0.29, blue: 0.25) case .veryUnhealthy: return Color(red: 0.65, green: 0.32, blue: 0.86) + case .extremelyPoor: + return Color(red: 0.50, green: 0.13, blue: 0.28) case .hazardous: return Color(red: 0.50, green: 0.13, blue: 0.28) case .unknown: @@ -560,7 +571,7 @@ struct LockScreenWeatherWidget: View { String( format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"), airQuality.index, - airQuality.category.displayName + "\(airQuality.scale.accessibilityLabel) \(airQuality.category.displayName)" ) } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 8381fa1..61d8e4c 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -188,6 +188,7 @@ struct GeneralSettings: View { @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle @Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI + @Default(.lockScreenWeatherAQIScale) var lockScreenWeatherAQIScale @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource @@ -405,6 +406,14 @@ struct GeneralSettings: View { Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI) .disabled(!lockScreenWeatherProviderSource.supportsAirQuality) + if lockScreenWeatherShowsAQI && lockScreenWeatherProviderSource.supportsAirQuality { + Picker("Air quality scale", selection: $lockScreenWeatherAQIScale) { + ForEach(LockScreenWeatherAirQualityScale.allCases) { scale in + Text(scale.displayName).tag(scale) + } + } + .pickerStyle(.segmented) + } if !lockScreenWeatherProviderSource.supportsAirQuality { Text("Air quality requires the Open Meteo provider.") .font(.caption) diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index da5827a..a4b5844 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -126,6 +126,51 @@ enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializab } } +enum LockScreenWeatherAirQualityScale: String, CaseIterable, Defaults.Serializable, Identifiable { + case us = "U.S. AQI" + case european = "EAQI" + + var id: String { rawValue } + + var displayName: String { rawValue } + + var compactLabel: String { + switch self { + case .us: + return "AQI" + case .european: + return "EAQI" + } + } + + var accessibilityLabel: String { + switch self { + case .us: + return "AQI" + case .european: + return "EAQI" + } + } + + var queryParameter: String { + switch self { + case .us: + return "us_aqi" + case .european: + return "european_aqi" + } + } + + var gaugeRange: ClosedRange { + switch self { + case .us: + return 0...500 + case .european: + return 0...120 + } + } +} + enum LockScreenReminderChipStyle: String, CaseIterable, Defaults.Serializable, Identifiable { case eventColor = "Event color" case monochrome = "White" diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index 4eb74b6..6312279 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -198,6 +198,8 @@ final class LockScreenWeatherManager: ObservableObject { Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: []) .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherProviderSource, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherAQIScale, options: []) .map { _ in () }.eraseToAnyPublisher() ] @@ -229,6 +231,17 @@ final class LockScreenWeatherManager: ObservableObject { } .store(in: &cancellables) + Defaults.publisher(.lockScreenWeatherAQIScale, options: []) + .sink { [weak self] _ in + guard let self else { return } + self.latestWeatherPayload = nil + Task { @MainActor in + NSLog("LockScreenWeatherManager: AQI scale changed, forcing refresh") + _ = await self.refresh(force: true) + } + } + .store(in: &cancellables) + Defaults.publisher(.enableLockScreenWeatherWidget, options: []) .sink { [weak self] change in guard let self else { return } @@ -419,20 +432,28 @@ struct LockScreenWeatherSnapshot: Equatable { struct AirQualityInfo: Equatable { enum Category: String, Equatable { case good + case fair case moderate case unhealthyForSensitive case unhealthy + case poor + case veryPoor case veryUnhealthy + case extremelyPoor case hazardous case unknown var displayName: String { switch self { case .good: return "Good" + case .fair: return "Fair" case .moderate: return "Moderate" case .unhealthyForSensitive: return "Sensitive" case .unhealthy: return "Unhealthy" + case .poor: return "Poor" + case .veryPoor: return "Very Poor" case .veryUnhealthy: return "Very Unhealthy" + case .extremelyPoor: return "Extremely Poor" case .hazardous: return "Hazardous" case .unknown: return "Unknown" } @@ -441,6 +462,7 @@ struct LockScreenWeatherSnapshot: Equatable { let index: Int let category: Category + let scale: LockScreenWeatherAirQualityScale } let temperatureText: String @@ -459,24 +481,46 @@ struct LockScreenWeatherSnapshot: Equatable { } extension LockScreenWeatherSnapshot.AirQualityInfo.Category { - init(aqiValue: Int) { - switch aqiValue { - case ..<0: - self = .unknown - case 0...50: - self = .good - case 51...100: - self = .moderate - case 101...150: - self = .unhealthyForSensitive - case 151...200: - self = .unhealthy - case 201...300: - self = .veryUnhealthy - case 301...: - self = .hazardous - default: - self = .unknown + init(index: Int, scale: LockScreenWeatherAirQualityScale) { + switch scale { + case .us: + switch index { + case ..<0: + self = .unknown + case 0...50: + self = .good + case 51...100: + self = .moderate + case 101...150: + self = .unhealthyForSensitive + case 151...200: + self = .unhealthy + case 201...300: + self = .veryUnhealthy + case 301...: + self = .hazardous + default: + self = .unknown + } + case .european: + switch index { + case ..<0: + self = .unknown + case 0...20: + self = .good + case 21...40: + self = .fair + case 41...60: + self = .moderate + case 61...80: + self = .poor + case 81...100: + self = .veryPoor + case 101...: + self = .extremelyPoor + default: + self = .unknown + } } } } @@ -570,9 +614,11 @@ private actor LockScreenWeatherProvider { let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo? if let index = condition.airQuality?.usIndexValue { + let scale: LockScreenWeatherAirQualityScale = .us airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo( index: index, - category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index) + category: LockScreenWeatherSnapshot.AirQualityInfo.Category(index: index, scale: scale), + scale: scale ) } else { airQualityInfo = nil @@ -648,9 +694,11 @@ private actor LockScreenWeatherProvider { unitSymbol: unitSymbol ) + let selectedScale = Defaults[.lockScreenWeatherAQIScale] + var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo? if Defaults[.lockScreenWeatherShowsAQI] { - airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude) + airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude, scale: selectedScale) } return LockScreenWeatherSnapshot( @@ -670,12 +718,12 @@ private actor LockScreenWeatherProvider { ) } - private func fetchOpenMeteoAirQuality(latitude: String, longitude: String) async throws -> LockScreenWeatherSnapshot.AirQualityInfo? { + private func fetchOpenMeteoAirQuality(latitude: String, longitude: String, scale: LockScreenWeatherAirQualityScale) async throws -> LockScreenWeatherSnapshot.AirQualityInfo? { var airComponents = URLComponents(string: "https://air-quality-api.open-meteo.com/v1/air-quality") airComponents?.queryItems = [ URLQueryItem(name: "latitude", value: latitude), URLQueryItem(name: "longitude", value: longitude), - URLQueryItem(name: "current", value: "us_aqi"), + URLQueryItem(name: "current", value: scale.queryParameter), URLQueryItem(name: "timezone", value: "auto") ] @@ -691,14 +739,27 @@ private actor LockScreenWeatherProvider { let airDecoder = JSONDecoder() airDecoder.keyDecodingStrategy = .convertFromSnakeCase let airPayload = try airDecoder.decode(OpenMeteoAirQualityResponse.self, from: airData) - guard let airCurrent = airPayload.current, let indexValue = airCurrent.usAqi else { + guard let airCurrent = airPayload.current else { + return nil + } + + let rawValue: Double? + switch scale { + case .us: + rawValue = airCurrent.usAqi + case .european: + rawValue = airCurrent.europeanAqi + } + + guard let indexValue = rawValue else { return nil } let index = Int(round(indexValue)) return LockScreenWeatherSnapshot.AirQualityInfo( index: index, - category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index) + category: LockScreenWeatherSnapshot.AirQualityInfo.Category(index: index, scale: scale), + scale: scale ) } } @@ -830,6 +891,7 @@ private struct OpenMeteoAirQualityResponse: Decodable { struct Current: Decodable { let time: String? let usAqi: Double? + let europeanAqi: Double? } let current: Current? diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index f7372df..90a5242 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -385,6 +385,7 @@ extension Defaults.Keys { static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false) + static let lockScreenWeatherAQIScale = Key("lockScreenWeatherAQIScale", default: .us) static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid)