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

4
.gitignore vendored
View file

@ -146,4 +146,6 @@ borrow_migrations.md
borrowing/
MoveToSky.xcodeproj
/MoveToSky/
mew-notch/
mew-notch/
/References/

View file

@ -221,6 +221,7 @@
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */,
7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
@ -541,6 +542,14 @@
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" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Lakr233/SkyLightWindow";

View file

@ -1,5 +1,5 @@
{
"originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944",
"originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce",
"pins" : [
{
"identity" : "defaults",
@ -55,6 +55,15 @@
"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",
"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.",
"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%%" : {
"localizations" : {
"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" : {
"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 \"%@\"?" : {
"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" : {
@ -3497,6 +3523,9 @@
}
}
},
"Battery at %d percent" : {
"comment" : "Battery percentage"
},
"Battery charging" : {
"comment" : "Battery charging without estimate"
},
@ -4385,6 +4414,7 @@
}
},
"Charging" : {
"comment" : "Charging fallback label when no estimate is available",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -8466,6 +8496,12 @@
}
}
},
"Full" : {
"comment" : "Full battery level label"
},
"Fully charged" : {
"comment" : "Charging fallback label when already charged"
},
"Gemini API Key" : {
"localizations" : {
"fr" : {
@ -9437,6 +9473,7 @@
}
},
"High" : {
"comment" : "High battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -10589,6 +10626,7 @@
}
},
"Low" : {
"comment" : "Low battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -11799,6 +11837,7 @@
}
},
"Medium" : {
"comment" : "Medium battery level label",
"localizations" : {
"ar" : {
"stringUnit" : {
@ -19603,6 +19642,9 @@
}
}
}
},
"Weather data provider" : {
},
"Weather: %@ %@" : {
"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." : {
},
"Widget layout" : {
},
"Width:" : {
"localizations" : {

View file

@ -1,181 +1,568 @@
import SwiftUI
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 {
HStack(alignment: .firstTextBaseline, spacing: 16) {
if let charging = snapshot.charging {
chargingSegment(for: charging)
}
private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var gaugeLineWidth: CGFloat { 6 }
private var gaugeDiameter: CGFloat { 64 }
if let bluetooth = snapshot.bluetooth {
bluetoothSegment(for: bluetooth)
}
private var gaugeBackgroundColor: Color {
snapshot.usesGaugeTint ? Color.white.opacity(0.22) : Color.white.opacity(0.32)
}
weatherSegment
private var monochromeGaugeTint: Color {
Color.white.opacity(0.9)
}
if shouldShowLocation {
locationSegment
}
}
.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)
}
var body: some View {
HStack(alignment: stackAlignment, spacing: stackSpacing) {
if let charging = snapshot.charging {
chargingSegment(for: charging)
}
private var weatherSegment: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
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)
}
}
if let bluetooth = snapshot.bluetooth {
bluetoothSegment(for: bluetooth)
}
@ViewBuilder
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let iconName = chargingIconName(for: info) {
HStack(spacing: 4) {
Image(systemName: iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
if let airQuality = snapshot.airQuality {
airQualitySegment(for: airQuality)
}
if let text = formattedChargingTime(for: info) {
Text(text)
.font(primaryFont)
.lineLimit(1)
.minimumScaleFactor(0.75)
.layoutPriority(1.2)
}
}
.layoutPriority(1)
}
}
weatherSegment
@ViewBuilder
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
HStack(spacing: 4) {
Image(systemName: info.iconName)
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
Text("\(clampedBatteryLevel(info.batteryLevel))%")
.font(primaryFont)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.85)
.layoutPriority(1.4)
}
}
if shouldShowLocation {
locationSegment
}
}
.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)
}
@ViewBuilder
private var locationSegment: some View {
if let locationName = snapshot.locationName {
Text(locationName)
.font(primaryFont)
.lineLimit(1)
.truncationMode(.tail)
.minimumScaleFactor(0.75)
.layoutPriority(0.7)
}
}
@ViewBuilder
private var weatherSegment: some View {
switch snapshot.widgetStyle {
case .inline:
inlineWeatherSegment
case .circular:
circularWeatherSegment
}
}
private var shouldShowLocation: Bool {
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
}
private var inlineWeatherSegment: some View {
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? {
let icon = info.iconName
return icon.isEmpty ? nil : icon
}
@ViewBuilder
private var circularWeatherSegment: some View {
if let info = snapshot.temperatureInfo {
temperatureGauge(for: info)
} else {
inlineWeatherSegment
}
}
private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
guard let minutes = info.minutesRemaining, minutes > 0 else {
return nil
}
@ViewBuilder
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
inlineChargingSegment(for: info)
case .circular:
circularChargingSegment(for: info)
}
}
let hours = minutes / 60
let remainingMinutes = minutes % 60
private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
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 {
return "\(hours)h \(remainingMinutes)m"
}
return "\(remainingMinutes)m"
}
@ViewBuilder
private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let level = info.batteryLevel {
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 {
min(max(level, 0), 100)
}
@ViewBuilder
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 {
var components: [String] = []
private func inlineBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
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 {
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
)
)
}
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
VStack(spacing: 6) {
let clamped = clampedBatteryLevel(info.batteryLevel)
circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
Image(systemName: info.iconName)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white)
}
Text(bluetoothStatusLabel(for: info.batteryLevel))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.layoutPriority(1)
}
if let charging = snapshot.charging {
components.append(accessibilityChargingText(for: charging))
}
@ViewBuilder
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 {
components.append(accessibilityBluetoothText(for: bluetooth))
}
private func inlineAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
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 {
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
)
}
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
VStack(spacing: 6) {
circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
temperatureCenterLabel(for: info)
}
HStack {
Text(minimumTemperatureLabel(for: info))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
Spacer()
Text(maximumTemperatureLabel(for: info))
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.frame(width: gaugeDiameter)
}
.layoutPriority(1)
}
if charging.isPluggedIn && !charging.isCharging {
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
}
private var locationSegment: some View {
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 {
return String(
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
bluetooth.deviceName,
bluetooth.batteryLevel
)
}
private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? {
let icon = info.iconName
return icon.isEmpty ? nil : icon
}
@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(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
var body: some View {
Form {
@ -329,9 +334,32 @@ struct GeneralSettings: View {
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show lock screen weather", key: .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 charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
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") {

View file

@ -77,3 +77,28 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi
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 {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
let providerSource = Defaults[.lockScreenWeatherProviderSource]
let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill",
@ -93,7 +94,12 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: snapshot?.locationName,
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : 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
@ -138,7 +144,17 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsCharging, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsChargingPercentage, options: [])
.map { _ in () }.eraseToAnyPublisher(),
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()
]
@ -147,6 +163,16 @@ final class LockScreenWeatherManager: ObservableObject {
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
}
.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) {
@ -176,6 +202,11 @@ final class LockScreenWeatherManager: ObservableObject {
let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : 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(
temperatureText: payload.temperatureText,
@ -184,7 +215,12 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: locationName,
charging: chargingInfo,
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 remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
return LockScreenWeatherSnapshot.ChargingInfo(
minutesRemaining: remaining,
isCharging: isCharging,
isPluggedIn: isPluggedIn
isPluggedIn: isPluggedIn,
batteryLevel: isPluggedIn || isCharging ? clampedLevel : nil
)
}
@ -239,10 +279,37 @@ final class LockScreenWeatherManager: ObservableObject {
}
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 {
let minutesRemaining: Int?
let isCharging: Bool
let isPluggedIn: Bool
let batteryLevel: Int?
var iconName: String {
if isCharging {
@ -261,6 +328,33 @@ struct LockScreenWeatherSnapshot: Equatable {
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 symbolName: String
let description: String
@ -268,6 +362,34 @@ struct LockScreenWeatherSnapshot: Equatable {
let charging: ChargingInfo?
let bluetooth: BluetoothInfo?
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 {
@ -283,6 +405,19 @@ private actor LockScreenWeatherProvider {
}
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
if let coordinate = location?.coordinate {
let lat = String(format: "%.4f", coordinate.latitude)
@ -292,7 +427,8 @@ private actor LockScreenWeatherProvider {
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 {
throw WeatherProviderError.invalidURL
@ -309,8 +445,27 @@ private actor LockScreenWeatherProvider {
}
let usesMetric = Locale.current.usesMetricSystem
let temperature = usesMetric ? condition.tempC : condition.tempF
let temperatureText = "\(temperature)°"
let rawTemperature = usesMetric ? condition.tempC : condition.tempF
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 symbol = WeatherSymbolMapper.symbol(for: code)
@ -319,6 +474,16 @@ private actor LockScreenWeatherProvider {
let nearest = payload.nearestArea.first
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(
temperatureText: temperatureText,
symbolName: symbol,
@ -326,7 +491,117 @@ private actor LockScreenWeatherProvider {
locationName: locationName,
charging: 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 {
let current_condition: [WTTRCurrentCondition]
let nearest_area: [WTTRNearestArea]?
let weather: [WTTRDailyWeather]?
var currentCondition: [WTTRCurrentCondition] { current_condition }
var nearestArea: [WTTRNearestArea] { nearest_area ?? [] }
var dailyWeather: [WTTRDailyWeather] { weather ?? [] }
}
private struct WTTRCurrentCondition: Decodable {
@ -352,6 +629,9 @@ private struct WTTRCurrentCondition: Decodable {
case weatherCode
case weatherDesc
case langEn = "lang_en"
case pressure = "pressure"
case pressureInches = "pressureInches"
case airQuality = "air_quality"
}
let tempC: String
@ -359,6 +639,9 @@ private struct WTTRCurrentCondition: Decodable {
let weatherCode: String
let weatherDesc: [WTTRTextValue]?
let langEn: [WTTRTextValue]?
let pressure: String?
let pressureInches: String?
let airQuality: WTTRAirQuality?
var localizedDescription: String {
if let english = langEn?.first?.value, !english.isEmpty {
@ -375,6 +658,35 @@ private struct WTTRTextValue: Decodable {
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 {
let areaName: [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 {
static func symbol(for code: Int) -> String {
switch code {

View file

@ -362,7 +362,12 @@ extension Defaults.Keys {
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", 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 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 lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)