Merge pull request #101 from Ebullioscopic/feature/lock-screen

Update EAQI and Macbook Icon
This commit is contained in:
Hariharan Mudaliar 2025-11-14 00:54:01 +05:30 • committed by GitHub
commit 583e244e8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 205 additions and 42 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" : {
"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" : {
@ -10877,6 +10888,9 @@
},
"Mac battery at %d percent" : {
"comment" : "Mac battery gauge accessibility label"
},
"MacBook icon is available in the circular layout." : {
},
"Made ❤️ by Ebullioscopic" : {

View file

@ -158,12 +158,13 @@ struct LockScreenWeatherWidget: View {
private func circularBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
let level = clampedBatteryLevel(info.batteryLevel)
let symbolName = info.usesLaptopSymbol ? "laptopcomputer" : batteryIconName(for: level)
return VStack(spacing: 6) {
Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: batteryIconName(for: level))
Image(systemName: symbolName)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
@ -246,7 +247,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 +255,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 +267,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 +448,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 +572,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)"
)
}

View file

@ -188,8 +188,10 @@ 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
@Default(.lockScreenWeatherBatteryUsesLaptopSymbol) var lockScreenWeatherBatteryUsesLaptopSymbol
var body: some View {
Form {
@ -402,9 +404,26 @@ struct GeneralSettings: View {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
if lockScreenWeatherShowsBatteryGauge {
Defaults.Toggle("Use MacBook icon when on battery", key: .lockScreenWeatherBatteryUsesLaptopSymbol)
.disabled(lockScreenWeatherWidgetStyle != .circular)
if lockScreenWeatherWidgetStyle != .circular {
Text("MacBook icon is available in the circular layout.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
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)

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 {
case eventColor = "Event color"
case monochrome = "White"

View file

@ -105,7 +105,8 @@ final class LockScreenWeatherManager: ObservableObject {
let showsCharging = Defaults[.lockScreenWeatherShowsCharging]
let chargingInfo = showsCharging ? makeChargingInfo() : nil
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil
let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill",
@ -116,7 +117,7 @@ final class LockScreenWeatherManager: ObservableObject {
battery: batteryInfo,
showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
widgetStyle: widgetStyle,
showsChargingPercentage: Defaults[.lockScreenWeatherShowsChargingPercentage],
temperatureInfo: snapshot?.temperatureInfo,
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint]
@ -189,6 +190,8 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherBatteryUsesLaptopSymbol, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
@ -198,6 +201,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 +234,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 }
@ -276,7 +292,7 @@ final class LockScreenWeatherManager: ObservableObject {
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot(
@ -321,7 +337,7 @@ final class LockScreenWeatherManager: ObservableObject {
)
}
private func makeBatteryGaugeInfo(isCharging: Bool) -> LockScreenWeatherSnapshot.BatteryInfo? {
private func makeBatteryGaugeInfo(isCharging: Bool, widgetStyle: LockScreenWeatherWidgetStyle) -> LockScreenWeatherSnapshot.BatteryInfo? {
guard !isCharging else { return nil }
let battery = BatteryStatusViewModel.shared
@ -330,7 +346,12 @@ final class LockScreenWeatherManager: ObservableObject {
guard clampedLevel >= 0 else { return nil }
return LockScreenWeatherSnapshot.BatteryInfo(batteryLevel: clampedLevel)
let usesLaptopSymbol = widgetStyle == .circular && Defaults[.lockScreenWeatherBatteryUsesLaptopSymbol]
return LockScreenWeatherSnapshot.BatteryInfo(
batteryLevel: clampedLevel,
usesLaptopSymbol: usesLaptopSymbol
)
}
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
@ -414,25 +435,34 @@ struct LockScreenWeatherSnapshot: Equatable {
struct BatteryInfo: Equatable {
let batteryLevel: Int
let usesLaptopSymbol: Bool
}
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 +471,7 @@ struct LockScreenWeatherSnapshot: Equatable {
let index: Int
let category: Category
let scale: LockScreenWeatherAirQualityScale
}
let temperatureText: String
@ -459,24 +490,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 +623,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 +703,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 +727,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 +748,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 +900,7 @@ private struct OpenMeteoAirQualityResponse: Decodable {
struct Current: Decodable {
let time: String?
let usAqi: Double?
let europeanAqi: Double?
}
let current: Current?

View file

@ -382,9 +382,11 @@ extension Defaults.Keys {
static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
static let lockScreenWeatherBatteryUsesLaptopSymbol = Key<Bool>("lockScreenWeatherBatteryUsesLaptopSymbol", default: false)
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)