feat(overview): Add dynamic Bluetooth status icon to toolbar

This commit is contained in:
darken
2026-04-15 06:46:54 +02:00
committed by Matthias Urhahn
parent 5d5e6670d4
commit 4b80ac7336
7 changed files with 78 additions and 1 deletions
@@ -57,6 +57,7 @@ internal fun DashboardContent() = PreviewWrapper {
showUnmatchedDevices = false,
),
onRequestPermission = {},
onBluetoothSettings = {},
onManageDevices = {},
onSettings = {},
onUpgrade = {},
@@ -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 = {},
@@ -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<Permission>,
@@ -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<PodDevice> 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() {
@@ -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<LiveMergeState> = 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<PodDevice>,
val profiles: List<DeviceProfile>,
val aapStates: Map<BluetoothAddress, AapPodState>,
val connectedAddresses: Set<BluetoothAddress>,
)
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
@@ -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. */
+3
View File
@@ -207,6 +207,9 @@
<string name="overview_nomaindevice_label">No device configured</string>
<string name="overview_nomaindevice_description">Configure your device to start monitoring battery levels and enable additional features.</string>
<string name="overview_bluetooth_disabled_icon_cd">Bluetooth disabled, open settings</string>
<string name="overview_bluetooth_nearby_icon_cd">Device nearby, open Bluetooth settings</string>
<string name="overview_bluetooth_connected_icon_cd">Device connected, open Bluetooth settings</string>
<string name="overview_bluetooth_disabled_label">Bluetooth is disabled</string>
<string name="overview_bluetooth_disabled_description">Bluetooth is disabled, enable it ;)</string>
<string name="overview_monitoring_active_label">Monitoring for devices</string>
@@ -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,