mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 23:54:32 +00:00
Working lock screen stuff
This commit is contained in:
parent
4bb67c2cd3
commit
5a722373ce
5 changed files with 171 additions and 72 deletions
|
|
@ -4742,9 +4742,6 @@
|
||||||
},
|
},
|
||||||
"Configuration" : {
|
"Configuration" : {
|
||||||
|
|
||||||
},
|
|
||||||
"Connected" : {
|
|
||||||
|
|
||||||
},
|
},
|
||||||
"Continue" : {
|
"Continue" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
|
||||||
|
|
@ -150,9 +150,8 @@ struct InlineHUD: View {
|
||||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||||
.contentTransition(.interpolate)
|
.contentTransition(.interpolate)
|
||||||
} else if (type == .bluetoothAudio) {
|
} else if (type == .bluetoothAudio) {
|
||||||
// Bluetooth device battery display
|
|
||||||
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
|
||||||
if hasBatteryLevel {
|
if hasBatteryLevel {
|
||||||
|
HStack(spacing: useCircularIndicator ? 8 : 6) {
|
||||||
if useCircularIndicator {
|
if useCircularIndicator {
|
||||||
CircularBatteryIndicator(
|
CircularBatteryIndicator(
|
||||||
value: value,
|
value: value,
|
||||||
|
|
@ -176,16 +175,9 @@ struct InlineHUD: View {
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.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 {
|
} else {
|
||||||
// Volume and brightness displays
|
// Volume and brightness displays
|
||||||
Group {
|
Group {
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,25 @@ import SwiftUI
|
||||||
struct LockScreenWeatherWidget: View {
|
struct LockScreenWeatherWidget: View {
|
||||||
let snapshot: LockScreenWeatherSnapshot
|
let snapshot: LockScreenWeatherSnapshot
|
||||||
|
|
||||||
|
private let primaryFont = Font.system(size: 22, weight: .semibold, design: .rounded)
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let segments = infoSegments
|
HStack(alignment: .firstTextBaseline, spacing: 16) {
|
||||||
HStack(spacing: 8) {
|
if let charging = snapshot.charging {
|
||||||
Image(systemName: snapshot.symbolName)
|
chargingSegment(for: charging)
|
||||||
.font(.system(size: 26, weight: .medium))
|
|
||||||
.symbolRenderingMode(.hierarchical)
|
|
||||||
Text(snapshot.temperatureText)
|
|
||||||
.font(.system(size: 24, weight: .semibold, design: .rounded))
|
|
||||||
.kerning(-0.4)
|
|
||||||
|
|
||||||
if !segments.isEmpty {
|
|
||||||
Color.clear.frame(width: 6)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in
|
if let bluetooth = snapshot.bluetooth {
|
||||||
if index > 0 {
|
bluetoothSegment(for: bluetooth)
|
||||||
Color.clear.frame(width: 12)
|
|
||||||
}
|
}
|
||||||
segmentView(for: segment)
|
|
||||||
|
weatherSegment
|
||||||
|
|
||||||
|
if shouldShowLocation {
|
||||||
|
locationSegment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.foregroundStyle(Color.white.opacity(0.65))
|
.foregroundStyle(Color.white.opacity(0.65))
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
@ -33,56 +31,88 @@ struct LockScreenWeatherWidget: View {
|
||||||
.accessibilityLabel(accessibilityLabel)
|
.accessibilityLabel(accessibilityLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum InfoSegment: Equatable {
|
private var weatherSegment: some View {
|
||||||
case location(String)
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
case charging(String)
|
Image(systemName: snapshot.symbolName)
|
||||||
case bluetooth(String, Int)
|
.font(.system(size: 26, weight: .medium))
|
||||||
|
.symbolRenderingMode(.hierarchical)
|
||||||
|
Text(snapshot.temperatureText)
|
||||||
|
.font(primaryFont)
|
||||||
|
.kerning(-0.3)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.9)
|
||||||
|
.layoutPriority(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
@ViewBuilder
|
||||||
private func segmentView(for segment: InfoSegment) -> some View {
|
private func chargingSegment(for info: LockScreenWeatherSnapshot.ChargingInfo) -> some View {
|
||||||
switch segment {
|
if let iconName = chargingIconName(for: info) {
|
||||||
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) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: icon)
|
Image(systemName: iconName)
|
||||||
.font(.system(size: 18, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.symbolRenderingMode(.hierarchical)
|
.symbolRenderingMode(.hierarchical)
|
||||||
Text("\(clampedBatteryLevel(value))%")
|
|
||||||
.font(.system(size: 20, weight: .medium, design: .rounded))
|
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)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.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 {
|
private func clampedBatteryLevel(_ level: Int) -> Int {
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,20 @@ final class LockScreenWeatherManager: ObservableObject {
|
||||||
return snapshot
|
return snapshot
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)")
|
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? {
|
private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? {
|
||||||
let battery = BatteryStatusViewModel.shared
|
let battery = BatteryStatusViewModel.shared
|
||||||
let isPluggedIn = battery.isPluggedIn
|
let macStatus = MacBatteryManager.shared.currentStatus()
|
||||||
let isCharging = battery.isCharging
|
|
||||||
|
let isPluggedIn = battery.isPluggedIn || battery.isCharging
|
||||||
|
let isCharging = macStatus.isCharging || battery.isCharging
|
||||||
|
|
||||||
guard isPluggedIn || isCharging else {
|
guard isPluggedIn || isCharging else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let minutes = battery.timeToFullCharge
|
let rawMinutes = macStatus.timeRemainingMinutes ?? (battery.timeToFullCharge > 0 ? battery.timeToFullCharge : nil)
|
||||||
let remaining = minutes > 0 ? minutes : nil
|
let remaining = (rawMinutes ?? 0) > 0 ? rawMinutes : nil
|
||||||
|
|
||||||
return LockScreenWeatherSnapshot.ChargingInfo(
|
return LockScreenWeatherSnapshot.ChargingInfo(
|
||||||
minutesRemaining: remaining,
|
minutesRemaining: remaining,
|
||||||
|
|
@ -234,7 +249,7 @@ struct LockScreenWeatherSnapshot: Equatable {
|
||||||
return "bolt.fill"
|
return "bolt.fill"
|
||||||
}
|
}
|
||||||
if isPluggedIn {
|
if isPluggedIn {
|
||||||
return "powerplug.fill"
|
return "powerplug.portrait.fill"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
65
DynamicIsland/managers/MacBatteryManager.swift
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue