Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Stasis/Models/TimeRemainingEstimator.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
77 changes: 58 additions & 19 deletions Stasis/ViewModels/MenuViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ 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

private var metricsObservation: Task<Void, Never>?
private var settingsObservation: Task<Void, Never>?
private var uptimeTask: Task<Void, Never>?
private let timeEstimator = TimeRemainingEstimator()
private var powerModeObservation: Task<Void, Never>?

init(batteryService: BatteryService, chargeManager: ChargeManager) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -105,15 +111,21 @@ 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
? metrics.hardwareBatteryPercentage : metrics.batteryPercentage
displayPercentage = percentage
batteryPercentageText = "\(percentage)%"

let derivedPowerSource = derivePowerSource(battery: metrics, adapter: adapter)
let derivedPowerSource = derivePowerSource(
battery: metrics,
adapter: adapter
)

switch derivedPowerSource {
case .battery:
Expand All @@ -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 {
Expand All @@ -145,8 +154,12 @@ class MenuViewModel {
batteryTemperatureText =
"\(metrics.batteryTemperature.formatted(.number.precision(.fractionLength(1))))°C"

let voltageFormat = FloatingPointFormatStyle<Double>.number.precision(.fractionLength(2))
let currentFormat = FloatingPointFormatStyle<Double>.number.precision(.fractionLength(2))
let voltageFormat = FloatingPointFormatStyle<Double>.number.precision(
.fractionLength(2)
)
let currentFormat = FloatingPointFormatStyle<Double>.number.precision(
.fractionLength(2)
)

externalInputText =
"\(adapter.adapterVoltage.formatted(voltageFormat))V @ \(adapter.adapterCurrent.formatted(currentFormat))A"
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down