mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(overview): Add dynamic Bluetooth status icon to toolbar
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user