From 5f324ecde80896fb716f73de9b80f87a3de31c5e Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 3 Nov 2025 00:16:47 +0530 Subject: [PATCH 1/2] Add circular progress --- .gitignore | 4 +- DynamicIsland.xcodeproj/project.pbxproj | 9 + .../xcshareddata/swiftpm/Package.resolved | 11 +- DynamicIsland/Localizable.xcstrings | 45 ++ .../LockScreen/LockScreenWeatherWidget.swift | 691 ++++++++++++++---- .../components/Settings/SettingsView.swift | 28 + DynamicIsland/enums/generic.swift | 25 + .../managers/LockScreenWeatherManager.swift | 389 +++++++++- DynamicIsland/models/Constants.swift | 5 + 9 files changed, 1046 insertions(+), 161 deletions(-) diff --git a/.gitignore b/.gitignore index 72d7545..bbd634a 100644 --- a/.gitignore +++ b/.gitignore @@ -146,4 +146,6 @@ borrow_migrations.md borrowing/ MoveToSky.xcodeproj /MoveToSky/ -mew-notch/ \ No newline at end of file +mew-notch/ + +/References/ \ No newline at end of file diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 39fc314..cdd49d3 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -221,6 +221,7 @@ B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */, 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, + 7E09D5962EB7B497005AF41D /* XCRemoteSwiftPackageReference "open-meteo" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -541,6 +542,14 @@ 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" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 397f9e1..b8ee36b 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944", + "originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce", "pins" : [ { "identity" : "defaults", @@ -55,6 +55,15 @@ "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", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 9a627ef..0acd062 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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.", "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%%" : { "localizations" : { "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" : { "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 \"%@\"?" : { "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" : { @@ -3497,6 +3523,9 @@ } } }, + "Battery at %d percent" : { + "comment" : "Battery percentage" + }, "Battery charging" : { "comment" : "Battery charging without estimate" }, @@ -4385,6 +4414,7 @@ } }, "Charging" : { + "comment" : "Charging fallback label when no estimate is available", "localizations" : { "ar" : { "stringUnit" : { @@ -8466,6 +8496,12 @@ } } }, + "Full" : { + "comment" : "Full battery level label" + }, + "Fully charged" : { + "comment" : "Charging fallback label when already charged" + }, "Gemini API Key" : { "localizations" : { "fr" : { @@ -9437,6 +9473,7 @@ } }, "High" : { + "comment" : "High battery level label", "localizations" : { "ar" : { "stringUnit" : { @@ -10589,6 +10626,7 @@ } }, "Low" : { + "comment" : "Low battery level label", "localizations" : { "ar" : { "stringUnit" : { @@ -11799,6 +11837,7 @@ } }, "Medium" : { + "comment" : "Medium battery level label", "localizations" : { "ar" : { "stringUnit" : { @@ -19603,6 +19642,9 @@ } } } + }, + "Weather data provider" : { + }, "Weather: %@ %@" : { "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." : { + }, + "Widget layout" : { + }, "Width:" : { "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 07fdb19..8a2f5f7 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -1,181 +1,568 @@ import SwiftUI + 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 { - HStack(alignment: .firstTextBaseline, spacing: 16) { - if let charging = snapshot.charging { - chargingSegment(for: charging) - } + 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 } - if let bluetooth = snapshot.bluetooth { - bluetoothSegment(for: bluetooth) - } + private var gaugeBackgroundColor: Color { + snapshot.usesGaugeTint ? Color.white.opacity(0.22) : Color.white.opacity(0.32) + } - weatherSegment + private var monochromeGaugeTint: Color { + Color.white.opacity(0.9) + } - if shouldShowLocation { - locationSegment - } - } - .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) - } + var body: some View { + HStack(alignment: stackAlignment, spacing: stackSpacing) { + if let charging = snapshot.charging { + chargingSegment(for: charging) + } - private var weatherSegment: some View { - HStack(alignment: .firstTextBaseline, spacing: 6) { - 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) - } - } + if let bluetooth = snapshot.bluetooth { + bluetoothSegment(for: bluetooth) + } - @ViewBuilder - private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { - if let iconName = chargingIconName(for: info) { - HStack(spacing: 4) { - Image(systemName: iconName) - .font(.system(size: 20, weight: .semibold)) - .symbolRenderingMode(.hierarchical) + if let airQuality = snapshot.airQuality { + airQualitySegment(for: airQuality) + } - if let text = formattedChargingTime(for: info) { - Text(text) - .font(primaryFont) - .lineLimit(1) - .minimumScaleFactor(0.75) - .layoutPriority(1.2) - } - } - .layoutPriority(1) - } - } + weatherSegment - @ViewBuilder - private func bluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { - HStack(spacing: 4) { - Image(systemName: info.iconName) - .font(.system(size: 20, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - Text("\(clampedBatteryLevel(info.batteryLevel))%") - .font(primaryFont) - .lineLimit(1) - .truncationMode(.tail) - .minimumScaleFactor(0.85) - .layoutPriority(1.4) - } - } + if shouldShowLocation { + locationSegment + } + } + .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) + } - @ViewBuilder - private var locationSegment: some View { - if let locationName = snapshot.locationName { - Text(locationName) - .font(primaryFont) - .lineLimit(1) - .truncationMode(.tail) - .minimumScaleFactor(0.75) - .layoutPriority(0.7) - } - } + @ViewBuilder + private var weatherSegment: some View { + switch snapshot.widgetStyle { + case .inline: + inlineWeatherSegment + case .circular: + circularWeatherSegment + } + } - private var shouldShowLocation: Bool { - snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) - } + private var inlineWeatherSegment: some View { + 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? { - let icon = info.iconName - return icon.isEmpty ? nil : icon - } + @ViewBuilder + private var circularWeatherSegment: some View { + if let info = snapshot.temperatureInfo { + temperatureGauge(for: info) + } else { + inlineWeatherSegment + } + } - private func formattedChargingTime(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { - guard let minutes = info.minutesRemaining, minutes > 0 else { - return nil - } + @ViewBuilder + private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { + switch snapshot.widgetStyle { + case .inline: + inlineChargingSegment(for: info) + case .circular: + circularChargingSegment(for: info) + } + } - let hours = minutes / 60 - let remainingMinutes = minutes % 60 + private func inlineChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { + 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 { - return "\(hours)h \(remainingMinutes)m" - } - return "\(remainingMinutes)m" - } + @ViewBuilder + private func circularChargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View { + if let level = info.batteryLevel { + 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 { - min(max(level, 0), 100) - } + @ViewBuilder + 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 { - var components: [String] = [] + private func inlineBluetoothSegment(for info: LockScreenWeatherSnapshot.BluetoothInfo) -> some View { + 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 { - 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 - ) - ) - } + 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) { + Image(systemName: info.iconName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(Color.white) + } + Text(bluetoothStatusLabel(for: info.batteryLevel)) + .font(inlineSecondaryFont) + .foregroundStyle(secondaryLabelColor) + } + .layoutPriority(1) + } - if let charging = snapshot.charging { - components.append(accessibilityChargingText(for: charging)) - } + @ViewBuilder + 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 { - components.append(accessibilityBluetoothText(for: bluetooth)) - } + private func inlineAirQualitySegment(for info: LockScreenWeatherSnapshot.AirQualityInfo) -> some View { + 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 { - 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 - ) - } + 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) { + temperatureCenterLabel(for: info) + } + HStack { + Text(minimumTemperatureLabel(for: info)) + .font(inlineSecondaryFont) + .foregroundStyle(secondaryLabelColor) + Spacer() + Text(maximumTemperatureLabel(for: info)) + .font(inlineSecondaryFont) + .foregroundStyle(secondaryLabelColor) + } + .frame(width: gaugeDiameter) + } + .layoutPriority(1) + } - if charging.isPluggedIn && !charging.isCharging { - return NSLocalizedString("Battery fully charged", comment: "Battery is full") - } + private var locationSegment: some View { + 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 { - return String( - format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"), - bluetooth.deviceName, - bluetooth.batteryLevel - ) - } + private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { + let icon = info.iconName + return icon.isEmpty ? nil : icon + } + + @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( + 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: 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) + } +} + + + + diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index e5b0dc4..8a4f7fe 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -181,7 +181,12 @@ struct GeneralSettings: View { @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging + @Default(.lockScreenWeatherShowsChargingPercentage) var lockScreenWeatherShowsChargingPercentage @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth + @Default(.lockScreenWeatherWidgetStyle) var lockScreenWeatherWidgetStyle + @Default(.lockScreenWeatherShowsAQI) var lockScreenWeatherShowsAQI + @Default(.lockScreenWeatherUsesGaugeTint) var lockScreenWeatherUsesGaugeTint + @Default(.lockScreenWeatherProviderSource) var lockScreenWeatherProviderSource var body: some View { Form { @@ -329,9 +334,32 @@ struct GeneralSettings: View { Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show lock screen weather", key: .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 charging status", key: .lockScreenWeatherShowsCharging) + if lockScreenWeatherShowsCharging { + Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage) + } 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") { diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 58736df..ba3ac68 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -77,3 +77,28 @@ enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifi 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 + } + } +} diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index ae3483d..87d877e 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -86,6 +86,7 @@ final class LockScreenWeatherManager: ObservableObject { } catch { NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") + let providerSource = Defaults[.lockScreenWeatherProviderSource] let fallback = LockScreenWeatherSnapshot( temperatureText: snapshot?.temperatureText ?? "--", symbolName: snapshot?.symbolName ?? "cloud.fill", @@ -93,7 +94,12 @@ final class LockScreenWeatherManager: ObservableObject { locationName: snapshot?.locationName, charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : 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 @@ -138,7 +144,17 @@ final class LockScreenWeatherManager: ObservableObject { .map { _ in () }.eraseToAnyPublisher(), Defaults.publisher(.lockScreenWeatherShowsCharging, options: []) .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsChargingPercentage, options: []) + .map { _ in () }.eraseToAnyPublisher(), 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() ] @@ -147,6 +163,16 @@ final class LockScreenWeatherManager: ObservableObject { self?.handleAccessoryUpdate(triggerBluetoothRefresh: true) } .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) { @@ -176,6 +202,11 @@ final class LockScreenWeatherManager: ObservableObject { let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : 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( temperatureText: payload.temperatureText, @@ -184,7 +215,12 @@ final class LockScreenWeatherManager: ObservableObject { locationName: locationName, charging: chargingInfo, 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 remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil + let rawLevel = Int(round(Double(battery.levelBattery))) + let clampedLevel = min(max(rawLevel, 0), 100) + return LockScreenWeatherSnapshot.ChargingInfo( minutesRemaining: remaining, isCharging: isCharging, - isPluggedIn: isPluggedIn + isPluggedIn: isPluggedIn, + batteryLevel: isPluggedIn || isCharging ? clampedLevel : nil ) } @@ -239,10 +279,37 @@ final class LockScreenWeatherManager: ObservableObject { } 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 { let minutesRemaining: Int? let isCharging: Bool let isPluggedIn: Bool + let batteryLevel: Int? var iconName: String { if isCharging { @@ -261,6 +328,33 @@ struct LockScreenWeatherSnapshot: Equatable { 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 symbolName: String let description: String @@ -268,6 +362,34 @@ struct LockScreenWeatherSnapshot: Equatable { let charging: ChargingInfo? let bluetooth: BluetoothInfo? 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 { @@ -283,6 +405,19 @@ private actor LockScreenWeatherProvider { } 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 if let coordinate = location?.coordinate { let lat = String(format: "%.4f", coordinate.latitude) @@ -292,7 +427,8 @@ private actor LockScreenWeatherProvider { 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 { throw WeatherProviderError.invalidURL @@ -309,8 +445,27 @@ private actor LockScreenWeatherProvider { } let usesMetric = Locale.current.usesMetricSystem - let temperature = usesMetric ? condition.tempC : condition.tempF - let temperatureText = "\(temperature)°" + let rawTemperature = usesMetric ? condition.tempC : condition.tempF + 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 symbol = WeatherSymbolMapper.symbol(for: code) @@ -319,6 +474,16 @@ private actor LockScreenWeatherProvider { let nearest = payload.nearestArea.first 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( temperatureText: temperatureText, symbolName: symbol, @@ -326,7 +491,117 @@ private actor LockScreenWeatherProvider { locationName: locationName, charging: 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 { let current_condition: [WTTRCurrentCondition] let nearest_area: [WTTRNearestArea]? + let weather: [WTTRDailyWeather]? var currentCondition: [WTTRCurrentCondition] { current_condition } var nearestArea: [WTTRNearestArea] { nearest_area ?? [] } + var dailyWeather: [WTTRDailyWeather] { weather ?? [] } } private struct WTTRCurrentCondition: Decodable { @@ -352,6 +629,9 @@ private struct WTTRCurrentCondition: Decodable { case weatherCode case weatherDesc case langEn = "lang_en" + case pressure = "pressure" + case pressureInches = "pressureInches" + case airQuality = "air_quality" } let tempC: String @@ -359,6 +639,9 @@ private struct WTTRCurrentCondition: Decodable { let weatherCode: String let weatherDesc: [WTTRTextValue]? let langEn: [WTTRTextValue]? + let pressure: String? + let pressureInches: String? + let airQuality: WTTRAirQuality? var localizedDescription: String { if let english = langEn?.first?.value, !english.isEmpty { @@ -375,6 +658,35 @@ private struct WTTRTextValue: Decodable { 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 { let areaName: [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 { static func symbol(for code: Int) -> String { switch code { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 22cda55..d35b332 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -362,7 +362,12 @@ extension Defaults.Keys { static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) + static let lockScreenWeatherShowsChargingPercentage = Key("lockScreenWeatherShowsChargingPercentage", default: true) static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) + static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .inline) + static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false) + static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: true) + static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .wttr) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) From 2612cf9d7f570cc36e8fa34859e43805bc8e7a40 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 3 Nov 2025 01:28:33 +0530 Subject: [PATCH 2/2] Working with fallbacks --- DynamicIsland/Localizable.xcstrings | 3 -- .../LockScreen/LockScreenWeatherWidget.swift | 41 ++++++++----------- .../components/Settings/SettingsView.swift | 17 ++++---- .../managers/LockScreenWeatherManager.swift | 34 +++++++++++++-- .../LockScreenWeatherPanelManager.swift | 6 ++- DynamicIsland/models/Constants.swift | 6 +-- 6 files changed, 63 insertions(+), 44 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 0acd062..b4b6c0a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -8496,9 +8496,6 @@ } } }, - "Full" : { - "comment" : "Full battery level label" - }, "Fully charged" : { "comment" : "Charging fallback label when already charged" }, diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 8a2f5f7..8afc0b5 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -13,6 +13,8 @@ struct LockScreenWeatherWidget: View { 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) @@ -45,7 +47,8 @@ struct LockScreenWeatherWidget: View { .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) - .padding(.vertical, 6) + .padding(.top, topPadding) + .padding(.bottom, bottomPadding) .background(Color.clear) .shadow(color: .black.opacity(0.35), radius: 8, x: 0, y: 3) .accessibilityElement(children: .ignore) @@ -143,7 +146,7 @@ struct LockScreenWeatherWidget: View { Image(systemName: info.iconName) .font(.system(size: 20, weight: .semibold)) .symbolRenderingMode(.hierarchical) - Text(bluetoothStatusLabel(for: info.batteryLevel)) + Text(bluetoothPercentageText(for: info.batteryLevel)) .font(inlinePrimaryFont) .lineLimit(1) .minimumScaleFactor(0.85) @@ -159,7 +162,7 @@ struct LockScreenWeatherWidget: View { .font(.system(size: 22, weight: .semibold)) .foregroundStyle(Color.white) } - Text(bluetoothStatusLabel(for: info.batteryLevel)) + Text(bluetoothPercentageText(for: info.batteryLevel)) .font(inlineSecondaryFont) .foregroundStyle(secondaryLabelColor) } @@ -285,18 +288,8 @@ struct LockScreenWeatherWidget: View { 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 bluetoothPercentageText(for level: Int) -> String { + "\(clampedBatteryLevel(level))%" } private func minimumTemperatureLabel(for info: LockScreenWeatherSnapshot.TemperatureInfo) -> String { @@ -362,25 +355,27 @@ struct LockScreenWeatherWidget: View { private func batteryTint(for level: Int) -> Color { guard snapshot.usesGaugeTint else { return monochromeGaugeTint } - switch level { + let clamped = clampedBatteryLevel(level) + switch clamped { case ..<20: - return Color(red: 0.91, green: 0.29, blue: 0.25) + return Color(.systemRed) case 20..<50: - return Color(red: 0.99, green: 0.74, blue: 0.30) + return Color(.systemOrange) default: - return Color(red: 0.20, green: 0.79, blue: 0.39) + return Color(.systemGreen) } } private func bluetoothTint(for level: Int) -> Color { guard snapshot.usesGaugeTint else { return monochromeGaugeTint } - switch level { + let clamped = clampedBatteryLevel(level) + switch clamped { case ..<20: - return Color(red: 0.91, green: 0.29, blue: 0.25) + return Color(.systemRed) case 20..<50: - return Color(red: 0.97, green: 0.58, blue: 0.29) + return Color(.systemOrange) default: - return Color(red: 0.29, green: 0.63, blue: 1.00) + return Color(.systemGreen) } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 8a4f7fe..20d76bb 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -4,13 +4,12 @@ // // Created by Richard Kunkli on 07/08/2024. // - -import AVFoundation import Defaults import EventKit import KeyboardShortcuts import LaunchAtLogin import LottieUI +import AVFoundation import Sparkle import SwiftUI import SwiftUIIntrospect @@ -347,6 +346,7 @@ struct GeneralSettings: View { } .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) @@ -662,9 +662,6 @@ struct HUD: View { Section { Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD) - .onChange(of: enableSystemHUD) { _ in - // SystemHUDManager will automatically handle start/stop via its @Default observer - } if enableSystemHUD { Toggle("Volume HUD", isOn: $enableVolumeHUD) @@ -1943,34 +1940,34 @@ struct TimerSettings: View { let nextIndex = timerPresets.count + 1 let defaultColor = Defaults[.accentColor] let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor) - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.append(newPreset) } } private func movePresetUp(_ index: Int) { guard index > timerPresets.startIndex else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.swapAt(index, index - 1) } } private func movePresetDown(_ index: Int) { guard index < timerPresets.index(before: timerPresets.endIndex) else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.swapAt(index, index + 1) } } private func removePreset(_ index: Int) { guard timerPresets.indices.contains(index) else { return } - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets.remove(at: index) } } private func resetPresets() { - withAnimation(.smooth) { + _ = withAnimation(.smooth) { timerPresets = TimerPreset.defaultPresets } } diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index 87d877e..c938671 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -17,6 +17,11 @@ final class LockScreenWeatherManager: ObservableObject { private init() { observeAccessoryChanges() + Task { @MainActor [weak self] in + guard let self else { return } + self.locationProvider.prepareAuthorization() + _ = await self.refresh(force: true) + } } func prepareLocationAccess() { @@ -73,7 +78,7 @@ final class LockScreenWeatherManager: ObservableObject { do { let location = await locationProvider.currentLocation() - let payload = try await provider.fetchSnapshot(location: location) + let payload = try await fetchWeatherPayload(location: location) latestWeatherPayload = payload if Defaults[.lockScreenWeatherShowsBluetooth] { BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() @@ -108,6 +113,25 @@ final class LockScreenWeatherManager: ObservableObject { } } + private func fetchWeatherPayload(location: CLLocation?) async throws -> LockScreenWeatherSnapshot { + let primarySource = Defaults[.lockScreenWeatherProviderSource] + + do { + return try await provider.fetchSnapshot(location: location, source: primarySource) + } catch { + if primarySource == .openMeteo { + NSLog("LockScreenWeatherManager: Open Meteo fetch failed - %@. Falling back to wttr.in", error.localizedDescription) + do { + return try await provider.fetchSnapshot(location: location, source: .wttr) + } catch { + NSLog("LockScreenWeatherManager: wttr.in fallback also failed - %@", error.localizedDescription) + throw error + } + } + throw error + } + } + private func observeAccessoryChanges() { let bluetoothManager = BluetoothAudioManager.shared @@ -198,11 +222,11 @@ final class LockScreenWeatherManager: ObservableObject { } private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot { - let locationName = payload.locationName - let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) + 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 showsChargingPercentage = Defaults[.lockScreenWeatherShowsChargingPercentage] let providerSource = Defaults[.lockScreenWeatherProviderSource] let airQualityInfo = (Defaults[.lockScreenWeatherShowsAQI] && providerSource.supportsAirQuality) ? payload.airQuality : nil @@ -406,6 +430,10 @@ private actor LockScreenWeatherProvider { func fetchSnapshot(location: CLLocation?) async throws -> LockScreenWeatherSnapshot { let source = Defaults[.lockScreenWeatherProviderSource] + return try await fetchSnapshot(location: location, source: source) + } + + func fetchSnapshot(location: CLLocation?, source: LockScreenWeatherProviderSource) async throws -> LockScreenWeatherSnapshot { switch source { case .wttr: return try await fetchWttrSnapshot(location: location) diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 76fef4f..329e808 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -80,7 +80,9 @@ final class LockScreenWeatherPanelManager { let originX = screenFrame.midX - (size.width / 2) let verticalOffset = screenFrame.height * 0.15 let maxY = screenFrame.maxY - size.height - 48 - let originY = min(maxY, screenFrame.midY + verticalOffset) - return NSRect(x: originX, y: originY, width: size.width, height: size.height) + let baseY = min(maxY, screenFrame.midY + verticalOffset) + let loweredY = baseY - 36 + let clampedY = max(screenFrame.minY + 80, loweredY) + return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index d35b332..4c30f34 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -364,10 +364,10 @@ 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 lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .inline) + static let lockScreenWeatherWidgetStyle = Key("lockScreenWeatherWidgetStyle", default: .circular) static let lockScreenWeatherShowsAQI = Key("lockScreenWeatherShowsAQI", default: false) - static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: true) - static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .wttr) + static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) + static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)