diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 32f0179d..5753869e 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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), ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index 68df26f7..f5c5ef08 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -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) + } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index 40fd2370..798111c6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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), ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt index 9e40b5d0..6544168c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/UnknownPodDeviceCard.kt @@ -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), ) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt index eed95c24..355f49d8 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitorExtensions.kt @@ -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> = devices .map { devices -> devices.filter { it.profileId != null } } fun DeviceMonitor.primaryDevice(): Flow = 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() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt index e8d5973d..e624f0f0 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/ble/PodDeviceExtensions.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt index d6ae9605..a7353693 100644 --- a/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt +++ b/app/src/main/java/eu/darken/capod/reaction/ui/popup/PopUpContent.kt @@ -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)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4f954f70..c2c5e087 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -273,6 +273,8 @@ Identity verified Encrypted connection Direct connection active + Signal strength: %1$d of 4 bars + Signal: disconnected Yours Being worn Not being worn diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/cards/SignalLevelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/SignalLevelTest.kt new file mode 100644 index 00000000..cd18ce1b --- /dev/null +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/cards/SignalLevelTest.kt @@ -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 + } +}