From baf7dc0f7d7b85b25de704761d7b1882519b9e7a Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 14 Apr 2026 19:34:07 +0200 Subject: [PATCH] feat(overview): Add dynamic Bluetooth status icon to toolbar --- .../capod/screenshots/ScreenshotContent.kt | 1 + .../capod/main/ui/overview/OverviewScreen.kt | 40 +++++++++++++++++++ .../main/ui/overview/OverviewViewModel.kt | 12 ++++++ .../capod/monitor/core/DeviceMonitor.kt | 12 +++++- .../eu/darken/capod/monitor/core/PodDevice.kt | 2 + app/src/main/res/values/strings.xml | 3 ++ .../capod/monitor/core/DeviceMonitorTest.kt | 9 +++++ 7 files changed, 78 insertions(+), 1 deletion(-) diff --git a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt index cf98f7d6..0a372d18 100644 --- a/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt +++ b/app/src/debug/java/eu/darken/capod/screenshots/ScreenshotContent.kt @@ -57,6 +57,7 @@ internal fun DashboardContent() = PreviewWrapper { showUnmatchedDevices = false, ), onRequestPermission = {}, + onBluetoothSettings = {}, onManageDevices = {}, onSettings = {}, onUpgrade = {}, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 60623e60..32755b8b 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -10,11 +10,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.twotone.Bluetooth +import androidx.compose.material.icons.twotone.BluetoothConnected import androidx.compose.material.icons.twotone.DevicesOther import androidx.compose.material.icons.twotone.Settings import androidx.compose.material.icons.twotone.Stars import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -126,6 +129,13 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { OverviewScreen( state = currentState, onRequestPermission = { vm.requestPermission(it) }, + onBluetoothSettings = { + try { + context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) + } catch (_: SecurityException) { + // Some devices require BT permission to open BT settings + } + }, onManageDevices = { vm.goToDeviceManager() }, onSettings = { vm.goToSettings() }, onUpgrade = { vm.onUpgrade() }, @@ -140,6 +150,7 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) { fun OverviewScreen( state: OverviewViewModel.State, onRequestPermission: (Permission) -> Unit, + onBluetoothSettings: () -> Unit, onManageDevices: () -> Unit, onSettings: () -> Unit, onUpgrade: () -> Unit, @@ -155,6 +166,31 @@ fun OverviewScreen( ToolbarTitle(upgradeInfo = state.upgradeInfo) }, actions = { + // Bluetooth status icon + val btState = state.bluetoothIconState + if (btState != OverviewViewModel.BluetoothIconState.HIDDEN) { + IconButton(onClick = onBluetoothSettings) { + Icon( + imageVector = when (btState) { + OverviewViewModel.BluetoothIconState.CONNECTED -> Icons.TwoTone.BluetoothConnected + else -> Icons.TwoTone.Bluetooth + }, + contentDescription = stringResource( + when (btState) { + OverviewViewModel.BluetoothIconState.DISABLED -> R.string.overview_bluetooth_disabled_icon_cd + OverviewViewModel.BluetoothIconState.CONNECTED -> R.string.overview_bluetooth_connected_icon_cd + else -> R.string.overview_bluetooth_nearby_icon_cd + } + ), + tint = when (btState) { + OverviewViewModel.BluetoothIconState.DISABLED -> MaterialTheme.colorScheme.error + OverviewViewModel.BluetoothIconState.CONNECTED -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + ) + } + } + // Upgrade/donate button based on type and pro status val info = state.upgradeInfo when { @@ -345,6 +381,7 @@ private fun OverviewScreenWithDevicesPreview() = PreviewWrapper { showUnmatchedDevices = false, ), onRequestPermission = {}, + onBluetoothSettings = {}, onManageDevices = {}, onSettings = {}, onUpgrade = {}, @@ -367,6 +404,7 @@ private fun OverviewScreenEmptyPreview() = PreviewWrapper { showUnmatchedDevices = false, ), onRequestPermission = {}, + onBluetoothSettings = {}, onManageDevices = {}, onSettings = {}, onUpgrade = {}, @@ -389,6 +427,7 @@ private fun OverviewScreenNoProfilesPreview() = PreviewWrapper { showUnmatchedDevices = false, ), onRequestPermission = {}, + onBluetoothSettings = {}, onManageDevices = {}, onSettings = {}, onUpgrade = {}, @@ -411,6 +450,7 @@ private fun OverviewScreenBluetoothOffPreview() = PreviewWrapper { showUnmatchedDevices = false, ), onRequestPermission = {}, + onBluetoothSettings = {}, onManageDevices = {}, onSettings = {}, onUpgrade = {}, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 182eeb18..4c7ab57e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -127,6 +127,8 @@ class OverviewViewModel @Inject constructor( ) }.asLiveState() + enum class BluetoothIconState { HIDDEN, DISABLED, NEARBY, CONNECTED } + data class State( val now: Instant, val permissions: Set, @@ -143,6 +145,16 @@ class OverviewViewModel @Inject constructor( get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT) val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size val unmatchedDevices: List get() = devices.filter { it.profileId == null } + + val bluetoothIconState: BluetoothIconState + get() = when { + isScanBlocked -> BluetoothIconState.HIDDEN + profiles.isEmpty() -> BluetoothIconState.HIDDEN + !isBluetoothEnabled -> BluetoothIconState.DISABLED + profiledDevices.any { it.isSystemConnected } -> BluetoothIconState.CONNECTED + profiledDevices.any { it.isLive } -> BluetoothIconState.NEARBY + else -> BluetoothIconState.HIDDEN + } } fun onPermissionResult() { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index d8c55fb1..edcf1b43 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -1,6 +1,7 @@ package eu.darken.capod.monitor.core import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.common.TimeSource import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE @@ -24,6 +25,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import javax.inject.Inject import javax.inject.Singleton @@ -40,6 +42,7 @@ class DeviceMonitor @Inject constructor( @AppScope private val appScope: CoroutineScope, private val blePodMonitor: BlePodMonitor, private val aapManager: AapConnectionManager, + private val bluetoothManager: BluetoothManager2, private val deviceStateCache: DeviceStateCache, private val profilesRepo: DeviceProfilesRepo, private val aapLifecycleManager: AapLifecycleManager, @@ -52,9 +55,11 @@ class DeviceMonitor @Inject constructor( private val liveState: Flow = combine( blePodMonitor.devices, aapManager.allStates, + bluetoothManager.connectedDevices.onStart { emit(emptyList()) }, profilesRepo.profiles, - ) { pods, aapStates, profiles -> + ) { pods, aapStates, connectedBtDevices, profiles -> val profilesById = profiles.associateBy { it.id } + val connectedAddresses = connectedBtDevices.mapTo(mutableSetOf()) { it.address } // Live devices — BLE + AAP. Cache is merged later so cache writes don't feed back here. val liveDevices = pods.map { pod -> @@ -68,6 +73,7 @@ class DeviceMonitor @Inject constructor( profileModel = profile?.model, profileKeyState = profile.toBleKeyState(), reactions = profile.toReactionConfig(), + isSystemConnected = profile?.address in connectedAddresses, ) } @@ -75,6 +81,7 @@ class DeviceMonitor @Inject constructor( liveDevices = liveDevices, profiles = profiles, aapStates = aapStates, + connectedAddresses = connectedAddresses, ) }.onEach { liveState -> persistLiveDevices(liveState.liveDevices) @@ -89,6 +96,7 @@ class DeviceMonitor @Inject constructor( } val profiles = liveState.profiles val aapStates = liveState.aapStates + val connectedAddresses = liveState.connectedAddresses // Collapse live duplicates sharing an identity-backed profile — e.g. when the legacy // signal-quality fallback misattributed ambient strangers to our profile. @@ -138,6 +146,7 @@ class DeviceMonitor @Inject constructor( profileModel = profile.model, profileKeyState = profile.toBleKeyState(), reactions = profile.toReactionConfig(), + isSystemConnected = profile.address in connectedAddresses, ) } @@ -183,6 +192,7 @@ class DeviceMonitor @Inject constructor( val liveDevices: List, val profiles: List, val aapStates: Map, + val connectedAddresses: Set, ) suspend fun getDeviceForProfile(profileId: String): PodDevice? { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index e7604cea..35658a6a 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -49,6 +49,8 @@ data class PodDevice( internal val profileKeyState: BleKeyState = BleKeyState.NONE, /** Reaction toggle snapshot from the profile. Defaults to all-off when no profile is matched. */ val reactions: ReactionConfig = ReactionConfig(), + /** True when the profile's BR/EDR address is in the system's connected Bluetooth devices. */ + val isSystemConnected: Boolean = false, ) { val model: PodModel get() = ble?.model ?: profileModel ?: cached?.model ?: PodModel.UNKNOWN /** Bonded BR/EDR address (from profile). Used for AAP commands. */ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8026e671..74a8dacd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -207,6 +207,9 @@ No device configured Configure your device to start monitoring battery levels and enable additional features. + Bluetooth disabled, open settings + Device nearby, open Bluetooth settings + Device connected, open Bluetooth settings Bluetooth is disabled Bluetooth is disabled, enable it ;) Monitoring for devices diff --git a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt index 411d6251..d590c2dc 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/DeviceMonitorTest.kt @@ -2,6 +2,7 @@ package eu.darken.capod.monitor.core import eu.darken.capod.common.TimeSource import eu.darken.capod.common.bluetooth.BluetoothAddress +import eu.darken.capod.common.bluetooth.BluetoothManager2 import eu.darken.capod.monitor.core.aap.AapLifecycleManager import eu.darken.capod.monitor.core.ble.BlePodMonitor import eu.darken.capod.monitor.core.cache.CachedDeviceState @@ -154,11 +155,15 @@ class DeviceMonitorTest : BaseTest() { every { this@mockk.profiles } returns MutableStateFlow(profiles) } val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true) + val bluetoothManager: BluetoothManager2 = mockk { + every { connectedDevices } returns MutableStateFlow(emptyList()) + } return DeviceMonitor( appScope = scope, blePodMonitor = blePodMonitor, aapManager = aapManager, + bluetoothManager = bluetoothManager, deviceStateCache = deviceStateCache, profilesRepo = profilesRepo, aapLifecycleManager = aapLifecycleManager, @@ -560,11 +565,15 @@ class DeviceMonitorTest : BaseTest() { every { profiles } returns profilesFlow } val aapLifecycleManager: AapLifecycleManager = mockk(relaxed = true) + val bluetoothManager: BluetoothManager2 = mockk { + every { connectedDevices } returns MutableStateFlow(emptyList()) + } val monitor = DeviceMonitor( appScope = backgroundScope, blePodMonitor = blePodMonitor, aapManager = aapManager, + bluetoothManager = bluetoothManager, deviceStateCache = deviceStateCache, profilesRepo = profilesRepo, aapLifecycleManager = aapLifecycleManager,