feat(device-settings): Move device details to bottom sheet

Move serial, firmware, build, manufacturer, and per-pod serials from

the info card into a ModalBottomSheet triggered by an info icon.

Firmware+build and left+right pod serials render as paired rows.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 3ad732ff09
commit 35c06d9982
6 changed files with 266 additions and 88 deletions
@@ -87,6 +87,7 @@ import eu.darken.capod.common.settings.SettingsSection
import eu.darken.capod.common.settings.SettingsSliderItem
import eu.darken.capod.common.settings.SettingsSwitchItem
import eu.darken.capod.main.ui.devicesettings.cards.AapUnavailableCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceDetailItem
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.buildModelLabel
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
@@ -276,6 +277,41 @@ fun DeviceSettingsScreen(
) {
device.firstSeenFormatted(state.now)
} else null
val info = device.deviceInfo
val detailItems = buildList<DeviceDetailItem> {
if (info != null) {
if (info.manufacturer.isNotBlank()) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_manufacturer_label), info.manufacturer))
}
if (info.serialNumber.isNotBlank()) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_serial_label), info.serialNumber))
}
val hasFirmware = info.firmwareVersion.isNotBlank()
val hasBuild = !info.buildNumber.isNullOrBlank()
if (hasFirmware && hasBuild) {
add(DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_firmware_label), info.firmwareVersion),
end = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_build_label), info.buildNumber!!),
))
} else if (hasFirmware) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_firmware_label), info.firmwareVersion))
} else if (hasBuild) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_build_label), info.buildNumber!!))
}
val hasLeft = !info.leftEarbudSerial.isNullOrBlank()
val hasRight = !info.rightEarbudSerial.isNullOrBlank()
if (hasLeft && hasRight) {
add(DeviceDetailItem.Paired(
start = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_left_serial_label), info.leftEarbudSerial!!),
end = DeviceDetailItem.Single(stringResource(R.string.device_settings_info_right_serial_label), info.rightEarbudSerial!!),
))
} else if (hasLeft) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_left_serial_label), info.leftEarbudSerial!!))
} else if (hasRight) {
add(DeviceDetailItem.Single(stringResource(R.string.device_settings_info_right_serial_label), info.rightEarbudSerial!!))
}
}
}
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
@@ -283,6 +319,7 @@ fun DeviceSettingsScreen(
connectionStateLabel = stateDetection?.state?.getLabel(context),
lastSeen = device.lastSeenFormatted(state.now),
firstSeen = firstSeen,
detailItems = detailItems,
canRename = device.isAapReady,
onRename = onDeviceNameChange,
)
@@ -0,0 +1,6 @@
package eu.darken.capod.main.ui.devicesettings.cards
sealed interface DeviceDetailItem {
data class Single(val label: String, val value: String) : DeviceDetailItem
data class Paired(val start: Single, val end: Single) : DeviceDetailItem
}
@@ -0,0 +1,84 @@
package eu.darken.capod.main.ui.devicesettings.cards
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
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
@Composable
internal fun DeviceInfoBottomSheet(
items: List<DeviceDetailItem>,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(start = 16.dp, end = 16.dp, bottom = 32.dp),
) {
Text(
text = stringResource(R.string.device_settings_info_details_label),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
items.forEach { item ->
when (item) {
is DeviceDetailItem.Single -> InfoRow(label = item.label, value = item.value)
is DeviceDetailItem.Paired -> Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(
label = item.start.label,
value = item.start.value,
modifier = Modifier.weight(1f),
)
InfoRow(
label = item.end.label,
value = item.end.value,
modifier = Modifier.weight(1f),
textAlign = TextAlign.End,
)
}
}
}
}
}
}
@Preview2
@Composable
private fun DeviceInfoBottomSheetPreview() = PreviewWrapper {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Device Details",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp),
)
InfoRow(label = "Manufacturer", value = "Apple Inc.")
InfoRow(label = "Serial Number", value = "W5J7KV0N04")
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Firmware", value = "7A305", modifier = Modifier.weight(1f))
InfoRow(label = "Build", value = "8454624", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
Row(modifier = Modifier.fillMaxWidth()) {
InfoRow(label = "Left Pod Serial", value = "H3KL7HR926JY", modifier = Modifier.weight(1f))
InfoRow(label = "Right Pod Serial", value = "H3KL2AYL26K0", modifier = Modifier.weight(1f), textAlign = TextAlign.End)
}
}
}
@@ -7,6 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Edit
import androidx.compose.material.icons.twotone.Info
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
@@ -46,10 +48,16 @@ internal fun DeviceInfoCard(
connectionStateLabel: String?,
lastSeen: String?,
firstSeen: String?,
detailItems: List<DeviceDetailItem> = emptyList(),
canRename: Boolean = false,
onRename: (String) -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
var showBottomSheet by remember { mutableStateOf(false) }
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) showBottomSheet = false
}
if (showRenameDialog && deviceInfo != null) {
RenameDialog(
@@ -62,6 +70,13 @@ internal fun DeviceInfoCard(
)
}
if (showBottomSheet && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { showBottomSheet = false },
)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -69,70 +84,49 @@ internal fun DeviceInfoCard(
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
) {
Column(modifier = Modifier.padding(16.dp)) {
if (modelLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_model_label),
value = modelLabel,
)
}
if (deviceInfo != null) {
if (deviceInfo.name.isNotBlank()) {
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (modelLabel != null || detailItems.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (modelLabel != null) {
InfoRow(
label = stringResource(R.string.device_settings_info_bt_name_label),
value = deviceInfo.name,
label = stringResource(R.string.device_settings_info_model_label),
value = modelLabel,
modifier = Modifier.weight(1f),
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
)
if (canRename) {
IconButton(onClick = { showRenameDialog = true }) {
Icon(
imageVector = Icons.TwoTone.Edit,
contentDescription = stringResource(R.string.device_settings_rename_label),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (detailItems.isNotEmpty()) {
IconButton(onClick = { showBottomSheet = true }) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (deviceInfo.serialNumber.isNotBlank()) {
}
if (deviceInfo != null && deviceInfo.name.isNotBlank()) {
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
InfoRow(
label = stringResource(R.string.device_settings_info_serial_label),
value = deviceInfo.serialNumber,
label = stringResource(R.string.device_settings_info_bt_name_label),
value = deviceInfo.name,
modifier = Modifier.weight(1f),
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
)
}
if (deviceInfo.firmwareVersion.isNotBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_firmware_label),
value = deviceInfo.firmwareVersion,
)
}
if (!deviceInfo.buildNumber.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_build_label),
value = deviceInfo.buildNumber,
)
}
if (!deviceInfo.leftEarbudSerial.isNullOrBlank() || !deviceInfo.rightEarbudSerial.isNullOrBlank()) {
Row(modifier = Modifier.fillMaxWidth()) {
if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_left_serial_label),
value = deviceInfo.leftEarbudSerial,
modifier = Modifier.weight(1f),
)
}
if (!deviceInfo.rightEarbudSerial.isNullOrBlank()) {
InfoRow(
label = stringResource(R.string.device_settings_info_right_serial_label),
value = deviceInfo.rightEarbudSerial,
modifier = Modifier.weight(1f),
textAlign = if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) TextAlign.End else TextAlign.Start,
if (canRename) {
IconButton(onClick = { showRenameDialog = true }) {
Icon(
imageVector = Icons.TwoTone.Edit,
contentDescription = stringResource(R.string.device_settings_rename_label),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -169,7 +163,7 @@ internal fun DeviceInfoCard(
}
@Composable
private fun InfoRow(
internal fun InfoRow(
label: String,
value: String,
modifier: Modifier = Modifier,
@@ -215,6 +209,13 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
detailItems = listOf(
DeviceDetailItem.Single("Serial Number", "W5J7KV0N04"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Firmware", "7A305"),
end = DeviceDetailItem.Single("Build", "8454624"),
),
),
canRename = true,
)
}
@@ -235,27 +236,14 @@ private fun DeviceInfoCardMismatchPreview() = PreviewWrapper {
connectionStateLabel = "Connected",
lastSeen = "Just now",
firstSeen = "5 minutes ago",
canRename = true,
)
}
@Composable
@Preview2
private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper {
DeviceInfoCard(
deviceInfo = AapDeviceInfo(
name = "AirPods Pro",
modelNumber = "A2699",
manufacturer = "Apple Inc.",
serialNumber = "W5J7KV0N04",
firmwareVersion = "7A305",
detailItems = listOf(
DeviceDetailItem.Single("Serial Number", "W5J7KV0N04"),
DeviceDetailItem.Paired(
start = DeviceDetailItem.Single("Firmware", "7A305"),
end = DeviceDetailItem.Single("Build", "8454624"),
),
),
modelLabel = "AirPods Pro 2 (A2699)",
systemBluetoothName = "AirPods Pro",
connectionStateLabel = "Disconnected",
lastSeen = "2 hours ago",
firstSeen = null,
canRename = false,
canRename = true,
)
}
@@ -74,6 +74,8 @@ internal class AapConnection(
private var lastAncCommandSentAt: Long = 0L
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
private var ancResendJob: Job? = null
/** True after one automatic resend — prevents further retries for the same user action. */
private var ancResendAttempted: Boolean = false
private var lastSentCommand: AapCommand? = null
private var lastSentAt: Long = 0L
@@ -140,6 +142,7 @@ internal class AapConnection(
ancDebounceJob = null
ancResendJob?.cancel()
ancResendJob = null
ancResendAttempted = false
pendingAncMode = null
lastCommandedAncMode = null
connectionScope = null
@@ -158,6 +161,7 @@ internal class AapConnection(
lastCommandedAncMode = command.mode
ancResendJob?.cancel()
ancResendJob = null
ancResendAttempted = false
val earDetection = currentState.setting<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) {
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
@@ -318,6 +322,7 @@ internal class AapConnection(
} finally {
ancDebounceJob?.cancel()
ancResendJob?.cancel()
ancResendAttempted = false
pendingAncMode = null
lastCommandedAncMode = null
cleanupSocket()
@@ -409,20 +414,50 @@ internal class AapConnection(
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
// After our command, firmware may cycle through modes before settling.
// Schedule a verification: if settled mode != commanded mode, re-send once.
// Capped at one retry per user action — if the device rejects the mode
// (e.g. OFF without AllowOffOption), stop instead of looping.
lastCommandedAncMode?.let { commanded ->
ancResendJob?.cancel()
ancResendJob = connectionScope?.launch {
delay(1000L)
val current = _state.value.setting<AapSetting.AncMode>()?.current
if (current != null && current != commanded) {
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
lastCommandedAncMode = null
sendRaw(AapCommand.SetAncMode(commanded))
} else {
lastCommandedAncMode = null
if (ancResendAttempted) {
// Already retried once — accept the device's answer as final.
val current = (value as AapSetting.AncMode).current
if (current != commanded) {
log(TAG) { "ANC mode rejected: commanded=$commanded settled=$current, giving up after retry" }
// OFF specifically requires AllowOffOption — rejection means it's disabled
if (commanded == AapSetting.AncMode.Value.OFF) {
val prev = _state.value.setting<AapSetting.AllowOffOption>()
if (prev == null || prev.enabled) {
_state.value = _state.value.withSetting(
AapSetting.AllowOffOption::class,
AapSetting.AllowOffOption(enabled = false),
)
log(TAG) { "Inferred: AllowOffOption = false (OFF mode rejected by device)" }
}
}
}
lastCommandedAncMode = null
} else {
ancResendJob = connectionScope?.launch {
delay(1000L)
val current = _state.value.setting<AapSetting.AncMode>()?.current
val ear = _state.value.setting<AapSetting.EarDetection>()
// Abort if pods moved to case/disconnected — firmware is doing its own thing
if (ear != null && !ear.isEitherPodInEar) {
log(TAG) { "ANC resend aborted: no pod in ear (ear=$ear)" }
lastCommandedAncMode = null
return@launch
}
if (current != null && current != commanded) {
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
ancResendAttempted = true
sendRaw(AapCommand.SetAncMode(commanded))
} else {
lastCommandedAncMode = null
}
}
}
}
@@ -432,6 +467,7 @@ internal class AapConnection(
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
}
}
return
@@ -449,6 +485,7 @@ internal class AapConnection(
}
_state.value = newState
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
applyInferences(value)
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
@@ -531,6 +568,29 @@ internal class AapConnection(
}
}
/**
* Infer settings that the device never pushes but whose state can be deduced from other signals.
* Called after every setting update. Only SETS inferred values — never clears them based on absence.
*/
private fun applyInferences(trigger: AapSetting) {
val inferred = mutableListOf<Pair<KClass<out AapSetting>, AapSetting>>()
// AncMode=OFF is only possible when AllowOffOption is enabled — the device rejects
// SetAncMode(OFF) otherwise. If we see OFF in the burst or after a mode change,
// AllowOffOption must be true.
if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) {
val current = _state.value.setting<AapSetting.AllowOffOption>()
if (current == null || !current.enabled) {
inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true)
}
}
for ((key, value) in inferred) {
_state.value = _state.value.withSetting(key, value)
log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" }
}
}
private fun cleanupSocket() {
try {
socket?.close()
+5 -2
View File
@@ -443,11 +443,14 @@
<string name="device_settings_info_name_label">Name</string>
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
<string name="device_settings_info_model_label">Model</string>
<string name="device_settings_info_manufacturer_label">Manufacturer</string>
<string name="device_settings_info_serial_label">Serial Number</string>
<string name="device_settings_info_firmware_label">Firmware</string>
<string name="device_settings_info_build_label">Build</string>
<string name="device_settings_info_left_serial_label">Left Serial</string>
<string name="device_settings_info_right_serial_label">Right Serial</string>
<string name="device_settings_info_left_serial_label">Left Pod Serial</string>
<string name="device_settings_info_right_serial_label">Right Pod Serial</string>
<string name="device_settings_info_details_label">Device Details</string>
<string name="device_settings_info_details_action">Show device details</string>
<string name="device_settings_info_status_label">Status</string>
<string name="device_settings_info_last_seen_label">Last Seen</string>
<string name="device_settings_info_first_seen_label">First Seen</string>