From 865a6092031bf6dcf8c25acf11398f6f34759582 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 25 Aug 2026 20:52:45 +0200 Subject: [PATCH] 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 --- .../ui/devicesettings/DeviceSettingsScreen.kt | 101 ++++++++++-------- .../ui/devicesettings/cards/DeviceInfoCard.kt | 21 +--- 2 files changed, 63 insertions(+), 59 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index 55d048ab..d59706db 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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 }, ) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt index 985445ba..f3426927 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/cards/DeviceInfoCard.kt @@ -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 = 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),