feat(overview): Sort and collapse inactive device cards

Sort profiled devices into tiers (system-connected > nearby > cached), ordered by profile list within each tier. Collapse non-pinned cards to a compact battery tray with mini gauge rings matching the expanded card design. System-connected and top devices stay expanded; others can be tapped to toggle.
This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent 74e48e79eb
commit 77ea31216f
6 changed files with 389 additions and 203 deletions
@@ -171,6 +171,34 @@ inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, R> combine(
flow: Flow<T1>,
flow2: Flow<T2>,
flow3: Flow<T3>,
flow4: Flow<T4>,
flow5: Flow<T5>,
flow6: Flow<T6>,
flow7: Flow<T7>,
flow8: Flow<T8>,
flow9: Flow<T9>,
crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R
): Flow<R> = kotlinx.coroutines.flow.combine(
flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9
) { args: Array<*> ->
transform(
args[0] as T1,
args[1] as T2,
args[2] as T3,
args[3] as T4,
args[4] as T5,
args[5] as T6,
args[6] as T7,
args[7] as T8,
args[8] as T9
)
}
@Suppress("UNCHECKED_CAST", "LongParameterList")
inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
flow: Flow<T1>,
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Bluetooth
import androidx.compose.material.icons.twotone.BluetoothConnected
@@ -143,6 +144,9 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onDeviceSettings = { device -> vm.goToDeviceSettings(device) },
onEditProfile = { device -> vm.goToEditProfile(device) },
onToggleDeviceExpansion = { device ->
device.profileId?.let { vm.toggleDeviceExpansion(it) }
},
)
}
@@ -158,6 +162,7 @@ fun OverviewScreen(
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
onDeviceSettings: (PodDevice) -> Unit = {},
onEditProfile: (PodDevice) -> Unit = {},
onToggleDeviceExpansion: (PodDevice) -> Unit = {},
) {
Scaffold(
topBar = {
@@ -263,15 +268,21 @@ fun OverviewScreen(
// 4. Profiled device cards (limited to 1 for free users)
if (!state.isScanBlocked && state.isBluetoothEnabled) {
items(
itemsIndexed(
items = state.visibleProfiledDevices,
key = { it.identifier?.toString() ?: it.hashCode() },
) { device ->
key = { _, device -> requireNotNull(device.profileId) },
) { index, device ->
val isCollapsed = !state.isExpanded(device, index)
val isToggleable = state.isToggleable(device, index)
PodDeviceCard(
device = device,
isPro = state.upgradeInfo.isPro,
showDebug = state.isDebugMode,
now = state.now,
isCollapsed = isCollapsed,
onToggleCollapse = if (isToggleable) {
{ onToggleDeviceExpansion(device) }
} else null,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onUpgrade = onUpgrade,
onDeviceSettings = { onDeviceSettings(device) },
@@ -335,6 +346,8 @@ private fun PodDeviceCard(
isPro: Boolean,
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onUpgrade: () -> Unit,
onDeviceSettings: (() -> Unit)? = null,
@@ -343,6 +356,8 @@ private fun PodDeviceCard(
when {
device.hasDualPods -> DualPodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
onDeviceSettings = onDeviceSettings,
@@ -350,6 +365,8 @@ private fun PodDeviceCard(
)
device.model != PodModel.UNKNOWN -> SinglePodsCard(
device = device, isPro = isPro, showDebug = showDebug, now = now,
isCollapsed = isCollapsed,
onToggleCollapse = onToggleCollapse,
onAncModeChange = onAncModeChange,
onUpgrade = onUpgrade,
onDeviceSettings = onDeviceSettings,
@@ -59,6 +59,7 @@ class OverviewViewModel @Inject constructor(
val requestPermissionEvent = SingleEventFlow<Permission>()
private val showUnmatchedDevices = MutableStateFlow(false)
private val userExpansionOverrides = MutableStateFlow<Set<String>>(emptySet())
val workerAutolaunch = permissionTool.missingScanPermissions
.onEach { permissions ->
@@ -114,7 +115,12 @@ class OverviewViewModel @Inject constructor(
profilesRepo.profiles,
upgradeRepo.upgradeInfo,
showUnmatchedDevices,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched ->
userExpansionOverrides,
) { _, permissions, devices, isDebugMode, isBluetoothEnabled, profiles, upgradeInfo, showUnmatched, expandedIds ->
// Prune stale overrides (profiles that no longer exist)
val currentProfileIds = profiles.map { it.id }.toSet()
val prunedExpandedIds = expandedIds.filter { it in currentProfileIds }.toSet()
State(
now = timeSource.now(),
permissions = permissions,
@@ -124,6 +130,7 @@ class OverviewViewModel @Inject constructor(
profiles = profiles,
upgradeInfo = upgradeInfo,
showUnmatchedDevices = showUnmatched,
userExpandedIds = prunedExpandedIds,
)
}.asLiveState()
@@ -138,9 +145,22 @@ class OverviewViewModel @Inject constructor(
val profiles: List<DeviceProfile>,
val upgradeInfo: UpgradeRepo.Info,
val showUnmatchedDevices: Boolean,
val userExpandedIds: Set<String> = emptySet(),
) {
val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking }
val profiledDevices: List<PodDevice> get() = devices.filter { it.profileId != null }
/** Profile list order used as tiebreaker within each connection tier. */
private val profileOrder: Map<String, Int> by lazy {
profiles.mapIndexed { index, profile -> profile.id to index }.toMap()
}
val profiledDevices: List<PodDevice> by lazy {
devices.filter { it.profileId != null }.sortedWith(
compareBy<PodDevice> { deviceTierRank(it) }
.thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE }
)
}
val visibleProfiledDevices: List<PodDevice>
get() = if (upgradeInfo.isPro) profiledDevices else profiledDevices.take(FREE_DEVICE_LIMIT)
val hiddenProfiledDeviceCount: Int get() = profiledDevices.size - visibleProfiledDevices.size
@@ -155,6 +175,15 @@ class OverviewViewModel @Inject constructor(
profiledDevices.any { it.isLive } -> BluetoothIconState.NEARBY
else -> BluetoothIconState.HIDDEN
}
fun isPinned(device: PodDevice, index: Int): Boolean =
device.isSystemConnected || index == 0
fun isExpanded(device: PodDevice, index: Int): Boolean =
isPinned(device, index) || device.profileId in userExpandedIds
fun isToggleable(device: PodDevice, index: Int): Boolean =
!isPinned(device, index)
}
fun onPermissionResult() {
@@ -202,6 +231,13 @@ class OverviewViewModel @Inject constructor(
showUnmatchedDevices.value = !showUnmatchedDevices.value
}
fun toggleDeviceExpansion(profileId: String) {
log(TAG, INFO) { "toggleDeviceExpansion(profileId=$profileId)" }
userExpansionOverrides.value = userExpansionOverrides.value.let { current ->
if (profileId in current) current - profileId else current + profileId
}
}
fun requestPermission(permission: Permission) {
log(TAG, INFO) { "requestPermission($permission)" }
requestPermissionEvent.tryEmit(permission)
@@ -222,5 +258,12 @@ class OverviewViewModel @Inject constructor(
companion object {
private const val FREE_DEVICE_LIMIT = 1
private val TAG = logTag("Overview", "VM")
/** Connection tier rank for sorting: lower = higher priority. */
internal fun deviceTierRank(device: PodDevice): Int = when {
device.isSystemConnected -> 0
device.isLive -> 1
else -> 2
}
}
}
@@ -1,12 +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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -46,6 +49,7 @@ import eu.darken.capod.R
import eu.darken.capod.monitor.core.visibleAncModes
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.CompactBatterySummary
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
@@ -72,6 +76,8 @@ fun DualPodsCard(
isPro: Boolean = true,
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
onDeviceSettings: (() -> Unit)? = null,
@@ -87,12 +93,22 @@ fun DualPodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = Modifier
.padding(16.dp)
.animateContentSize(),
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(
if (onToggleCollapse != null) {
Modifier.clickable(onClick = onToggleCollapse)
} else {
Modifier
}
),
) {
Image(
painter = painterResource(device.iconRes),
@@ -170,89 +186,108 @@ fun DualPodsCard(
}
}
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
Surface(
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(),
isCharging = device.isLeftPodCharging ?: false,
isInEar = device.isLeftInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(),
isCharging = device.isRightPodCharging ?: false,
isInEar = device.isRightInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
}
// Case row
if (device.hasCase) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
CaseRow(device = device)
}
}
}
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
if (isCollapsed) {
CompactBatterySummary(device = device)
} else {
DualPodsCardExpanded(
device = device,
showDebug = showDebug,
now = now,
onAncModeChange = onAncModeChange,
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
}
}
}
}
@Composable
private fun ColumnScope.DualPodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
Spacer(modifier = Modifier.height(16.dp))
// Circular battery gauges side by side
Surface(
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
PodGauge(
iconRes = device.leftPodIcon,
batteryPercent = device.batteryLeft.toBatteryFloat(),
isCharging = device.isLeftPodCharging ?: false,
isInEar = device.isLeftInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isLeftPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
PodGauge(
iconRes = device.rightPodIcon,
batteryPercent = device.batteryRight.toBatteryFloat(),
isCharging = device.isRightPodCharging ?: false,
isInEar = device.isRightInEar ?: false,
showEarDetection = device.hasEarDetection && device.hasDualPods,
isMicrophone = device.isRightPodMicrophone ?: false,
showMicrophone = device.hasDualMicrophone,
modifier = Modifier.weight(1f),
)
}
// Case row
if (device.hasCase) {
HorizontalDivider(
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
)
CaseRow(device = device)
}
}
}
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(8.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
}
}
@Composable
private fun PodGauge(
iconRes: Int,
@@ -439,6 +474,18 @@ private fun DualPodsCardCachedPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun DualPodsCardCollapsedPreview() = PreviewWrapper {
DualPodsCard(
device = MockPodDataProvider.dualPodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
isCollapsed = true,
onToggleCollapse = {},
)
}
@Preview2
@Composable
private fun DualPodsCardMissingAddressPreview() = PreviewWrapper {
@@ -5,9 +5,11 @@ 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.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
@@ -45,6 +47,7 @@ import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.monitor.core.visibleAncModes
import eu.darken.capod.main.ui.overview.cards.components.AncModeSelector
import eu.darken.capod.main.ui.overview.cards.components.CompactBatterySummary
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
@@ -66,6 +69,8 @@ fun SinglePodsCard(
isPro: Boolean = true,
showDebug: Boolean,
now: Instant,
isCollapsed: Boolean = false,
onToggleCollapse: (() -> Unit)? = null,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onUpgrade: (() -> Unit)? = null,
onDeviceSettings: (() -> Unit)? = null,
@@ -73,20 +78,6 @@ fun SinglePodsCard(
) {
val context = LocalContext.current
val clamped = device.batteryHeadset?.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()
@@ -95,12 +86,22 @@ fun SinglePodsCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.padding(16.dp),
modifier = Modifier
.padding(16.dp)
.animateContentSize(),
) {
// Header
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.then(
if (onToggleCollapse != null) {
Modifier.clickable(onClick = onToggleCollapse)
} else {
Modifier
}
),
) {
Image(
painter = painterResource(device.iconRes),
@@ -167,115 +168,150 @@ fun SinglePodsCard(
}
}
Spacer(modifier = Modifier.height(12.dp))
// Central gauge
Surface(
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(88.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(88.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(88.dp),
color = ringColor,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadset),
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(modifier = Modifier.height(8.dp))
// Status chips
FlowRow(
modifier = Modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isHeadsetBeingCharged == true) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device.isBeingWorn == true) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = stringResource(R.string.pods_inear_label),
)
}
}
}
}
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
if (isCollapsed) {
CompactBatterySummary(device = device)
} else {
SinglePodsCardExpanded(
device = device,
showDebug = showDebug,
now = now,
onAncModeChange = onAncModeChange,
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
}
}
}
}
@Composable
private fun ColumnScope.SinglePodsCardExpanded(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)?,
) {
val context = LocalContext.current
val clamped = device.batteryHeadset?.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
}
Spacer(modifier = Modifier.height(12.dp))
// Central gauge
Surface(
modifier = Modifier
.fillMaxWidth()
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(88.dp),
) {
// Track ring
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(88.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(88.dp),
color = ringColor,
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
}
// Battery text inside ring
Text(
text = formatBatteryPercent(context, device.batteryHeadset),
style = MaterialTheme.typography.headlineSmall,
color = if (device.batteryHeadset != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
Spacer(modifier = Modifier.height(8.dp))
// Status chips
FlowRow(
modifier = Modifier.animateContentSize(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (device.isHeadsetBeingCharged == true) {
StatusChip(
icon = Icons.TwoTone.BatteryChargingFull,
label = stringResource(R.string.pods_charging_label),
)
}
if (device.isBeingWorn == true) {
StatusChip(
icon = Icons.TwoTone.Hearing,
label = stringResource(R.string.pods_inear_label),
)
}
}
}
}
// Cached battery indicator
if (device.isBatteryCached && !device.isLive) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.battery_cached_label, device.cachedBatteryFormatted(now)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.End),
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = device.visibleAncModes,
onModeSelected = { onAncModeChange?.invoke(it) },
pendingMode = device.pendingAncMode,
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
}
}
@Preview2
@Composable
private fun SinglePodsCardFullPreview() = PreviewWrapper {
@@ -307,6 +343,18 @@ private fun SinglePodsCardCachedPreview() = PreviewWrapper {
)
}
@Preview2
@Composable
private fun SinglePodsCardCollapsedPreview() = PreviewWrapper {
SinglePodsCard(
device = MockPodDataProvider.singlePodMonitored(),
showDebug = false,
now = SystemTimeSource.now(),
isCollapsed = true,
onToggleCollapse = {},
)
}
@Preview2
@Composable
private fun SinglePodsCardMissingAddressPreview() = PreviewWrapper {
+3
View File
@@ -259,7 +259,10 @@
<string name="pods_dual_left_label">Left pod</string>
<string name="pods_dual_right_label">Right pod</string>
<string name="pods_dual_left_short_label">L</string>
<string name="pods_dual_right_short_label">R</string>
<string name="pods_case_label">Case</string>
<string name="battery_unavailable_label">Battery unavailable</string>
<string name="pods_case_status_open_label">Open</string>
<string name="pods_case_status_closed_label">Closed</string>
<string name="pods_connection_state_disconnected_label">Not connected to a device</string>