fix: Show signal strength as bar indicator instead of percent

This commit is contained in:
darken
2026-04-07 17:56:29 +02:00
committed by Matthias Urhahn
parent 57fc4d8e7b
commit a102b03261
9 changed files with 241 additions and 121 deletions
@@ -47,7 +47,6 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods.LidState
@@ -91,12 +90,23 @@ fun DualPodsCard(
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
SignalIndicator(
signalQuality = device.signalQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
val deviceLabel = buildString {
append(device.getLabel(context))
val podStyle = device.ble as? HasPodStyle
@@ -109,6 +119,7 @@ fun DualPodsCard(
}
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -119,12 +130,10 @@ fun DualPodsCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = Modifier.width(6.dp))
SignalBadge(
signalText = device.getSignalQuality(context),
DeviceConnectionBadge(
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
}
@@ -1,9 +1,11 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -20,14 +22,13 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.Key
import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.twotone.LinkOff
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
@@ -41,8 +42,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
@@ -159,14 +165,90 @@ fun StatusChipRow(
}
}
@VisibleForTesting
internal enum class SignalLevel { DISCONNECTED, BARS_0, BARS_1, BARS_2, BARS_3, BARS_4 }
@VisibleForTesting
internal fun signalLevelOf(signalQuality: Float, isLive: Boolean): SignalLevel {
if (!isLive) return SignalLevel.DISCONNECTED
if (!signalQuality.isFinite()) return SignalLevel.BARS_0
val q = signalQuality.coerceIn(0f, 1f)
return when {
q >= 0.80f -> SignalLevel.BARS_4
q >= 0.60f -> SignalLevel.BARS_3
q >= 0.40f -> SignalLevel.BARS_2
q >= 0.20f -> SignalLevel.BARS_1
else -> SignalLevel.BARS_0
}
}
@Composable
fun SignalBadge(
signalText: String,
bleKeyState: BleKeyState = BleKeyState.NONE,
isAapConnected: Boolean = false,
isLive: Boolean = true,
fun SignalIndicator(
signalQuality: Float,
isLive: Boolean,
modifier: Modifier = Modifier,
) {
val level = signalLevelOf(signalQuality, isLive)
val description = when (level) {
SignalLevel.DISCONNECTED -> stringResource(R.string.signal_indicator_disconnected_cd)
SignalLevel.BARS_0 -> stringResource(R.string.signal_indicator_bars_cd, 0)
SignalLevel.BARS_1 -> stringResource(R.string.signal_indicator_bars_cd, 1)
SignalLevel.BARS_2 -> stringResource(R.string.signal_indicator_bars_cd, 2)
SignalLevel.BARS_3 -> stringResource(R.string.signal_indicator_bars_cd, 3)
SignalLevel.BARS_4 -> stringResource(R.string.signal_indicator_bars_cd, 4)
}
val tint = MaterialTheme.colorScheme.onSurfaceVariant
val rootModifier = modifier
.size(12.dp)
.semantics(mergeDescendants = true) { contentDescription = description }
if (level == SignalLevel.DISCONNECTED) {
Icon(
imageVector = Icons.TwoTone.LinkOff,
contentDescription = null,
modifier = rootModifier,
tint = tint,
)
return
}
val activeBars = when (level) {
SignalLevel.BARS_0 -> 0
SignalLevel.BARS_1 -> 1
SignalLevel.BARS_2 -> 2
SignalLevel.BARS_3 -> 3
SignalLevel.BARS_4 -> 4
SignalLevel.DISCONNECTED -> 0 // unreachable; handled above
}
val inactiveColor = tint.copy(alpha = 0.3f)
Canvas(modifier = rootModifier) {
val barCount = 4
val gapWidth = size.width * 0.12f
val totalGap = gapWidth * (barCount - 1)
val barWidth = (size.width - totalGap) / barCount
val corner = CornerRadius(barWidth * 0.4f, barWidth * 0.4f)
for (i in 0 until barCount) {
val heightFraction = (i + 1).toFloat() / barCount
val barHeight = size.height * heightFraction
val x = i * (barWidth + gapWidth)
val y = size.height - barHeight
drawRoundRect(
color = if (i < activeBars) tint else inactiveColor,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = corner,
)
}
}
}
@Composable
fun DeviceConnectionBadge(
bleKeyState: BleKeyState,
isAapConnected: Boolean,
modifier: Modifier = Modifier,
) {
if (bleKeyState == BleKeyState.NONE && !isAapConnected) return
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
@@ -176,47 +258,25 @@ fun SignalBadge(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!isLive) {
if (bleKeyState != BleKeyState.NONE) {
Icon(
imageVector = Icons.TwoTone.LinkOff,
contentDescription = null,
imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = stringResource(
if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd
else R.string.signal_badge_key_irk_cd
),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
if (bleKeyState != BleKeyState.NONE) {
Icon(
imageVector = if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) Icons.TwoTone.Key else Icons.Outlined.Key,
contentDescription = stringResource(
if (bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) R.string.signal_badge_key_encrypted_cd
else R.string.signal_badge_key_irk_cd
),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
}
if (isAapConnected) {
Icon(
imageVector = Icons.TwoTone.Bluetooth,
contentDescription = stringResource(R.string.signal_badge_aap_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
}
if (isAapConnected) Spacer(modifier = Modifier.width(3.dp))
}
if (isAapConnected) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = null,
imageVector = Icons.TwoTone.Bluetooth,
contentDescription = stringResource(R.string.signal_badge_aap_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -307,18 +367,36 @@ fun ConversationAwarenessToggle(
@Preview2
@Composable
private fun SignalBadgeDefaultPreview() = PreviewWrapper {
SignalBadge(signalText = "85%")
private fun DeviceConnectionBadgeIrkPreview() = PreviewWrapper {
DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_ONLY, isAapConnected = false)
}
@Preview2
@Composable
private fun SignalBadgeIrkOnlyPreview() = PreviewWrapper {
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_ONLY)
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
DeviceConnectionBadge(bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true)
}
@Preview2
@Composable
private fun SignalBadgeAllIconsPreview() = PreviewWrapper {
SignalBadge(signalText = "85%", bleKeyState = BleKeyState.IRK_AND_ENCRYPTED, isAapConnected = true)
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
DeviceConnectionBadge(bleKeyState = BleKeyState.NONE, isAapConnected = false)
}
@Preview2
@Composable
private fun SignalIndicatorPreviewsGroup() = PreviewWrapper {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SignalIndicator(signalQuality = 0.10f, isLive = true)
SignalIndicator(signalQuality = 0.35f, isLive = true)
SignalIndicator(signalQuality = 0.55f, isLive = true)
SignalIndicator(signalQuality = 0.75f, isLive = true)
SignalIndicator(signalQuality = 0.95f, isLive = true)
SignalIndicator(signalQuality = Float.NaN, isLive = true)
SignalIndicator(signalQuality = -0.3f, isLive = true)
SignalIndicator(signalQuality = 0.85f, isLive = false)
}
}
@@ -47,7 +47,6 @@ import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.cachedBatteryFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import java.time.Instant
@@ -101,13 +100,25 @@ fun SinglePodsCard(
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = device.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
SignalIndicator(
signalQuality = device.signalQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
@@ -118,12 +129,10 @@ fun SinglePodsCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = Modifier.width(6.dp))
SignalBadge(
signalText = device.getSignalQuality(context),
DeviceConnectionBadge(
bleKeyState = device.bleKeyState,
isAapConnected = device.isAapConnected,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
}
@@ -6,12 +6,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -19,13 +14,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
@Composable
@@ -43,7 +38,7 @@ fun UnknownPodDeviceCard(
Column(
modifier = Modifier.padding(16.dp),
) {
// Header with signal in top-right
// Header: label + signal indicator (sits right after the text)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -51,20 +46,14 @@ fun UnknownPodDeviceCard(
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
text = device.getSignalQuality(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
SignalIndicator(
signalQuality = device.signalQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
@@ -1,23 +1,16 @@
package eu.darken.capod.monitor.core
import android.content.Context
import android.icu.text.RelativeDateTimeFormatter
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import java.time.Duration
import java.time.Instant
import kotlin.math.roundToInt
fun DeviceMonitor.devicesWithProfiles(): Flow<List<PodDevice>> = devices
.map { devices -> devices.filter { it.profileId != null } }
fun DeviceMonitor.primaryDevice(): Flow<PodDevice?> = devicesWithProfiles().map { it.firstOrNull() }
fun PodDevice.getSignalQuality(context: Context): String {
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}%"
}
fun PodDevice.lastSeenFormatted(now: Instant): String {
val lastAt = seenLastAt ?: return ""
val formatter = RelativeDateTimeFormatter.getInstance()
@@ -18,11 +18,6 @@ fun formatBatteryPercent(context: Context, percent: Float?): String =
percent?.let { "${(it * 100).roundToInt()}%" }
?: context.getString(R.string.general_value_not_available_label)
fun BlePodSnapshot.getSignalQuality(context: Context): String {
val multiplier = 100 * signalQuality
return "${multiplier.roundToInt()}%"
}
@DrawableRes
fun getBatteryDrawable(percent: Float?): Int = when {
percent == null -> R.drawable.ic_baseline_battery_unknown_24
@@ -12,8 +12,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.SignalCellularAlt
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -34,8 +32,8 @@ import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import eu.darken.capod.main.ui.overview.cards.SignalIndicator
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
import eu.darken.capod.pods.core.apple.ble.getBatteryDrawable
@@ -63,7 +61,7 @@ fun PopUpContent(
.padding(top = 20.dp, bottom = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Header: label + signal quality
// Header: label + signal indicator (sits right after the text)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -76,21 +74,11 @@ fun PopUpContent(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
val signalText = device.getSignalQuality(context)
if (signalText.isNotBlank()) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.TwoTone.SignalCellularAlt,
contentDescription = null,
modifier = Modifier.size(12.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = signalText,
style = MaterialTheme.typography.labelSmall,
)
}
SignalIndicator(
signalQuality = device.signalQuality,
isLive = device.isLive,
modifier = Modifier.padding(start = 6.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
+2
View File
@@ -273,6 +273,8 @@
<string name="signal_badge_key_irk_cd">Identity verified</string>
<string name="signal_badge_key_encrypted_cd">Encrypted connection</string>
<string name="signal_badge_aap_cd">Direct connection active</string>
<string name="signal_indicator_bars_cd">Signal strength: %1$d of 4 bars</string>
<string name="signal_indicator_disconnected_cd">Signal: disconnected</string>
<string name="pods_yours">Yours</string>
<string name="headset_being_worn_label">Being worn</string>
<string name="headset_not_being_worn_label">Not being worn</string>
@@ -0,0 +1,57 @@
package eu.darken.capod.main.ui.overview.cards
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class SignalLevelTest : BaseTest() {
@Test
fun `not live always returns DISCONNECTED regardless of quality`() {
signalLevelOf(signalQuality = 1.0f, isLive = false) shouldBe SignalLevel.DISCONNECTED
signalLevelOf(signalQuality = 0.5f, isLive = false) shouldBe SignalLevel.DISCONNECTED
signalLevelOf(signalQuality = 0.0f, isLive = false) shouldBe SignalLevel.DISCONNECTED
signalLevelOf(signalQuality = Float.NaN, isLive = false) shouldBe SignalLevel.DISCONNECTED
}
@Test
fun `exact boundaries map upward`() {
signalLevelOf(signalQuality = 0.20f, isLive = true) shouldBe SignalLevel.BARS_1
signalLevelOf(signalQuality = 0.40f, isLive = true) shouldBe SignalLevel.BARS_2
signalLevelOf(signalQuality = 0.60f, isLive = true) shouldBe SignalLevel.BARS_3
signalLevelOf(signalQuality = 0.80f, isLive = true) shouldBe SignalLevel.BARS_4
}
@Test
fun `just below boundaries fall into lower bucket`() {
signalLevelOf(signalQuality = 0.199f, isLive = true) shouldBe SignalLevel.BARS_0
signalLevelOf(signalQuality = 0.399f, isLive = true) shouldBe SignalLevel.BARS_1
signalLevelOf(signalQuality = 0.599f, isLive = true) shouldBe SignalLevel.BARS_2
signalLevelOf(signalQuality = 0.799f, isLive = true) shouldBe SignalLevel.BARS_3
}
@Test
fun `zero quality maps to BARS_0`() {
signalLevelOf(signalQuality = 0.0f, isLive = true) shouldBe SignalLevel.BARS_0
}
@Test
fun `full quality maps to BARS_4`() {
signalLevelOf(signalQuality = 1.0f, isLive = true) shouldBe SignalLevel.BARS_4
}
@Test
fun `non-finite live inputs fall back to BARS_0`() {
signalLevelOf(signalQuality = Float.NaN, isLive = true) shouldBe SignalLevel.BARS_0
signalLevelOf(signalQuality = Float.POSITIVE_INFINITY, isLive = true) shouldBe SignalLevel.BARS_0
signalLevelOf(signalQuality = Float.NEGATIVE_INFINITY, isLive = true) shouldBe SignalLevel.BARS_0
}
@Test
fun `out of range values are clamped`() {
signalLevelOf(signalQuality = -0.5f, isLive = true) shouldBe SignalLevel.BARS_0
signalLevelOf(signalQuality = -0.001f, isLive = true) shouldBe SignalLevel.BARS_0
signalLevelOf(signalQuality = 1.7f, isLive = true) shouldBe SignalLevel.BARS_4
signalLevelOf(signalQuality = 99f, isLive = true) shouldBe SignalLevel.BARS_4
}
}