Add circular progress

This commit is contained in:
Hariharan Mudaliar 2025-11-03 00:16:47 +05:30
commit 5f324ecde8
9 changed files with 1071 additions and 186 deletions

2
.gitignore vendored
View file

@ -147,3 +147,5 @@ borrowing/
MoveToSky.xcodeproj MoveToSky.xcodeproj
/MoveToSky/ /MoveToSky/
mew-notch/ mew-notch/
/References/

View file

@ -221,6 +221,7 @@
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */, B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
); );
preferredProjectObjectVersion = 77; preferredProjectObjectVersion = 77;
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
@ -541,6 +542,14 @@
minimumVersion = 2.6.4; minimumVersion = 2.6.4;
}; };
}; };
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/open-meteo/open-meteo";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.4.16;
};
};
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = { 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; repositoryURL = "https://github.com/Lakr233/SkyLightWindow";

View file

@ -1,5 +1,5 @@
{ {
"originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944", "originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce",
"pins" : [ "pins" : [
{ {
"identity" : "defaults", "identity" : "defaults",
@ -55,6 +55,15 @@
"version" : "0.1.0" "version" : "0.1.0"
} }
}, },
{
"identity" : "open-meteo",
"kind" : "remoteSourceControl",
"location" : "https://github.com/open-meteo/open-meteo",
"state" : {
"revision" : "f18ccb781804c444b87e6175a4b62ecfbe0212dc",
"version" : "1.4.16"
}
},
{ {
"identity" : "pow", "identity" : "pow",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View file

@ -201,6 +201,10 @@
} }
} }
}, },
" %@" : {
"comment" : "A secondary line of text displayed in a lock screen weather view, below the main weather information. The first argument is the main weather information. The second argument is an optional secondary piece of information to display below the main",
"isCommentAutoGenerated" : true
},
"-%@" : { "-%@" : {
"comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.", "comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
@ -735,6 +739,10 @@
} }
} }
}, },
"%@°" : {
"comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
"isCommentAutoGenerated" : true
},
"%d%%" : { "%d%%" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -2301,6 +2309,20 @@
} }
} }
} }
},
"Air quality index %d, %@" : {
"comment" : "Air quality accessibility label",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Air quality index %1$d, %2$@"
}
}
}
},
"Air quality requires the Open Meteo provider." : {
}, },
"AirDrop" : { "AirDrop" : {
"localizations" : { "localizations" : {
@ -3144,6 +3166,10 @@
} }
} }
}, },
"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" : {
@ -3497,6 +3523,9 @@
} }
} }
}, },
"Battery at %d percent" : {
"comment" : "Battery percentage"
},
"Battery charging" : { "Battery charging" : {
"comment" : "Battery charging without estimate" "comment" : "Battery charging without estimate"
}, },
@ -4385,6 +4414,7 @@
} }
}, },
"Charging" : { "Charging" : {
"comment" : "Charging fallback label when no estimate is available",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@ -8466,6 +8496,12 @@
} }
} }
}, },
"Full" : {
"comment" : "Full battery level label"
},
"Fully charged" : {
"comment" : "Charging fallback label when already charged"
},
"Gemini API Key" : { "Gemini API Key" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -9437,6 +9473,7 @@
} }
}, },
"High" : { "High" : {
"comment" : "High battery level label",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@ -10589,6 +10626,7 @@
} }
}, },
"Low" : { "Low" : {
"comment" : "Low battery level label",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@ -11799,6 +11837,7 @@
} }
}, },
"Medium" : { "Medium" : {
"comment" : "Medium battery level label",
"localizations" : { "localizations" : {
"ar" : { "ar" : {
"stringUnit" : { "stringUnit" : {
@ -19603,6 +19642,9 @@
} }
} }
} }
},
"Weather data provider" : {
}, },
"Weather: %@ %@" : { "Weather: %@ %@" : {
"comment" : "Weather description and temperature", "comment" : "Weather description and temperature",
@ -19834,6 +19876,9 @@
}, },
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
},
"Widget layout" : {
}, },
"Width:" : { "Width:" : {
"localizations" : { "localizations" : {

View file

@ -1,181 +1,568 @@
import SwiftUI import SwiftUI
struct LockScreenWeatherWidget: View { struct LockScreenWeatherWidget: View {
let snapshot: LockScreenWeatherSnapshot let snapshot: LockScreenWeatherSnapshot
private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) private let inlinePrimaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
private let inlineSecondaryFont = Font.system(size: 13, weight: .medium, design: .rounded)
private let secondaryLabelColor = Color.white.opacity(0.7)
var body: some View { private var isInline: Bool { snapshot.widgetStyle == .inline }
HStack(alignment: .firstTextBaseline, spacing: 16) { private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
if let charging = snapshot.charging { private var stackSpacing: CGFloat { isInline ? 14 : 22 }
chargingSegment(for: charging) private var gaugeLineWidth: CGFloat { 6 }
} private var gaugeDiameter: CGFloat { 64 }
if let bluetooth = snapshot.bluetooth { private var gaugeBackgroundColor: Color {
bluetoothSegment(for: bluetooth) snapshot.usesGaugeTint ? Color.white.opacity(0.22) : Color.white.opacity(0.32)
} }
weatherSegment private var monochromeGaugeTint: Color {
Color.white.opacity(0.9)
}
if shouldShowLocation { var body: some View {
locationSegment HStack(alignment: stackAlignment, spacing: stackSpacing) {
} if let charging = snapshot.charging {
} chargingSegment(for: charging)
.frame(maxWidth: .infinity, alignment: .leading) }
.foregroundStyle(Color.white.opacity(0.65))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color.clear)
.shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabel)
}
private var weatherSegment: some View { if let bluetooth = snapshot.bluetooth {
HStack(alignment: .firstTextBaseline, spacing: 6) { bluetoothSegment(for: bluetooth)
Image(systemName: snapshot.symbolName) }
.font(.system(size: 26, weight: .medium))
.symbolRenderingMode(.hierarchical)
Text(snapshot.temperatureText)
.font(primaryFont)
.kerning(-0.3)
.lineLimit(1)
.minimumScaleFactor(0.9)
.layoutPriority(2)
}
}
@ViewBuilder if let airQuality = snapshot.airQuality {
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { airQualitySegment(for: airQuality)
if let iconName = chargingIconName(for: info) { }
HStack(spacing: 4) {
Image(systemName: iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
if let text = formattedChargingTime(for: info) { weatherSegment
Text(text)
.font(primaryFont)
.lineLimit(1)
.minimumScaleFactor(0.75)
.layoutPriority(1.2)
}
}
.layoutPriority(1)
}
}
@ViewBuilder if shouldShowLocation {
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { locationSegment
HStack(spacing: 4) { }
Image(systemName: info.iconName) }
.font(.system(size: 20, weight: .semibold)) .frame(maxWidth: .infinity, alignment: .leading)
.symbolRenderingMode(.hierarchical) .foregroundStyle(Color.white.opacity(0.65))
Text("\(clampedBatteryLevel(info.batteryLevel))%") .padding(.horizontal, 10)
.font(primaryFont) .padding(.vertical, 6)
.lineLimit(1) .background(Color.clear)
.truncationMode(.tail) .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3)
.minimumScaleFactor(0.85) .accessibilityElement(children: .ignore)
.layoutPriority(1.4) .accessibilityLabel(accessibilityLabel)
} }
}
@ViewBuilder @ViewBuilder
private var locationSegment: some View { private var weatherSegment: some View {
if let locationName = snapshot.locationName { switch snapshot.widgetStyle {
Text(locationName) case .inline:
.font(primaryFont) inlineWeatherSegment
.lineLimit(1) case .circular:
.truncationMode(.tail) circularWeatherSegment
.minimumScaleFactor(0.75) }
.layoutPriority(0.7) }
}
}
private var shouldShowLocation: Bool { private var inlineWeatherSegment: some View {
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) HStack(alignment: .center, spacing: 6) {
} Image(systemName: snapshot.symbolName)
.font(.system(size: 26, weight: .medium))
.symbolRenderingMode(.hierarchical)
Text(snapshot.temperatureText)
.font(inlinePrimaryFont)
.kerning(-0.3)
.lineLimit(1)
.minimumScaleFactor(0.9)
.layoutPriority(2)
}
}
private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { @ViewBuilder
let icon = info.iconName private var circularWeatherSegment: some View {
return icon.isEmpty ? nil : icon if let info = snapshot.temperatureInfo {
} temperatureGauge(for: info)
} else {
inlineWeatherSegment
}
}
private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { @ViewBuilder
guard let minutes = info.minutesRemaining, minutes > 0 else { private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
return nil switch snapshot.widgetStyle {
} case .inline:
inlineChargingSegment(for: info)
case .circular:
circularChargingSegment(for: info)
}
}
let hours = minutes / 60 private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
let remainingMinutes = minutes % 60 HStack(alignment: .firstTextBaseline, spacing: 6) {
if let iconName = chargingIconName(for: info) {
Image(systemName: iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
}
Text(inlineChargingLabel(for: info))
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
if hours > 0 { @ViewBuilder
return "\(hours)h \(remainingMinutes)m" private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
} if let level = info.batteryLevel {
return "\(remainingMinutes)m" VStack(spacing: 6) {
} circularBadge(progress: Double(level) / 100, tint: batteryTint(for: level), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
chargingGlyph(for: info)
}
Text(chargingDetailLabel(for: info))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
.lineLimit(1)
}
.layoutPriority(1)
} else {
inlineChargingSegment(for: info)
}
}
private func clampedBatteryLevel(_ level: Int) -> Int { @ViewBuilder
min(max(level, 0), 100) private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
} switch snapshot.widgetStyle {
case .inline:
inlineBluetoothSegment(for: info)
case .circular:
circularBluetoothSegment(for: info)
}
}
private var accessibilityLabel: String { private func inlineBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
var components: [String] = [] HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: info.iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
Text(bluetoothStatusLabel(for: info.batteryLevel))
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty { private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
components.append( VStack(spacing: 6) {
String( let clamped = clampedBatteryLevel(info.batteryLevel)
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
snapshot.description, Image(systemName: info.iconName)
snapshot.temperatureText, .font(.system(size: 22, weight: .semibold))
locationName .foregroundStyle(Color.white)
) }
) Text(bluetoothStatusLabel(for: info.batteryLevel))
} else { .font(inlineSecondaryFont)
components.append( .foregroundStyle(secondaryLabelColor)
String( }
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), .layoutPriority(1)
snapshot.description, }
snapshot.temperatureText
)
)
}
if let charging = snapshot.charging { @ViewBuilder
components.append(accessibilityChargingText(for: charging)) private func airQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
} switch snapshot.widgetStyle {
case .inline:
inlineAirQualitySegment(for: info)
case .circular:
circularAirQualitySegment(for: info)
}
}
if let bluetooth = snapshot.bluetooth { private func inlineAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
components.append(accessibilityBluetoothText(for: bluetooth)) HStack(alignment: .firstTextBaseline, spacing: 6) {
} Image(systemName: "wind")
.font(.system(size: 18, weight: .semibold))
.symbolRenderingMode(.hierarchical)
inlineComposite(primary: "AQI \(info.index)", secondary: info.category.displayName)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(1)
}
return components.joined(separator: ". ") private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
} VStack(spacing: 6) {
circularBadge(progress: aqiProgress(for: info.index), tint: aqiTint(for: info.category), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
Text("\(info.index)")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(Color.white)
}
Text("AQI · \(info.category.displayName)")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
.lineLimit(1)
}
.layoutPriority(1)
}
private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String { private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
if let minutes = charging.minutesRemaining, minutes > 0 { VStack(spacing: 6) {
let formatter = DateComponentsFormatter() circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
formatter.allowedUnits = [.hour, .minute] temperatureCenterLabel(for: info)
formatter.unitsStyle = .full }
let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes" HStack {
return String( Text(minimumTemperatureLabel(for: info))
format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"), .font(inlineSecondaryFont)
duration .foregroundStyle(secondaryLabelColor)
) Spacer()
} Text(maximumTemperatureLabel(for: info))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.frame(width: gaugeDiameter)
}
.layoutPriority(1)
}
if charging.isPluggedIn && !charging.isCharging { private var locationSegment: some View {
return NSLocalizedString("Battery fully charged", comment: "Battery is full") Text(snapshot.locationName ?? "")
} .font(isInline ? inlinePrimaryFont : inlineSecondaryFont)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.75)
.layoutPriority(0.7)
}
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate") private var shouldShowLocation: Bool {
} snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
}
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String { private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
return String( let icon = info.iconName
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"), return icon.isEmpty ? nil : icon
bluetooth.deviceName, }
bluetooth.batteryLevel
) @ViewBuilder
} private func chargingGlyph(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let iconName = chargingIconName(for: info) {
Image(systemName: iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
} else {
Image(systemName: "bolt.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
}
}
private func inlineChargingLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if let time = formattedChargingTime(for: info) {
return time
}
return chargingStatusFallback(for: info)
}
private func chargingDetailLabel(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
inlineChargingLabel(for: info)
}
private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
guard let minutes = info.minutesRemaining, minutes > 0 else {
return nil
}
let hours = minutes / 60
let remainingMinutes = minutes % 60
if hours > 0 {
return "\(hours)h \(remainingMinutes)m"
}
return "\(remainingMinutes)m"
}
private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if info.isPluggedIn && !info.isCharging {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
}
return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
}
private func bluetoothStatusLabel(for level: Int) -> String {
let value = clampedBatteryLevel(level)
switch value {
case ..<20:
return NSLocalizedString("Low", comment: "Low battery level label")
case 20..<50:
return NSLocalizedString("Medium", comment: "Medium battery level label")
case 50..<80:
return NSLocalizedString("High", comment: "High battery level label")
default:
return NSLocalizedString("Full", comment: "Full battery level label")
}
}
private func minimumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
if let minimum = info.displayMinimum {
return "\(minimum)°"
}
return "—"
}
private func maximumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String {
if let maximum = info.displayMaximum {
return "\(maximum)°"
}
return "—"
}
private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
let symbol = info.unitSymbol
let unitSuffix = symbol.replacingOccurrences(of: "°", with: "")
return HStack(alignment: .top, spacing: 2) {
Text("\(info.displayCurrent)°")
.font(.system(size: 20, weight: .semibold, design: .rounded))
if !unitSuffix.isEmpty {
Text(unitSuffix)
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
.offset(y: 2)
}
}
.foregroundStyle(Color.white)
}
private func clampedBatteryLevel(_ level: Int) -> Int {
min(max(level, 0), 100)
}
private func inlineComposite(primary: String, secondary: String?) -> Text {
var text = Text(primary).font(inlinePrimaryFont)
if let secondary, !secondary.isEmpty {
text = text + Text(" \(secondary)")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
return text
}
private func aqiProgress(for index: Int) -> Double {
let clamped = min(max(Double(index), 0), 500)
return clamped / 500
}
private func temperatureProgress(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> Double {
let current = info.current
let minValue = info.minimum ?? current
let maxValue = info.maximum ?? current
let lowerBound = min(minValue, current)
let upperBound = max(maxValue, current)
let span = max(upperBound - lowerBound, 1)
return min(max((current - lowerBound) / span, 0), 1)
}
private func batteryTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
switch level {
case ..<20:
return Color(red: 0.91, green: 0.29, blue: 0.25)
case 20..<50:
return Color(red: 0.99, green: 0.74, blue: 0.30)
default:
return Color(red: 0.20, green: 0.79, blue: 0.39)
}
}
private func bluetoothTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
switch level {
case ..<20:
return Color(red: 0.91, green: 0.29, blue: 0.25)
case 20..<50:
return Color(red: 0.97, green: 0.58, blue: 0.29)
default:
return Color(red: 0.29, green: 0.63, blue: 1.00)
}
}
private func aqiTint(for category: LockScreenWeatherSnapshot.AirQualityInfo.Category) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
switch category {
case .good:
return Color(red: 0.20, green: 0.79, blue: 0.39)
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 .veryUnhealthy:
return Color(red: 0.65, green: 0.32, blue: 0.86)
case .hazardous:
return Color(red: 0.50, green: 0.13, blue: 0.28)
case .unknown:
return Color(red: 0.63, green: 0.66, blue: 0.74)
}
}
private func temperatureTint(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let value = info.current
switch value {
case ..<0:
return Color(red: 0.29, green: 0.63, blue: 1.00)
case 0..<15:
return Color(red: 0.20, green: 0.79, blue: 0.93)
case 15..<25:
return Color(red: 0.20, green: 0.79, blue: 0.39)
case 25..<32:
return Color(red: 0.97, green: 0.58, blue: 0.29)
default:
return Color(red: 0.91, green: 0.29, blue: 0.25)
}
}
@ViewBuilder
private func circularBadge<Content: View>(
progress: Double?,
tint: Color,
background: Color? = nil,
lineWidth: CGFloat,
diameter: CGFloat,
@ViewBuilder content: @escaping () -> Content
) -> some View {
if let progress {
CircularMetricView(
progress: progress,
foreground: tint,
background: background ?? gaugeBackgroundColor,
lineWidth: lineWidth,
diameter: diameter,
content: content
)
}
}
private var accessibilityLabel: String {
var components: [String] = []
if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty {
components.append(
String(
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
snapshot.description,
snapshot.temperatureText,
locationName
)
)
} else {
components.append(
String(
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
snapshot.description,
snapshot.temperatureText
)
)
}
if let charging = snapshot.charging {
components.append(accessibilityChargingText(for: charging))
}
if let bluetooth = snapshot.bluetooth {
components.append(accessibilityBluetoothText(for: bluetooth))
}
if let airQuality = snapshot.airQuality {
components.append(accessibilityAirQualityText(for: airQuality))
}
return components.joined(separator: ". ")
}
private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if let minutes = charging.minutesRemaining, minutes > 0 {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute]
formatter.unitsStyle = .full
let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes"
return String(
format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"),
duration
)
}
if charging.isPluggedIn && !charging.isCharging {
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
}
if snapshot.showsChargingPercentage, let level = charging.batteryLevel {
return String(
format: NSLocalizedString("Battery at %d percent", comment: "Battery percentage"),
level
)
}
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
}
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
String(
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
bluetooth.deviceName,
bluetooth.batteryLevel
)
}
private func accessibilityAirQualityText(for airQuality: LockScreenWeatherSnapshot.AirQualityInfo) -> String {
String(
format: NSLocalizedString("Air quality index %d, %@", comment: "Air quality accessibility label"),
airQuality.index,
airQuality.category.displayName
)
}
} }
private struct CircularMetricView<Content: View>: View {
private let progress: Double
private let foreground: Color
private let background: Color
private let lineWidth: CGFloat
private let diameter: CGFloat
private let contentBuilder: () -> Content
init(
progress: Double,
foreground: Color,
background: Color,
lineWidth: CGFloat = 6,
diameter: CGFloat = 64,
@ViewBuilder content: @escaping () -> Content
) {
self.progress = min(max(progress, 0), 1)
self.foreground = foreground
self.background = background
self.lineWidth = lineWidth
self.diameter = diameter
self.contentBuilder = content
}
var body: some View {
ZStack {
Circle()
.stroke(background, lineWidth: lineWidth)
Circle()
.trim(from: 0, to: CGFloat(progress))
.stroke(
foreground,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)
)
.rotationEffect(.degrees(-90))
contentBuilder()
}
.frame(width: diameter, height: diameter)
}
}

View file

@ -181,7 +181,12 @@ struct GeneralSettings: View {
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
var body: some View { var body: some View {
Form { Form {
@ -329,9 +334,32 @@ struct GeneralSettings: View {
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
if enableLockScreenWeatherWidget { if enableLockScreenWeatherWidget {
Picker("Widget layout", selection: $lockScreenWeatherWidgetStyle) {
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) {
ForEach(LockScreenWeatherProviderSource.allCases) { source in
Text(source.displayName).tag(source)
}
}
.pickerStyle(.segmented)
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation) Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality)
if !lockScreenWeatherProviderSource.supportsAirQuality {
Text("Air quality requires the Open Meteo provider.")
.font(.caption)
.foregroundStyle(.secondary)
}
Defaults.Toggle("Use colored gauges", key: .lockScreenWeatherUsesGaugeTint)
} }
Button("Copy Latest Crash Report") { Button("Copy Latest Crash Report") {

View file

@ -77,3 +77,28 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi
var id: String { rawValue } var id: String { rawValue }
} }
enum LockScreenWeatherWidgetStyle: String, CaseIterable, Defaults.Serializable, Identifiable {
case inline = "Inline"
case circular = "Circular"
var id: String { rawValue }
}
enum LockScreenWeatherProviderSource: String, CaseIterable, Defaults.Serializable, Identifiable {
case wttr = "wttr.in"
case openMeteo = "Open Meteo"
var id: String { rawValue }
var displayName: String { rawValue }
var supportsAirQuality: Bool {
switch self {
case .wttr:
return false
case .openMeteo:
return true
}
}
}

View file

@ -86,6 +86,7 @@ final class LockScreenWeatherManager: ObservableObject {
} catch { } catch {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let fallback = LockScreenWeatherSnapshot( let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--", temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill", symbolName: snapshot?.symbolName ?? "cloud.fill",
@ -93,7 +94,12 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: snapshot?.locationName, locationName: snapshot?.locationName,
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil,
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
showsLocation: snapshot?.showsLocation ?? false showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
showsChargingPercentage: Defaults[.lockScreenWeatherShowsChargingPercentage],
temperatureInfo: snapshot?.temperatureInfo,
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint]
) )
self.snapshot = fallback self.snapshot = fallback
@ -138,7 +144,17 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsCharging, options: []) Defaults.publisher(.lockScreenWeatherShowsCharging, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsChargingPercentage, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
.map { _ in () }.eraseToAnyPublisher() .map { _ in () }.eraseToAnyPublisher()
] ]
@ -147,6 +163,16 @@ final class LockScreenWeatherManager: ObservableObject {
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true) self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.lockScreenWeatherProviderSource, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
_ = await self.refresh(force: true)
}
}
.store(in: &cancellables)
} }
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) { private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
@ -176,6 +202,11 @@ final class LockScreenWeatherManager: ObservableObject {
let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot( return LockScreenWeatherSnapshot(
temperatureText: payload.temperatureText, temperatureText: payload.temperatureText,
@ -184,7 +215,12 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: locationName, locationName: locationName,
charging: chargingInfo, charging: chargingInfo,
bluetooth: bluetoothInfo, bluetooth: bluetoothInfo,
showsLocation: shouldShowLocation showsLocation: shouldShowLocation,
airQuality: airQualityInfo,
widgetStyle: widgetStyle,
showsChargingPercentage: showsChargingPercentage,
temperatureInfo: payload.temperatureInfo,
usesGaugeTint: usesGaugeTint
) )
} }
@ -202,10 +238,14 @@ final class LockScreenWeatherManager: ObservableObject {
let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil) let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil)
let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
return LockScreenWeatherSnapshot.ChargingInfo( return LockScreenWeatherSnapshot.ChargingInfo(
minutesRemaining: remaining, minutesRemaining: remaining,
isCharging: isCharging, isCharging: isCharging,
isPluggedIn: isPluggedIn isPluggedIn: isPluggedIn,
batteryLevel: isPluggedIn || isCharging ? clampedLevel : nil
) )
} }
@ -239,10 +279,37 @@ final class LockScreenWeatherManager: ObservableObject {
} }
struct LockScreenWeatherSnapshot: Equatable { struct LockScreenWeatherSnapshot: Equatable {
struct TemperatureInfo: Equatable {
let current: Double
let minimum: Double?
let maximum: Double?
let unitSymbol: String
var displayMinimum: String? {
guard let minimum else { return nil }
return Self.formatted(value: minimum)
}
var displayMaximum: String? {
guard let maximum else { return nil }
return Self.formatted(value: maximum)
}
var displayCurrent: String {
Self.formatted(value: current)
}
private static func formatted(value: Double) -> String {
let rounded = Int(round(value))
return "\(rounded)"
}
}
struct ChargingInfo: Equatable { struct ChargingInfo: Equatable {
let minutesRemaining: Int? let minutesRemaining: Int?
let isCharging: Bool let isCharging: Bool
let isPluggedIn: Bool let isPluggedIn: Bool
let batteryLevel: Int?
var iconName: String { var iconName: String {
if isCharging { if isCharging {
@ -261,6 +328,33 @@ struct LockScreenWeatherSnapshot: Equatable {
let iconName: String let iconName: String
} }
struct AirQualityInfo: Equatable {
enum Category: String, Equatable {
case good
case moderate
case unhealthyForSensitive
case unhealthy
case veryUnhealthy
case hazardous
case unknown
var displayName: String {
switch self {
case .good: return "Good"
case .moderate: return "Moderate"
case .unhealthyForSensitive: return "Sensitive"
case .unhealthy: return "Unhealthy"
case .veryUnhealthy: return "Very Unhealthy"
case .hazardous: return "Hazardous"
case .unknown: return "Unknown"
}
}
}
let index: Int
let category: Category
}
let temperatureText: String let temperatureText: String
let symbolName: String let symbolName: String
let description: String let description: String
@ -268,6 +362,34 @@ struct LockScreenWeatherSnapshot: Equatable {
let charging: ChargingInfo? let charging: ChargingInfo?
let bluetooth: BluetoothInfo? let bluetooth: BluetoothInfo?
let showsLocation: Bool let showsLocation: Bool
let airQuality: AirQualityInfo?
let widgetStyle: LockScreenWeatherWidgetStyle
let showsChargingPercentage: Bool
let temperatureInfo: TemperatureInfo?
let usesGaugeTint: Bool
}
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
}
}
} }
private actor LockScreenWeatherProvider { private actor LockScreenWeatherProvider {
@ -283,6 +405,19 @@ private actor LockScreenWeatherProvider {
} }
func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot { func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
let source = Defaults[.lockScreenWeatherProviderSource]
switch source {
case .wttr:
return try await fetchWttrSnapshot(location: location)
case .openMeteo:
guard let location else {
return try await fetchWttrSnapshot(location: nil)
}
return try await fetchOpenMeteoSnapshot(location: location)
}
}
private func fetchWttrSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot {
let locationSuffix: String let locationSuffix: String
if let coordinate = location?.coordinate { if let coordinate = location?.coordinate {
let lat = String(format: "%.4f", coordinate.latitude) let lat = String(format: "%.4f", coordinate.latitude)
@ -292,7 +427,8 @@ private actor LockScreenWeatherProvider {
locationSuffix = "" locationSuffix = ""
} }
let urlString = locationSuffix.isEmpty ? "https://wttr.in/?format=j1" : "https://wttr.in/\(locationSuffix)?format=j1" let query = "?format=j1&aqi=yes"
let urlString = locationSuffix.isEmpty ? "https://wttr.in/\(query)" : "https://wttr.in/\(locationSuffix)\(query)"
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
throw WeatherProviderError.invalidURL throw WeatherProviderError.invalidURL
@ -309,8 +445,27 @@ private actor LockScreenWeatherProvider {
} }
let usesMetric = Locale.current.usesMetricSystem let usesMetric = Locale.current.usesMetricSystem
let temperature = usesMetric ? condition.tempC : condition.tempF let rawTemperature = usesMetric ? condition.tempC : condition.tempF
let temperatureText = "\(temperature)°" let temperatureValue = Double(rawTemperature) ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°"
let unitSymbol = usesMetric ? "°C" : "°F"
let forecast = payload.dailyWeather.first
let minTempValue = forecast.flatMap { daily in
let value = usesMetric ? daily.mintempC : daily.mintempF
return value.flatMap(Double.init)
}
let maxTempValue = forecast.flatMap { daily in
let value = usesMetric ? daily.maxtempC : daily.maxtempF
return value.flatMap(Double.init)
}
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
minimum: minTempValue,
maximum: maxTempValue,
unitSymbol: unitSymbol
)
let code = Int(condition.weatherCode) ?? 113 let code = Int(condition.weatherCode) ?? 113
let symbol = WeatherSymbolMapper.symbol(for: code) let symbol = WeatherSymbolMapper.symbol(for: code)
@ -319,6 +474,16 @@ private actor LockScreenWeatherProvider {
let nearest = payload.nearestArea.first let nearest = payload.nearestArea.first
let locationName = nearest?.preferredName let locationName = nearest?.preferredName
let airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if let index = condition.airQuality?.usIndexValue {
airQualityInfo = LockScreenWeatherSnapshot.AirQualityInfo(
index: index,
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
)
} else {
airQualityInfo = nil
}
return LockScreenWeatherSnapshot( return LockScreenWeatherSnapshot(
temperatureText: temperatureText, temperatureText: temperatureText,
symbolName: symbol, symbolName: symbol,
@ -326,7 +491,117 @@ private actor LockScreenWeatherProvider {
locationName: locationName, locationName: locationName,
charging: nil, charging: nil,
bluetooth: nil, bluetooth: nil,
showsLocation: true showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
)
}
private func fetchOpenMeteoSnapshot(location: CLLocation) async throws -> LockScreenWeatherSnapshot {
let latitude = String(format: "%.4f", location.coordinate.latitude)
let longitude = String(format: "%.4f", location.coordinate.longitude)
let usesMetric = Locale.current.usesMetricSystem
var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
var weatherQueryItems: [URLQueryItem] = [
URLQueryItem(name: "latitude", value: latitude),
URLQueryItem(name: "longitude", value: longitude),
URLQueryItem(name: "current", value: "temperature_2m,weather_code,pressure_msl"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min"),
URLQueryItem(name: "forecast_days", value: "1"),
URLQueryItem(name: "timezone", value: "auto")
]
if !usesMetric {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: "fahrenheit"))
}
weatherComponents?.queryItems = weatherQueryItems
guard let weatherURL = weatherComponents?.url else {
throw WeatherProviderError.invalidURL
}
let (weatherData, weatherResponse) = try await session.data(from: weatherURL)
guard let weatherHTTP = weatherResponse as? HTTPURLResponse, (200..<300).contains(weatherHTTP.statusCode) else {
throw WeatherProviderError.invalidResponse
}
let weatherDecoder = JSONDecoder()
weatherDecoder.keyDecodingStrategy = .convertFromSnakeCase
let weatherPayload = try weatherDecoder.decode(OpenMeteoForecastResponse.self, from: weatherData)
guard let current = weatherPayload.current else {
throw WeatherProviderError.noData
}
let temperatureValue = current.temperature2M ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let unitSymbol = usesMetric ? "°C" : "°F"
let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
minimum: minTempValue,
maximum: maxTempValue,
unitSymbol: unitSymbol
)
var airQualityInfo: LockScreenWeatherSnapshot.AirQualityInfo?
if Defaults[.lockScreenWeatherShowsAQI] {
airQualityInfo = try? await fetchOpenMeteoAirQuality(latitude: latitude, longitude: longitude)
}
return LockScreenWeatherSnapshot(
temperatureText: temperatureText,
symbolName: mapping.symbol,
description: mapping.description,
locationName: nil,
charging: nil,
bluetooth: nil,
showsLocation: true,
airQuality: airQualityInfo,
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
)
}
private func fetchOpenMeteoAirQuality(latitude: String, longitude: String) 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: "timezone", value: "auto")
]
guard let airURL = airComponents?.url else {
throw WeatherProviderError.invalidURL
}
let (airData, airResponse) = try await session.data(from: airURL)
guard let airHTTP = airResponse as? HTTPURLResponse, (200..<300).contains(airHTTP.statusCode) else {
throw WeatherProviderError.invalidResponse
}
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 {
return nil
}
let index = Int(round(indexValue))
return LockScreenWeatherSnapshot.AirQualityInfo(
index: index,
category: LockScreenWeatherSnapshot.AirQualityInfo.Category(aqiValue: index)
) )
} }
} }
@ -340,9 +615,11 @@ enum WeatherProviderError: Error {
private struct WTTRResponse: Decodable { private struct WTTRResponse: Decodable {
let current_condition: [WTTRCurrentCondition] let current_condition: [WTTRCurrentCondition]
let nearest_area: [WTTRNearestArea]? let nearest_area: [WTTRNearestArea]?
let weather: [WTTRDailyWeather]?
var currentCondition: [WTTRCurrentCondition] { current_condition } var currentCondition: [WTTRCurrentCondition] { current_condition }
var nearestArea: [WTTRNearestArea] { nearest_area ?? [] } var nearestArea: [WTTRNearestArea] { nearest_area ?? [] }
var dailyWeather: [WTTRDailyWeather] { weather ?? [] }
} }
private struct WTTRCurrentCondition: Decodable { private struct WTTRCurrentCondition: Decodable {
@ -352,6 +629,9 @@ private struct WTTRCurrentCondition: Decodable {
case weatherCode case weatherCode
case weatherDesc case weatherDesc
case langEn = "lang_en" case langEn = "lang_en"
case pressure = "pressure"
case pressureInches = "pressureInches"
case airQuality = "air_quality"
} }
let tempC: String let tempC: String
@ -359,6 +639,9 @@ private struct WTTRCurrentCondition: Decodable {
let weatherCode: String let weatherCode: String
let weatherDesc: [WTTRTextValue]? let weatherDesc: [WTTRTextValue]?
let langEn: [WTTRTextValue]? let langEn: [WTTRTextValue]?
let pressure: String?
let pressureInches: String?
let airQuality: WTTRAirQuality?
var localizedDescription: String { var localizedDescription: String {
if let english = langEn?.first?.value, !english.isEmpty { if let english = langEn?.first?.value, !english.isEmpty {
@ -375,6 +658,35 @@ private struct WTTRTextValue: Decodable {
let value: String let value: String
} }
private struct WTTRAirQuality: Decodable {
private enum CodingKeys: String, CodingKey {
case usEpaIndex = "us-epa-index"
case gbDefraIndex = "gb-defra-index"
}
let usEpaIndex: String?
let gbDefraIndex: String?
var usIndexValue: Int? {
guard let value = usEpaIndex else { return nil }
return Int(value)
}
}
private struct WTTRDailyWeather: Decodable {
private enum CodingKeys: String, CodingKey {
case maxtempC = "maxtempC"
case maxtempF = "maxtempF"
case mintempC = "mintempC"
case mintempF = "mintempF"
}
let maxtempC: String?
let maxtempF: String?
let mintempC: String?
let mintempF: String?
}
private struct WTTRNearestArea: Decodable { private struct WTTRNearestArea: Decodable {
let areaName: [WTTRTextValue]? let areaName: [WTTRTextValue]?
let region: [WTTRTextValue]? let region: [WTTRTextValue]?
@ -400,6 +712,69 @@ private struct WTTRNearestArea: Decodable {
} }
} }
private struct OpenMeteoForecastResponse: Decodable {
struct Current: Decodable {
let time: String?
let temperature2M: Double?
let weatherCode: Int?
let pressureMsl: Double?
}
let current: Current?
let daily: Daily?
struct Daily: Decodable {
let temperature2MMax: [Double]?
let temperature2MMin: [Double]?
}
}
private struct OpenMeteoAirQualityResponse: Decodable {
struct Current: Decodable {
let time: String?
let usAqi: Double?
}
let current: Current?
}
private enum OpenMeteoSymbolMapper {
static func mapping(for code: Int) -> (symbol: String, description: String) {
switch code {
case 0:
return ("sun.max.fill", "Clear sky")
case 1:
return ("cloud.sun.fill", "Mainly clear")
case 2:
return ("cloud.sun.fill", "Partly cloudy")
case 3:
return ("cloud.fill", "Overcast")
case 45, 48:
return ("cloud.fog.fill", "Fog")
case 51, 53, 55:
return ("cloud.drizzle.fill", "Drizzle")
case 56, 57:
return ("cloud.sleet.fill", "Freezing drizzle")
case 61, 63, 65:
return ("cloud.rain.fill", "Rain")
case 66, 67:
return ("cloud.sleet.fill", "Freezing rain")
case 71, 73, 75, 77:
return ("cloud.snow.fill", "Snow")
case 80, 81, 82:
return ("cloud.heavyrain.fill", "Rain showers")
case 85, 86:
return ("cloud.snow.fill", "Snow showers")
case 95:
return ("cloud.bolt.rain.fill", "Thunderstorm")
case 96, 99:
return ("cloud.bolt.rain.fill", "Thunderstorm with hail")
default:
return ("cloud.sun.fill", "Cloudy")
}
}
}
private enum WeatherSymbolMapper { private enum WeatherSymbolMapper {
static func symbol(for code: Int) -> String { static func symbol(for code: Int) -> String {
switch code { switch code {

View file

@ -362,7 +362,12 @@ extension Defaults.Keys {
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true) static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
static let lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .inline)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: true)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .wttr)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false) static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true) static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)