diff --git a/boringNotch.xcodeproj/project.pbxproj b/boringNotch.xcodeproj/project.pbxproj index a2489cd7..8f520808 100644 --- a/boringNotch.xcodeproj/project.pbxproj +++ b/boringNotch.xcodeproj/project.pbxproj @@ -142,6 +142,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 */ @@ -308,6 +309,7 @@ B1F0A0012E60000100000001 /* BrightnessManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrightnessManager.swift; sourceTree = ""; }; B1F747F82EC7E94000F841DB /* LottieView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieView.swift; sourceTree = ""; }; B1FEB4982C7686630066EBBC /* PanGesture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PanGesture.swift; sourceTree = ""; }; + F35EA7AE2E75FFCD002EB37E /* SystemSoundHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemSoundHelper.swift; sourceTree = ""; }; F38DE6472D8243E2008B5C6D /* BatteryActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -455,6 +457,7 @@ 14288DD92C6E015000B9F80C /* helpers */ = { isa = PBXGroup; children = ( + F35EA7AE2E75FFCD002EB37E /* SystemSoundHelper.swift */, 118EBE242E92DCCB00D54B5A /* AssociatedObject.swift */, 11A45C782E34E63100CEB175 /* MediaChecker.swift */, 1153BD972D9881F900979FB0 /* AppleScriptHelper.swift */, @@ -986,6 +989,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 */, diff --git a/boringNotch/Localizable.xcstrings b/boringNotch/Localizable.xcstrings index fa343990..09302995 100644 --- a/boringNotch/Localizable.xcstrings +++ b/boringNotch/Localizable.xcstrings @@ -4543,7 +4543,7 @@ "fr" : { "stringUnit" : { "state" : "needs_review", - "value" : "Charge en pause : mode bureau" + "value" : "Charge en pause : mode bureau" } }, "hu" : { diff --git a/boringNotch/components/Settings/SettingsView.swift b/boringNotch/components/Settings/SettingsView.swift index cff23f33..6657ea5e 100644 --- a/boringNotch/components/Settings/SettingsView.swift +++ b/boringNotch/components/Settings/SettingsView.swift @@ -349,27 +349,55 @@ 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 { @@ -377,9 +405,53 @@ struct Charge: View { 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 { diff --git a/boringNotch/helpers/SystemSoundHelper.swift b/boringNotch/helpers/SystemSoundHelper.swift new file mode 100644 index 00000000..02da1ccb --- /dev/null +++ b/boringNotch/helpers/SystemSoundHelper.swift @@ -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: "") } + } +} diff --git a/boringNotch/managers/BatteryActivityManager.swift b/boringNotch/managers/BatteryActivityManager.swift index 61b1c665..b031f23f 100644 --- a/boringNotch/managers/BatteryActivityManager.swift +++ b/boringNotch/managers/BatteryActivityManager.swift @@ -88,8 +88,8 @@ class BatteryActivityManager { /// Checks for changes in a property and notifies observers private func checkAndNotify( - previous: T, - current: T, + previous: T, + current: T, eventGenerator: (T) -> BatteryEvent ) { if previous != current { @@ -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, diff --git a/boringNotch/models/BatteryStatusViewModel.swift b/boringNotch/models/BatteryStatusViewModel.swift index bd7ea89d..12f9ab87 100644 --- a/boringNotch/models/BatteryStatusViewModel.swift +++ b/boringNotch/models/BatteryStatusViewModel.swift @@ -4,43 +4,55 @@ 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? + /// 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 } @@ -48,16 +60,16 @@ class BatteryStatusViewModel: ObservableObject { } } - /// 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): @@ -65,10 +77,11 @@ class BatteryStatusViewModel: ObservableObject { 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")" @@ -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): @@ -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 @@ -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 { diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index 8477434c..8bc0e2d1 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -139,10 +139,15 @@ extension Defaults.Keys { ) // MARK: Battery - static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) static let showBatteryIndicator = Key("showBatteryIndicator", default: true) static let showBatteryPercentage = Key("showBatteryPercentage", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) + static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) + static let powerStatusNotificationSound = Key("powerStatusNotificationSound", default: "Disabled") + static let lowBatteryNotificationLevel = Key("lowBatteryNotificationLevel", default: 0) + static let lowBatteryNotificationSound = Key("lowBatteryNotificationSound", default: "Disabled") + static let highBatteryNotificationLevel = Key("highBatteryNotificationLevel", default: 0) + static let highBatteryNotificationSound = Key("highBatteryNotificationSound", default: "Disabled") // MARK: Downloads static let enableDownloadListener = Key("enableDownloadListener", default: true)