From 8dd165e1c8bf1df011ac12c1f0a5622a710bdedc Mon Sep 17 00:00:00 2001 From: darken Date: Wed, 25 Feb 2026 00:17:51 +0100 Subject: [PATCH] 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. --- .../main/ui/overview/cards/DualPodsCard.kt | 400 ++++++++++-------- .../ui/overview/cards/PodCardComponents.kt | 195 +++++++++ .../main/ui/overview/cards/SinglePodsCard.kt | 179 +++++--- 3 files changed, 529 insertions(+), 245 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt 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 f0b93fc2..f71c7f36 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 @@ -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()) } 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 new file mode 100644 index 00000000..fd4fe9f1 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -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, + 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, + ) + } +} 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 4c2ce25c..c98ce461 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 @@ -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) } } }