feat(ui): Modernize pod cards with circular battery gauges

Replace LinearProgressIndicator bars with circular progress rings
around pod icons for a more visual, glanceable battery display.
This commit is contained in:
darken
2026-02-25 00:17:51 +01:00
parent 119139cbc5
commit 8dd165e1c8
3 changed files with 529 additions and 245 deletions
@@ -1,8 +1,14 @@
package eu.darken.capod.main.ui.overview.cards
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
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.fillMaxWidth
@@ -10,28 +16,34 @@ 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.GridView
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.SettingsInputAntenna
import androidx.compose.material.icons.outlined.Key
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.pods.core.DualPodDevice
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.HasChargeDetectionDual
@@ -46,9 +58,6 @@ import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import java.time.Duration
import java.time.Instant
@@ -60,15 +69,17 @@ fun DualPodsCard(
) {
val context = LocalContext.current
Card(
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Header: device icon + name + type on left, signal in top-right
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -76,17 +87,18 @@ fun DualPodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(40.dp),
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Device name + key icon
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = device.meta.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (device is ApplePods && device.meta.isIRKMatch) {
Spacer(modifier = Modifier.width(6.dp))
@@ -111,34 +123,22 @@ fun DualPodsCard(
text = deviceLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Signal quality + antenna icon
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,
)
SignalBadge(signalText = device.getSignalQuality(context))
}
Spacer(modifier = Modifier.height(4.dp))
// Last seen
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// First seen (only show if > 1 minute has passed)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
@@ -147,74 +147,50 @@ fun DualPodsCard(
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// Left pod battery
BatteryRow(
label = "L",
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
// Circular battery gauges side by side
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeftPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isLeftPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isLeftPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isLeftPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
Spacer(modifier = Modifier.height(8.dp))
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
}
// Right pod battery
BatteryRow(
label = "R",
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRightPodPercent,
isCharging = (device as? HasChargeDetectionDual)?.isRightPodCharging ?: false,
isInEar = (device as? HasEarDetectionDual)?.isRightPodInEar ?: false,
showEarDetection = device is HasEarDetectionDual,
isMicrophone = (device as? HasDualMicrophone)?.isRightPodMicrophone ?: false,
showMicrophone = device is HasDualMicrophone,
)
// Case row
if (device is HasCase) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
// Case battery + lid state
if (device is HasCase) {
Spacer(modifier = Modifier.height(8.dp))
BatteryRow(
label = "C",
iconRes = device.caseIcon,
batteryPercent = device.batteryCasePercent,
isCharging = device.isCaseCharging,
isInEar = false,
showEarDetection = false,
isMicrophone = false,
showMicrophone = false,
)
// Case lid state (below case row, aligned with label text)
if (device is DualApplePods) {
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 32.dp, top = 2.dp),
) {
Icon(
imageVector = Icons.TwoTone.GridView,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
CaseRow(device = device)
}
}
}
@@ -222,7 +198,6 @@ fun DualPodsCard(
// Connection state
if (device is HasStateDetection) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = device.state.getLabel(context),
style = MaterialTheme.typography.bodySmall,
@@ -232,19 +207,162 @@ fun DualPodsCard(
// Debug info
if (showDebug) {
Spacer(modifier = Modifier.height(8.dp))
DebugSection(rawDataHex = device.rawDataHex)
}
}
}
}
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
@Composable
private fun PodGauge(
iconRes: Int,
batteryPercent: Float?,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
) {
val context = LocalContext.current
val clamped = batteryPercent?.coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Ring with icon inside
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(80.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(80.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
// Progress ring
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(80.dp),
color = ringColor,
strokeWidth = 6.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
Text(
text = device.rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
}
// Pod icon
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(36.dp),
)
}
Spacer(modifier = Modifier.height(6.dp))
// Battery percentage
Text(
text = formatBatteryPercent(context, batteryPercent),
style = MaterialTheme.typography.titleMedium,
color = if (batteryPercent != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
Spacer(modifier = Modifier.height(4.dp))
// Status chips
StatusChipRow(
isCharging = isCharging,
isInEar = isInEar,
showEarDetection = showEarDetection,
isMicrophone = isMicrophone,
showMicrophone = showMicrophone,
chargingLabel = stringResource(R.string.pods_charging_label),
inEarLabel = stringResource(R.string.pods_inear_label),
microphoneLabel = stringResource(R.string.pods_microphone_label),
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CaseRow(
device: HasCase,
) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(device.caseIcon),
contentDescription = null,
modifier = Modifier.size(28.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatBatteryPercent(context, device.batteryCasePercent),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(48.dp),
)
BatteryCapsule(
percent = device.batteryCasePercent,
modifier = Modifier
.weight(1f)
.height(8.dp),
)
Spacer(modifier = Modifier.width(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isCaseCharging) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device is DualApplePods) {
val lidState = device.caseLidState
if (lidState == LidState.OPEN || lidState == LidState.CLOSED) {
StatusChip(
icon = Icons.TwoTone.GridView,
label = when (lidState) {
LidState.OPEN -> stringResource(R.string.pods_case_status_open_label)
LidState.CLOSED -> stringResource(R.string.pods_case_status_closed_label)
else -> ""
},
)
}
}
}
}
}
@@ -285,76 +403,8 @@ private fun DualPodsCardNoCasePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.powerBeatsPro(), showDebug = false, now = Instant.now())
}
@Preview2
@Composable
private fun BatteryRow(
label: String,
iconRes: Int,
batteryPercent: Float?,
isCharging: Boolean,
isInEar: Boolean,
showEarDetection: Boolean,
isMicrophone: Boolean,
showMicrophone: Boolean,
) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$label: ${formatBatteryPercent(context, batteryPercent)}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(80.dp),
)
LinearProgressIndicator(
progress = { batteryPercent ?: 0f },
modifier = Modifier
.weight(1f)
.height(8.dp),
)
// Status icons — fixed width, always rendered, alpha toggles visibility so positions stay stable
Row(
modifier = Modifier.width(52.dp),
horizontalArrangement = Arrangement.End,
) {
Icon(
imageVector = Icons.TwoTone.BatteryChargingFull,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(if (isCharging) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
Icon(
imageVector = Icons.TwoTone.KeyboardVoice,
contentDescription = stringResource(R.string.pods_microphone_label),
modifier = Modifier
.size(16.dp)
.alpha(if (showMicrophone && isMicrophone) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
Icon(
imageVector = Icons.TwoTone.Hearing,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(if (showEarDetection && isInEar) 1f else 0f),
tint = MaterialTheme.colorScheme.primary,
)
}
}
private fun DualPodsCardMicrophonePreview() = PreviewWrapper {
DualPodsCard(device = MockPodDataProvider.airPodsGen1Wearing(), showDebug = false, now = Instant.now())
}
@@ -0,0 +1,195 @@
package eu.darken.capod.main.ui.overview.cards
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.background
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.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.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.KeyboardVoice
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.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.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
private val CapsuleShape = RoundedCornerShape(6.dp)
@Composable
fun BatteryCapsule(
percent: Float?,
modifier: Modifier = Modifier,
) {
val clamped = percent?.coerceIn(0f, 1f)
val animatedFraction by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "batteryFill",
)
val barColor = when {
clamped == null -> 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 != null) {
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,
)
}
}
}
@Composable
fun SignalBadge(
signalText: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.TwoTone.SettingsInputAntenna,
contentDescription = null,
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,
)
}
}
}
@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,
)
}
}
@@ -1,8 +1,15 @@
package eu.darken.capod.main.ui.overview.cards
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.Image
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.fillMaxWidth
@@ -10,23 +17,30 @@ 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.twotone.BatteryChargingFull
import androidx.compose.material.icons.twotone.Hearing
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
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.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
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.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.SinglePodDevice
@@ -34,12 +48,10 @@ import eu.darken.capod.pods.core.firstSeenFormatted
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.getSignalQuality
import eu.darken.capod.pods.core.lastSeenFormatted
import eu.darken.capod.common.compose.Preview2
import eu.darken.capod.common.compose.PreviewWrapper
import eu.darken.capod.common.compose.preview.MockPodDataProvider
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SinglePodsCard(
device: SinglePodDevice,
@@ -48,15 +60,31 @@ fun SinglePodsCard(
) {
val context = LocalContext.current
Card(
val clamped = device.batteryHeadsetPercent?.coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "gaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
// Header: name + device icon on left, signal on right
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
@@ -64,7 +92,7 @@ fun SinglePodsCard(
Image(
painter = painterResource(device.iconRes),
contentDescription = null,
modifier = Modifier.size(40.dp),
modifier = Modifier.size(48.dp),
)
Spacer(modifier = Modifier.width(12.dp))
@@ -73,39 +101,29 @@ fun SinglePodsCard(
Text(
text = device.meta.profile?.label ?: "?",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = device.getLabel(context),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
// Signal quality + antenna icon
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,
)
SignalBadge(signalText = device.getSignalQuality(context))
}
Spacer(modifier = Modifier.height(4.dp))
// Last seen
// Timestamps
Text(
text = stringResource(R.string.last_seen_x, device.lastSeenFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// First seen (only show if > 1 minute has passed)
if (Duration.between(device.seenFirstAt, device.seenLastAt).toMinutes() >= 1) {
Text(
text = stringResource(R.string.first_seen_x, device.firstSeenFormatted(now)),
@@ -114,64 +132,85 @@ fun SinglePodsCard(
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(16.dp))
// Battery level
Row(
verticalAlignment = Alignment.CenterVertically,
// Central gauge
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
tonalElevation = 1.dp,
) {
Text(
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(48.dp),
)
LinearProgressIndicator(
progress = { device.batteryHeadsetPercent ?: 0f },
Column(
modifier = Modifier
.weight(1f)
.height(8.dp),
)
Row(
modifier = Modifier.width(60.dp),
horizontalArrangement = Arrangement.End,
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
Icon(
imageVector = Icons.TwoTone.BatteryChargingFull,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(100.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(100.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
// Progress ring
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(100.dp),
color = ringColor,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadsetPercent),
style = MaterialTheme.typography.headlineMedium,
color = if (device.batteryHeadsetPercent != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
if (device is HasEarDetection && device.isBeingWorn) {
Icon(
imageVector = Icons.TwoTone.Hearing,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
// Status chips
FlowRow(
modifier = Modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device is HasChargeDetection && device.isHeadsetBeingCharged) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device is HasEarDetection && device.isBeingWorn) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = stringResource(R.string.pods_inear_label),
)
}
}
}
}
// Debug info
if (showDebug) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "--- Debug ---",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = device.rawDataHex.joinToString("\n"),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
DebugSection(rawDataHex = device.rawDataHex)
}
}
}