From 5a722373ce40c9371147625bdb7a797b44930e8c Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Mon, 27 Oct 2025 00:40:03 +0530 Subject: [PATCH] Working lock screen stuff --- DynamicIsland/Localizable.xcstrings | 3 - .../Live activities/InlineHUD.swift | 14 +- .../LockScreen/LockScreenWeatherWidget.swift | 148 +++++++++++------- .../managers/LockScreenWeatherManager.swift | 27 +++- .../managers/MacBatteryManager.swift | 65 ++++++++ 5 files changed, 178 insertions(+), 79 deletions(-) create mode 100644 DynamicIsland/managers/MacBatteryManager.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4269151..c83114d 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4742,9 +4742,6 @@ }, "Configuration" : { - }, - "Connected" : { - }, "Continue" : { "localizations" : { diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index cbdb549..e721a41 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -150,9 +150,8 @@ struct InlineHUD: View { .frame(maxWidth: .infinity, alignment: .trailing) .contentTransition(.interpolate) } else if (type == .bluetoothAudio) { - // Bluetooth device battery display - HStack(spacing: useCircularIndicator ? 8 : 6) { - if hasBatteryLevel { + if hasBatteryLevel { + HStack(spacing: useCircularIndicator ? 8 : 6) { if useCircularIndicator { CircularBatteryIndicator( value: value, @@ -176,16 +175,9 @@ struct InlineHUD: View { .foregroundStyle(.white) .lineLimit(1) } - } else { - // No battery info available - Text("Connected") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.gray) - .lineLimit(1) } + .frame(maxWidth: .infinity, alignment: .trailing) } - .frame(maxWidth: .infinity, alignment: .trailing) } else { // Volume and brightness displays Group { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index d4bcc66..07fdb19 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -3,27 +3,25 @@ import SwiftUI struct LockScreenWeatherWidget: View { let snapshot: LockScreenWeatherSnapshot - var body: some View { - let segments = infoSegments - HStack(spacing: 8) { - Image(systemName: snapshot.symbolName) - .font(.system(size: 26, weight: .medium)) - .symbolRenderingMode(.hierarchical) - Text(snapshot.temperatureText) - .font(.system(size: 24, weight: .semibold, design: .rounded)) - .kerning(-0.4) + private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded) - if !segments.isEmpty { - Color.clear.frame(width: 6) + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 16) { + if let charging = snapshot.charging { + chargingSegment(for: charging) } - ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in - if index > 0 { - Color.clear.frame(width: 12) - } - segmentView(for: segment) + if let bluetooth = snapshot.bluetooth { + bluetoothSegment(for: bluetooth) + } + + weatherSegment + + if shouldShowLocation { + locationSegment } } + .frame(maxWidth: .infinity, alignment: .leading) .foregroundStyle(Color.white.opacity(0.65)) .padding(.horizontal, 10) .padding(.vertical, 6) @@ -33,58 +31,90 @@ struct LockScreenWeatherWidget: View { .accessibilityLabel(accessibilityLabel) } - private enum InfoSegment: Equatable { - case location(String) - case charging(String) - case bluetooth(String, Int) - } - - private var infoSegments: [InfoSegment] { - var segments: [InfoSegment] = [] - - if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty { - segments.append(.location(locationName)) + 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 charging = snapshot.charging { - let iconName = charging.iconName - if !iconName.isEmpty { - segments.append(.charging(iconName)) - } - } - - if let bluetooth = snapshot.bluetooth { - segments.append(.bluetooth(bluetooth.iconName, bluetooth.batteryLevel)) - } - - return segments } @ViewBuilder - private func segmentView(for segment: InfoSegment) -> some View { - switch segment { - case .location(let value): - Text(value) - .font(.system(size: 20, weight: .medium, design: .rounded)) + 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 text = formattedChargingTime(for: info) { + Text(text) + .font(primaryFont) + .lineLimit(1) + .minimumScaleFactor(0.75) + .layoutPriority(1.2) + } + } + .layoutPriority(1) + } + } + + @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) - case .charging(let icon): - Image(systemName: icon) - .font(.system(size: 18, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - case .bluetooth(let icon, let value): - HStack(spacing: 4) { - Image(systemName: icon) - .font(.system(size: 18, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - Text("\(clampedBatteryLevel(value))%") - .font(.system(size: 20, weight: .medium, design: .rounded)) - .lineLimit(1) - .truncationMode(.tail) - } + .minimumScaleFactor(0.85) + .layoutPriority(1.4) } } + @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) + } + } + + private var shouldShowLocation: Bool { + snapshot.showsLocation && (snapshot.locationName?.isEmpty == false) + } + + private func chargingIconName(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String? { + let icon = info.iconName + return icon.isEmpty ? nil : icon + } + + 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 clampedBatteryLevel(_ level: Int) -> Int { min(max(level, 0), 100) } diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index f16539e..ae3483d 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -85,7 +85,20 @@ final class LockScreenWeatherManager: ObservableObject { return snapshot } catch { NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") - return snapshot + + let fallback = LockScreenWeatherSnapshot( + temperatureText: snapshot?.temperatureText ?? "--", + symbolName: snapshot?.symbolName ?? "cloud.fill", + description: snapshot?.description ?? "", + locationName: snapshot?.locationName, + charging: Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil, + bluetooth: Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil, + showsLocation: snapshot?.showsLocation ?? false + ) + + self.snapshot = fallback + deliver(fallback, forceShow: false) + return fallback } } @@ -177,15 +190,17 @@ final class LockScreenWeatherManager: ObservableObject { private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? { let battery = BatteryStatusViewModel.shared - let isPluggedIn = battery.isPluggedIn - let isCharging = battery.isCharging + let macStatus = MacBatteryManager.shared.currentStatus() + + let isPluggedIn = battery.isPluggedIn || battery.isCharging + let isCharging = macStatus.isCharging || battery.isCharging guard isPluggedIn || isCharging else { return nil } - let minutes = battery.timeToFullCharge - let remaining = minutes > 0 ? minutes : nil + let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil) + let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil return LockScreenWeatherSnapshot.ChargingInfo( minutesRemaining: remaining, @@ -234,7 +249,7 @@ struct LockScreenWeatherSnapshot: Equatable { return "bolt.fill" } if isPluggedIn { - return "powerplug.fill" + return "powerplug.portrait.fill" } return "" } diff --git a/DynamicIsland/managers/MacBatteryManager.swift b/DynamicIsland/managers/MacBatteryManager.swift new file mode 100644 index 0000000..fe8ee69 --- /dev/null +++ b/DynamicIsland/managers/MacBatteryManager.swift @@ -0,0 +1,65 @@ +import Foundation +import IOKit.ps + +/// Lightweight helper for querying macOS battery charging status and ETA. +final class MacBatteryManager { + static let shared = MacBatteryManager() + + private init() {} + + struct BatteryStatus { + let timeRemainingMinutes: Int? + let isCharging: Bool + let percentage: Int? + } + + func currentStatus() -> BatteryStatus { + guard let sourcesInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let sourcesList = IOPSCopyPowerSourcesList(sourcesInfo)?.takeRetainedValue() as? [CFTypeRef] else { + return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil) + } + + for source in sourcesList { + guard let description = IOPSGetPowerSourceDescription(sourcesInfo, source)?.takeUnretainedValue() as? [String: Any], + let type = description[kIOPSTypeKey] as? String, + type == kIOPSInternalBatteryType else { + continue + } + + let isCharging = description[kIOPSIsChargingKey] as? Bool ?? false + let timeRemaining = description[kIOPSTimeToFullChargeKey] as? Int + let currentCapacity = description[kIOPSCurrentCapacityKey] as? Int + let maxCapacity = description[kIOPSMaxCapacityKey] as? Int + + let percentage: Int? + if let current = currentCapacity, let max = maxCapacity, max > 0 { + percentage = (current * 100) / max + } else { + percentage = nil + } + + return BatteryStatus( + timeRemainingMinutes: timeRemaining, + isCharging: isCharging, + percentage: percentage + ) + } + + return BatteryStatus(timeRemainingMinutes: nil, isCharging: false, percentage: nil) + } + + func formattedTimeToFullCharge() -> String? { + let status = currentStatus() + guard status.isCharging, let minutes = status.timeRemainingMinutes, minutes > 0 else { + return nil + } + + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + + if hours > 0 { + return "\(hours)h \(remainingMinutes)m" + } + return "\(remainingMinutes)m" + } +}