Add Sunrise widget and update the icons

This commit is contained in:
Hariharan Mudaliar 2025-12-24 00:12:45 +05:30
commit eddca8b4dd
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
5 changed files with 138 additions and 9 deletions

View file

@ -18478,6 +18478,10 @@
},
"Style" : {
},
"Sunrise at %@" : {
"comment" : "A view representing the sunrise time, displayed in a horizontal stack with an icon and the time itself.",
"isCommentAutoGenerated" : true
},
"Support Us" : {
"extractionState" : "stale",

View file

@ -12,6 +12,13 @@ struct LockScreenWeatherWidget: View {
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)
private static let sunriseFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
formatter.locale = .current
return formatter
}()
private var isInline: Bool { snapshot.widgetStyle == .inline }
private var stackAlignment: VerticalAlignment { isInline ? .firstTextBaseline : .top }
@ -62,12 +69,21 @@ struct LockScreenWeatherWidget: View {
weatherSegment
if snapshot.showsSunrise, let sunriseText = sunriseTimeText {
sunriseSegment(text: sunriseText)
}
if shouldShowLocation {
locationSegment
}
}
}
private var sunriseTimeText: String? {
guard let sunrise = snapshot.sunCycle?.sunrise else { return nil }
return Self.sunriseFormatter.string(from: sunrise)
}
@ViewBuilder
private var weatherSegment: some View {
switch snapshot.widgetStyle {
@ -346,6 +362,20 @@ struct LockScreenWeatherWidget: View {
.layoutPriority(0.7)
}
private func sunriseSegment(text: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Image(systemName: "sunrise.fill")
.font(.system(size: 20, weight: .semibold))
.symbolRenderingMode(.hierarchical)
Text(text)
.font(inlinePrimaryFont)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.layoutPriority(0.8)
.accessibilityLabel("Sunrise at \(text)")
}
private var shouldShowLocation: Bool {
snapshot.showsLocation && (snapshot.locationName?.isEmpty == false)
}

View file

@ -2974,6 +2974,7 @@ struct LockScreenSettings: View {
@Default(.lockScreenWeatherShowsCharging) private var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsBatteryGauge) private var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherShowsSunrise) private var lockScreenWeatherShowsSunrise
@Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale
private func highlightID(_ title: String) -> String {
@ -3089,6 +3090,10 @@ struct LockScreenSettings: View {
.settingsHighlight(id: highlightID("Show charging percentage"))
}
Defaults.Toggle("Show sunrise time", key: .lockScreenWeatherShowsSunrise)
.disabled(lockScreenWeatherWidgetStyle != .inline)
.settingsHighlight(id: highlightID("Show sunrise time"))
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
.settingsHighlight(id: highlightID("Show battery indicator"))

View file

@ -107,6 +107,9 @@ final class LockScreenWeatherManager: ObservableObject {
let showsBatteryGauge = Defaults[.lockScreenWeatherShowsBatteryGauge]
let widgetStyle = Defaults[.lockScreenWeatherWidgetStyle]
let batteryInfo = showsBatteryGauge ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil
let showsSunriseSetting = Defaults[.lockScreenWeatherShowsSunrise]
let sunCycle = snapshot?.sunCycle
let shouldShowSunrise = showsSunriseSetting && widgetStyle == .inline && sunCycle?.sunrise != nil
let fallback = LockScreenWeatherSnapshot(
temperatureText: snapshot?.temperatureText ?? "--",
symbolName: snapshot?.symbolName ?? "cloud.fill",
@ -120,7 +123,9 @@ final class LockScreenWeatherManager: ObservableObject {
widgetStyle: widgetStyle,
showsChargingPercentage: Defaults[.lockScreenWeatherShowsChargingPercentage],
temperatureInfo: snapshot?.temperatureInfo,
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint]
usesGaugeTint: Defaults[.lockScreenWeatherUsesGaugeTint],
sunCycle: sunCycle,
showsSunrise: shouldShowSunrise
)
self.snapshot = fallback
@ -192,6 +197,8 @@ final class LockScreenWeatherManager: ObservableObject {
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherBatteryUsesLaptopSymbol, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherShowsSunrise, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherWidgetStyle, options: [])
.map { _ in () }.eraseToAnyPublisher(),
Defaults.publisher(.lockScreenWeatherTemperatureUnit, options: [])
@ -294,6 +301,8 @@ final class LockScreenWeatherManager: ObservableObject {
let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil
let batteryInfo = Defaults[.lockScreenWeatherShowsBatteryGauge] ? makeBatteryGaugeInfo(isCharging: chargingInfo != nil, widgetStyle: widgetStyle) : nil
let usesGaugeTint = Defaults[.lockScreenWeatherUsesGaugeTint]
let showsSunriseSetting = Defaults[.lockScreenWeatherShowsSunrise]
let shouldShowSunrise = showsSunriseSetting && widgetStyle == .inline && payload.sunCycle?.sunrise != nil
return LockScreenWeatherSnapshot(
temperatureText: payload.temperatureText,
@ -308,7 +317,9 @@ final class LockScreenWeatherManager: ObservableObject {
widgetStyle: widgetStyle,
showsChargingPercentage: showsChargingPercentage,
temperatureInfo: payload.temperatureInfo,
usesGaugeTint: usesGaugeTint
usesGaugeTint: usesGaugeTint,
sunCycle: payload.sunCycle,
showsSunrise: shouldShowSunrise
)
}
@ -384,6 +395,11 @@ final class LockScreenWeatherManager: ObservableObject {
}
struct LockScreenWeatherSnapshot: Equatable {
struct SunCycleInfo: Equatable {
let sunrise: Date?
let sunset: Date?
}
struct TemperatureInfo: Equatable {
let current: Double
let minimum: Double?
@ -487,6 +503,8 @@ struct LockScreenWeatherSnapshot: Equatable {
let showsChargingPercentage: Bool
let temperatureInfo: TemperatureInfo?
let usesGaugeTint: Bool
let sunCycle: SunCycleInfo?
let showsSunrise: Bool
}
extension LockScreenWeatherSnapshot.AirQualityInfo.Category {
@ -615,7 +633,9 @@ private actor LockScreenWeatherProvider {
)
let code = Int(condition.weatherCode) ?? 113
let symbol = WeatherSymbolMapper.symbol(for: code)
let isDaytime = condition.isDaytime ?? true
let baseSymbol = WeatherSymbolMapper.symbol(for: code)
let symbol = symbolAdjustedForDaylight(baseSymbol, isDaytime: isDaytime)
let description = condition.localizedDescription
let nearest = payload.nearestArea.first
@ -646,7 +666,9 @@ private actor LockScreenWeatherProvider {
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
usesGaugeTint: true,
sunCycle: nil,
showsSunrise: false
)
}
@ -660,9 +682,9 @@ private actor LockScreenWeatherProvider {
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: "current", value: "temperature_2m,weather_code,is_day,pressure_msl"),
URLQueryItem(name: "daily", value: "temperature_2m_max,temperature_2m_min,sunrise,sunset"),
URLQueryItem(name: "forecast_days", value: "2"),
URLQueryItem(name: "timezone", value: "auto")
]
@ -692,9 +714,21 @@ private actor LockScreenWeatherProvider {
let temperatureText = "\(Int(round(temperatureValue)))°"
let code = current.weatherCode ?? 0
let mapping = OpenMeteoSymbolMapper.mapping(for: code)
let isDaytime = (current.isDay ?? 1) == 1
let symbolName = symbolAdjustedForDaylight(mapping.symbol, isDaytime: isDaytime)
let unitSymbol = unit.symbol
let minTempValue = weatherPayload.daily?.temperature2MMin?.first
let maxTempValue = weatherPayload.daily?.temperature2MMax?.first
let timezoneIdentifier = weatherPayload.timezone
let utcOffsetSeconds = weatherPayload.utcOffsetSeconds
let sunriseDate = nextSunEvent(from: weatherPayload.daily?.sunrise, timezoneIdentifier: timezoneIdentifier, offsetSeconds: utcOffsetSeconds)
let sunsetDate = nextSunEvent(from: weatherPayload.daily?.sunset, timezoneIdentifier: timezoneIdentifier, offsetSeconds: utcOffsetSeconds)
let sunCycle: LockScreenWeatherSnapshot.SunCycleInfo?
if sunriseDate != nil || sunsetDate != nil {
sunCycle = LockScreenWeatherSnapshot.SunCycleInfo(sunrise: sunriseDate, sunset: sunsetDate)
} else {
sunCycle = nil
}
let temperatureInfo = LockScreenWeatherSnapshot.TemperatureInfo(
current: temperatureValue,
@ -712,7 +746,7 @@ private actor LockScreenWeatherProvider {
return LockScreenWeatherSnapshot(
temperatureText: temperatureText,
symbolName: mapping.symbol,
symbolName: symbolName,
description: mapping.description,
locationName: nil,
charging: nil,
@ -723,7 +757,9 @@ private actor LockScreenWeatherProvider {
widgetStyle: .inline,
showsChargingPercentage: true,
temperatureInfo: temperatureInfo,
usesGaugeTint: true
usesGaugeTint: true,
sunCycle: sunCycle,
showsSunrise: false
)
}
@ -771,6 +807,30 @@ private actor LockScreenWeatherProvider {
scale: scale
)
}
private func nextSunEvent(from values: [String]?, timezoneIdentifier: String?, offsetSeconds: Int?) -> Date? {
guard let values else { return nil }
let now = Date()
for value in values {
if let date = parseLocalSunTime(value, timezoneIdentifier: timezoneIdentifier, offsetSeconds: offsetSeconds), date >= now {
return date
}
}
guard let fallbackValue = values.last else { return nil }
return parseLocalSunTime(fallbackValue, timezoneIdentifier: timezoneIdentifier, offsetSeconds: offsetSeconds)
}
private func parseLocalSunTime(_ value: String, timezoneIdentifier: String?, offsetSeconds: Int?) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
if let identifier = timezoneIdentifier, let timeZone = TimeZone(identifier: identifier) {
formatter.timeZone = timeZone
} else if let offset = offsetSeconds, let timeZone = TimeZone(secondsFromGMT: offset) {
formatter.timeZone = timeZone
}
return formatter.date(from: value)
}
}
enum WeatherProviderError: Error {
@ -799,6 +859,7 @@ private struct WTTRCurrentCondition: Decodable {
case pressure = "pressure"
case pressureInches = "pressureInches"
case airQuality = "air_quality"
case isday = "isday"
}
let tempC: String
@ -809,6 +870,7 @@ private struct WTTRCurrentCondition: Decodable {
let pressure: String?
let pressureInches: String?
let airQuality: WTTRAirQuality?
let isday: String?
var localizedDescription: String {
if let english = langEn?.first?.value, !english.isEmpty {
@ -819,6 +881,12 @@ private struct WTTRCurrentCondition: Decodable {
}
return ""
}
var isDaytime: Bool? {
guard let isday else { return nil }
let normalized = isday.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized == "1" || normalized == "yes"
}
}
private struct WTTRTextValue: Decodable {
@ -885,14 +953,19 @@ private struct OpenMeteoForecastResponse: Decodable {
let temperature2M: Double?
let weatherCode: Int?
let pressureMsl: Double?
let isDay: Int?
}
let current: Current?
let daily: Daily?
let timezone: String?
let utcOffsetSeconds: Int?
struct Daily: Decodable {
let temperature2MMax: [Double]?
let temperature2MMin: [Double]?
let sunrise: [String]?
let sunset: [String]?
}
}
@ -968,6 +1041,22 @@ private enum WeatherSymbolMapper {
}
}
private func symbolAdjustedForDaylight(_ symbol: String, isDaytime: Bool) -> String {
guard !isDaytime else { return symbol }
switch symbol {
case "sun.max.fill":
return "moon.stars.fill"
case "cloud.sun.fill":
return "cloud.moon.fill"
case "cloud.sun.rain.fill":
return "cloud.moon.rain.fill"
case "cloud.sun.bolt.fill":
return "cloud.moon.bolt.fill"
default:
return symbol
}
}
@MainActor
private final class LockScreenWeatherLocationProvider: NSObject, CLLocationManagerDelegate {
private let manager: CLLocationManager

View file

@ -466,6 +466,7 @@ extension Defaults.Keys {
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
static let lockScreenWeatherShowsBatteryGauge = Key<Bool>("lockScreenWeatherShowsBatteryGauge", default: true)
static let lockScreenWeatherBatteryUsesLaptopSymbol = Key<Bool>("lockScreenWeatherBatteryUsesLaptopSymbol", default: true)
static let lockScreenWeatherShowsSunrise = Key<Bool>("lockScreenWeatherShowsSunrise", default: true)
static let lockScreenWeatherWidgetStyle = Key<LockScreenWeatherWidgetStyle>("lockScreenWeatherWidgetStyle", default: .circular)
static let lockScreenWeatherTemperatureUnit = Key<LockScreenWeatherTemperatureUnit>("lockScreenWeatherTemperatureUnit", default: .celsius)
static let lockScreenWeatherShowsAQI = Key<Bool>("lockScreenWeatherShowsAQI", default: true)