Akku-Popover sagt, was gerade geschieht

„Am Netz" beantwortet die Frage nicht, die man hat, wenn der Ladestand
stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist, oder
stimmt etwas nicht? Jetzt steht es dort — lädt, angehalten wegen Ladelimit
mit der Prozentzahl, am Netz ohne zu laden, oder Akkubetrieb.

Der Vergleich mit dem Limit hat zwei Punkte Toleranz: der Ladestand pendelt
um die Grenze, und auf das Prozent genau zu vergleichen hieße, den Grund die
halbe Zeit zu verschweigen. Weit unter dem Limit wird dagegen kein
Limit-Halt behauptet — dann steht das Laden aus einem anderen Grund, und
einen falschen zu nennen wäre schlimmer als keinen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 14:31:19 +02:00
parent 312514e6ce
commit 504c518cfb
4 changed files with 826 additions and 641 deletions

View File

@@ -0,0 +1,37 @@
import Foundation
/// Was mit dem Akku gerade geschieht in Worten statt in Symbolen.
///
/// Am Netz" allein beantwortet die Frage nicht, die man hat, wenn der
/// Ladestand stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist,
/// oder stimmt etwas nicht?
public enum ChargeActivity: Equatable, Sendable {
case charging
/// Angehalten, weil das eingestellte Ladelimit erreicht ist.
case pausedAtLimit(Int)
/// Am Netz, lädt aber nicht Grund unbekannt.
case pluggedNotCharging
case onBattery
/// Wie weit unter dem Limit noch als am Limit" gilt.
///
/// Der Ladestand pendelt um die Grenze. Auf das Prozent genau zu
/// vergleichen hieße, den Grund die halbe Zeit zu verschweigen.
public static let limitTolerance = 2
public static func resolve(isCharging: Bool, isPluggedIn: Bool,
charge: Double, limit: Int?) -> ChargeActivity {
if isCharging { return .charging }
guard isPluggedIn else { return .onBattery }
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
// Ein Limit von hundert ist keines.
if let limit, limit < 100, percent >= limit - limitTolerance {
return .pausedAtLimit(limit)
}
// Steht das Laden aus einem anderen Grund etwa weil macOS den Akku
// schont , ist das der ehrliche Befund. Einen falschen Grund zu
// nennen wäre schlimmer als keinen.
return .pluggedNotCharging
}
}

View File

@@ -1,9 +1,7 @@
{
"sourceLanguage": "en",
"strings": {
"" : {
},
"": {},
"%lld": {
"comment": "A label displaying the speed of a fan. The argument is the speed of the fan, in revolutions per minute.",
"isCommentAutoGenerated": true,
@@ -40,6 +38,24 @@
}
}
},
"Lüfter %lld": {
"comment": "A label that shows the name of a fan. The argument is the index of the fan.",
"isCommentAutoGenerated": true,
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lüfter %lld"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Fan %lld"
}
}
}
},
"battery.charging": {
"localizations": {
"de": {
@@ -250,24 +266,6 @@
}
}
},
"Lüfter %lld" : {
"comment" : "A label that shows the name of a fan. The argument is the index of the fan.",
"isCommentAutoGenerated" : true,
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Lüfter %lld"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fan %lld"
}
}
}
},
"menubar.short.battery": {
"localizations": {
"de": {
@@ -549,13 +547,13 @@
"de": {
"stringUnit": {
"state": "translated",
"value" : "Lädt mit"
"value": "Lädt"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value" : "Charging at"
"value": "Charging"
}
}
}
@@ -656,6 +654,54 @@
}
}
},
"popover.battery.notCharging": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Am Netz, lädt nicht"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Plugged in, not charging"
}
}
}
},
"popover.battery.onBattery": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Akkubetrieb"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Running on battery"
}
}
}
},
"popover.battery.pausedAtLimit %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Angehalten — Ladelimit von %lld %% erreicht"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Paused — charge limit of %lld %% reached"
}
}
}
},
"popover.battery.pluggedIn": {
"localizations": {
"de": {

View File

@@ -266,6 +266,10 @@ private struct BatteryDetail: View {
}
}
// Was gerade geschieht, in Worten. Am Netz" allein beantwortet die
// Frage nicht, die man hat, wenn der Ladestand stehen bleibt.
activityLine(battery)
// Das Ladelimit steht hier und nicht in den Einstellungen: es ist
// eine Akkusache, und man greift danach, während man auf den
// Ladestand schaut.
@@ -316,6 +320,47 @@ private struct BatteryDetail: View {
}
}
@ViewBuilder
private func activityLine(_ battery: BatteryReading) -> some View {
let activity = ChargeActivity.resolve(isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: control?.chargeLimit)
HStack(spacing: 6) {
Image(systemName: symbol(for: activity))
.font(.caption)
.foregroundStyle(tint(for: activity))
switch activity {
case .charging:
Text("popover.battery.charging", bundle: .module)
case .pausedAtLimit(let limit):
Text("popover.battery.pausedAtLimit \(limit)", bundle: .module)
case .pluggedNotCharging:
Text("popover.battery.notCharging", bundle: .module)
case .onBattery:
Text("popover.battery.onBattery", bundle: .module)
}
Spacer(minLength: 0)
}
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
private func symbol(for activity: ChargeActivity) -> String {
switch activity {
case .charging: "bolt.fill"
case .pausedAtLimit: "pause.fill"
case .pluggedNotCharging: "powerplug.fill"
case .onBattery: "battery.50percent"
}
}
private func tint(for activity: ChargeActivity) -> Color {
// Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man
// sucht. Alles andere bleibt zurückhaltend.
activity == .charging ? .green : .secondary
}
private func duration(_ seconds: TimeInterval) -> String {
let total = Int(seconds / 60)
return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min"

View File

@@ -0,0 +1,57 @@
import Testing
@testable import MetricsProvider
// Am Netz" allein beantwortet die Frage nicht, die man hat, wenn der
// Ladestand stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist,
// oder stimmt etwas nicht?
@Suite("Ladezustand in Worten")
struct ChargeActivityTests {
@Test("Lädt schlägt alles andere")
func chargingWins() {
#expect(ChargeActivity.resolve(isCharging: true, isPluggedIn: true,
charge: 0.5, limit: 85) == .charging)
}
@Test("Ohne Netzteil ist es Akkubetrieb")
func unpluggedIsOnBattery() {
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: false,
charge: 0.5, limit: 85) == .onBattery)
}
@Test("Am Limit steht der Grund dabei")
func atLimitIsExplained() {
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: true,
charge: 0.85, limit: 85) == .pausedAtLimit(85))
}
@Test("Knapp unter dem Limit zählt auch")
func justBelowTheLimitCounts() {
// Der Ladestand pendelt um die Grenze; auf das Prozent genau zu
// vergleichen hieße, den Grund die halbe Zeit zu verschweigen.
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: true,
charge: 0.84, limit: 85) == .pausedAtLimit(85))
}
@Test("Weit unter dem Limit ist es kein Limit-Halt")
func wellBelowIsNotTheLimit() {
// Dann steht das Laden aus einem anderen Grund etwa weil macOS den
// Akku schont. Einen falschen Grund zu nennen wäre schlimmer als
// keinen.
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: true,
charge: 0.40, limit: 85) == .pluggedNotCharging)
}
@Test("Ohne bekanntes Limit bleibt es beim schlichten Befund")
func withoutLimitStaysPlain() {
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: true,
charge: 0.99, limit: nil) == .pluggedNotCharging)
}
@Test("Ein Limit von hundert ist kein Limit")
func hundredIsNoLimit() {
#expect(ChargeActivity.resolve(isCharging: false, isPluggedIn: true,
charge: 1.0, limit: 100) == .pluggedNotCharging)
}
}