Working Color, Bluetooth and not working DND

This commit is contained in:
Hariharan Mudaliar 2025-10-07 19:45:55 +05:30
commit c00b6db984
12 changed files with 13150 additions and 11440 deletions

View file

@ -372,9 +372,12 @@
); );
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist; INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@ -415,9 +418,12 @@
); );
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = DynamicIsland/Info.plist; INFOPLIST_FILE = DynamicIsland/Info.plist;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Dynamic Island uses AppleScripts to control Spotify and Apple Music";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Show calender events inside the notch";
INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCalendarsUsageDescription = "Need for Calendar Mode";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSDesktopFolderUsageDescription = "Dynamic Island needs access to save screenshots to your desktop.";
INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "Dynamic Island needs access to save and read AI assistant files.";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",

View file

@ -82,6 +82,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var timer: Timer? var timer: Timer?
let calendarManager = CalendarManager.shared let calendarManager = CalendarManager.shared
let webcamManager = WebcamManager.shared let webcamManager = WebcamManager.shared
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
var closeNotchWorkItem: DispatchWorkItem? var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]? private var previousScreens: [NSScreen]?
private var onboardingWindowController: NSWindowController? private var onboardingWindowController: NSWindowController?

View file

@ -20,6 +20,7 @@ enum SneakContentType {
case download case download
case timer case timer
case recording case recording
case bluetoothAudio
} }
struct sneakPeek { struct sneakPeek {

View file

@ -7,16 +7,10 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSAppleEventsUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>Dynamic Island uses AppleScripts to control Spotify and Apple Music</string> <string>Dynamic Island needs Bluetooth access to detect when audio devices connect and display their battery status in the HUD.</string>
<key>NSCalendarsFullAccessUsageDescription</key>
<string>Show calender events inside the notch</string>
<key>NSScreenCaptureUsageDescription</key> <key>NSScreenCaptureUsageDescription</key>
<string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string> <string>Dynamic Island needs screen recording permission to capture screenshots for the AI assistant feature.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>Dynamic Island needs access to save screenshots to your desktop.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Dynamic Island needs access to save and read AI assistant files.</string>
<key>SUEnableDownloaderService</key> <key>SUEnableDownloaderService</key>
<true/> <true/>
<key>SUEnableInstallerLauncherService</key> <key>SUEnableInstallerLauncherService</key>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,179 @@
//
// ColorCodedProgressBar.swift
// DynamicIsland
//
// Created for color-coded battery and volume displays
// Provides smooth gradient transitions from red to green (or reversed)
//
import SwiftUI
import Defaults
struct ColorCodedProgressBar: View {
let value: CGFloat // 0.0 to 1.0
let reversed: Bool // If true: green at low, red at high (for volume)
let width: CGFloat
let height: CGFloat
let smoothGradient: Bool // If true: smooth gradient, if false: discrete colors
init(value: CGFloat, reversed: Bool = false, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) {
self.value = min(max(value, 0), 1) // Clamp between 0 and 1
self.reversed = reversed
self.width = width
self.height = height
self.smoothGradient = smoothGradient
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background track
RoundedRectangle(cornerRadius: height / 2)
.fill(Color.white.opacity(0.2))
.frame(width: width, height: height)
// Filled track with color gradient or solid color
RoundedRectangle(cornerRadius: height / 2)
.fill(smoothGradient ?
AnyShapeStyle(LinearGradient(
gradient: gradientForValue,
startPoint: .leading,
endPoint: .trailing
)) :
AnyShapeStyle(colorForPercentage(value, reversed: reversed))
)
.frame(width: width * value, height: height)
}
}
.frame(width: width, height: height)
}
/// Generates a gradient based on the current value
private var gradientForValue: Gradient {
let color = colorForPercentage(value, reversed: reversed)
// Create a gradient that transitions smoothly
let startColor = colorForPercentage(max(value - 0.1, 0), reversed: reversed)
let endColor = color
return Gradient(colors: [startColor, endColor])
}
/// Calculates discrete color for a given percentage (no smooth transitions)
/// - Parameters:
/// - percentage: Value from 0.0 to 1.0
/// - reversed: If true, reverses the color scale (green at low, red at high)
/// - Returns: Discrete color (red, yellow, or green only)
private func discreteColorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
let normalized = reversed ? (1.0 - percentage) : percentage
switch normalized {
case 0.0..<0.33:
// Red zone (critical/low)
return Color(red: 1.0, green: 0.23, blue: 0.19)
case 0.33..<0.67:
// Yellow zone (medium)
return Color(red: 1.0, green: 0.8, blue: 0.0)
default:
// Green zone (healthy/high)
return Color(red: 0.2, green: 0.78, blue: 0.35)
}
}
/// Calculates color for a given percentage with smooth transitions
/// - Parameters:
/// - percentage: Value from 0.0 to 1.0
/// - reversed: If true, reverses the color scale (green at low, red at high)
/// - Returns: Color with smooth gradient transitions
private func colorForPercentage(_ percentage: CGFloat, reversed: Bool) -> Color {
let normalized = reversed ? (1.0 - percentage) : percentage
switch normalized {
case 0.0..<0.2:
// Pure Red zone (critical/low)
return Color(red: 1.0, green: 0.23, blue: 0.19)
case 0.2..<0.35:
// Red → Orange transition
let t = (normalized - 0.2) / 0.15
let red: CGFloat = 1.0
let green: CGFloat = 0.23 + (0.37 * t) // 0.23 → 0.6
let blue: CGFloat = 0.19
return Color(red: red, green: green, blue: blue)
case 0.35..<0.5:
// Orange → Yellow transition
let t = (normalized - 0.35) / 0.15
let red: CGFloat = 1.0
let green: CGFloat = 0.6 + (0.4 * t) // 0.6 → 1.0
let blue: CGFloat = 0.19
return Color(red: red, green: green, blue: blue)
case 0.5..<0.65:
// Yellow → Yellow-Green transition
let t = (normalized - 0.5) / 0.15
let red: CGFloat = 1.0 - (0.4 * t) // 1.0 → 0.6
let green: CGFloat = 1.0
let blue: CGFloat = 0.19 + (0.4 * t) // 0.19 → 0.59
return Color(red: red, green: green, blue: blue)
case 0.65..<0.8:
// Yellow-Green → Green transition
let t = (normalized - 0.65) / 0.15
let red: CGFloat = 0.6 - (0.4 * t) // 0.6 → 0.2
let green: CGFloat = 1.0 - (0.22 * t) // 1.0 → 0.78
let blue: CGFloat = 0.59 - (0.24 * t) // 0.59 → 0.35
return Color(red: red, green: green, blue: blue)
default:
// Pure Green zone (healthy/high)
return Color(red: 0.2, green: 0.78, blue: 0.35)
}
}
}
// MARK: - Convenience Extensions
extension ColorCodedProgressBar {
/// Creates a battery-style progress bar (green at high, red at low)
static func battery(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
ColorCodedProgressBar(value: value, reversed: false, width: width, height: height, smoothGradient: smoothGradient)
}
/// Creates a volume-style progress bar (red at high, green at low)
static func volume(value: CGFloat, width: CGFloat = 100, height: CGFloat = 4, smoothGradient: Bool = true) -> some View {
ColorCodedProgressBar(value: value, reversed: true, width: width, height: height, smoothGradient: smoothGradient)
}
}
// MARK: - Preview
#Preview("Battery Mode") {
VStack(spacing: 20) {
ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in
HStack {
Text("\(Int(value * 100))%")
.frame(width: 50, alignment: .trailing)
.foregroundColor(.white)
ColorCodedProgressBar.battery(value: value, width: 200)
}
}
}
.padding()
.background(Color.black)
}
#Preview("Volume Mode (Reversed)") {
VStack(spacing: 20) {
ForEach([0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0], id: \.self) { value in
HStack {
Text("\(Int(value * 100))%")
.frame(width: 50, alignment: .trailing)
.foregroundColor(.white)
ColorCodedProgressBar.volume(value: value, width: 200)
}
}
}
.padding()
.background(Color.black)
}

View file

@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import Defaults
struct InlineHUD: View { struct InlineHUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel @EnvironmentObject var vm: DynamicIslandViewModel
@ -14,6 +15,15 @@ struct InlineHUD: View {
@Binding var icon: String @Binding var icon: String
@Binding var hoverAnimation: Bool @Binding var hoverAnimation: Bool
@Binding var gestureProgress: CGFloat @Binding var gestureProgress: CGFloat
@Default(.useColorCodedBatteryDisplay) var useColorCodedBatteryDisplay
@Default(.useColorCodedVolumeDisplay) var useColorCodedVolumeDisplay
@Default(.useSmoothColorGradient) var useSmoothColorGradient
@Default(.progressBarStyle) var progressBarStyle
@ObservedObject var bluetoothManager = BluetoothAudioManager.shared
@State private var displayName: String = ""
var body: some View { var body: some View {
HStack { HStack {
HStack(spacing: 5) { HStack(spacing: 5) {
@ -21,7 +31,9 @@ struct InlineHUD: View {
switch (type) { switch (type) {
case .volume: case .volume:
if icon.isEmpty { if icon.isEmpty {
Image(systemName: SpeakerSymbol(value)) // Show headphone icon if Bluetooth audio is connected, otherwise speaker
let baseIcon = bluetoothManager.isBluetoothAudioConnected ? "headphones" : SpeakerSymbol(value)
Image(systemName: baseIcon)
.contentTransition(.interpolate) .contentTransition(.interpolate)
.symbolVariant(value > 0 ? .none : .slash) .symbolVariant(value > 0 ? .none : .slash)
.frame(width: 20, height: 15, alignment: .leading) .frame(width: 20, height: 15, alignment: .leading)
@ -51,6 +63,11 @@ struct InlineHUD: View {
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate) .contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center) .frame(width: 20, height: 15, alignment: .center)
case .bluetoothAudio:
Image(systemName: icon.isEmpty ? "bluetooth" : icon)
.symbolRenderingMode(.hierarchical)
.contentTransition(.interpolate)
.frame(width: 20, height: 15, alignment: .center)
default: default:
EmptyView() EmptyView()
} }
@ -58,6 +75,26 @@ struct InlineHUD: View {
.foregroundStyle(.white) .foregroundStyle(.white)
.symbolVariant(.fill) .symbolVariant(.fill)
// Use marquee text for device names to handle long names
if type == .bluetoothAudio {
MarqueeText(
$displayName,
font: .system(size: 13, weight: .medium),
nsFont: .body,
textColor: .white,
minDuration: 0.5,
frameWidth: 85 - (hoverAnimation ? 0 : 12) + gestureProgress / 2
)
.onAppear {
displayName = Type2Name(type)
}
.onChange(of: type) { _, _ in
displayName = Type2Name(type)
}
.onChange(of: bluetoothManager.lastConnectedDevice?.name) { _, _ in
displayName = Type2Name(type)
}
} else {
Text(Type2Name(type)) Text(Type2Name(type))
.font(.subheadline) .font(.subheadline)
.fontWeight(.medium) .fontWeight(.medium)
@ -65,6 +102,7 @@ struct InlineHUD: View {
.allowsTightening(true) .allowsTightening(true)
.contentTransition(.numericText()) .contentTransition(.numericText())
} }
}
.frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading) .frame(width: 100 - (hoverAnimation ? 0 : 12) + gestureProgress / 2, height: vm.notchSize.height - (hoverAnimation ? 0 : 12), alignment: .leading)
Rectangle() Rectangle()
@ -88,9 +126,45 @@ struct InlineHUD: View {
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
.contentTransition(.interpolate) .contentTransition(.interpolate)
} else if (type == .bluetoothAudio) {
// Bluetooth device battery display
HStack(spacing: 4) {
if value > 0 {
// Show color-coded battery bar if battery info available
// Color-coding disabled for segmented or hierarchical mode
if useColorCodedBatteryDisplay && progressBarStyle == .gradient {
ColorCodedProgressBar.battery(value: value, width: 60, height: 4, smoothGradient: useSmoothColorGradient)
} else { } else {
DraggableProgressBar(value: .constant(value))
.frame(width: 60)
}
Text("\(Int(value * 100))%")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white)
.lineLimit(1)
} else {
// No battery info available
Text("Connected")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.gray)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
} else {
// Volume and brightness displays
HStack { HStack {
// Color-coding only available in gradient mode, not hierarchical or segmented
if type == .volume && useColorCodedVolumeDisplay && progressBarStyle == .gradient {
// Color-coded volume (reversed: red at high, green at low)
ColorCodedProgressBar.volume(value: value, width: 70, height: 4, smoothGradient: useSmoothColorGradient)
} else {
// Default draggable progress bar
DraggableProgressBar(value: $value) DraggableProgressBar(value: $value)
}
if (type == .volume && value.isZero) { if (type == .volume && value.isZero) {
Text("muted") Text("muted")
.font(.caption) .font(.caption)
@ -145,6 +219,8 @@ struct InlineHUD: View {
return "Backlight" return "Backlight"
case .mic: case .mic:
return "Mic" return "Mic"
case .bluetoothAudio:
return BluetoothAudioManager.shared.lastConnectedDevice?.name ?? "Bluetooth"
default: default:
return "" return ""
} }

View file

@ -75,11 +75,21 @@ struct MarqueeText: View {
self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize) self.textSize = CGSize(width: size.width / 2, height: NSFont.preferredFont(forTextStyle: nsFont).pointSize)
self.animate = false self.animate = false
self.offset = 0 self.offset = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01){ DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){
if needsScrolling { if needsScrolling {
self.animate = true self.animate = true
self.offset = -(textSize.width + 10) self.offset = -(textSize.width + 20)
}
}
}
.onChange(of: text) { _, _ in
// Reset animation when text changes
self.animate = false
self.offset = 0
DispatchQueue.main.asyncAfter(deadline: .now() + 0.02){
if needsScrolling {
self.animate = true
self.offset = -(textSize.width + 20)
} }
} }
} }

View file

@ -543,6 +543,43 @@ struct HUD: View {
} }
} }
Section {
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
} header: {
Text("Bluetooth Audio Devices")
} footer: {
Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
.disabled(progressBarStyle != .gradient)
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
.disabled(progressBarStyle != .gradient)
if progressBarStyle == .gradient && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
}
} header: {
Text("Color-Coded Progress Bars")
} footer: {
if progressBarStyle != .gradient {
Text("Color-coded displays are only available in Gradient mode. Switch Progressbar style to Gradient to enable this feature.")
.foregroundStyle(.secondary)
.font(.caption)
} else if Defaults[.useSmoothColorGradient] {
Text("Smooth: Battery/Volume gradually transitions through all colors\nBattery: Green (high) → Yellow-Green → Yellow → Orange → Red (low)\nVolume: Green (quiet) → Yellow → Orange → Red (loud)")
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Discrete: Battery/Volume uses 3 distinct colors only\nBattery: Green (high), Yellow (medium), Red (low)\nVolume: Green (quiet), Yellow (medium), Red (loud)")
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section { Section {
Picker("HUD style", selection: $inlineHUD) { Picker("HUD style", selection: $inlineHUD) {
Text("Default") Text("Default")

View file

@ -0,0 +1,470 @@
//
// BluetoothAudioManager.swift
// DynamicIsland
//
// Created for Bluetooth audio device connection detection and monitoring
// Detects when audio devices connect and displays HUD with battery status
//
import Foundation
import Combine
import AppKit
import Defaults
import SwiftUI
import IOBluetooth
/// Manages detection and monitoring of Bluetooth audio device connections
class BluetoothAudioManager: ObservableObject {
static let shared = BluetoothAudioManager()
// MARK: - Published Properties
@Published var lastConnectedDevice: BluetoothAudioDevice?
@Published var connectedDevices: [BluetoothAudioDevice] = []
@Published var isBluetoothAudioConnected: Bool = false
// MARK: - Private Properties
private var observers: [NSObjectProtocol] = []
private var cancellables = Set<AnyCancellable>()
private let coordinator = DynamicIslandViewCoordinator.shared
private var pollingTimer: Timer?
// MARK: - Initialization
private init() {
print("🎧 [BluetoothAudioManager] Initializing...")
setupBluetoothObservers()
checkInitialDevices()
startPollingForChanges()
}
deinit {
cleanup()
}
// MARK: - Setup Methods
/// Sets up observers for Bluetooth device connection/disconnection events
private func setupBluetoothObservers() {
print("🎧 [BluetoothAudioManager] Setting up Bluetooth observers...")
// Use DistributedNotificationCenter for IOBluetooth notifications
let dnc = DistributedNotificationCenter.default()
// Observe device connected notifications
dnc.addObserver(
self,
selector: #selector(handleDeviceConnectedNotification(_:)),
name: NSNotification.Name("IOBluetoothDeviceConnectedNotification"),
object: nil
)
// Observe device disconnected notifications
dnc.addObserver(
self,
selector: #selector(handleDeviceDisconnectedNotification(_:)),
name: NSNotification.Name("IOBluetoothDeviceDisconnectedNotification"),
object: nil
)
print("🎧 [BluetoothAudioManager] ✅ Observers registered with DistributedNotificationCenter")
}
/// Starts polling for device connection changes (fallback mechanism)
private func startPollingForChanges() {
print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...")
pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in
self?.checkForDeviceChanges()
}
}
/// Checks for device connection/disconnection changes
private func checkForDeviceChanges() {
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
// Bluetooth is off - clear connected devices if any
if !connectedDevices.isEmpty {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth powered off - clearing connected devices")
connectedDevices.removeAll()
isBluetoothAudioConnected = false
}
return
}
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
return
}
let currentlyConnectedAddresses = Set(
pairedDevices
.filter { $0.isConnected() && isAudioDevice($0) }
.compactMap { $0.addressString }
)
let previousAddresses = Set(connectedDevices.map { $0.address })
// Check for new connections
let newAddresses = currentlyConnectedAddresses.subtracting(previousAddresses)
if !newAddresses.isEmpty {
print("🎧 [BluetoothAudioManager] 🔍 Polling detected new connection(s)")
checkForNewlyConnectedDevices()
}
// Check for disconnections
let removedAddresses = previousAddresses.subtracting(currentlyConnectedAddresses)
if !removedAddresses.isEmpty {
print("🎧 [BluetoothAudioManager] 🔍 Polling detected disconnection(s)")
updateConnectedDevices()
}
}
/// Checks for already connected Bluetooth audio devices on init
private func checkInitialDevices() {
print("🎧 [BluetoothAudioManager] Checking for initially connected devices...")
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping initial check")
return
}
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
print("🎧 [BluetoothAudioManager] No paired devices found")
return
}
let connectedAudioDevices = pairedDevices.filter { device in
device.isConnected() && isAudioDevice(device)
}
print("🎧 [BluetoothAudioManager] Found \(connectedAudioDevices.count) connected audio devices")
connectedDevices = connectedAudioDevices.compactMap { device in
createBluetoothAudioDevice(from: device)
}
// Update connection state
isBluetoothAudioConnected = !connectedDevices.isEmpty
if let lastDevice = connectedDevices.last {
lastConnectedDevice = lastDevice
print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)")
}
}
// MARK: - Device Event Handlers
/// Handles Bluetooth device connection notification from DistributedNotificationCenter
@objc private func handleDeviceConnectedNotification(_ notification: Notification) {
print("🎧 [BluetoothAudioManager] 📡 Device connection notification received")
// Re-check all devices since distributed notification doesn't contain device object
checkForNewlyConnectedDevices()
}
/// Handles Bluetooth device disconnection notification from DistributedNotificationCenter
@objc private func handleDeviceDisconnectedNotification(_ notification: Notification) {
print("🎧 [BluetoothAudioManager] 📡 Device disconnection notification received")
// Re-check all devices to update connection state
updateConnectedDevices()
}
/// Checks for newly connected devices and displays HUD for new ones
private func checkForNewlyConnectedDevices() {
// Check if Bluetooth is powered on
guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else {
print("🎧 [BluetoothAudioManager] ⚠️ Bluetooth is powered off - skipping device check")
return
}
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
return
}
let currentlyConnectedDevices = pairedDevices.filter { device in
device.isConnected() && isAudioDevice(device)
}
// Find devices that are newly connected
for device in currentlyConnectedDevices {
let address = device.addressString ?? "Unknown"
// Check if this device wasn't in our list before
if !connectedDevices.contains(where: { $0.address == address }) {
print("🎧 [BluetoothAudioManager] 🎉 New audio device connected: \(device.name ?? "Unknown")")
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
continue
}
// Add to connected devices
connectedDevices.append(audioDevice)
lastConnectedDevice = audioDevice
isBluetoothAudioConnected = true
// Show HUD for new connection
showDeviceConnectedHUD(audioDevice)
}
}
}
/// Updates the list of connected devices (for disconnections)
private func updateConnectedDevices() {
guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else {
return
}
let currentlyConnectedAddresses = pairedDevices
.filter { $0.isConnected() && isAudioDevice($0) }
.compactMap { $0.addressString }
// Remove disconnected devices
let previousCount = connectedDevices.count
connectedDevices.removeAll { device in
!currentlyConnectedAddresses.contains(device.address)
}
if connectedDevices.count < previousCount {
print("🎧 [BluetoothAudioManager] 👋 Audio device(s) disconnected")
}
isBluetoothAudioConnected = !connectedDevices.isEmpty
}
/// Handles Bluetooth device connection event (legacy - kept for compatibility)
private func handleDeviceConnected(_ notification: Notification) {
guard let device = notification.object as? IOBluetoothDevice else {
print("🎧 [BluetoothAudioManager] ⚠️ Could not extract device from notification")
return
}
// Only handle audio devices
guard isAudioDevice(device) else {
print("🎧 [BluetoothAudioManager] Device is not an audio device, ignoring")
return
}
print("🎧 [BluetoothAudioManager] 🎉 Audio device connected: \(device.name ?? "Unknown")")
guard let audioDevice = createBluetoothAudioDevice(from: device) else {
return
}
// Add to connected devices list
if !connectedDevices.contains(where: { $0.address == audioDevice.address }) {
connectedDevices.append(audioDevice)
}
// Update last connected device
lastConnectedDevice = audioDevice
isBluetoothAudioConnected = true
// Show HUD
showDeviceConnectedHUD(audioDevice)
}
/// Handles Bluetooth device disconnection event
private func handleDeviceDisconnected(_ notification: Notification) {
guard let device = notification.object as? IOBluetoothDevice else {
return
}
guard isAudioDevice(device) else {
return
}
print("🎧 [BluetoothAudioManager] 👋 Audio device disconnected: \(device.name ?? "Unknown")")
// Remove from connected devices
let address = device.addressString ?? "Unknown"
connectedDevices.removeAll { $0.address == address }
isBluetoothAudioConnected = !connectedDevices.isEmpty
}
// MARK: - Device Detection Helpers
/// Determines if a Bluetooth device is an audio device
private func isAudioDevice(_ device: IOBluetoothDevice) -> Bool {
// Check if device has audio service UUID
let audioServiceUUID = IOBluetoothSDPUUID(uuid16: 0x110B) // Audio Sink
let headsetServiceUUID = IOBluetoothSDPUUID(uuid16: 0x1108) // Headset
let handsfreeServiceUUID = IOBluetoothSDPUUID(uuid16: 0x111E) // Handsfree
// Check if device has any audio-related services
if device.getServiceRecord(for: audioServiceUUID) != nil {
return true
}
if device.getServiceRecord(for: headsetServiceUUID) != nil {
return true
}
if device.getServiceRecord(for: handsfreeServiceUUID) != nil {
return true
}
// Check device class (major class: Audio/Video)
let deviceClass = device.classOfDevice
let majorClass = (deviceClass >> 8) & 0x1F
let audioVideoMajorClass: UInt32 = 0x04
return majorClass == audioVideoMajorClass
}
/// Creates a BluetoothAudioDevice model from IOBluetoothDevice
private func createBluetoothAudioDevice(from device: IOBluetoothDevice) -> BluetoothAudioDevice? {
let name = device.name ?? "Bluetooth Device"
let address = device.addressString ?? "Unknown"
let batteryLevel = getBatteryLevel(from: device)
let deviceType = detectDeviceType(from: device, name: name)
return BluetoothAudioDevice(
name: name,
address: address,
batteryLevel: batteryLevel,
deviceType: deviceType
)
}
/// Extracts battery level from Bluetooth device
private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? {
// Try to read battery level from device
// Note: This may require additional permissions or may not be available for all devices
// Placeholder: Return nil if not available
// TODO: Implement actual battery level reading if device supports it
return nil
}
/// Detects the type of audio device based on name and properties
private func detectDeviceType(from device: IOBluetoothDevice, name: String) -> BluetoothAudioDeviceType {
let lowercaseName = name.lowercased()
// Check for specific AirPods models
if lowercaseName.contains("airpods") {
if lowercaseName.contains("max") {
return .airpodsMax
} else if lowercaseName.contains("pro") {
return .airpodsPro
}
return .airpods
}
// Check for other brands
if lowercaseName.contains("beats") {
return .beats
} else if lowercaseName.contains("speaker") || lowercaseName.contains("boombox") {
return .speaker
} else if lowercaseName.contains("headphone") || lowercaseName.contains("headset") ||
lowercaseName.contains("buds") || lowercaseName.contains("earbuds") {
return .headphones
}
// Check device class for more specific detection
let deviceClass = device.classOfDevice
let minorClass = (deviceClass >> 2) & 0x3F
// Minor classes for audio devices
switch minorClass {
case 0x01: return .headphones // Wearable Headset
case 0x02: return .headphones // Hands-free
case 0x06: return .headphones // Headphones
case 0x08: return .speaker // Portable Audio
case 0x0C: return .speaker // Loudspeaker
default: return .generic
}
}
// MARK: - HUD Display
/// Shows HUD notification for newly connected audio device
private func showDeviceConnectedHUD(_ device: BluetoothAudioDevice) {
guard Defaults[.showBluetoothDeviceConnections] else { return }
print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD")
// Convert battery percentage to 0.0-1.0 value
let batteryValue: CGFloat = if let battery = device.batteryLevel {
CGFloat(battery) / 100.0
} else {
0.0 // 0 means battery info not available
}
// Show HUD via coordinator
coordinator.toggleSneakPeek(
status: true,
type: .bluetoothAudio,
duration: 2.5,
value: batteryValue,
icon: device.deviceType.sfSymbol
)
}
// MARK: - Cleanup
private func cleanup() {
print("🎧 [BluetoothAudioManager] Cleaning up observers...")
pollingTimer?.invalidate()
pollingTimer = nil
let dnc = DistributedNotificationCenter.default()
dnc.removeObserver(self)
observers.removeAll()
cancellables.removeAll()
}
}
// MARK: - Models
struct BluetoothAudioDevice: Identifiable {
let id = UUID()
let name: String
let address: String
let batteryLevel: Int? // 0-100, nil if not available
let deviceType: BluetoothAudioDeviceType
}
enum BluetoothAudioDeviceType {
case airpods
case airpodsPro
case airpodsMax
case beats
case headphones
case speaker
case generic
var sfSymbol: String {
switch self {
case .airpods:
return "airpods"
case .airpodsPro:
return "airpodspro"
case .airpodsMax:
return "airpodsmax"
case .beats:
return "beats.headphones"
case .headphones:
return "headphones"
case .speaker:
return "hifispeaker.fill"
case .generic:
return "bluetooth.circle.fill"
}
}
var displayName: String {
switch self {
case .airpods: return "AirPods"
case .airpodsPro: return "AirPods Pro"
case .airpodsMax: return "AirPods Max"
case .beats: return "Beats"
case .headphones: return "Headphones"
case .speaker: return "Speaker"
case .generic: return "Bluetooth Device"
}
}
}
// MARK: - Notification Name Constants
private let IOBluetoothDeviceConnectionNotification = "IOBluetoothDeviceConnectionNotification"
private let IOBluetoothDeviceDisconnectionNotification = "IOBluetoothDeviceDisconnectionNotification"

View file

@ -0,0 +1,445 @@
//
// DoNotDisturbManager.swift
// DynamicIsland
//
// Created for Do Not Disturb / Focus Mode detection
// Monitors macOS Focus Mode state changes via distributed notifications
//
import Foundation
import Combine
import AppKit
import Defaults
import SwiftUI
import UserNotifications // For notification permission checks
import ApplicationServices // For AXIsProcessTrusted
/// Manages detection and state tracking for macOS Focus Mode (Do Not Disturb)
class DoNotDisturbManager: ObservableObject {
static let shared = DoNotDisturbManager()
// MARK: - Published Properties
@Published var isDoNotDisturbActive: Bool = false
@Published var currentFocusModeName: String = ""
@Published var currentFocusModeIdentifier: String = ""
// MARK: - Private Properties
private var observers: [NSObjectProtocol] = []
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
private init() {
print("🔔 [DoNotDisturbManager] Initializing...")
checkPermissions()
setupFocusModeObservers()
checkInitialState()
}
deinit {
cleanup()
}
// MARK: - Permission Checks
/// Checks if the app has necessary permissions for notification observation
private func checkPermissions() {
print("🔔 [DoNotDisturbManager] ==========================================")
print("🔔 [DoNotDisturbManager] 🔐 CHECKING PERMISSIONS & CAPABILITIES")
print("🔔 [DoNotDisturbManager] ==========================================")
// Check if running in sandbox
let isSandboxed = ProcessInfo.processInfo.environment["APP_SANDBOX_CONTAINER_ID"] != nil
print(" 📦 Sandboxed: \(isSandboxed ? "YES ⚠️" : "NO ✅")")
// Check notification center permissions
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.getNotificationSettings { settings in
print(" 🔔 Notification Authorization: \(settings.authorizationStatus.rawValue)")
print(" - .notDetermined = 0")
print(" - .denied = 1")
print(" - .authorized = 2")
print(" - .provisional = 3")
print(" - .ephemeral = 4")
}
// Check accessibility permissions
let trusted = AXIsProcessTrusted()
print(" ♿️ Accessibility Trusted: \(trusted ? "YES ✅" : "NO ⚠️")")
if !trusted {
print(" ⚠️ App needs Accessibility permission!")
print(" ⚠️ Go to: System Settings > Privacy & Security > Accessibility")
}
// Check entitlements
print(" 📝 Checking Entitlements...")
if let entitlements = Bundle.main.object(forInfoDictionaryKey: "Entitlements") as? [String: Any] {
print(" Entitlements found: \(entitlements.keys)")
} else {
print(" ⚠️ No entitlements in Info.plist")
}
// Test if we can observe notifications at all
print(" 🧪 Testing notification observation capability...")
testNotificationObservation()
print("🔔 [DoNotDisturbManager] ==========================================")
}
/// Test if notification observation is working at all
private func testNotificationObservation() {
// Test 1: Local NotificationCenter
let testObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name("TEST_NOTIFICATION"),
object: nil,
queue: .main,
using: { notification in
print(" ✅ Local NotificationCenter works!")
}
)
NotificationCenter.default.post(name: NSNotification.Name("TEST_NOTIFICATION"), object: nil)
NotificationCenter.default.removeObserver(testObserver)
// Test 2: Distributed NotificationCenter (this is what we need)
let dnc = DistributedNotificationCenter.default()
print(" 🌐 DistributedNotificationCenter instance: \(dnc)")
print(" 🌐 Attempting to post test distributed notification...")
let testDistributedObserver = dnc.addObserver(
forName: NSNotification.Name("com.test.distributed.notification"),
object: nil,
queue: .main,
using: { notification in
print(" ✅ Distributed NotificationCenter observation works!")
}
)
// Post a test distributed notification
dnc.postNotificationName(
NSNotification.Name("com.test.distributed.notification"),
object: nil,
userInfo: ["test": "data"],
deliverImmediately: true
)
// Wait a bit then remove observer
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
dnc.removeObserver(testDistributedObserver)
print(" 🧹 Test observer cleaned up")
}
}
// MARK: - Setup Methods
/// Sets up distributed notification observers for Focus Mode changes
private func setupFocusModeObservers() {
print("🔔 [DoNotDisturbManager] ==========================================")
print("🔔 [DoNotDisturbManager] 🚨 SETTING UP NOTIFICATION OBSERVERS 🚨")
print("🔔 [DoNotDisturbManager] ==========================================")
// Method 1: Darwin Notifications (low-level, no permissions needed)
setupDarwinNotificationObserver()
// Method 2: Distributed Notifications (system-wide)
setupDistributedNotificationObserver()
// Method 3: NSWorkspace Notifications (local)
setupNSWorkspaceNotificationObserver()
// Method 4: Polling as fallback
startPollingForDND()
print("🔔 [DoNotDisturbManager] ✅ All observers registered")
print("🔔 [DoNotDisturbManager] Please toggle Do Not Disturb now...")
print("🔔 [DoNotDisturbManager] ==========================================")
}
private func setupDarwinNotificationObserver() {
print("🔔 [DoNotDisturbManager] Setting up DARWIN notification observer...")
// Darwin notifications - these are low-level and work without special permissions
let darwinNotificationNames = [
"com.apple.controlcenter.modeChanged",
"com.apple.donotdisturb.changed",
"com.apple.donotdisturb.state",
"com.apple.springboard.donotdisturb"
]
let center = CFNotificationCenterGetDarwinNotifyCenter()
for notificationName in darwinNotificationNames {
CFNotificationCenterAddObserver(
center,
Unmanaged.passUnretained(self).toOpaque(),
{ (center, observer, name, object, userInfo) in
guard let name = name else { return }
let notificationName = name.rawValue as String
print("🎯 [DARWIN NOTIFICATION] \(notificationName)")
// Get the manager instance
if let observer = observer {
let manager = Unmanaged<DoNotDisturbManager>.fromOpaque(observer).takeUnretainedValue()
manager.handleDarwinNotification(notificationName)
}
},
notificationName as CFString,
nil,
.deliverImmediately
)
print(" ✅ Darwin observer registered for: \(notificationName)")
}
}
private func handleDarwinNotification(_ name: String) {
DispatchQueue.main.async { [weak self] in
print("🔔🔔🔔 [DARWIN] Focus Mode notification received: \(name)")
// Toggle DND state
self?.isDoNotDisturbActive.toggle()
self?.showFocusModeActivatedHUD()
}
}
private func setupDistributedNotificationObserver() {
let dnc = DistributedNotificationCenter.default()
print("🔔 [DoNotDisturbManager] Setting up DISTRIBUTED notification observer...")
// Observer for ANY AND ALL distributed notifications (for discovery)
let allNotificationsObserver = dnc.addObserver(
forName: nil, // nil = listen to EVERYTHING
object: nil,
queue: .main,
using: { [weak self] notification in
let notificationName = notification.name.rawValue
// PRINT EVERY SINGLE NOTIFICATION (no filtering)
print("📢 [DISTRIBUTED] \(notificationName)")
// Also check if it looks Focus Mode related
if notificationName.lowercased().contains("focus") ||
notificationName.lowercased().contains("dnd") ||
notificationName.lowercased().contains("disturb") ||
notificationName.lowercased().contains("mode") ||
notificationName.contains("com.apple.controlcenter") ||
notificationName.contains("com.apple.donotdisturb") {
print("🔔🔔🔔 [DoNotDisturbManager] ⚡️⚡️⚡️ POTENTIAL FOCUS MODE NOTIFICATION:")
print(" Name: \(notificationName)")
print(" Object: \(String(describing: notification.object))")
print(" UserInfo Keys: \(notification.userInfo?.keys.map { String(describing: $0) } ?? [])")
print(" UserInfo: \(notification.userInfo ?? [:])")
print(" ===============================================")
self?.handleFocusModeNotification(notification)
}
}
)
observers.append(allNotificationsObserver)
print(" ✅ Distributed notification observer registered")
}
private func setupNSWorkspaceNotificationObserver() {
let nc = NSWorkspace.shared.notificationCenter
print("🔔 [DoNotDisturbManager] Setting up NSWORKSPACE notification observer...")
// Try to observe workspace notifications
let workspaceObserver = nc.addObserver(
forName: nil, // Listen to all workspace notifications
object: nil,
queue: .main,
using: { notification in
let notificationName = notification.name.rawValue
// Print all workspace notifications
print("🏢 [NSWORKSPACE] \(notificationName)")
// Check for Focus Mode related
if notificationName.lowercased().contains("focus") ||
notificationName.lowercased().contains("dnd") ||
notificationName.lowercased().contains("disturb") {
print("🔔🔔🔔 [WORKSPACE FOCUS NOTIFICATION]")
print(" Name: \(notificationName)")
print(" UserInfo: \(notification.userInfo ?? [:])")
}
}
)
observers.append(workspaceObserver)
print(" ✅ NSWorkspace notification observer registered")
}
private func startPollingForDND() {
print("🔔 [DoNotDisturbManager] Starting polling fallback (every 2 seconds)...")
// Poll every 2 seconds to check for DND state changes
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.checkDNDStateViaDefaults()
}
print(" ✅ Polling timer started")
}
private func checkDNDStateViaDefaults() {
// Try to read DND state from UserDefaults or system preferences
// This is a fallback detection method
let cfPrefs = CFPreferencesCopyAppValue("dndStart" as CFString, "com.apple.controlcenter" as CFString)
if let prefs = cfPrefs {
print("🔍 [POLLING] Found controlcenter prefs: \(prefs)")
}
// Try reading notification center settings
let ncPrefs = CFPreferencesCopyAppValue("doNotDisturb" as CFString, "com.apple.notificationcenterui" as CFString)
if let nc = ncPrefs {
print("🔍 [POLLING] Found notification center prefs: \(nc)")
}
}
/// Checks initial Focus Mode state on app launch
private func checkInitialState() {
// TODO: Find a way to check current Focus Mode state
// Options to explore:
// 1. NSWorkspace properties (if any exist)
// 2. Private APIs
// 3. AppleScript (last resort)
print("🔔 [DoNotDisturbManager] Checking initial Focus Mode state...")
print(" Note: Initial state detection not yet implemented")
}
// MARK: - Notification Handling
/// Handles incoming Focus Mode notifications
private func handleFocusModeNotification(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Parse notification payload
let userInfo = notification.userInfo ?? [:]
// Try to extract Focus Mode state and identifier
// Note: Actual keys depend on the notification structure (to be discovered)
if let stateString = userInfo["state"] as? String {
self.isDoNotDisturbActive = (stateString == "enabled" || stateString == "on" || stateString == "active")
} else if let enabled = userInfo["enabled"] as? Bool {
self.isDoNotDisturbActive = enabled
} else if let active = userInfo["active"] as? Bool {
self.isDoNotDisturbActive = active
}
// Try to extract mode name
if let modeName = userInfo["name"] as? String {
self.currentFocusModeName = modeName
} else if let mode = userInfo["mode"] as? String {
self.currentFocusModeName = mode
}
// Try to extract mode identifier
if let identifier = userInfo["identifier"] as? String {
self.currentFocusModeIdentifier = identifier
}
print("🔔 [DoNotDisturbManager] State updated:")
print(" Active: \(self.isDoNotDisturbActive)")
print(" Mode Name: \(self.currentFocusModeName.isEmpty ? "N/A" : self.currentFocusModeName)")
print(" Identifier: \(self.currentFocusModeIdentifier.isEmpty ? "N/A" : self.currentFocusModeIdentifier)")
// Trigger HUD display
if self.isDoNotDisturbActive {
self.showFocusModeActivatedHUD()
} else {
self.showFocusModeDeactivatedHUD()
}
}
}
// MARK: - HUD Display Methods
/// Shows HUD when Focus Mode is activated
private func showFocusModeActivatedHUD() {
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode ACTIVATED HUD")
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 1)
}
/// Shows HUD when Focus Mode is deactivated
private func showFocusModeDeactivatedHUD() {
print("🔔 [DoNotDisturbManager] 📱 Showing Focus Mode DEACTIVATED HUD")
// TODO: Integrate with DynamicIslandViewCoordinator to show HUD
// coordinator.toggleSneakPeek(status: true, type: .doNotDisturb, value: 0)
}
// MARK: - Public Methods
/// Manually refresh Focus Mode state
func refreshState() {
print("🔔 [DoNotDisturbManager] Manual state refresh requested")
checkInitialState()
}
// MARK: - Cleanup
private func cleanup() {
print("🔔 [DoNotDisturbManager] Cleaning up observers...")
// Remove Darwin notification observers
let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterRemoveEveryObserver(center, Unmanaged.passUnretained(self).toOpaque())
// Remove other observers
let dnc = DistributedNotificationCenter.default()
for observer in observers {
dnc.removeObserver(observer)
}
observers.removeAll()
cancellables.removeAll()
}
}
// MARK: - Focus Mode Types
enum FocusModeType: String {
case doNotDisturb = "com.apple.donotdisturb.mode"
case work = "com.apple.focus.work"
case personal = "com.apple.focus.personal"
case sleep = "com.apple.focus.sleep"
case driving = "com.apple.focus.driving"
case fitness = "com.apple.focus.fitness"
case gaming = "com.apple.focus.gaming"
case mindfulness = "com.apple.focus.mindfulness"
case reading = "com.apple.focus.reading"
case custom = "com.apple.focus.custom"
case unknown = ""
var displayName: String {
switch self {
case .doNotDisturb: return "Do Not Disturb"
case .work: return "Work"
case .personal: return "Personal"
case .sleep: return "Sleep"
case .driving: return "Driving"
case .fitness: return "Fitness"
case .gaming: return "Gaming"
case .mindfulness: return "Mindfulness"
case .reading: return "Reading"
case .custom: return "Focus"
case .unknown: return "Focus Mode"
}
}
var sfSymbol: String {
switch self {
case .doNotDisturb: return "moon.fill"
case .work: return "briefcase.fill"
case .personal: return "person.fill"
case .sleep: return "bed.double.fill"
case .driving: return "car.fill"
case .fitness: return "figure.run"
case .gaming: return "gamecontroller.fill"
case .mindfulness: return "brain.head.profile"
case .reading: return "book.fill"
case .custom: return "app.badge"
case .unknown: return "moon.zzz.fill"
}
}
}

View file

@ -298,6 +298,12 @@ extension Defaults.Keys {
// MARK: Media Controller // MARK: Media Controller
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController) static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
// MARK: Bluetooth Audio Devices
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
static let useColorCodedBatteryDisplay = Key<Bool>("useColorCodedBatteryDisplay", default: true)
static let useColorCodedVolumeDisplay = Key<Bool>("useColorCodedVolumeDisplay", default: false)
static let useSmoothColorGradient = Key<Bool>("useSmoothColorGradient", default: true)
// MARK: Stats Feature // MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false) static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true) static let autoStartStatsMonitoring = Key<Bool>("autoStartStatsMonitoring", default: true)