update to gauges

This commit is contained in:
Hariharan Mudaliar 2025-11-03 10:40:06 +05:30
commit cb2369e621
6 changed files with 240 additions and 109 deletions

View file

@ -1323,6 +1323,10 @@
} }
} }
}, },
"0" : {
"comment" : "A label displayed in a circular gauge to indicate the minimum battery level.",
"isCommentAutoGenerated" : true
},
"1x" : { "1x" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -1433,6 +1437,10 @@
} }
} }
}, },
"100" : {
"comment" : "The text displayed below a 100% battery level in the lock screen.",
"isCommentAutoGenerated" : true
},
"100%" : { "100%" : {
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -10823,6 +10831,9 @@
} }
} }
}, },
"Mac battery at %d percent" : {
"comment" : "Mac battery gauge accessibility label"
},
"Made ❤️ by Ebullioscopic" : { "Made ❤️ by Ebullioscopic" : {
}, },
@ -18186,6 +18197,9 @@
} }
} }
} }
},
"Temperature unit" : {
}, },
"TheDynamicIsland" : { "TheDynamicIsland" : {

View file

@ -11,15 +11,10 @@ struct LockScreenWeatherWidget: View {
private var isInline: Bool { snapshot.widgetStyle == .inline } private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
private var stackSpacing: CGFloat { isInline ? 14 : 22 } private var stackSpacing: CGFloat { isInline ? 14 : 22 }
private var gaugeLineWidth: CGFloat { 6 }
private var gaugeDiameter: CGFloat { 64 } private var gaugeDiameter: CGFloat { 64 }
private var topPadding: CGFloat { isInline ? 6 : 22 } private var topPadding: CGFloat { isInline ? 6 : 22 }
private var bottomPadding: CGFloat { isInline ? 6 : 10 } private var bottomPadding: CGFloat { isInline ? 6 : 10 }
private var gaugeBackgroundColor: Color {
snapshot.usesGaugeTint ? Color.white.opacity(0.22) : Color.white.opacity(0.32)
}
private var monochromeGaugeTint: Color { private var monochromeGaugeTint: Color {
Color.white.opacity(0.9) Color.white.opacity(0.9)
} }
@ -30,6 +25,10 @@ struct LockScreenWeatherWidget: View {
chargingSegment(for: charging) chargingSegment(for: charging)
} }
if !isInline, let battery = snapshot.battery {
batterySegment(for: battery)
}
if let bluetooth = snapshot.bluetooth { if let bluetooth = snapshot.bluetooth {
bluetoothSegment(for: bluetooth) bluetoothSegment(for: bluetooth)
} }
@ -98,6 +97,16 @@ struct LockScreenWeatherWidget: View {
} }
} }
@ViewBuilder
private func batterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
switch snapshot.widgetStyle {
case .inline:
EmptyView()
case .circular:
circularBatterySegment(for: info)
}
}
private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 6) {
if let iconName = chargingIconName(for: info) { if let iconName = chargingIconName(for: info) {
@ -115,11 +124,27 @@ struct LockScreenWeatherWidget: View {
@ViewBuilder @ViewBuilder
private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
if let level = info.batteryLevel { if let rawLevel = info.batteryLevel {
let level = clampedBatteryLevel(rawLevel)
VStack(spacing: 6) { VStack(spacing: 6) {
circularBadge(progress: Double(level) / 100, tint: batteryTint(for: level), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
chargingGlyph(for: info) chargingGlyph(for: info)
} minimumValueLabel: {
Text("0")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
} maximumValueLabel: {
Text("100")
.font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor)
} }
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(chargingDetailLabel(for: info)) Text(chargingDetailLabel(for: info))
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -131,6 +156,32 @@ struct LockScreenWeatherWidget: View {
} }
} }
private func circularBatterySegment(for info: LockScreenWeatherSnapshot.BatteryInfo) -> some View {
let level = clampedBatteryLevel(info.batteryLevel)
return VStack(spacing: 6) {
Gauge(value: Double(level), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: "battery.100")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
}
.gaugeStyle(.accessoryCircularCapacity)
.tint(batteryTint(for: level))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("\(level)%")
.font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor)
}
.layoutPriority(1)
}
@ViewBuilder @ViewBuilder
private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
switch snapshot.widgetStyle { switch snapshot.widgetStyle {
@ -155,13 +206,24 @@ struct LockScreenWeatherWidget: View {
} }
private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View {
VStack(spacing: 6) { let clamped = clampedBatteryLevel(info.batteryLevel)
let clamped = clampedBatteryLevel(info.batteryLevel)
circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { return VStack(spacing: 6) {
Gauge(value: Double(clamped), in: 0...100) {
EmptyView()
} currentValueLabel: {
Image(systemName: info.iconName) Image(systemName: info.iconName)
.font(.system(size: 22, weight: .semibold)) .font(.system(size: 22, weight: .semibold))
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} minimumValueLabel: {
EmptyView()
} maximumValueLabel: {
EmptyView()
} }
.gaugeStyle(.accessoryCircularCapacity)
.tint(bluetoothTint(for: clamped))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text(bluetoothPercentageText(for: info.batteryLevel)) Text(bluetoothPercentageText(for: info.batteryLevel))
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -193,11 +255,17 @@ struct LockScreenWeatherWidget: View {
private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { private func circularAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View {
VStack(spacing: 6) { VStack(spacing: 6) {
circularBadge(progress: aqiProgress(for: info.index), tint: aqiTint(for: info.category), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { Gauge(value: Double(info.index), in: 0...500) {
EmptyView()
} currentValueLabel: {
Text("\(info.index)") Text("\(info.index)")
.font(.system(size: 20, weight: .semibold, design: .rounded)) .font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(Color.white) .foregroundStyle(Color.white)
} }
.gaugeStyle(.accessoryCircular)
.tint(aqiTint(for: info.category))
.frame(width: gaugeDiameter, height: gaugeDiameter)
Text("AQI · \(info.category.displayName)") Text("AQI · \(info.category.displayName)")
.font(inlineSecondaryFont) .font(inlineSecondaryFont)
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
@ -207,17 +275,25 @@ struct LockScreenWeatherWidget: View {
} }
private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View { private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View {
VStack(spacing: 6) { let range = temperatureRange(for: info)
circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) {
return VStack(spacing: 6) {
Gauge(value: info.current, in: range) {
EmptyView()
} currentValueLabel: {
temperatureCenterLabel(for: info) temperatureCenterLabel(for: info)
} }
.gaugeStyle(.accessoryCircular)
.tint(temperatureTint(for: info))
.frame(width: gaugeDiameter, height: gaugeDiameter)
HStack { HStack {
Text(minimumTemperatureLabel(for: info)) Text(minimumTemperatureLabel(for: info))
.font(inlineSecondaryFont) .font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
Spacer() Spacer()
Text(maximumTemperatureLabel(for: info)) Text(maximumTemperatureLabel(for: info))
.font(inlineSecondaryFont) .font(.system(size: 11, weight: .medium, design: .rounded))
.foregroundStyle(secondaryLabelColor) .foregroundStyle(secondaryLabelColor)
} }
.frame(width: gaugeDiameter) .frame(width: gaugeDiameter)
@ -307,21 +383,27 @@ struct LockScreenWeatherWidget: View {
} }
private func temperatureCenterLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View { 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) { return HStack(alignment: .top, spacing: 2) {
Text("\(info.displayCurrent)°") Text("\(info.displayCurrent)°")
.font(.system(size: 20, weight: .semibold, design: .rounded)) .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) .foregroundStyle(Color.white)
} }
private func temperatureRange(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> ClosedRange<Double> {
let minimumCandidate = info.minimum ?? info.current
let maximumCandidate = info.maximum ?? info.current
var lowerBound = min(minimumCandidate, info.current)
var upperBound = max(maximumCandidate, info.current)
if lowerBound == upperBound {
lowerBound -= 1
upperBound += 1
}
return lowerBound...upperBound
}
private func clampedBatteryLevel(_ level: Int) -> Int { private func clampedBatteryLevel(_ level: Int) -> Int {
min(max(level, 0), 100) min(max(level, 0), 100)
} }
@ -336,23 +418,6 @@ struct LockScreenWeatherWidget: View {
return text 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 { private func batteryTint(for level: Int) -> Color {
guard snapshot.usesGaugeTint else { return monochromeGaugeTint } guard snapshot.usesGaugeTint else { return monochromeGaugeTint }
let clamped = clampedBatteryLevel(level) let clamped = clampedBatteryLevel(level)
@ -416,27 +481,6 @@ struct LockScreenWeatherWidget: View {
} }
} }
@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 { private var accessibilityLabel: String {
var components: [String] = [] var components: [String] = []
@ -471,6 +515,10 @@ struct LockScreenWeatherWidget: View {
components.append(accessibilityAirQualityText(for: airQuality)) components.append(accessibilityAirQualityText(for: airQuality))
} }
if let battery = snapshot.battery, !isInline {
components.append(accessibilityBatteryText(for: battery))
}
return components.joined(separator: ". ") return components.joined(separator: ". ")
} }
@ -515,46 +563,12 @@ struct LockScreenWeatherWidget: View {
airQuality.category.displayName airQuality.category.displayName
) )
} }
}
private struct CircularMetricView<Content: View>: View { private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String {
private let progress: Double String(
private let foreground: Color format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"),
private let background: Color clampedBatteryLevel(battery.batteryLevel)
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

@ -182,7 +182,9 @@ struct GeneralSettings: View {
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage @Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
@Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint
@Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource
@ -345,12 +347,19 @@ struct GeneralSettings: View {
} }
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
Text(unit.rawValue).tag(unit)
}
}
.pickerStyle(.segmented)
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation) Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
.disabled(lockScreenWeatherWidgetStyle == .circular) .disabled(lockScreenWeatherWidgetStyle == .circular)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging { if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage) Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
} }
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI) Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality) .disabled(!lockScreenWeatherProviderSource.supportsAirQuality)

View file

@ -102,3 +102,26 @@ enum LockScreenWeatherProviderSource: String, CaseIterable, Defaults.Serializabl
} }
} }
} }
enum LockScreenWeatherTemperatureUnit: String, CaseIterable, Defaults.Serializable, Identifiable {
case celsius = "Celsius"
case fahrenheit = "Fahrenheit"
var id: String { rawValue }
var usesMetricSystem: Bool { self == .celsius }
var symbol: String {
switch self {
case .celsius: return "°C"
case .fahrenheit: return "°F"
}
}
var openMeteoTemperatureParameter: String? {
switch self {
case .celsius: return nil
case .fahrenheit: return "fahrenheit"
}
}
}

View file

@ -59,8 +59,11 @@ final class LockScreenWeatherManager: ObservableObject {
@discardableResult @discardableResult
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? { func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false")
let interval = Defaults[.lockScreenWeatherRefreshInterval] let interval = Defaults[.lockScreenWeatherRefreshInterval]
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval { if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
let remaining = interval - Date().timeIntervalSince(lastFetchDate)
NSLog("LockScreenWeatherManager: skipping refresh (%.0f s remaining until next fetch)", max(remaining, 0))
if let payload = latestWeatherPayload { if let payload = latestWeatherPayload {
if Defaults[.lockScreenWeatherShowsBluetooth] { if Defaults[.lockScreenWeatherShowsBluetooth] {
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
@ -78,6 +81,12 @@ final class LockScreenWeatherManager: ObservableObject {
do { do {
let location = await locationProvider.currentLocation() let location = await locationProvider.currentLocation()
let provider = Defaults[.lockScreenWeatherProviderSource]
NSLog(
"LockScreenWeatherManager: fetching weather from %@ (location %@)",
provider.displayName,
location != nil ? "available" : "missing"
)
let payload = try await fetchWeatherPayload(location: location) let payload = try await fetchWeatherPayload(location: location)
latestWeatherPayload = payload latestWeatherPayload = payload
if Defaults[.lockScreenWeatherShowsBluetooth] { if Defaults[.lockScreenWeatherShowsBluetooth] {
@ -87,18 +96,24 @@ final class LockScreenWeatherManager: ObservableObject {
self.snapshot = snapshot self.snapshot = snapshot
lastFetchDate = Date() lastFetchDate = Date()
deliver(snapshot, forceShow: false) deliver(snapshot, forceShow: false)
NSLog("LockScreenWeatherManager: weather refresh succeeded")
return snapshot return snapshot
} catch { } catch {
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
let providerSource = Defaults[.lockScreenWeatherProviderSource] let providerSource = Defaults[.lockScreenWeatherProviderSource]
let showsCharging = Defaults[.lockScreenWeatherShowsCharging]
let chargingInfo = showsCharging ? makeChargingInfo() : nil
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let fallback = LockScreenWeatherSnapshot( let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--", temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill", symbolName: snapshot?.symbolName ?? "cloud.fill",
description: snapshot?.description ?? "", description: snapshot?.description ?? "",
locationName: snapshot?.locationName, locationName: snapshot?.locationName,
charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, charging: chargingInfo,
bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil,
battery: batteryInfo,
showsLocation: snapshot?.showsLocation ?? false, showsLocation: snapshot?.showsLocation ?? false,
airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil, airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil,
widgetStyle: Defaults[.lockScreenWeatherWidgetStyle], widgetStyle: Defaults[.lockScreenWeatherWidgetStyle],
@ -172,8 +187,12 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: []) Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsAQI, options: []) Defaults.publisher(.lockScreenWeatherShowsAQI, options: [])
.map { _ in () }.eraseToAnyPublisher(), .map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: []) Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: [])
@ -193,10 +212,37 @@ final class LockScreenWeatherManager: ObservableObject {
guard let self else { return } guard let self else { return }
self.latestWeatherPayload = nil self.latestWeatherPayload = nil
Task { @MainActor in Task { @MainActor in
NSLog("LockScreenWeatherManager: provider changed, forcing refresh")
_ = await self.refresh(force: true) _ = await self.refresh(force: true)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
.sink { [weak self] _ in
guard let self else { return }
self.latestWeatherPayload = nil
Task { @MainActor in
NSLog("LockScreenWeatherManager: temperature unit changed, forcing refresh")
_ = await self.refresh(force: true)
}
}
.store(in: &cancellables)
Defaults.publisher(.enableLockScreenWeatherWidget, options: [])
.sink { [weak self] change in
guard let self else { return }
Task { @MainActor in
if change.newValue {
NSLog("LockScreenWeatherManager: widget enabled, triggering refresh")
self.locationProvider.prepareAuthorization()
_ = await self.refresh(force: true)
} else {
LockScreenWeatherPanelManager.shared.hide()
}
}
}
.store(in: &cancellables)
} }
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) { private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
@ -222,14 +268,15 @@ final class LockScreenWeatherManager: ObservableObject {
} }
private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot { private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot {
let locationName = payload.locationName let locationName = payload.locationName
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 widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) let shouldShowLocation = widgetStyle == .inline && Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage] let showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage]
let providerSource = Defaults[.lockScreenWeatherProviderSource] let providerSource = Defaults[.lockScreenWeatherProviderSource]
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint] let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
return LockScreenWeatherSnapshot( return LockScreenWeatherSnapshot(
@ -239,6 +286,7 @@ final class LockScreenWeatherManager: ObservableObject {
locationName: locationName, locationName: locationName,
charging: chargingInfo, charging: chargingInfo,
bluetooth: bluetoothInfo, bluetooth: bluetoothInfo,
battery: batteryInfo,
showsLocation: shouldShowLocation, showsLocation: shouldShowLocation,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: widgetStyle, widgetStyle: widgetStyle,
@ -273,6 +321,18 @@ final class LockScreenWeatherManager: ObservableObject {
) )
} }
private func makeBatteryGaugeInfo(isCharging: Bool) -> LockScreenWeatherSnapshot.BatteryInfo? {
guard !isCharging else { return nil }
let battery = BatteryStatusViewModel.shared
let rawLevel = Int(round(Double(battery.levelBattery)))
let clampedLevel = min(max(rawLevel, 0), 100)
guard clampedLevel >= 0 else { return nil }
return LockScreenWeatherSnapshot.BatteryInfo(batteryLevel: clampedLevel)
}
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? { private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
let manager = BluetoothAudioManager.shared let manager = BluetoothAudioManager.shared
@ -352,6 +412,10 @@ struct LockScreenWeatherSnapshot: Equatable {
let iconName: String let iconName: String
} }
struct BatteryInfo: Equatable {
let batteryLevel: Int
}
struct AirQualityInfo: Equatable { struct AirQualityInfo: Equatable {
enum Category: String, Equatable { enum Category: String, Equatable {
case good case good
@ -385,6 +449,7 @@ struct LockScreenWeatherSnapshot: Equatable {
let locationName: String? let locationName: String?
let charging: ChargingInfo? let charging: ChargingInfo?
let bluetooth: BluetoothInfo? let bluetooth: BluetoothInfo?
let battery: BatteryInfo?
let showsLocation: Bool let showsLocation: Bool
let airQuality: AirQualityInfo? let airQuality: AirQualityInfo?
let widgetStyle: LockScreenWeatherWidgetStyle let widgetStyle: LockScreenWeatherWidgetStyle
@ -472,11 +537,12 @@ private actor LockScreenWeatherProvider {
throw WeatherProviderError.noData throw WeatherProviderError.noData
} }
let usesMetric = Locale.current.usesMetricSystem let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
let rawTemperature = usesMetric ? condition.tempC : condition.tempF let rawTemperature = usesMetric ? condition.tempC : condition.tempF
let temperatureValue = Double(rawTemperature) ?? 0 let temperatureValue = Double(rawTemperature) ?? 0
let temperatureText = "\(Int(round(temperatureValue)))°" let temperatureText = "\(Int(round(temperatureValue)))°"
let unitSymbol = usesMetric ? "°C" : "°F" let unitSymbol = unit.symbol
let forecast = payload.dailyWeather.first let forecast = payload.dailyWeather.first
let minTempValue = forecast.flatMap { daily in let minTempValue = forecast.flatMap { daily in
@ -519,6 +585,7 @@ private actor LockScreenWeatherProvider {
locationName: locationName, locationName: locationName,
charging: nil, charging: nil,
bluetooth: nil, bluetooth: nil,
battery: nil,
showsLocation: true, showsLocation: true,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: .inline, widgetStyle: .inline,
@ -532,7 +599,8 @@ private actor LockScreenWeatherProvider {
let latitude = String(format: "%.4f", location.coordinate.latitude) let latitude = String(format: "%.4f", location.coordinate.latitude)
let longitude = String(format: "%.4f", location.coordinate.longitude) let longitude = String(format: "%.4f", location.coordinate.longitude)
let usesMetric = Locale.current.usesMetricSystem let unit = Defaults[.lockScreenWeatherTemperatureUnit]
let usesMetric = unit.usesMetricSystem
var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast") var weatherComponents = URLComponents(string: "https://api.open-meteo.com/v1/forecast")
var weatherQueryItems: [URLQueryItem] = [ var weatherQueryItems: [URLQueryItem] = [
URLQueryItem(name: "latitude", value: latitude), URLQueryItem(name: "latitude", value: latitude),
@ -543,8 +611,8 @@ private actor LockScreenWeatherProvider {
URLQueryItem(name: "timezone", value: "auto") URLQueryItem(name: "timezone", value: "auto")
] ]
if !usesMetric { if let temperatureUnitParameter = unit.openMeteoTemperatureParameter {
weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: "fahrenheit")) weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter))
} }
weatherComponents?.queryItems = weatherQueryItems weatherComponents?.queryItems = weatherQueryItems
@ -569,7 +637,7 @@ private actor LockScreenWeatherProvider {
let temperatureText = "\(Int(round(temperatureValue)))°" let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0 let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code) let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let unitSymbol = usesMetric ? "°C" : "°F" let unitSymbol = unit.symbol
let minTempValue = weatherPayload.daily?.temperature2MMin?.first let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
@ -592,6 +660,7 @@ private actor LockScreenWeatherProvider {
locationName: nil, locationName: nil,
charging: nil, charging: nil,
bluetooth: nil, bluetooth: nil,
battery: nil,
showsLocation: true, showsLocation: true,
airQuality: airQualityInfo, airQuality: airQualityInfo,
widgetStyle: .inline, widgetStyle: .inline,

View file

@ -364,7 +364,9 @@ extension Defaults.Keys {
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 lockScreenWeatherShowsChargingPercentage = Key<Bool>("lockScreenWeatherShowsChargingPercentage", default: true)
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false) static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: false)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)