refactor(ui): Split PodCardComponents into per-component files

Move each composable from PodCardComponents.kt into its own file under a new cards/components/ subpackage. Add previews to each file. Also add getBatteryIcon() for Compose Material Icon battery levels used in the popup.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 04c23ecf63
commit 34c23751b3
14 changed files with 673 additions and 471 deletions
@@ -43,6 +43,13 @@ 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.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.BatteryCapsule
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.main.ui.overview.cards.components.StatusChipRow
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@@ -1,468 +0,0 @@
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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.KeyboardVoice
import androidx.compose.material.icons.twotone.LinkOff
import androidx.compose.material.icons.twotone.Lock
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material.icons.twotone.Stars
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.monitor.core.BleKeyState
import eu.darken.capod.monitor.core.ConnectionState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
private val CapsuleShape = RoundedCornerShape(6.dp)
@Composable
fun BatteryCapsule(
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val animatedFraction by animateFloatAsState(
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
Box(
modifier = modifier
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped >= 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction = animatedFraction)
.clip(CapsuleShape)
.background(barColor),
)
}
}
}
@Composable
fun StatusChip(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusChipRow(
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
chargingLabel: String,
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isCharging) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
)
}
if (showMicrophone && isMicrophone) {
StatusChip(
icon = Icons.TwoTone.KeyboardVoice,
label = microphoneLabel,
)
}
if (showEarDetection && isInEar) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = inEarLabel,
)
}
}
}
@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 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(
state: ConnectionState,
modifier: Modifier = Modifier,
) {
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
if (state.hasBleData) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = stringResource(R.string.signal_badge_ble_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState != BleKeyState.NONE) {
Icon(
imageVector = Icons.Outlined.Key,
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
Icon(
imageVector = Icons.TwoTone.Lock,
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.isAapConnected) {
Icon(
imageVector = Icons.TwoTone.Bluetooth,
contentDescription = stringResource(R.string.signal_badge_aap_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
fun DebugSection(
rawDataHex: List<String>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
fun AncModeSelector(
modifier: Modifier = Modifier,
currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
pendingMode: AapSetting.AncMode.Value? = null,
enabled: Boolean = true,
) {
val displayMode = pendingMode ?: currentMode
Box(modifier = modifier.fillMaxWidth()) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == displayMode,
onClick = { onModeSelected(mode) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
SegmentedButtonDefaults.colors()
},
label = {
Text(
text = when (mode) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
@Composable
fun ConversationAwarenessToggle(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.conversation_awareness_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
isAapConnected = true,
rssiQuality = 1f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0.7f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_ONLY,
isAapConnected = false,
rssiQuality = 0.5f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = true,
rssiQuality = 0f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0f,
),
)
}
@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)
}
}
@@ -43,6 +43,11 @@ 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.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.DebugSection
import eu.darken.capod.main.ui.overview.cards.components.DeviceConnectionBadge
import eu.darken.capod.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.main.ui.overview.cards.components.StatusChip
import eu.darken.capod.common.SystemTimeSource
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@@ -17,6 +17,7 @@ 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.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
@@ -0,0 +1,91 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@Composable
fun AncModeSelector(
modifier: Modifier = Modifier,
currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
pendingMode: AapSetting.AncMode.Value? = null,
enabled: Boolean = true,
) {
val displayMode = pendingMode ?: currentMode
Box(modifier = modifier.fillMaxWidth()) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == displayMode,
onClick = { onModeSelected(mode) },
enabled = enabled,
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
colors = if (pendingMode != null && mode == displayMode) {
SegmentedButtonDefaults.colors(
activeContainerColor = MaterialTheme.colorScheme.secondaryContainer,
activeContentColor = MaterialTheme.colorScheme.onSecondaryContainer,
)
} else {
SegmentedButtonDefaults.colors()
},
label = {
Text(
text = when (mode) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
@Preview2
@Composable
private fun AncModeSelectorPreview() = PreviewWrapper {
AncModeSelector(
currentMode = AapSetting.AncMode.Value.ON,
supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
AapSetting.AncMode.Value.ADAPTIVE,
),
onModeSelected = {},
)
}
@Preview2
@Composable
private fun AncModeSelectorPendingPreview() = PreviewWrapper {
AncModeSelector(
currentMode = AapSetting.AncMode.Value.ON,
supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.TRANSPARENCY,
),
onModeSelected = {},
pendingMode = AapSetting.AncMode.Value.TRANSPARENCY,
)
}
@@ -0,0 +1,76 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
private val CapsuleShape = RoundedCornerShape(6.dp)
@Composable
fun BatteryCapsule(
percent: Float,
modifier: Modifier = Modifier,
) {
val clamped = if (percent >= 0f) percent.coerceIn(0f, 1f) else -1f
val animatedFraction by animateFloatAsState(
targetValue = if (clamped >= 0f) clamped else 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped < 0f -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
Box(
modifier = modifier
.clip(CapsuleShape)
.background(MaterialTheme.colorScheme.surfaceVariant),
) {
if (clamped >= 0f) {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(fraction = animatedFraction)
.clip(CapsuleShape)
.background(barColor),
)
}
}
}
@Preview2
@Composable
private fun BatteryCapsuleFullPreview() = PreviewWrapper {
BatteryCapsule(percent = 1.0f, modifier = Modifier.width(120.dp).height(8.dp))
}
@Preview2
@Composable
private fun BatteryCapsuleLowPreview() = PreviewWrapper {
BatteryCapsule(percent = 0.10f, modifier = Modifier.width(120.dp).height(8.dp))
}
@Preview2
@Composable
private fun BatteryCapsuleUnknownPreview() = PreviewWrapper {
BatteryCapsule(percent = -1f, modifier = Modifier.width(120.dp).height(8.dp))
}
@@ -0,0 +1,47 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import eu.darken.capod.R
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun ConversationAwarenessToggle(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.conversation_awareness_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@Preview2
@Composable
private fun ConversationAwarenessToggleEnabledPreview() = PreviewWrapper {
ConversationAwarenessToggle(enabled = true, onToggle = {})
}
@Preview2
@Composable
private fun ConversationAwarenessToggleDisabledPreview() = PreviewWrapper {
ConversationAwarenessToggle(enabled = false, onToggle = {})
}
@@ -0,0 +1,38 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun DebugSection(
rawDataHex: List<String>,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview2
@Composable
private fun DebugSectionPreview() = PreviewWrapper {
DebugSection(rawDataHex = listOf("07 19 01 0E 20 75 AA B5 31 00 00 30 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00"))
}
@@ -0,0 +1,142 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.Lock
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
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.monitor.core.BleKeyState
import eu.darken.capod.monitor.core.ConnectionState
@Composable
fun DeviceConnectionBadge(
state: ConnectionState,
modifier: Modifier = Modifier,
) {
if (!state.hasBleData && state.bleKeyState == BleKeyState.NONE && !state.isAapConnected) return
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
) {
if (state.hasBleData) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = stringResource(R.string.signal_badge_ble_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState != BleKeyState.NONE) {
Icon(
imageVector = Icons.Outlined.Key,
contentDescription = stringResource(R.string.signal_badge_key_irk_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.bleKeyState == BleKeyState.IRK_AND_ENCRYPTED) {
Icon(
imageVector = Icons.TwoTone.Lock,
contentDescription = stringResource(R.string.signal_badge_key_encrypted_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (state.isAapConnected) {
Icon(
imageVector = Icons.TwoTone.Bluetooth,
contentDescription = stringResource(R.string.signal_badge_aap_cd),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAllIconsPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_AND_ENCRYPTED,
isAapConnected = true,
rssiQuality = 1f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0.7f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeBleIrkPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = true,
bleKeyState = BleKeyState.IRK_ONLY,
isAapConnected = false,
rssiQuality = 0.5f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeAapOnlyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = true,
rssiQuality = 0f,
),
)
}
@Preview2
@Composable
private fun DeviceConnectionBadgeEmptyPreview() = PreviewWrapper {
DeviceConnectionBadge(
state = ConnectionState(
hasBleData = false,
bleKeyState = BleKeyState.NONE,
isAapConnected = false,
rssiQuality = 0f,
),
)
}
@@ -0,0 +1,119 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.LinkOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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
@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 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,
)
}
}
}
@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)
}
}
@@ -0,0 +1,118 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
@Composable
fun StatusChip(
icon: ImageVector,
label: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun StatusChipRow(
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
chargingLabel: String,
inEarLabel: String,
microphoneLabel: String,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (isCharging) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = chargingLabel,
)
}
if (showMicrophone && isMicrophone) {
StatusChip(
icon = Icons.TwoTone.KeyboardVoice,
label = microphoneLabel,
)
}
if (showEarDetection && isInEar) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = inEarLabel,
)
}
}
}
@Preview2
@Composable
private fun StatusChipChargingPreview() = PreviewWrapper {
StatusChip(icon = Icons.TwoTone.BatteryChargingFull, label = "Charging")
}
@Preview2
@Composable
private fun StatusChipRowAllPreview() = PreviewWrapper {
StatusChipRow(
isCharging = true,
isInEar = true,
showEarDetection = true,
isMicrophone = true,
showMicrophone = true,
chargingLabel = "Charging",
inEarLabel = "In Ear",
microphoneLabel = "Mic",
)
}
@@ -3,6 +3,17 @@ package eu.darken.capod.pods.core.apple.ble
import android.content.Context
import android.icu.text.RelativeDateTimeFormatter
import androidx.annotation.DrawableRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Battery0Bar
import androidx.compose.material.icons.twotone.Battery1Bar
import androidx.compose.material.icons.twotone.Battery2Bar
import androidx.compose.material.icons.twotone.Battery3Bar
import androidx.compose.material.icons.twotone.Battery4Bar
import androidx.compose.material.icons.twotone.Battery5Bar
import androidx.compose.material.icons.twotone.Battery6Bar
import androidx.compose.material.icons.twotone.BatteryFull
import androidx.compose.material.icons.automirrored.twotone.BatteryUnknown
import androidx.compose.ui.graphics.vector.ImageVector
import eu.darken.capod.R
import java.time.Duration
import java.time.Instant
@@ -31,6 +42,18 @@ fun getBatteryDrawable(percent: Float?): Int = when {
else -> R.drawable.ic_baseline_battery_0_bar_24
}
fun getBatteryIcon(percent: Float?): ImageVector = when {
percent == null -> Icons.AutoMirrored.TwoTone.BatteryUnknown
percent > 0.95f -> Icons.TwoTone.BatteryFull
percent > 0.80f -> Icons.TwoTone.Battery6Bar
percent > 0.65f -> Icons.TwoTone.Battery5Bar
percent > 0.50f -> Icons.TwoTone.Battery4Bar
percent > 0.35f -> Icons.TwoTone.Battery3Bar
percent > 0.20f -> Icons.TwoTone.Battery2Bar
percent > 0.05f -> Icons.TwoTone.Battery1Bar
else -> Icons.TwoTone.Battery0Bar
}
fun BlePodSnapshot.lastSeenFormatted(now: Instant): String {
val formatter = RelativeDateTimeFormatter.getInstance()
val duration = Duration.between(seenLastAt, now)
@@ -34,11 +34,11 @@ 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.main.ui.overview.cards.components.SignalIndicator
import eu.darken.capod.monitor.core.PodDevice
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
import eu.darken.capod.pods.core.apple.ble.getBatteryIcon
import eu.darken.capod.pods.core.apple.ble.toBatteryFloat
import eu.darken.capod.pods.core.apple.ble.toBatteryOrNull
@@ -184,7 +184,7 @@ private fun BatteryColumn(
)
} else {
Icon(
painter = painterResource(getBatteryDrawable(nullablePercent)),
imageVector = getBatteryIcon(nullablePercent),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
@@ -1,5 +1,8 @@
package eu.darken.capod.main.ui.overview.cards
import eu.darken.capod.main.ui.overview.cards.components.SignalLevel
import eu.darken.capod.main.ui.overview.cards.components.signalLevelOf
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest