diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 59a1a82..8b3d0bf 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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", diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index edfe261..9c817a3 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -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) } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index fd56c1d..49660ed 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -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")) diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index 580f677..86b7df3 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -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 diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 206a553..36093b3 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -466,6 +466,7 @@ extension Defaults.Keys { static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenWeatherShowsBatteryGauge = Key("lockScreenWeatherShowsBatteryGauge", default: true) static let lockScreenWeatherBatteryUsesLaptopSymbol = Key("lockScreenWeatherBatteryUsesLaptopSymbol", default: true) + static let lockScreenWeatherShowsSunrise = Key("lockScreenWeatherShowsSunrise", default: true) static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherTemperatureUnit = Key("lockScreenWeatherTemperatureUnit", default: .celsius) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: true)