fix(ui): Keep the device detail sheet reachable while scrolled

The detail sheet was composed inside the device-info lazy item, so tapping the
battery runtime warning banner after that item had scrolled out of composition
did nothing visible while still setting the visibility flag, which made the
sheet pop up unprompted on scrolling back up. Build the detail items and compose
the sheet at screen scope instead; the card now only reports the tap.

Fixes review finding F1
This commit is contained in:
darken
2026-08-25 23:42:23 +02:00
committed by Matthias Urhahn
parent 9f213cf499
commit 865a609203
2 changed files with 63 additions and 59 deletions
@@ -47,6 +47,7 @@ import eu.darken.capod.main.ui.devicesettings.cards.BatteryCard
import eu.darken.capod.main.ui.devicesettings.cards.BatteryHealthTexts
import eu.darken.capod.main.ui.devicesettings.cards.BatteryRuntimeWarningBanner
import eu.darken.capod.main.ui.devicesettings.cards.ControlsCard
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoBottomSheet
import eu.darken.capod.main.ui.devicesettings.cards.DeviceInfoCard
import eu.darken.capod.main.ui.devicesettings.cards.NoiseControlCard
import eu.darken.capod.main.ui.devicesettings.cards.NotConnectedCard
@@ -242,9 +243,65 @@ fun DeviceSettingsScreen(
val enabled = device?.isAapReady == true
val isPro = state.isPro
// Hoisted out of DeviceInfoCard: the runtime warning banner opens the same detail sheet as the
// card's info icon.
// card's info icon, and both entry points can be scrolled out of composition independently.
var showDeviceDetails by rememberSaveable { mutableStateOf(false) }
val context = LocalContext.current
val stateDetection = device?.ble as? HasStateDetection
val seenFirst = device?.seenFirstAt
val seenLast = device?.seenLastAt
val firstSeen = if (device != null && seenFirst != null && seenLast != null &&
Duration.between(seenFirst, seenLast).toMinutes() >= 1
) {
device.firstSeenFormatted(state.now)
} else null
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailLabels = rememberDeviceInfoDetailLabels()
val batteryHealthTexts = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
}
val detailItems = if (device != null) {
buildDeviceInfoDetailItems(
info = device.deviceInfo,
labels = detailLabels,
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = batteryHealthTexts,
)
} else {
emptyList()
}
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) showDeviceDetails = false
}
if (showDeviceDetails && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { showDeviceDetails = false },
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -286,45 +343,6 @@ fun DeviceSettingsScreen(
// Device Info
if (device != null) {
item("device_info") {
val context = LocalContext.current
val stateDetection = device.ble as? HasStateDetection
val seenFirst = device.seenFirstAt
val seenLast = device.seenLastAt
val firstSeen = if (seenFirst != null && seenLast != null && Duration.between(seenFirst, seenLast)
.toMinutes() >= 1
) {
device.firstSeenFormatted(state.now)
} else null
val info = device.deviceInfo
val locale = LocalConfiguration.current.locales[0]
val zoneId = ZoneId.systemDefault()
val dateFormatter = remember(locale, zoneId) {
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(locale)
.withZone(zoneId)
}
val detailItems = buildDeviceInfoDetailItems(
info = info,
labels = rememberDeviceInfoDetailLabels(),
formatDate = { instant -> dateFormatter.format(instant) },
batteryHealth = when {
state.batteryHealth != null -> BatteryHealthTexts(
left = state.batteryHealth.left?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
right = state.batteryHealth.right?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
headset = state.batteryHealth.headset?.let {
stringResource(R.string.device_settings_info_battery_health_value, it.percent)
},
)
state.batteryHealthPending -> BatteryHealthTexts(
pending = stringResource(R.string.device_settings_info_battery_health_pending),
)
else -> null
},
)
DeviceInfoCard(
deviceInfo = device.deviceInfo,
modelLabel = buildModelLabel(device),
@@ -335,8 +353,7 @@ fun DeviceSettingsScreen(
detailItems = detailItems,
canRename = device.isAapReady,
onRename = onDeviceNameChange,
showDetails = showDeviceDetails,
onShowDetailsChange = { showDeviceDetails = it },
onShowDetails = { showDeviceDetails = true },
)
}
}
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.size
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
@@ -53,15 +52,10 @@ internal fun DeviceInfoCard(
detailItems: List<DeviceDetailItem> = emptyList(),
canRename: Boolean = false,
onRename: (String) -> Unit = {},
showDetails: Boolean = false,
onShowDetailsChange: (Boolean) -> Unit = {},
onShowDetails: () -> Unit = {},
) {
var showRenameDialog by remember { mutableStateOf(false) }
LaunchedEffect(detailItems) {
if (detailItems.isEmpty()) onShowDetailsChange(false)
}
if (showRenameDialog && deviceInfo != null) {
RenameDialog(
currentName = deviceInfo.name,
@@ -73,13 +67,6 @@ internal fun DeviceInfoCard(
)
}
if (showDetails && detailItems.isNotEmpty()) {
DeviceInfoBottomSheet(
items = detailItems,
onDismiss = { onShowDetailsChange(false) },
)
}
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
@@ -104,7 +91,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.weight(1f),
)
if (showInfoIconInModelRow) {
IconButton(onClick = { onShowDetailsChange(true) }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -145,7 +132,7 @@ internal fun DeviceInfoCard(
} else null,
)
if (showInfoIconInNameRow) {
IconButton(onClick = { onShowDetailsChange(true) }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),
@@ -160,7 +147,7 @@ internal fun DeviceInfoCard(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
IconButton(onClick = { onShowDetailsChange(true) }) {
IconButton(onClick = onShowDetails) {
Icon(
imageVector = Icons.TwoTone.Info,
contentDescription = stringResource(R.string.device_settings_info_details_action),