diff --git a/Stasis/Models/TimeRemainingEstimator.swift b/Stasis/Models/TimeRemainingEstimator.swift new file mode 100644 index 0000000..16caae1 --- /dev/null +++ b/Stasis/Models/TimeRemainingEstimator.swift @@ -0,0 +1,20 @@ +import Foundation + +struct TimeRemainingEstimator { + func estimateTimeRemaining( + reportedMinutes: Int, + isCharging: Bool, + batteryPercentage: Int, + chargingTargetPercentage: Int + ) -> Int? { + if batteryPercentage >= chargingTargetPercentage && isCharging { + return 0 + } + + guard reportedMinutes >= 0 else { + return nil + } + + return reportedMinutes + } +} diff --git a/Stasis/ViewModels/MenuViewModel.swift b/Stasis/ViewModels/MenuViewModel.swift index 1810947..262606f 100644 --- a/Stasis/ViewModels/MenuViewModel.swift +++ b/Stasis/ViewModels/MenuViewModel.swift @@ -30,7 +30,9 @@ class MenuViewModel { var isCharging: Bool = false var isLowPowerModeEnabled: Bool = false - var chargeLimitOverrideActive: Bool { chargeManager.chargeLimitOverrideActive } + var chargeLimitOverrideActive: Bool { + chargeManager.chargeLimitOverrideActive + } var forceDischargeActive: Bool { chargeManager.forceDischargeActive } var manageChargingEnabled: Bool { Defaults[.manageCharging] } var adapterConnected: Bool = false @@ -38,6 +40,7 @@ class MenuViewModel { private var metricsObservation: Task? private var settingsObservation: Task? private var uptimeTask: Task? + private let timeEstimator = TimeRemainingEstimator() private var powerModeObservation: Task? init(batteryService: BatteryService, chargeManager: ChargeManager) { @@ -73,7 +76,10 @@ class MenuViewModel { private func startObservingSettings() { settingsObservation = Task { [weak self] in - for await _ in Defaults.updates([.useHardwarePercentage], initial: false) { + for await _ in Defaults.updates( + [.useHardwarePercentage], + initial: false + ) { guard let self else { return } self.updateFormattedValues( from: self.batteryService.metrics, @@ -105,7 +111,10 @@ class MenuViewModel { chargeManager.toggleForceDischarge() } - private func updateFormattedValues(from metrics: BatteryMetrics, adapter: AdapterMetrics) { + private func updateFormattedValues( + from metrics: BatteryMetrics, + adapter: AdapterMetrics + ) { let useHardware = Defaults[.useHardwarePercentage] let percentage = useHardware @@ -113,7 +122,10 @@ class MenuViewModel { displayPercentage = percentage batteryPercentageText = "\(percentage)%" - let derivedPowerSource = derivePowerSource(battery: metrics, adapter: adapter) + let derivedPowerSource = derivePowerSource( + battery: metrics, + adapter: adapter + ) switch derivedPowerSource { case .battery: @@ -124,9 +136,6 @@ class MenuViewModel { powerSourceText = "Battery & Power Adapter" } - let formatted = formatTimeRemaining(minutes: metrics.timeRemaining) - timeRemainingText = formatted.isEmpty ? "Calculating..." : formatted - updateUptimeText() if derivedPowerSource == .acAdapter { @@ -145,8 +154,12 @@ class MenuViewModel { batteryTemperatureText = "\(metrics.batteryTemperature.formatted(.number.precision(.fractionLength(1))))°C" - let voltageFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) - let currentFormat = FloatingPointFormatStyle.number.precision(.fractionLength(2)) + let voltageFormat = FloatingPointFormatStyle.number.precision( + .fractionLength(2) + ) + let currentFormat = FloatingPointFormatStyle.number.precision( + .fractionLength(2) + ) externalInputText = "\(adapter.adapterVoltage.formatted(voltageFormat))V @ \(adapter.adapterCurrent.formatted(currentFormat))A" @@ -161,11 +174,19 @@ class MenuViewModel { isCharging = metrics.isCharging adapterConnected = adapter.adapterConnected + timeRemainingText = formatTimeRemaining( + minutes: metrics.timeRemaining, + isCharging: metrics.isCharging + ) + cycleCountText = "\(metrics.cycleCount)" batteryHealthText = "\(metrics.batteryHealth)%" } - private func derivePowerSource(battery: BatteryMetrics, adapter: AdapterMetrics) -> PowerSource { + private func derivePowerSource( + battery: BatteryMetrics, + adapter: AdapterMetrics + ) -> PowerSource { guard adapter.adapterConnected else { return .battery } if adapter.adapterPower == 0 { @@ -183,10 +204,11 @@ class MenuViewModel { return } - let uptime = Date().timeIntervalSince(bootTimestamp) - let hours = Int(uptime) / 3600 - let minutes = (Int(uptime) % 3600) / 60 - uptimeText = String(format: "%02d:%02d", hours, minutes) + let uptime = max(0, Int(Date().timeIntervalSince(bootTimestamp))) + let duration = Duration.seconds(uptime) + uptimeText = duration.formatted( + .units(allowed: [.days, .hours, .minutes], width: .abbreviated) + ) } private func startUptimeTimer() { @@ -220,15 +242,32 @@ class MenuViewModel { NSApplication.shared.terminate(nil) } - private func formatTimeRemaining(minutes: Int) -> String { - if minutes < 0 { - return "" + private func formatTimeRemaining(minutes: Int, isCharging: Bool) -> String { + if adapterConnected && !isCharging { + return "N/A" + } + + let estimatedMinutes = timeEstimator.estimateTimeRemaining( + reportedMinutes: minutes, + isCharging: isCharging, + batteryPercentage: displayPercentage, + chargingTargetPercentage: chargingTargetPercentage + ) + guard let validMinutes = estimatedMinutes, validMinutes >= 0 else { + return "Calculating..." } - let hours = minutes / 60 - let mins = minutes % 60 + let hours = validMinutes / 60 + let mins = validMinutes % 60 return String(format: "%02d:%02d", hours, mins) } + private var chargingTargetPercentage: Int { + if Defaults[.manageCharging] && !chargeLimitOverrideActive { + return Defaults[.chargeLimit] + } + return 100 + } + deinit { MainActor.assumeIsolated { metricsObservation?.cancel()