mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Added EAQI support
This commit is contained in:
parent
d718cbbb07
commit
7e31286ae3
6 changed files with 175 additions and 36 deletions
|
|
@ -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" : {
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
case eventColor = "Event color"
|
||||
case monochrome = "White"
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -385,6 +385,7 @@ extension Defaults.Keys {
|
|||
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
|
||||
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
|
||||
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 lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
|
||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue