Added EAQI support

This commit is contained in:
Hariharan Mudaliar 2025-11-14 00:30:35 +05:30
commit 7e31286ae3
6 changed files with 175 additions and 36 deletions

View file

@ -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" : { "%@ Models" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -2342,6 +2354,9 @@
}, },
"Air quality requires the Open Meteo provider." : { "Air quality requires the Open Meteo provider." : {
},
"Air quality scale" : {
}, },
"AirDrop" : { "AirDrop" : {
"localizations" : { "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 \"%@\"?" : { "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.", "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" : { "localizations" : {

View file

@ -246,7 +246,7 @@ struct LockScreenWeatherWidget: View {
Image(systemName: "wind") Image(systemName: "wind")
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 18, weight: .semibold))
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
inlineComposite(primary: "AQI \(info.index)", secondary: info.category.displayName) inlineComposite(primary: "\(info.scale.compactLabel) \(info.index)", secondary: info.category.displayName)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
} }
@ -254,8 +254,11 @@ struct LockScreenWeatherWidget: View {
} }
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
VStack(spacing: 6) { let range = info.scale.gaugeRange
Gauge(value: Double(info.index), in: 0...500) { let clampedValue = min(max(Double(info.index), range.lowerBound), range.upperBound)
return VStack(spacing: 6) {
Gauge(value: clampedValue, in: range) {
EmptyView() EmptyView()
} currentValueLabel: { } currentValueLabel: {
Text("\(info.index)") Text("\(info.index)")
@ -263,10 +266,10 @@ struct LockScreenWeatherWidget: View {
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} }
.gaugeStyle(.accessoryCircular) .gaugeStyle(.accessoryCircular)
.tint(aqiTint(for: info.category)) .tint(aqiTint(for: info))
.frame(width: gaugeDiameter, height: gaugeDiameter) .frame(width: gaugeDiameter, height: gaugeDiameter)
Text("AQI · \(info.category.displayName)") Text("\(info.scale.compactLabel) · \(info.category.displayName)")
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
.lineLimit(1) .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 } guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
switch category { switch info.category {
case .good: case .good:
return Color(red: 0.20, green: 0.79, blue: 0.39) 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: case .moderate:
return Color(red: 0.97, green: 0.82, blue: 0.30) return Color(red: 0.97, green: 0.82, blue: 0.30)
case .unhealthyForSensitive: case .unhealthyForSensitive:
return Color(red: 0.98, green: 0.57, blue: 0.24) return Color(red: 0.98, green: 0.57, blue: 0.24)
case .unhealthy: case .unhealthy:
return Color(red: 0.91, green: 0.29, blue: 0.25) 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: case .veryUnhealthy:
return Color(red: 0.65, green: 0.32, blue: 0.86) 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: case .hazardous:
return Color(red: 0.50, green: 0.13, blue: 0.28) return Color(red: 0.50, green: 0.13, blue: 0.28)
case .unknown: case .unknown:
@ -560,7 +571,7 @@ struct LockScreenWeatherWidget: View {
String( String(
format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"), format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"),
airQuality.index, airQuality.index,
airQuality.category.displayName "\(airQuality.scale.accessibilityLabel) \(airQuality.category.displayName)"
) )
} }

View file

@ -188,6 +188,7 @@ struct GeneralSettings: View {
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit @Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherAQIScale) var lockScreenWeatherAQIScale
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
@ -405,6 +406,14 @@ struct GeneralSettings: View {
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI) Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality) .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 { if !lockScreenWeatherProviderSource.supportsAirQuality {
Text("Air quality requires the Open Meteo provider.") Text("Air quality requires the Open Meteo provider.")
.font(.caption) .font(.caption)

View file

@ -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<Double> {
switch self {
case .us:
return 0...500
case .european:
return 0...120
}
}
}
enum LockScreenReminderChipStyle: String, CaseIterable, Defaults.Serializable, Identifiable { enum LockScreenReminderChipStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
case eventColor = "Event color" case eventColor = "Event color"
case monochrome = "White" case monochrome = "White"

View file

@ -198,6 +198,8 @@ final class LockScreenWeatherManager: ObservableObject {
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: []) Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherProviderSource, options: []) Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherAQIScale, options: [])
.map { _ in () }.eraseToAnyPublisher() .map { _ in () }.eraseToAnyPublisher()
] ]
@ -229,6 +231,17 @@ final class LockScreenWeatherManager: ObservableObject {
} }
.store(in: &cancellables) .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: []) Defaults.publisher(.enableLockScreenWeatherWidget, options: [])
.sink { [weak self] change in .sink { [weak self] change in
guard let self else { return } guard let self else { return }
@ -419,20 +432,28 @@ struct LockScreenWeatherSnapshot: Equatable {
struct AirQualityInfo: Equatable { struct AirQualityInfo: Equatable {
enum Category: String, Equatable { enum Category: String, Equatable {
case good case good
case fair
case moderate case moderate
case unhealthyForSensitive case unhealthyForSensitive
case unhealthy case unhealthy
case poor
case veryPoor
case veryUnhealthy case veryUnhealthy
case extremelyPoor
case hazardous case hazardous
case unknown case unknown
var displayName: String { var displayName: String {
switch self { switch self {
case .good: return "Good" case .good: return "Good"
case .fair: return "Fair"
case .moderate: return "Moderate" case .moderate: return "Moderate"
case .unhealthyForSensitive: return "Sensitive" case .unhealthyForSensitive: return "Sensitive"
case .unhealthy: return "Unhealthy" case .unhealthy: return "Unhealthy"
case .poor: return "Poor"
case .veryPoor: return "Very Poor"
case .veryUnhealthy: return "Very Unhealthy" case .veryUnhealthy: return "Very Unhealthy"
case .extremelyPoor: return "Extremely Poor"
case .hazardous: return "Hazardous" case .hazardous: return "Hazardous"
case .unknown: return "Unknown" case .unknown: return "Unknown"
} }
@ -441,6 +462,7 @@ struct LockScreenWeatherSnapshot: Equatable {
let index: Int let index: Int
let category: Category let category: Category
let scale: LockScreenWeatherAirQualityScale
} }
let temperatureText: String let temperatureText: String
@ -459,24 +481,46 @@ struct LockScreenWeatherSnapshot: Equatable {
} }
extension LockScreenWeatherSnapshot.AirQualityInfo.Category { extension LockScreenWeatherSnapshot.AirQualityInfo.Category {
init(aqiValue: Int) { init(index: Int, scale: LockScreenWeatherAirQualityScale) {
switch aqiValue { switch scale {
case ..<0: case .us:
self = .unknown switch index {
case 0...50: case ..<0:
self = .good self = .unknown
case 51...100: case 0...50:
self = .moderate self = .good
case 101...150: case 51...100:
self = .unhealthyForSensitive self = .moderate
case 151...200: case 101...150:
self = .unhealthy self = .unhealthyForSensitive
case 201...300: case 151...200:
self = .veryUnhealthy self = .unhealthy
case 301...: case 201...300:
self = .hazardous self = .veryUnhealthy
default: case 301...:
self = .unknown 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? let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if let index = condition.airQuality?.usIndexValue { if let index = condition.airQuality?.usIndexValue {
let scale: LockScreenWeatherAirQualityScale = .us
airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo( airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo(
index: index, index: index,
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index) category: LockScreenWeatherSnapshot.AirQualityInfo.Category(index: index, scale: scale),
scale: scale
) )
} else { } else {
airQualityInfo = nil airQualityInfo = nil
@ -648,9 +694,11 @@ private actor LockScreenWeatherProvider {
unitSymbol: unitSymbol unitSymbol: unitSymbol
) )
let selectedScale = Defaults[.lockScreenWeatherAQIScale]
var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo? var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if Defaults[.lockScreenWeatherShowsAQI] { if Defaults[.lockScreenWeatherShowsAQI] {
airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude) airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude, scale: selectedScale)
} }
return LockScreenWeatherSnapshot( 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") var airComponents = URLComponents(string: "https://air-quality-api.open-meteo.com/v1/air-quality")
airComponents?.queryItems = [ airComponents?.queryItems = [
URLQueryItem(name: "latitude", value: latitude), URLQueryItem(name: "latitude", value: latitude),
URLQueryItem(name: "longitude", value: longitude), URLQueryItem(name: "longitude", value: longitude),
URLQueryItem(name: "current", value: "us_aqi"), URLQueryItem(name: "current", value: scale.queryParameter),
URLQueryItem(name: "timezone", value: "auto") URLQueryItem(name: "timezone", value: "auto")
] ]
@ -691,14 +739,27 @@ private actor LockScreenWeatherProvider {
let airDecoder = JSONDecoder() let airDecoder = JSONDecoder()
airDecoder.keyDecodingStrategy = .convertFromSnakeCase airDecoder.keyDecodingStrategy = .convertFromSnakeCase
let airPayload = try airDecoder.decode(OpenMeteoAirQualityResponse.self, from: airData) 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 return nil
} }
let index = Int(round(indexValue)) let index = Int(round(indexValue))
return LockScreenWeatherSnapshot.AirQualityInfo( return LockScreenWeatherSnapshot.AirQualityInfo(
index: index, 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 { struct Current: Decodable {
let time: String? let time: String?
let usAqi: Double? let usAqi: Double?
let europeanAqi: Double?
} }
let current: Current? let current: Current?

View file

@ -385,6 +385,7 @@ extension Defaults.Keys {
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false) static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherAQIScale = Key<LockScreenWeatherAirQualityScale>("lockScreenWeatherAQIScale", default: .us)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)