mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working battery percentage
This commit is contained in:
parent
88f5cb68d8
commit
a75497bbbf
6 changed files with 1014 additions and 34 deletions
|
|
@ -3157,6 +3157,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"Battery charging" : {
|
||||
"comment" : "Battery charging without estimate"
|
||||
},
|
||||
"Battery charging, %@ remaining" : {
|
||||
"comment" : "Charging time remaining"
|
||||
},
|
||||
"Battery fully charged" : {
|
||||
"comment" : "Battery is full"
|
||||
},
|
||||
"Battery Information" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
@ -3464,6 +3473,17 @@
|
|||
"comment" : "A section header that lists Bluetooth audio devices.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Bluetooth device %@ at %d percent" : {
|
||||
"comment" : "Bluetooth device battery",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Bluetooth device %1$@ at %2$d percent"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Boost your productivity with Clipboard Manager" : {
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
|
|
|
|||
|
|
@ -11,12 +11,13 @@ struct LockScreenWeatherWidget: View {
|
|||
Text(snapshot.temperatureText)
|
||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
||||
.kerning(-0.4)
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
Text("•")
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
Text(locationName)
|
||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
|
||||
ForEach(Array(infoSegments.enumerated()), id: \.offset) { _, segment in
|
||||
HStack(spacing: 4) {
|
||||
Text("•")
|
||||
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||
segmentView(for: segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Color.white.opacity(0.65))
|
||||
|
|
@ -28,20 +29,119 @@ 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))
|
||||
}
|
||||
|
||||
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))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func clampedBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
|
||||
private var accessibilityLabel: String {
|
||||
if let locationName = snapshot.locationName, !locationName.isEmpty {
|
||||
return String(
|
||||
format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText,
|
||||
locationName
|
||||
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))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
return NSLocalizedString("Battery charging", comment: "Battery charging without estimate")
|
||||
}
|
||||
|
||||
private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String {
|
||||
return String(
|
||||
format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"),
|
||||
snapshot.description,
|
||||
snapshot.temperatureText
|
||||
format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"),
|
||||
bluetooth.deviceName,
|
||||
bluetooth.batteryLevel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,6 +178,10 @@ struct GeneralSettings: View {
|
|||
@Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon
|
||||
@Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder
|
||||
@Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur
|
||||
@Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget
|
||||
@Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation
|
||||
@Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging
|
||||
@Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -324,6 +328,11 @@ struct GeneralSettings: View {
|
|||
Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur)
|
||||
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
|
||||
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
|
||||
if enableLockScreenWeatherWidget {
|
||||
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
|
||||
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
|
||||
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
|
||||
}
|
||||
|
||||
Button("Copy Latest Crash Report") {
|
||||
copyLatestCrashReport()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import AppKit
|
|||
import Defaults
|
||||
import SwiftUI
|
||||
import IOBluetooth
|
||||
import IOKit
|
||||
|
||||
/// Manages detection and monitoring of Bluetooth audio device connections
|
||||
class BluetoothAudioManager: ObservableObject {
|
||||
|
|
@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject {
|
|||
private var cancellables = Set<AnyCancellable>()
|
||||
private let coordinator = DynamicIslandViewCoordinator.shared
|
||||
private var pollingTimer: Timer?
|
||||
private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth"
|
||||
|
||||
@Published private(set) var batteryStatus: [String: String] = [:]
|
||||
|
||||
private var batteryStatusByAddress: [String: Int] = [:]
|
||||
private var batteryStatusByName: [String: Int] = [:]
|
||||
private var missingBatteryLog: Set<String> = []
|
||||
private var lastBatteryStatusUpdate: Date?
|
||||
private let batteryStatusUpdateInterval: TimeInterval = 20
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
|
|
@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
// Update connection state
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
if let lastDevice = connectedDevices.last {
|
||||
lastConnectedDevice = lastDevice
|
||||
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
|
||||
|
|
@ -202,8 +214,14 @@ class BluetoothAudioManager: ObservableObject {
|
|||
lastConnectedDevice = audioDevice
|
||||
isBluetoothAudioConnected = true
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
|
||||
// Show HUD for new connection
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
if let refreshedDevice = connectedDevices.last {
|
||||
showDeviceConnectedHUD(refreshedDevice)
|
||||
} else {
|
||||
showDeviceConnectedHUD(audioDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
|
||||
isBluetoothAudioConnected = !connectedDevices.isEmpty
|
||||
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
|
||||
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
|
||||
|
|
@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject {
|
|||
|
||||
/// Extracts battery level from Bluetooth device
|
||||
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
|
||||
// Try to read battery level from device
|
||||
// Note: This may require additional permissions or may not be available for all devices
|
||||
updateBatteryStatuses()
|
||||
|
||||
// Placeholder: Return nil if not available
|
||||
// TODO: Implement actual battery level reading if device supports it
|
||||
if let level = batteryLevelFromRegistry(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromRegistry(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let level = batteryLevelFromDefaults(forAddress: device.addressString) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
if let name = device.name, let level = batteryLevelFromDefaults(forName: name) {
|
||||
clearMissingBatteryInfo(for: device)
|
||||
return level
|
||||
}
|
||||
|
||||
logMissingBatteryInfo(for: device)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -373,6 +411,590 @@ class BluetoothAudioManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func refreshBatteryLevelsForConnectedDevices() {
|
||||
updateBatteryStatuses(force: true)
|
||||
|
||||
var updatedDevices: [BluetoothAudioDevice] = []
|
||||
for device in connectedDevices {
|
||||
let refreshedLevel =
|
||||
batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
?? device.batteryLevel
|
||||
let updatedDevice = device.withBatteryLevel(refreshedLevel)
|
||||
updatedDevices.append(updatedDevice)
|
||||
|
||||
if let refreshedLevel {
|
||||
clearMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
} else {
|
||||
logMissingBatteryInfo(forName: device.name, address: device.address)
|
||||
}
|
||||
}
|
||||
|
||||
connectedDevices = updatedDevices
|
||||
if let last = connectedDevices.last {
|
||||
lastConnectedDevice = last
|
||||
}
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
let normalizedTarget = normalizeBluetoothIdentifier(address)
|
||||
var bestMatch: Int?
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromDefaults(forName name: String) -> Int? {
|
||||
guard !name.isEmpty else { return nil }
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil }
|
||||
guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil }
|
||||
|
||||
var bestMatch: Int?
|
||||
|
||||
for value in deviceCache.values {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String)
|
||||
if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame {
|
||||
if let level = extractBatteryPercentage(from: payload) {
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
bestMatch = max(bestMatch ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forName name: String) -> Int? {
|
||||
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let normalized = normalizeProductName(trimmed)
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
if let value = batteryStatusByName[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func updateBatteryStatuses(force: Bool = false) {
|
||||
let now = Date()
|
||||
if !force, let lastBatteryStatusUpdate,
|
||||
now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval {
|
||||
return
|
||||
}
|
||||
|
||||
var combinedAddressPercentages: [String: Int] = [:]
|
||||
var combinedNamePercentages: [String: Int] = [:]
|
||||
|
||||
let registry = collectRegistryBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names)
|
||||
|
||||
let defaults = collectDefaultsBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names)
|
||||
|
||||
let profiler = collectSystemProfilerBatteryLevels()
|
||||
mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses)
|
||||
mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names)
|
||||
|
||||
var statuses: [String: String] = [:]
|
||||
for (key, value) in combinedAddressPercentages {
|
||||
statuses[key] = String(clampBatteryPercentage(value))
|
||||
}
|
||||
|
||||
let applyUpdates = {
|
||||
self.batteryStatus = statuses
|
||||
self.batteryStatusByAddress = combinedAddressPercentages
|
||||
self.batteryStatusByName = combinedNamePercentages
|
||||
self.lastBatteryStatusUpdate = now
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
applyUpdates()
|
||||
} else {
|
||||
DispatchQueue.main.sync(execute: applyUpdates)
|
||||
}
|
||||
}
|
||||
|
||||
private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) {
|
||||
guard !source.isEmpty else { return }
|
||||
for (key, value) in source {
|
||||
guard !key.isEmpty else { continue }
|
||||
if let existing = target[key] {
|
||||
target[key] = max(existing, value)
|
||||
} else {
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
var iterator = io_iterator_t()
|
||||
let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService")
|
||||
|
||||
let servicePort: mach_port_t
|
||||
if #available(macOS 12.0, *) {
|
||||
servicePort = kIOMainPortDefault
|
||||
} else {
|
||||
servicePort = kIOMasterPortDefault
|
||||
}
|
||||
|
||||
let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator)
|
||||
|
||||
if kernResult == KERN_SUCCESS {
|
||||
var entry: io_object_t = IOIteratorNext(iterator)
|
||||
while entry != 0 {
|
||||
if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int {
|
||||
let normalizedPercent = clampBatteryPercentage(percent)
|
||||
|
||||
let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"]
|
||||
for key in identifierKeys {
|
||||
if let identifier = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedIdentifier = normalizeBluetoothIdentifier(identifier)
|
||||
if !normalizedIdentifier.isEmpty {
|
||||
if let existing = addressPercentages[normalizedIdentifier] {
|
||||
addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
addressPercentages[normalizedIdentifier] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let nameKeys = [
|
||||
"Product",
|
||||
"ProductName",
|
||||
"DeviceName",
|
||||
"Name",
|
||||
"USB Product Name",
|
||||
"Bluetooth Product Name"
|
||||
]
|
||||
|
||||
for key in nameKeys {
|
||||
if let product = stringValue(forKey: key, entry: entry) {
|
||||
let normalizedName = normalizeProductName(product)
|
||||
if !normalizedName.isEmpty {
|
||||
if let existing = namePercentages[normalizedName] {
|
||||
namePercentages[normalizedName] = max(existing, normalizedPercent)
|
||||
} else {
|
||||
namePercentages[normalizedName] = normalizedPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(entry)
|
||||
entry = IOIteratorNext(iterator)
|
||||
}
|
||||
}
|
||||
|
||||
IOObjectRelease(iterator)
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite),
|
||||
let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
for (key, value) in deviceCache {
|
||||
guard let payload = value as? [String: Any] else { continue }
|
||||
guard let level = extractBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(level)
|
||||
|
||||
let normalizedKey = normalizeBluetoothIdentifier(key)
|
||||
if !normalizedKey.isEmpty {
|
||||
addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for identifier in identifiersFromDeviceCachePayload(payload) {
|
||||
addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) {
|
||||
let normalizedName = normalizeProductName(name)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) {
|
||||
guard let root = systemProfilerBluetoothDictionary() else {
|
||||
return ([:], [:])
|
||||
}
|
||||
|
||||
var addressPercentages: [String: Int] = [:]
|
||||
var namePercentages: [String: Int] = [:]
|
||||
|
||||
if let connectedList = root["device_connected"] as? [[String: [String: Any]]] {
|
||||
for deviceGroup in connectedList {
|
||||
for (rawName, payload) in deviceGroup {
|
||||
guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue }
|
||||
let clamped = clampBatteryPercentage(percent)
|
||||
|
||||
let normalizedName = normalizeProductName(rawName)
|
||||
if !normalizedName.isEmpty {
|
||||
namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped)
|
||||
}
|
||||
|
||||
for address in profilerAddressCandidates(from: payload) {
|
||||
let normalizedAddress = normalizeBluetoothIdentifier(address)
|
||||
if !normalizedAddress.isEmpty {
|
||||
addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (addressPercentages, namePercentages)
|
||||
}
|
||||
|
||||
private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] {
|
||||
var identifiers: Set<String> = []
|
||||
let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"]
|
||||
|
||||
for key in candidateKeys {
|
||||
if let value = payload[key] as? String {
|
||||
let normalized = normalizeBluetoothIdentifier(value)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8) {
|
||||
let normalized = normalizeBluetoothIdentifier(ascii)
|
||||
if !normalized.isEmpty {
|
||||
identifiers.insert(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array(identifiers)
|
||||
}
|
||||
|
||||
private func systemProfilerBluetoothDictionary() -> [String: Any]? {
|
||||
let process = Process()
|
||||
process.launchPath = "/usr/sbin/system_profiler"
|
||||
process.arguments = ["SPBluetoothDataType", "-json"]
|
||||
|
||||
let outputPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
let errorPipe = Pipe()
|
||||
process.standardError = errorPipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
guard !data.isEmpty else { return nil }
|
||||
|
||||
guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
|
||||
let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]],
|
||||
let root = entries.first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let preferredKeys = [
|
||||
"device_batteryLevelCase",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"device_batteryLevel",
|
||||
"device_batteryLevelCombined",
|
||||
"device_batteryPercentCombined",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level",
|
||||
"Battery Level",
|
||||
"BatteryPercent"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in preferredKeys {
|
||||
if let raw = payload[key], let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty {
|
||||
for (key, raw) in payload where key.lowercased().contains("battery") {
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let validValues = values.filter { $0 >= 0 }
|
||||
return validValues.max()
|
||||
}
|
||||
|
||||
private func profilerAddressCandidates(from payload: [String: Any]) -> [String] {
|
||||
var addresses: Set<String> = []
|
||||
let keys = [
|
||||
"device_address",
|
||||
"device_mac_address",
|
||||
"device_serial_num",
|
||||
"device_serialNumber",
|
||||
"device_serial_number"
|
||||
]
|
||||
|
||||
for key in keys {
|
||||
if let value = payload[key] as? String, !value.isEmpty {
|
||||
addresses.insert(value)
|
||||
} else if let data = payload[key] as? Data,
|
||||
let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
addresses.insert(ascii)
|
||||
}
|
||||
}
|
||||
|
||||
return Array(addresses)
|
||||
}
|
||||
|
||||
private func batteryLevelFromRegistry(forAddress address: String?) -> Int? {
|
||||
guard let address, !address.isEmpty else { return nil }
|
||||
let normalized = normalizeBluetoothIdentifier(address)
|
||||
if let value = batteryStatusByAddress[normalized] {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
if let storedValue = batteryStatus[normalized], let value = Int(storedValue) {
|
||||
return clampBatteryPercentage(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractBatteryPercentage(from payload: [String: Any]) -> Int? {
|
||||
let keys = [
|
||||
"BatteryPercent",
|
||||
"BatteryPercentCase",
|
||||
"BatteryPercentLeft",
|
||||
"BatteryPercentRight",
|
||||
"BatteryPercentSingle",
|
||||
"BatteryPercentCombined",
|
||||
"BatteryPercentMain",
|
||||
"device_batteryLevelLeft",
|
||||
"device_batteryLevelRight",
|
||||
"device_batteryLevelMain",
|
||||
"Left Battery Level",
|
||||
"Right Battery Level"
|
||||
]
|
||||
|
||||
var values: [Int] = []
|
||||
|
||||
for key in keys {
|
||||
guard let raw = payload[key] else { continue }
|
||||
if let converted = convertToBatteryPercentage(raw) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
|
||||
if values.isEmpty,
|
||||
let services = payload["Services"] as? [[String: Any]] {
|
||||
for service in services {
|
||||
if let serviceValues = service["BatteryPercentages"] as? [String: Any] {
|
||||
for value in serviceValues.values {
|
||||
if let converted = convertToBatteryPercentage(value) {
|
||||
values.append(converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return values.max()
|
||||
}
|
||||
|
||||
private func convertToBatteryPercentage(_ value: Any) -> Int? {
|
||||
if let number = value as? Int {
|
||||
if number == 1 {
|
||||
return 100
|
||||
}
|
||||
return number
|
||||
}
|
||||
if let number = value as? Double {
|
||||
if number <= 1.0 {
|
||||
return Int(number * 100)
|
||||
}
|
||||
return Int(number)
|
||||
}
|
||||
if let string = value as? String {
|
||||
let trimmed = string.replacingOccurrences(of: "%", with: "")
|
||||
if let doubleValue = Double(trimmed) {
|
||||
if doubleValue <= 1.0 {
|
||||
return Int(doubleValue * 100)
|
||||
}
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func clampBatteryPercentage(_ value: Int) -> Int {
|
||||
min(max(value, 0), 100)
|
||||
}
|
||||
|
||||
private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool {
|
||||
if normalizeBluetoothIdentifier(key) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
let candidateFields: [String?] = [
|
||||
payload["DeviceAddress"] as? String,
|
||||
payload["Address"] as? String,
|
||||
payload["BD_ADDR"] as? String,
|
||||
payload["SerialNumber"] as? String
|
||||
]
|
||||
|
||||
for field in candidateFields {
|
||||
if let field, normalizeBluetoothIdentifier(field) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if let deviceAddressData = payload["DeviceAddress"] as? Data,
|
||||
let ascii = String(data: deviceAddressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let addressData = payload["BD_ADDR"] as? Data,
|
||||
let ascii = String(data: addressData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
if let serialData = payload["SerialNumber"] as? Data,
|
||||
let ascii = String(data: serialData, encoding: .utf8),
|
||||
normalizeBluetoothIdentifier(ascii) == normalizedTarget {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
logMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(for device: IOBluetoothDevice) {
|
||||
let name = device.name ?? ""
|
||||
let address = device.addressString ?? ""
|
||||
clearMissingBatteryInfo(forName: name, address: address)
|
||||
}
|
||||
|
||||
private func logMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
guard !missingBatteryLog.contains(key) else { return }
|
||||
missingBatteryLog.insert(key)
|
||||
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress
|
||||
print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))")
|
||||
}
|
||||
|
||||
private func clearMissingBatteryInfo(forName name: String, address: String) {
|
||||
let key = missingBatteryKey(name: name, address: address)
|
||||
missingBatteryLog.remove(key)
|
||||
}
|
||||
|
||||
private func missingBatteryKey(name: String, address: String) -> String {
|
||||
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let normalizedName = normalizeProductName(trimmedName)
|
||||
|
||||
let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame
|
||||
let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress)
|
||||
|
||||
if normalizedName.isEmpty && normalizedAddress.isEmpty {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
return normalizedName + "#" + normalizedAddress
|
||||
}
|
||||
|
||||
private func stringValue(forKey key: String, entry: io_object_t) -> String? {
|
||||
guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = unmanaged.takeRetainedValue()
|
||||
|
||||
if let string = value as? String, !string.isEmpty {
|
||||
return string
|
||||
}
|
||||
|
||||
if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty {
|
||||
return ascii
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func normalizeBluetoothIdentifier(_ value: String) -> String {
|
||||
return value
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: ":", with: "")
|
||||
.replacingOccurrences(of: "-", with: "")
|
||||
.replacingOccurrences(of: " ", with: "")
|
||||
}
|
||||
|
||||
private func normalizeProductName(_ name: String) -> String {
|
||||
let components = name
|
||||
.lowercased()
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
|
||||
return components.joined()
|
||||
}
|
||||
|
||||
// MARK: - HUD Display
|
||||
|
||||
/// Shows HUD notification for newly connected audio device
|
||||
|
|
@ -382,8 +1004,14 @@ class BluetoothAudioManager: ObservableObject {
|
|||
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
|
||||
|
||||
// Convert battery percentage to 0.0-1.0 value
|
||||
let batteryValue: CGFloat = if let battery = device.batteryLevel {
|
||||
CGFloat(battery) / 100.0
|
||||
let reportedBattery = device.batteryLevel
|
||||
?? batteryLevelFromRegistry(forAddress: device.address)
|
||||
?? batteryLevelFromRegistry(forName: device.name)
|
||||
?? batteryLevelFromDefaults(forAddress: device.address)
|
||||
?? batteryLevelFromDefaults(forName: device.name)
|
||||
|
||||
let batteryValue: CGFloat = if let battery = reportedBattery {
|
||||
CGFloat(clampBatteryPercentage(battery)) / 100.0
|
||||
} else {
|
||||
0.0 // 0 means battery info not available
|
||||
}
|
||||
|
|
@ -411,16 +1039,47 @@ class BluetoothAudioManager: ObservableObject {
|
|||
observers.removeAll()
|
||||
cancellables.removeAll()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshConnectedDeviceBatteries() {
|
||||
refreshBatteryLevelsForConnectedDevices()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
struct BluetoothAudioDevice: Identifiable {
|
||||
let id = UUID()
|
||||
let id: UUID
|
||||
let name: String
|
||||
let address: String
|
||||
let batteryLevel: Int? // 0-100, nil if not available
|
||||
let deviceType: BluetoothAudioDeviceType
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
address: String,
|
||||
batteryLevel: Int?,
|
||||
deviceType: BluetoothAudioDeviceType
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.batteryLevel = batteryLevel
|
||||
self.deviceType = deviceType
|
||||
}
|
||||
}
|
||||
|
||||
extension BluetoothAudioDevice {
|
||||
func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice {
|
||||
BluetoothAudioDevice(
|
||||
id: id,
|
||||
name: name,
|
||||
address: address,
|
||||
batteryLevel: batteryLevel,
|
||||
deviceType: deviceType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum BluetoothAudioDeviceType {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Foundation
|
||||
import Defaults
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class LockScreenWeatherManager: ObservableObject {
|
||||
|
|
@ -11,8 +12,12 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
private let provider = LockScreenWeatherProvider()
|
||||
private let locationProvider = LockScreenWeatherLocationProvider()
|
||||
private var lastFetchDate: Date?
|
||||
private var latestWeatherPayload: LockScreenWeatherSnapshot?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private init() {}
|
||||
private init() {
|
||||
observeAccessoryChanges()
|
||||
}
|
||||
|
||||
func prepareLocationAccess() {
|
||||
locationProvider.prepareAuthorization()
|
||||
|
|
@ -28,9 +33,15 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.refresh(force: self.snapshot == nil)
|
||||
if let snapshot = self.snapshot {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
let snapshot = await self.refresh(force: self.snapshot == nil)
|
||||
|
||||
guard LockScreenManager.shared.currentLockStatus else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
return
|
||||
}
|
||||
|
||||
if let snapshot {
|
||||
self.deliver(snapshot, forceShow: true)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
|
@ -41,32 +52,207 @@ final class LockScreenWeatherManager: ObservableObject {
|
|||
LockScreenWeatherPanelManager.shared.hide()
|
||||
}
|
||||
|
||||
func refresh(force: Bool = false) async {
|
||||
@discardableResult
|
||||
func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? {
|
||||
let interval = Defaults[.lockScreenWeatherRefreshInterval]
|
||||
if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval {
|
||||
if let snapshot = snapshot {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
if let payload = latestWeatherPayload {
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
} else if let snapshot = snapshot {
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
}
|
||||
return
|
||||
return snapshot
|
||||
}
|
||||
|
||||
do {
|
||||
let location = await locationProvider.currentLocation()
|
||||
let snapshot = try await provider.fetchSnapshot(location: location)
|
||||
let payload = try await provider.fetchSnapshot(location: location)
|
||||
latestWeatherPayload = payload
|
||||
if Defaults[.lockScreenWeatherShowsBluetooth] {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
lastFetchDate = Date()
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
deliver(snapshot, forceShow: false)
|
||||
return snapshot
|
||||
} catch {
|
||||
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
|
||||
return snapshot
|
||||
}
|
||||
}
|
||||
|
||||
private func observeAccessoryChanges() {
|
||||
let bluetoothManager = BluetoothAudioManager.shared
|
||||
|
||||
bluetoothManager.$connectedDevices
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
bluetoothManager.$lastConnectedDevice
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let batteryPublishers: [AnyPublisher<Void, Never>] = [
|
||||
battery.$isCharging.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(),
|
||||
battery.$timeToFullCharge.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(batteryPublishers)
|
||||
.debounce(for: .milliseconds(150), scheduler: RunLoop.main)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: false)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
let defaultsPublishers: [AnyPublisher<Void, Never>] = [
|
||||
Defaults.publisher(.lockScreenWeatherShowsLocation, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsCharging, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher(),
|
||||
Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: [])
|
||||
.map { _ in () }.eraseToAnyPublisher()
|
||||
]
|
||||
|
||||
Publishers.MergeMany(defaultsPublishers)
|
||||
.sink { [weak self] in
|
||||
self?.handleAccessoryUpdate(triggerBluetoothRefresh: true)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) {
|
||||
guard let payload = latestWeatherPayload else { return }
|
||||
|
||||
if triggerBluetoothRefresh {
|
||||
BluetoothAudioManager.shared.refreshConnectedDeviceBatteries()
|
||||
}
|
||||
|
||||
let snapshot = makeSnapshot(from: payload)
|
||||
self.snapshot = snapshot
|
||||
deliver(snapshot, forceShow: false)
|
||||
}
|
||||
|
||||
private func deliver(_ snapshot: LockScreenWeatherSnapshot, forceShow: Bool) {
|
||||
guard LockScreenManager.shared.currentLockStatus else { return }
|
||||
|
||||
if forceShow {
|
||||
LockScreenWeatherPanelManager.shared.show(with: snapshot)
|
||||
} else {
|
||||
LockScreenWeatherPanelManager.shared.update(with: snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot {
|
||||
let locationName = payload.locationName
|
||||
let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true)
|
||||
let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil
|
||||
let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil
|
||||
|
||||
return LockScreenWeatherSnapshot(
|
||||
temperatureText: payload.temperatureText,
|
||||
symbolName: payload.symbolName,
|
||||
description: payload.description,
|
||||
locationName: locationName,
|
||||
charging: chargingInfo,
|
||||
bluetooth: bluetoothInfo,
|
||||
showsLocation: shouldShowLocation
|
||||
)
|
||||
}
|
||||
|
||||
private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? {
|
||||
let battery = BatteryStatusViewModel.shared
|
||||
let isPluggedIn = battery.isPluggedIn
|
||||
let isCharging = battery.isCharging
|
||||
|
||||
guard isPluggedIn || isCharging else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let minutes = battery.timeToFullCharge
|
||||
let remaining = minutes > 0 ? minutes : nil
|
||||
|
||||
return LockScreenWeatherSnapshot.ChargingInfo(
|
||||
minutesRemaining: remaining,
|
||||
isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn
|
||||
)
|
||||
}
|
||||
|
||||
private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? {
|
||||
let manager = BluetoothAudioManager.shared
|
||||
|
||||
guard manager.isBluetoothAudioConnected else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let device = manager.connectedDevices.last ?? manager.lastConnectedDevice
|
||||
|
||||
guard let device else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let batteryLevel = device.batteryLevel else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return LockScreenWeatherSnapshot.BluetoothInfo(
|
||||
deviceName: device.name,
|
||||
batteryLevel: clampBluetoothBatteryLevel(batteryLevel),
|
||||
iconName: device.deviceType.sfSymbol
|
||||
)
|
||||
}
|
||||
|
||||
private func clampBluetoothBatteryLevel(_ level: Int) -> Int {
|
||||
min(max(level, 0), 100)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockScreenWeatherSnapshot: Equatable {
|
||||
struct ChargingInfo: Equatable {
|
||||
let minutesRemaining: Int?
|
||||
let isCharging: Bool
|
||||
let isPluggedIn: Bool
|
||||
|
||||
var iconName: String {
|
||||
if isCharging {
|
||||
return "bolt.fill"
|
||||
}
|
||||
if isPluggedIn {
|
||||
return "powerplug.fill"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
struct BluetoothInfo: Equatable {
|
||||
let deviceName: String
|
||||
let batteryLevel: Int
|
||||
let iconName: String
|
||||
}
|
||||
|
||||
let temperatureText: String
|
||||
let symbolName: String
|
||||
let description: String
|
||||
let locationName: String?
|
||||
let charging: ChargingInfo?
|
||||
let bluetooth: BluetoothInfo?
|
||||
let showsLocation: Bool
|
||||
}
|
||||
|
||||
private actor LockScreenWeatherProvider {
|
||||
|
|
@ -122,7 +308,10 @@ private actor LockScreenWeatherProvider {
|
|||
temperatureText: temperatureText,
|
||||
symbolName: symbol,
|
||||
description: description,
|
||||
locationName: locationName
|
||||
locationName: locationName,
|
||||
charging: nil,
|
||||
bluetooth: nil,
|
||||
showsLocation: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,6 +339,9 @@ extension Defaults.Keys {
|
|||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
|
||||
static let lockScreenWeatherRefreshInterval = Key<TimeInterval>("lockScreenWeatherRefreshInterval", default: 30 * 60)
|
||||
static let lockScreenWeatherShowsLocation = Key<Bool>("lockScreenWeatherShowsLocation", default: true)
|
||||
static let lockScreenWeatherShowsCharging = Key<Bool>("lockScreenWeatherShowsCharging", default: true)
|
||||
static let lockScreenWeatherShowsBluetooth = Key<Bool>("lockScreenWeatherShowsBluetooth", default: true)
|
||||
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
|
||||
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
|
||||
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue