diff --git a/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt b/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt index b93286be..e5e72913 100644 --- a/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt +++ b/app/src/main/java/eu/darken/capod/common/flow/FlowCombineExtensions.kt @@ -171,6 +171,34 @@ inline fun combine( ) } +@Suppress("UNCHECKED_CAST", "LongParameterList") +inline fun combine( + flow: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + flow8: Flow, + flow9: Flow, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9) -> R +): Flow = 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 combine( flow: Flow, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index 32755b8b..7fbd1ebb 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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, diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 4c7ab57e..8e9c96f3 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -59,6 +59,7 @@ class OverviewViewModel @Inject constructor( val requestPermissionEvent = SingleEventFlow() private val showUnmatchedDevices = MutableStateFlow(false) + private val userExpansionOverrides = MutableStateFlow>(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, val upgradeInfo: UpgradeRepo.Info, val showUnmatchedDevices: Boolean, + val userExpandedIds: Set = emptySet(), ) { val isScanBlocked: Boolean get() = permissions.any { it.isScanBlocking } - val profiledDevices: List get() = devices.filter { it.profileId != null } + + /** Profile list order used as tiebreaker within each connection tier. */ + private val profileOrder: Map by lazy { + profiles.mapIndexed { index, profile -> profile.id to index }.toMap() + } + + val profiledDevices: List by lazy { + devices.filter { it.profileId != null }.sortedWith( + compareBy { deviceTierRank(it) } + .thenBy { profileOrder[it.profileId] ?: Int.MAX_VALUE } + ) + } + val visibleProfiledDevices: List 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 + } } } 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 eb711bb0..8871b278 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,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 { 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 6cc262ab..1368f378 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 @@ -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 { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae29f610..9f0147e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -259,7 +259,10 @@ Left pod Right pod + L + R Case + Battery unavailable Open Closed Not connected to a device