mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user