Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e3ea93f
πŸ””πŸ”‹ Battery Status & Level Alert Enhancements
AlexLemus-Dev Sep 13, 2025
c8cb047
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Oct 1, 2025
79b5c21
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 17, 2025
59d7ef7
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
ace991d
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
0f24988
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
93955ce
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
c1d1416
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
0031628
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
108f250
Update boringNotch/components/Settings/SettingsView.swift
AlexLemus-Dev Nov 18, 2025
16c7b9f
fix(Localizable): Fixed new change localizables
AlexLemus-Dev Nov 18, 2025
dd59ff3
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 18, 2025
25bc246
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 18, 2025
756e525
fix(SettingsView): misplaced closing brace in Charge view
AlexLemus-Dev Nov 19, 2025
2758e1e
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 19, 2025
29e96df
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 23, 2025
7a45871
Merge branch 'dev' into feature/battery-threshold-notifications
AlexLemus-Dev Nov 24, 2025
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
6 changes: 6 additions & 0 deletions boringNotch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
B1F0A0022E60000100000001 /* BrightnessManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0A0012E60000100000001 /* BrightnessManager.swift */; };
B1F747F92EC7E94000F841DB /* LottieView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F747F82EC7E94000F841DB /* LottieView.swift */; };
B1FEB4992C7686630066EBBC /* PanGesture.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1FEB4982C7686630066EBBC /* PanGesture.swift */; };
F35EA7AF2E75FFCE002EB37E /* SystemSoundHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35EA7AE2E75FFCD002EB37E /* SystemSoundHelper.swift */; };
F38DE6482D8243E7008B5C6D /* BatteryActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */; };
/* End PBXBuildFile section */

Expand Down Expand Up @@ -301,6 +302,7 @@
B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = "<group>"; };
B1F747F82EC7E94000F841DB /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = "<group>"; };
B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = "<group>"; };
F35EA7AE2E75FFCD002EB37E /* SystemSoundHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSoundHelper.swift; sourceTree = "<group>"; };
F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -445,6 +447,7 @@
14288DD92C6E015000B9F80C /* helpers */ = {
isa = PBXGroup;
children = (
F35EA7AE2E75FFCD002EB37E /* SystemSoundHelper.swift */,
118EBE242E92DCCB00D54B5A /* AssociatedObject.swift */,
11A45C782E34E63100CEB175 /* MediaChecker.swift */,
1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */,
Expand Down Expand Up @@ -976,6 +979,9 @@
507266DB2C908E2E00A2D00D /* HoverButton.swift in Sources */,
1471A8592C6281BD0058408D /* BoringNotchWindow.swift in Sources */,
14CEF4182C5CAED300855D72 /* ContentView.swift in Sources */,
9A987A0D2C73CA66005CA465 /* NotchShelfView.swift in Sources */,
F35EA7AF2E75FFCE002EB37E /* SystemSoundHelper.swift in Sources */,
9A987A0A2C73CA66005CA465 /* TrayDrop.swift in Sources */,
9A987A0D2C73CA66005CA465 /* ShelfView.swift in Sources */,
1132E5142E777B920068732D /* YouTubeMusicNetworking.swift in Sources */,
1132E5122E777B6E0068732D /* YouTubeMusicModels.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion boringNotch/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4449,7 +4449,7 @@
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Charge en pauseΒ : mode bureau"
"value" : "Charge en pause : mode bureau"
}
},
"hu" : {
Expand Down
99 changes: 85 additions & 14 deletions boringNotch/components/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -343,37 +343,108 @@ struct GeneralSettings: View {
}

struct Charge: View {

@Default(.powerStatusNotificationSound) var powerStatusNotificationSound
@Default(.lowBatteryNotificationLevel) var lowBatteryNotificationLevel
@Default(.lowBatteryNotificationSound) var lowBatteryNotificationSound
@Default(.highBatteryNotificationLevel) var highBatteryNotificationLevel
@Default(.highBatteryNotificationSound) var highBatteryNotificationSound

let batteryLevels: [Int] = [3, 5] + Array(stride(from: 10, through: 90, by: 1)) + [95, 97, 100]
let sounds: [String] = ["Disabled"] + SystemSoundHelper.availableSystemSounds()

var body: some View {
Form {
Section {
Defaults.Toggle(key: .showBatteryIndicator) {
Text("Show battery indicator")
}
Defaults.Toggle(key: .showPowerStatusNotifications) {
Text("Show power status notifications")
}
Defaults.Toggle("Show battery indicator", key: .showBatteryIndicator)
} header: {
Text("General")
}
Section {
Defaults.Toggle(key: .showBatteryPercentage) {
Text("Show battery percentage")
}
Defaults.Toggle(key: .showPowerStatusIcons) {
Text("Show power status icons")
}
Defaults.Toggle("Show percentage", key: .showBatteryPercentage)
Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons)
} header: {
Text("Battery Information")
Text("Information")
}
Section {
HStack {
Defaults.Toggle(
"Power status",
key: .showPowerStatusNotifications
)
Divider()
PickerSoundAlert(sounds: sounds, sound: $powerStatusNotificationSound)
}
BatteryNotificationConf(
title: "Low level",
batteryLevels: batteryLevels,
sounds: sounds,
level: $lowBatteryNotificationLevel,
sound: $lowBatteryNotificationSound
)
BatteryNotificationConf(
title: "High level",
batteryLevels: batteryLevels,
sounds: sounds,
level: $highBatteryNotificationLevel,
sound: $highBatteryNotificationSound
)

} header: {
Text("Notifications")
}
.onAppear {
Task { @MainActor in
await XPCHelperClient.shared.isAccessibilityAuthorized()
}
}
.accentColor(.effectiveAccent)
.navigationTitle("Battery")
}

/// A view for configuring battery notifications.
/// - title: Picker label for battery level.
/// - batteryLevels: List of selectable battery levels.
/// - sounds: List of available sounds.
/// - level: Selected battery level (binding).
/// - sound: Selected sound (binding).
struct BatteryNotificationConf: View {

let title: String
let batteryLevels: [Int]
let sounds: [String]
@Binding var level: Int
@Binding var sound: String

var body: some View {
HStack {
Picker(title, selection: $level) {
Text("Disabled").tag(0)
ForEach(batteryLevels, id: \.self) { level in
Text("\(level)%").tag(level)
}
}
Divider()
PickerSoundAlert(sounds: sounds, sound: $sound)
}
}
}

/// A picker for selecting a sound alert.
/// - sounds: List of available sounds.
/// - sound: Currently selected sound (binding).
struct PickerSoundAlert: View {

let sounds: [String]
@Binding var sound: String
var body: some View {
Picker("Sound", selection: $sound) {
ForEach(sounds, id: \.self) { sound in
Text(sound).tag(sound)
}
}
}
}

}

//struct Downloads: View {
Expand Down
11 changes: 11 additions & 0 deletions boringNotch/helpers/SystemSoundHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

final class SystemSoundHelper {
static func availableSystemSounds() -> [String] {
let soundDirectory = "/System/Library/Sounds"
guard let soundFiles = try? FileManager.default.contentsOfDirectory(atPath: soundDirectory) else {
return []
}
return soundFiles.map { $0.replacingOccurrences(of: ".aiff", with: "") }
}
}
8 changes: 5 additions & 3 deletions boringNotch/managers/BatteryActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ class BatteryActivityManager {

/// Checks for changes in a property and notifies observers
private func checkAndNotify<T: Equatable>(
previous: T,
current: T,
previous: T,
current: T,
eventGenerator: (T) -> BatteryEvent
) {
if previous != current {
Expand All @@ -104,7 +104,9 @@ class BatteryActivityManager {

// Check for changes
if let previousInfo = previousBatteryInfo {
// Usar la funciΓ³n auxiliar para cada propiedad

// Compare each property and notify if changed

checkAndNotify(
previous: previousInfo.isPluggedIn,
current: batteryInfo.isPluggedIn,
Expand Down
86 changes: 65 additions & 21 deletions boringNotch/models/BatteryStatusViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,71 +4,84 @@ import Foundation
import IOKit.ps
import SwiftUI

/// A view model that manages and monitors the battery status of the device
/// A view model that manages and monitors the battery status of the device.
class BatteryStatusViewModel: ObservableObject {

private var wasCharging: Bool = false
/// Callback for power source changes.
private var powerSourceChangedCallback: IOPowerSourceCallbackType?
/// Run loop source for battery monitoring.
private var runLoopSource: Unmanaged<CFRunLoopSource>?

/// Shared coordinator for view updates.
@ObservedObject var coordinator = BoringViewCoordinator.shared

/// Current battery level (0.0 - 100.0).
@Published private(set) var levelBattery: Float = 0.0
/// Maximum battery capacity.
@Published private(set) var maxCapacity: Float = 0.0
/// Indicates if the device is plugged in.
@Published private(set) var isPluggedIn: Bool = false
/// Indicates if the device is charging.
@Published private(set) var isCharging: Bool = false
/// Indicates if low power mode is enabled.
@Published private(set) var isInLowPowerMode: Bool = false
/// Indicates if the initial battery info has been loaded.
@Published private(set) var isInitial: Bool = false
/// Estimated time to full charge (in minutes).
@Published private(set) var timeToFullCharge: Int = 0
/// Textual status of the battery, often with emojis.
@Published private(set) var statusText: String = ""

/// Shared battery manager instance.
private let managerBattery = BatteryActivityManager.shared
/// Observer ID for battery manager events.
private var managerBatteryId: Int?

/// Singleton instance of the view model.
static let shared = BatteryStatusViewModel()

/// Initializes the view model with a given BoringViewModel instance
/// - Parameter vm: The BoringViewModel instance
/// Initializes the view model and sets up battery monitoring.
private init() {
setupPowerStatus()
setupMonitor()
}

/// Sets up the initial power status by fetching battery information
/// Fetches initial battery information and updates properties.
private func setupPowerStatus() {
let batteryInfo = managerBattery.initializeBatteryInfo()
updateBatteryInfo(batteryInfo)
}

/// Sets up the monitor to observe battery events
/// Registers observer for 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
/// Handles battery events and updates published properties.
/// - Parameter event: The battery event to process.
private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) {
switch event {
case .powerSourceChanged(let isPluggedIn):
print("πŸ”Œ Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
self.notifyImportanChangeStatus(sound: Defaults[.powerStatusNotificationSound])
withAnimation {
self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? "Plugged In" : "Unplugged"
self.notifyImportanChangeStatus()
}

case .batteryLevelChanged(let level):
print("πŸ”‹ Battery level: \(Int(level))%")
withAnimation {
self.levelBattery = level
}
self.batteryLevelNotification(level: Int(level))

case .lowPowerModeChanged(let isEnabled):
print("⚑ Low power mode: \(isEnabled ? "Enabled" : "Disabled")")
self.notifyImportanChangeStatus()
self.notifyImportanChangeStatus(sound: Defaults[.powerStatusNotificationSound])
withAnimation {
self.isInLowPowerMode = isEnabled
self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")"
Expand All @@ -78,13 +91,13 @@ class BatteryStatusViewModel: ObservableObject {
print("πŸ”Œ Charging: \(isCharging ? "Yes" : "No")")
print("maxCapacity: \(self.maxCapacity)")
print("levelBattery: \(self.levelBattery)")
self.notifyImportanChangeStatus()
self.notifyImportanChangeStatus(sound: Defaults[.powerStatusNotificationSound])
withAnimation {
self.isCharging = isCharging
self.statusText =
isCharging
? "Charging battery"
: (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge")
? "Charging"
: (self.levelBattery < self.maxCapacity ? "Not Charging" : "Full Charge")
}

case .timeToFullChargeChanged(let time):
Expand All @@ -104,9 +117,10 @@ class BatteryStatusViewModel: ObservableObject {
}
}

/// Updates the battery information with the given BatteryInfo instance
/// - Parameter batteryInfo: The BatteryInfo instance containing the battery data
/// Updates all battery properties from a BatteryInfo instance.
/// - Parameter batteryInfo: The battery information to apply.
private func updateBatteryInfo(_ batteryInfo: BatteryInfo) {
self.notifyImportanChangeStatus(sound: Defaults[.powerStatusNotificationSound])
withAnimation {
self.levelBattery = batteryInfo.currentCapacity
self.isPluggedIn = batteryInfo.isPluggedIn
Expand All @@ -116,17 +130,47 @@ class BatteryStatusViewModel: ObservableObject {
self.maxCapacity = batteryInfo.maxCapacity
self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged"
}
Task {
try? await Task.sleep(for: .seconds(2.0))
self.batteryLevelNotification(level: Int(self.levelBattery), initial: true)
}
}

/// Notifies important changes in the battery status with an optional delay
/// - Parameter delay: The delay before notifying the change, default is 0.0
private func notifyImportanChangeStatus(delay: Double = 0.0) {
Task {
try? await Task.sleep(for: .seconds(delay))
self.coordinator.toggleExpandingView(status: true, type: .battery)
private func batteryLevelNotification(level: Int, initial: Bool = false) {
guard let text = notificationText(for: level, initial: initial) else { return }

let sound = text == "Low Battery"
? Defaults[.lowBatteryNotificationSound]
: Defaults[.highBatteryNotificationSound]

self.notifyImportanChangeStatus(sound: sound)
withAnimation {
self.statusText = text
}
}

private func notificationText(for level: Int, initial: Bool) -> String? {
let lowThreshold = Defaults[.lowBatteryNotificationLevel]
let highThreshold = Defaults[.highBatteryNotificationLevel]

if !self.isCharging && (level == lowThreshold || (initial && level <= lowThreshold)) {
return "Low Battery"
}
if self.isCharging && (level == highThreshold || (initial && level >= highThreshold)) {
return "High Battery"
}
return nil
}

/// Notifies the coordinator about important battery status changes.
private func notifyImportanChangeStatus(sound: String) {
self.coordinator.toggleExpandingView(status: true, type: .battery)
if sound != "Disabled" {
NSSound(named: NSSound.Name(sound))?.play()
}
}

/// Cleans up battery monitoring observers on deinitialization.
deinit {
print("πŸ”Œ Cleaning up battery monitoring...")
if let managerBatteryId: Int = managerBatteryId {
Expand Down
7 changes: 6 additions & 1 deletion boringNotch/models/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,15 @@ extension Defaults.Keys {
)

// MARK: Battery
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
static let showBatteryIndicator = Key<Bool>("showBatteryIndicator", default: true)
static let showBatteryPercentage = Key<Bool>("showBatteryPercentage", default: true)
static let showPowerStatusIcons = Key<Bool>("showPowerStatusIcons", default: true)
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
static let powerStatusNotificationSound = Key<String>("powerStatusNotificationSound", default: "Disabled")
static let lowBatteryNotificationLevel = Key<Int>("lowBatteryNotificationLevel", default: 0)
static let lowBatteryNotificationSound = Key<String>("lowBatteryNotificationSound", default: "Disabled")
static let highBatteryNotificationLevel = Key<Int>("highBatteryNotificationLevel", default: 0)
static let highBatteryNotificationSound = Key<String>("highBatteryNotificationSound", default: "Disabled")

// MARK: Downloads
static let enableDownloadListener = Key<Bool>("enableDownloadListener", default: true)
Expand Down
Loading