diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b4b6c0a..2210001 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1323,6 +1323,10 @@ } } }, + "0" : { + "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", + "isCommentAutoGenerated" : true + }, "1x" : { "localizations" : { "fr" : { @@ -1433,6 +1437,10 @@ } } }, + "100" : { + "comment" : "The text displayed below a 100% battery level in the lock screen.", + "isCommentAutoGenerated" : true + }, "100%" : { "localizations" : { "fr" : { @@ -10823,6 +10831,9 @@ } } }, + "Mac battery at %d percent" : { + "comment" : "Mac battery gauge accessibility label" + }, "Made ❤️ by Ebullioscopic" : { }, @@ -18186,6 +18197,9 @@ } } } + }, + "Temperature unit" : { + }, "TheDynamicIsland" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 8afc0b5..9ddfd95 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -11,15 +11,10 @@ struct LockScreenWeatherWidget: View { private var isInline: Bool { snapshot.widgetStyle == .inline } private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top } private var stackSpacing: CGFloat { isInline ? 14 : 22 } - private var gaugeLineWidth: CGFloat { 6 } private var gaugeDiameter: CGFloat { 64 } private var topPadding: CGFloat { isInline ? 6 : 22 } 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 { Color.white.opacity(0.9) } @@ -30,6 +25,10 @@ struct LockScreenWeatherWidget: View { chargingSegment(for: charging) } + if !isInline, let battery = snapshot.battery { + batterySegment(for: battery) + } + if let bluetooth = snapshot.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 { HStack(alignment: .firstTextBaseline, spacing: 6) { if let iconName = chargingIconName(for: info) { @@ -115,11 +124,27 @@ struct LockScreenWeatherWidget: View { @ViewBuilder 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) { - 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) + } 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)) .font(inlineSecondaryFont) .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 private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { switch snapshot.widgetStyle { @@ -155,13 +206,24 @@ struct LockScreenWeatherWidget: View { } private func circularBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { - VStack(spacing: 6) { - let clamped = clampedBatteryLevel(info.batteryLevel) - circularBadge(progress: Double(clamped) / 100, tint: bluetoothTint(for: clamped), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { + let clamped = clampedBatteryLevel(info.batteryLevel) + + return VStack(spacing: 6) { + Gauge(value: Double(clamped), in: 0...100) { + EmptyView() + } currentValueLabel: { Image(systemName: info.iconName) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Color.white) + } minimumValueLabel: { + EmptyView() + } maximumValueLabel: { + EmptyView() } + .gaugeStyle(.accessoryCircularCapacity) + .tint(bluetoothTint(for: clamped)) + .frame(width: gaugeDiameter, height: gaugeDiameter) + Text(bluetoothPercentageText(for: info.batteryLevel)) .font(inlineSecondaryFont) .foregroundStyle(secondaryLabelColor) @@ -193,11 +255,17 @@ struct LockScreenWeatherWidget: View { 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) { + Gauge(value: Double(info.index), in: 0...500) { + EmptyView() + } currentValueLabel: { Text("\(info.index)") .font(.system(size: 20, weight: .semibold, design: .rounded)) .foregroundStyle(Color.white) } + .gaugeStyle(.accessoryCircular) + .tint(aqiTint(for: info.category)) + .frame(width: gaugeDiameter, height: gaugeDiameter) + Text("AQI · \(info.category.displayName)") .font(inlineSecondaryFont) .foregroundStyle(secondaryLabelColor) @@ -207,17 +275,25 @@ struct LockScreenWeatherWidget: View { } private func temperatureGauge(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> some View { - VStack(spacing: 6) { - circularBadge(progress: temperatureProgress(for: info), tint: temperatureTint(for: info), lineWidth: gaugeLineWidth, diameter: gaugeDiameter) { + let range = temperatureRange(for: info) + + return VStack(spacing: 6) { + Gauge(value: info.current, in: range) { + EmptyView() + } currentValueLabel: { temperatureCenterLabel(for: info) } + .gaugeStyle(.accessoryCircular) + .tint(temperatureTint(for: info)) + .frame(width: gaugeDiameter, height: gaugeDiameter) + HStack { Text(minimumTemperatureLabel(for: info)) - .font(inlineSecondaryFont) + .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(secondaryLabelColor) Spacer() Text(maximumTemperatureLabel(for: info)) - .font(inlineSecondaryFont) + .font(.system(size: 11, weight: .medium, design: .rounded)) .foregroundStyle(secondaryLabelColor) } .frame(width: gaugeDiameter) @@ -307,21 +383,27 @@ struct LockScreenWeatherWidget: 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) { 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 temperatureRange(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> ClosedRange { + 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 { min(max(level, 0), 100) } @@ -336,23 +418,6 @@ struct LockScreenWeatherWidget: View { 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 } let clamped = clampedBatteryLevel(level) @@ -416,27 +481,6 @@ struct LockScreenWeatherWidget: View { } } - @ViewBuilder - private func circularBadge( - 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] = [] @@ -471,6 +515,10 @@ struct LockScreenWeatherWidget: View { components.append(accessibilityAirQualityText(for: airQuality)) } + if let battery = snapshot.battery, !isInline { + components.append(accessibilityBatteryText(for: battery)) + } + return components.joined(separator: ". ") } @@ -515,46 +563,12 @@ struct LockScreenWeatherWidget: View { airQuality.category.displayName ) } -} -private struct CircularMetricView: 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) + private func accessibilityBatteryText(for battery: LockScreenWeatherSnapshot.BatteryInfo) -> String { + String( + format: NSLocalizedString("Mac battery at %d percent", comment: "Mac battery gauge accessibility label"), + clampedBatteryLevel(battery.batteryLevel) + ) } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 20d76bb..9e9135f 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -182,7 +182,9 @@ struct GeneralSettings: View { @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging @Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth + @Default(.lockScreenWeatherShowsBatteryGauge) var lockScreenWeatherShowsBatteryGauge @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle + @Default(.lockScreenWeatherTemperatureUnit) var lockScreenWeatherTemperatureUnit @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource @@ -345,12 +347,19 @@ struct GeneralSettings: View { } } .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) .disabled(lockScreenWeatherWidgetStyle == .circular) Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) if lockScreenWeatherShowsCharging { Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage) } + Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge) Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI) .disabled(!lockScreenWeatherProviderSource.supportsAirQuality) diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index ba3ac68..1ee0b07 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -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" + } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index c938671..4eb74b6 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -59,8 +59,11 @@ final class LockScreenWeatherManager: ObservableObject { @discardableResult func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? { + NSLog("LockScreenWeatherManager: refresh requested (force=%@)", force ? "true" : "false") let interval = Defaults[.lockScreenWeatherRefreshInterval] 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 Defaults[.lockScreenWeatherShowsBluetooth] { BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() @@ -78,6 +81,12 @@ final class LockScreenWeatherManager: ObservableObject { do { 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) latestWeatherPayload = payload if Defaults[.lockScreenWeatherShowsBluetooth] { @@ -87,18 +96,24 @@ final class LockScreenWeatherManager: ObservableObject { self.snapshot = snapshot lastFetchDate = Date() deliver(snapshot, forceShow: false) + NSLog("LockScreenWeatherManager: weather refresh succeeded") return snapshot } catch { NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") 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( temperatureText: snapshot?.temperatureText ?? "--", symbolName: snapshot?.symbolName ?? "cloud.fill", description: snapshot?.description ?? "", locationName: snapshot?.locationName, - charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, + charging: chargingInfo, bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, + battery: batteryInfo, showsLocation: snapshot?.showsLocation ?? false, airQuality: (providerSource.supportsAirQuality && Defaults[.lockScreenWeatherShowsAQI]) ? snapshot?.airQuality : nil, widgetStyle: Defaults[.lockScreenWeatherWidgetStyle], @@ -172,8 +187,12 @@ final class LockScreenWeatherManager: ObservableObject { .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsBatteryGauge, options: []) + .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherWidgetStyle, options: []) .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: []) + .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherShowsAQI, options: []) .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherUsesGaugeTint, options: []) @@ -193,10 +212,37 @@ final class LockScreenWeatherManager: ObservableObject { guard let self else { return } self.latestWeatherPayload = nil Task { @MainActor in + NSLog("LockScreenWeatherManager: provider changed, forcing refresh") _ = await self.refresh(force: true) } } .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) { @@ -222,14 +268,15 @@ final class LockScreenWeatherManager: ObservableObject { } private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot { - let locationName = payload.locationName + let locationName = payload.locationName let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil 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 providerSource = Defaults[.lockScreenWeatherProviderSource] let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil + let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil) : nil let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint] return LockScreenWeatherSnapshot( @@ -239,6 +286,7 @@ final class LockScreenWeatherManager: ObservableObject { locationName: locationName, charging: chargingInfo, bluetooth: bluetoothInfo, + battery: batteryInfo, showsLocation: shouldShowLocation, airQuality: airQualityInfo, 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? { let manager = BluetoothAudioManager.shared @@ -352,6 +412,10 @@ struct LockScreenWeatherSnapshot: Equatable { let iconName: String } + struct BatteryInfo: Equatable { + let batteryLevel: Int + } + struct AirQualityInfo: Equatable { enum Category: String, Equatable { case good @@ -385,6 +449,7 @@ struct LockScreenWeatherSnapshot: Equatable { let locationName: String? let charging: ChargingInfo? let bluetooth: BluetoothInfo? + let battery: BatteryInfo? let showsLocation: Bool let airQuality: AirQualityInfo? let widgetStyle: LockScreenWeatherWidgetStyle @@ -472,11 +537,12 @@ private actor LockScreenWeatherProvider { throw WeatherProviderError.noData } - let usesMetric = Locale.current.usesMetricSystem + let unit = Defaults[.lockScreenWeatherTemperatureUnit] + let usesMetric = unit.usesMetricSystem let rawTemperature = usesMetric ? condition.tempC : condition.tempF let temperatureValue = Double(rawTemperature) ?? 0 let temperatureText = "\(Int(round(temperatureValue)))°" - let unitSymbol = usesMetric ? "°C" : "°F" + let unitSymbol = unit.symbol let forecast = payload.dailyWeather.first let minTempValue = forecast.flatMap { daily in @@ -519,6 +585,7 @@ private actor LockScreenWeatherProvider { locationName: locationName, charging: nil, bluetooth: nil, + battery: nil, showsLocation: true, airQuality: airQualityInfo, widgetStyle: .inline, @@ -532,7 +599,8 @@ private actor LockScreenWeatherProvider { let latitude = String(format: "%.4f", location.coordinate.latitude) 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 weatherQueryItems: [URLQueryItem] = [ URLQueryItem(name: "latitude", value: latitude), @@ -543,8 +611,8 @@ private actor LockScreenWeatherProvider { URLQueryItem(name: "timezone", value: "auto") ] - if !usesMetric { - weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: "fahrenheit")) + if let temperatureUnitParameter = unit.openMeteoTemperatureParameter { + weatherQueryItems.append(URLQueryItem(name: "temperature_unit", value: temperatureUnitParameter)) } weatherComponents?.queryItems = weatherQueryItems @@ -569,7 +637,7 @@ private actor LockScreenWeatherProvider { let temperatureText = "\(Int(round(temperatureValue)))°" let code = current.weatherCode ?? 0 let mapping = OpenMeteoSymbolMapper.mapping(for: code) - let unitSymbol = usesMetric ? "°C" : "°F" + let unitSymbol = unit.symbol let minTempValue = weatherPayload.daily?.temperature2MMin?.first let maxTempValue = weatherPayload.daily?.temperature2MMax?.first @@ -592,6 +660,7 @@ private actor LockScreenWeatherProvider { locationName: nil, charging: nil, bluetooth: nil, + battery: nil, showsLocation: true, airQuality: airQualityInfo, widgetStyle: .inline, diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4c30f34..a02af29 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -364,7 +364,9 @@ extension Defaults.Keys { static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) static let lockScreenWeatherShowsChargingPercentage = Key("lockScreenWeatherShowsChargingPercentage", default: true) static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) + static let lockScreenWeatherShowsBatteryGauge = Key("lockScreenWeatherShowsBatteryGauge", default: true) static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) + static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false) static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo)