mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add circular progress
This commit is contained in:
parent
bd1f4cc6ed
commit
5f324ecde8
9 changed files with 1071 additions and 186 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -147,3 +147,5 @@ borrowing/
|
||||||
MoveToSky.xcodeproj
|
MoveToSky.xcodeproj
|
||||||
/MoveToSky/
|
/MoveToSky/
|
||||||
mew-notch/
|
mew-notch/
|
||||||
|
|
||||||
|
/References/
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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" : {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue