From 15ebeafdf50a45c9a58d80c6573072c93b2cc04e Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Sun, 22 Jun 2025 22:53:09 +1000 Subject: [PATCH] Close, just a fwe linter issues --- .../com/geeksville/mesh/ui/debug/Debug.kt | 156 +++++++++++++++--- .../geeksville/mesh/ui/debug/DebugSearch.kt | 7 +- 2 files changed, 139 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt index f13e5f3b..3ed7bcba 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt @@ -57,7 +57,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -81,6 +83,9 @@ import com.geeksville.mesh.model.DebugViewModel.UiMeshLog import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.ui.common.components.CopyIconButton import android.widget.Toast +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.material.icons.filled.Delete import androidx.compose.ui.platform.LocalContext import androidx.compose.runtime.rememberCoroutineScope @@ -89,6 +94,9 @@ import com.geeksville.mesh.android.BuildUtils.warn import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import androidx.compose.material3.IconButton +import androidx.compose.ui.zIndex +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) @@ -112,10 +120,35 @@ internal fun DebugScreen( }.toImmutableList() } + // Track scroll direction and focus for header + var lastScrollOffset by remember { mutableStateOf(0) } + var headerVisible by remember { mutableStateOf(true) } + var headerHasFocus by remember { mutableStateOf(false) } + var programmaticScroll by remember { mutableStateOf(false) } + var ignoreNextScroll by remember { mutableStateOf(false) } + + // header display + val headerHeightPx = remember { mutableStateOf(0) } + val density = LocalDensity.current + val headerHeightDp = with(density) { headerHeightPx.value.toDp() } + + fun setHeaderVisible(newValue: Boolean) { + if (headerVisible != newValue) { + headerVisible = newValue + ignoreNextScroll = true // Ignore the next scroll event caused by this change (and search) + } + } + fun setHeaderHasFocus(newValue: Boolean) { + headerHasFocus = newValue + } + LaunchedEffect(filteredLogs) { viewModel.updateFilteredLogs(filteredLogs) } + // This code automatically scrolls the log list to the top (item 0) whenever the filteredLogs change, + // but only if the user is already near the top (within the first 3 items) and not currently scrolling. + // It uses a derived state to determine if auto-scroll should occur, and triggers the scroll in a LaunchedEffect. val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } } if (shouldAutoScroll) { LaunchedEffect(filteredLogs) { @@ -124,35 +157,72 @@ internal fun DebugScreen( } } } - // Handle search result navigation + + // Scrolls to the currently selected search match in the log list when searchState changes. LaunchedEffect(searchState) { if (searchState.currentMatchIndex >= 0 && searchState.currentMatchIndex < searchState.allMatches.size) { + programmaticScroll = true listState.requestScrollToItem(searchState.allMatches[searchState.currentMatchIndex].logIndex) + programmaticScroll = false } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - state = listState, - ) { - stickyHeader { - DebugSearchStateviewModelDefaults( - searchState = searchState, - filterTexts = filterTexts, - presetFilters = viewModel.presetFilters, - ) - } + handleHeaderVisibilityOnScroll( + listState = listState, + headerHasFocus = headerHasFocus, + programmaticScroll = programmaticScroll, + ignoreNextScroll = ignoreNextScroll, + setHeaderVisible = { setHeaderVisible(it) }, + setIgnoreNextScroll = { ignoreNextScroll = it } + ) - items(filteredLogs, key = { it.uuid }) { log -> - DebugItem( - modifier = Modifier.animateItem(), - log = log, - searchText = searchState.searchText, - isSelected = selectedLogId == log.uuid, - onLogClick = { - viewModel.setSelectedLogId(if (selectedLogId == log.uuid) null else log.uuid) - } - ) + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress) { + setHeaderHasFocus(false) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp) + ) { + item { + Spacer(modifier = Modifier.height(headerHeightDp)) + } + items(filteredLogs, key = { it.uuid }) { log -> + DebugItem( + modifier = Modifier.animateItem(), + log = log, + searchText = searchState.searchText, + isSelected = selectedLogId == log.uuid, + onLogClick = { + setHeaderHasFocus(false) + viewModel.setSelectedLogId(if (selectedLogId == log.uuid) null else log.uuid) + } + ) + } + } + if (headerVisible) { + androidx.compose.material3.Surface( + tonalElevation = 4.dp, + shadowElevation = 4.dp, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + .zIndex(1f) + .onGloballyPositioned { coordinates -> + headerHeightPx.value = coordinates.size.height + } + ) { + DebugSearchStateviewModelDefaults( + searchState = searchState, + filterTexts = filterTexts, + presetFilters = viewModel.presetFilters, + onHeaderFocusChanged = { focused -> setHeaderHasFocus(focused) } + ) + } } } } @@ -690,7 +760,7 @@ fun DebugMenuActions( contentDescription = "Clear All" ) } - } +} private suspend fun exportAllLogs(context: Context, logs: List) = withContext(Dispatchers.IO) { try { @@ -738,3 +808,43 @@ private suspend fun exportAllLogs(context: Context, logs: List) = wit warn("Error:IOException: " + e.toString()) } } + +@Composable +private fun handleHeaderVisibilityOnScroll( + listState: LazyListState, + headerHasFocus: Boolean, + programmaticScroll: Boolean, + ignoreNextScroll: Boolean, + setHeaderVisible: (Boolean) -> Unit, + setIgnoreNextScroll: (Boolean) -> Unit +) { + var lastScrollOffset by remember { mutableStateOf(0) } + LaunchedEffect( + listState.firstVisibleItemScrollOffset, + listState.firstVisibleItemIndex, + headerHasFocus, + programmaticScroll + ) { + if (ignoreNextScroll) { + setIgnoreNextScroll(false) + return@LaunchedEffect + } + const itemIndexMultiplier = 10000 + val currentOffset = listState.firstVisibleItemScrollOffset + + listState.firstVisibleItemIndex * itemIndexMultiplier + val scrollingUp = currentOffset < lastScrollOffset + val scrollingDown = currentOffset > lastScrollOffset + val idle = currentOffset == lastScrollOffset + + when { + headerHasFocus -> setHeaderVisible(true) + programmaticScroll -> setHeaderVisible(true) + scrollingUp -> setHeaderVisible(true) + scrollingDown -> setHeaderVisible(false) + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + -> setHeaderVisible(true) + idle -> { /* Do nothing */ } + } + lastScrollOffset = currentOffset + } +} diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt index 1d6b54e4..23a07188 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt @@ -53,6 +53,7 @@ import com.geeksville.mesh.model.LogSearchManager.SearchState import com.geeksville.mesh.ui.common.theme.AppTheme import com.geeksville.mesh.model.DebugViewModel import androidx.hilt.navigation.compose.hiltViewModel +import androidx.compose.ui.focus.onFocusChanged @Composable internal fun DebugSearchNavigation( @@ -158,6 +159,7 @@ internal fun DebugSearchState( onPreviousMatch: () -> Unit, onClearSearch: () -> Unit, onFilterTextsChange: (List) -> Unit, + onHeaderFocusChanged: (Boolean) -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme @@ -179,7 +181,8 @@ internal fun DebugSearchState( onSearchTextChange = onSearchTextChange, onNextMatch = onNextMatch, onPreviousMatch = onPreviousMatch, - onClearSearch = onClearSearch + onClearSearch = onClearSearch, + modifier = Modifier.onFocusChanged { onHeaderFocusChanged(it.isFocused) } ) DebugFilterBar( filterTexts = filterTexts, @@ -203,6 +206,7 @@ fun DebugSearchStateviewModelDefaults( searchState: SearchState, filterTexts: List, presetFilters: List, + onHeaderFocusChanged: (Boolean) -> Unit = {}, ) { val viewModel: DebugViewModel = hiltViewModel() DebugSearchState( @@ -214,6 +218,7 @@ fun DebugSearchStateviewModelDefaults( onPreviousMatch = viewModel.searchManager::goToPreviousMatch, onClearSearch = viewModel.searchManager::clearSearch, onFilterTextsChange = viewModel.filterManager::setFilterTexts, + onHeaderFocusChanged = onHeaderFocusChanged, ) }