/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import Cocoa
import Defaults
import Foundation
import IOKit.ps
import SwiftUI
enum BatteryTemporaryHUDKind: Equatable {
case charging
case lowBattery
case fullBattery
}
/// A view model that manages and monitors the battery status of the device
class BatteryStatusViewModel: ObservableObject {
private var wasCharging: Bool = false
private var powerSourceChangedCallback: IOPowerSourceCallbackType?
private var runLoopSource: Unmanaged?
var animations: DynamicIslandAnimations = DynamicIslandAnimations()
private let lowBatteryAlertSoundPlayer = AudioPlayer()
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published private(set) var levelBattery: Float = 0.0
@Published private(set) var maxCapacity: Float = 0.0
@Published private(set) var isPluggedIn: Bool = false
@Published private(set) var isCharging: Bool = false
@Published private(set) var isInLowPowerMode: Bool = false
@Published private(set) var isInitial: Bool = false
@Published private(set) var timeToFullCharge: Int = 0
@Published private(set) var statusText: String = ""
@Published private(set) var activeTemporaryHUDKind: BatteryTemporaryHUDKind?
@Published private(set) var activeTemporaryHUDToken: UUID = UUID()
@Published private(set) var activeTemporaryHUDTargetScreenName: String?
@Published private(set) var activeTemporaryHUDLevelOverride: Int?
@Published private(set) var activeTemporaryHUDLowPowerModeOverride: Bool?
private let managerBattery = BatteryActivityManager.shared
private var managerBatteryId: Int?
static let shared = BatteryStatusViewModel()
/// Initializes the view model with a given BoringViewModel instance
/// - Parameter vm: The BoringViewModel instance
private init() {
setupPowerStatus()
setupMonitor()
}
/// Sets up the initial power status by fetching battery information
private func setupPowerStatus() {
let batteryInfo = managerBattery.initializeBatteryInfo()
updateBatteryInfo(batteryInfo)
}
/// Sets up the monitor to observe battery events
private func setupMonitor() {
managerBatteryId = managerBattery.addObserver { [weak self] event in
guard let self = self else { return }
self.handleBatteryEvent(event)
}
}
/// Handles battery events and updates the corresponding properties
/// - Parameter event: The battery event to handle
private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) {
switch event {
case .powerSourceChanged(let isPluggedIn):
print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
let wasPluggedIn = self.isPluggedIn
withAnimation {
self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged")
}
if !wasPluggedIn && isPluggedIn {
presentTemporaryBatteryHUDIfNeeded(kind: .charging)
}
case .batteryLevelChanged(let level):
print("🔋 Battery level: \(Int(level))%")
let previousLevel = self.levelBattery
withAnimation {
self.levelBattery = level
}
self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level)
self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level)
case .lowPowerModeChanged(let isEnabled):
print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")")
let wasEnabled = self.isInLowPowerMode
withAnimation {
self.isInLowPowerMode = isEnabled
self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))")
}
if !wasEnabled && isEnabled {
presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery)
}
case .isChargingChanged(let isCharging):
print("🔌 Charging: \(isCharging ? "Yes" : "No")")
print("maxCapacity: \(self.maxCapacity)")
print("levelBattery: \(self.levelBattery)")
withAnimation {
self.isCharging = isCharging
self.statusText =
isCharging
? String(localized: "Charging battery")
: (self.levelBattery < self.maxCapacity ? String(localized: "Not charging") : String(localized: "Full charge"))
}
case .timeToFullChargeChanged(let time):
print("🕒 Time to full charge: \(time) minutes")
withAnimation {
self.timeToFullCharge = time
}
case .maxCapacityChanged(let capacity):
print("🔋 Max capacity: \(capacity)")
withAnimation {
self.maxCapacity = capacity
}
case .error(let description):
print("⚠️ Error: \(description)")
}
}
/// Updates the battery information with the given BatteryInfo instance
/// - Parameter batteryInfo: The BatteryInfo instance containing the battery data
private func updateBatteryInfo(_ batteryInfo: BatteryInfo) {
withAnimation {
self.levelBattery = batteryInfo.currentCapacity
self.isPluggedIn = batteryInfo.isPluggedIn
self.isCharging = batteryInfo.isCharging
self.isInLowPowerMode = batteryInfo.isInLowPowerMode
self.timeToFullCharge = batteryInfo.timeToFullCharge
self.maxCapacity = batteryInfo.maxCapacity
self.statusText = batteryInfo.isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged")
}
}
private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) {
presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false)
}
func triggerTestHUD(kind: BatteryTemporaryHUDKind) {
let previewLevel: Int
switch kind {
case .charging:
previewLevel = max(12, min(95, Int(levelBattery.rounded())))
case .lowBattery:
previewLevel = max(5, min(20, Defaults[.lowBatteryHUDThreshold]))
case .fullBattery:
previewLevel = 100
}
presentTemporaryBatteryHUDIfNeeded(
kind: kind,
force: true,
levelOverride: previewLevel,
lowPowerModeOverride: kind == .lowBattery ? isInLowPowerMode : nil
)
}
private func presentTemporaryBatteryHUDIfNeeded(
kind: BatteryTemporaryHUDKind,
force: Bool,
levelOverride: Int? = nil,
lowPowerModeOverride: Bool? = nil
) {
guard force || Defaults[.showPowerStatusNotifications] else { return }
let duration: Int
let isEnabled: Bool
switch kind {
case .charging:
duration = Defaults[.chargingBatteryHUDDuration]
isEnabled = Defaults[.showChargingBatteryHUD]
case .lowBattery:
duration = Defaults[.lowBatteryHUDDuration]
isEnabled = Defaults[.showLowBatteryHUD]
case .fullBattery:
duration = Defaults[.fullBatteryHUDDuration]
isEnabled = Defaults[.showFullBatteryHUD]
}
guard force || isEnabled else { return }
activeTemporaryHUDKind = kind
activeTemporaryHUDToken = UUID()
activeTemporaryHUDTargetScreenName = resolvedTemporaryHUDTargetScreenName()
activeTemporaryHUDLevelOverride = levelOverride
activeTemporaryHUDLowPowerModeOverride = lowPowerModeOverride
coordinator.toggleExpandingView(
status: true,
type: .battery,
autoHideDuration: TimeInterval(max(1, duration))
)
}
private func resolvedTemporaryHUDTargetScreenName() -> String? {
if Defaults[.showOnAllDisplays] {
return nil
}
let preferredNames = [
coordinator.selectedScreen,
coordinator.preferredScreen,
NSScreen.main?.localizedName
]
.compactMap { $0 }
for candidate in preferredNames where NSScreen.screens.contains(where: { $0.localizedName == candidate }) {
return candidate
}
return NSScreen.screens.first?.localizedName
}
private func preferredDynamicIslandTargetScreenName() -> String? {
let mainScreenName = NSScreen.main?.localizedName
let preferredNames = [coordinator.selectedScreen, coordinator.preferredScreen]
for candidate in preferredNames where shouldUseDynamicIslandMode(for: candidate) {
return candidate
}
if let externalDynamicIslandScreen = NSScreen.screens.first(where: {
$0.localizedName != mainScreenName && shouldUseDynamicIslandMode(for: $0.localizedName)
}) {
return externalDynamicIslandScreen.localizedName
}
if let anyDynamicIslandScreen = NSScreen.screens.first(where: {
shouldUseDynamicIslandMode(for: $0.localizedName)
}) {
return anyDynamicIslandScreen.localizedName
}
return nil
}
private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) {
guard !isPluggedIn, !isCharging else { return }
guard newLevel < previousLevel else { return }
let threshold = Float(Defaults[.lowBatteryHUDThreshold])
guard previousLevel > threshold && newLevel <= threshold else { return }
self.statusText = String(localized: "Low battery")
presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery)
if Defaults[.playLowBatteryAlertSound] {
playLowBatteryAlertSound()
}
}
private func handleFullBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) {
guard newLevel > previousLevel else { return }
let threshold = Float(Defaults[.fullBatteryHUDThreshold])
guard previousLevel < threshold && newLevel >= threshold else { return }
self.statusText = String(localized: "Full charge")
presentTemporaryBatteryHUDIfNeeded(kind: .fullBattery)
}
private func playLowBatteryAlertSound() {
lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3")
}
deinit {
print("🔌 Cleaning up battery monitoring...")
if let managerBatteryId: Int = managerBatteryId {
managerBattery.removeObserver(byId: managerBatteryId)
}
}
}