diff --git a/.gitignore b/.gitignore index 610cbc7b6..60be31b37 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ keystore.properties # Kotlin compiler .kotlin + +# VS code +.vscode/settings.json diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt new file mode 100644 index 000000000..71dc74269 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugFiltersTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.geeksville.mesh.R +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.platform.app.InstrumentationRegistry +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +@RunWith(AndroidJUnit4::class) +class DebugFiltersTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun debugFilterBar_showsFilterButtonAndMenu() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val filterLabel = context.getString(R.string.debug_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) } + var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + val presetFilters = listOf("Error", "Warning", "Info") + com.geeksville.mesh.ui.debug.DebugFilterBar( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it }, + customFilterText = customFilterText, + onCustomFilterTextChange = { customFilterText = it }, + presetFilters = presetFilters + ) + } + // The filter button should be visible + composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() + } + + @Test + fun debugFilterBar_addCustomFilter_displaysActiveFilter() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activeFiltersLabel = context.getString(R.string.debug_active_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) } + var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + com.geeksville.mesh.ui.debug.DebugActiveFilters( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + com.geeksville.mesh.ui.debug.DebugCustomFilterInput( + customFilterText = customFilterText, + onCustomFilterTextChange = { customFilterText = it }, + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + } + // Add a custom filter + composeTestRule.onNodeWithText("Add custom filter").performTextInput("MyFilter") + composeTestRule.onNodeWithContentDescription("Add filter").performClick() + // The active filters label and the filter chip should be visible + composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("MyFilter").assertIsDisplayed() + } + + @Test + fun debugActiveFilters_clearAllFilters_removesFilters() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activeFiltersLabel = context.getString(R.string.debug_active_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf("A", "B")) } + com.geeksville.mesh.ui.debug.DebugActiveFilters( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + } + // The active filters label and chips should be visible + composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("A").assertIsDisplayed() + composeTestRule.onNodeWithText("B").assertIsDisplayed() + // Click the clear all filters button + composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() + // The filter chips should no longer be visible + composeTestRule.onNodeWithText("A").assertDoesNotExist() + composeTestRule.onNodeWithText("B").assertDoesNotExist() + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt new file mode 100644 index 000000000..021312f83 --- /dev/null +++ b/app/src/androidTest/java/com/geeksville/mesh/compose/DebugSearchTest.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.printToLog +import androidx.compose.ui.test.printToString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.geeksville.mesh.R +import com.geeksville.mesh.model.LogSearchManager.SearchState +import com.geeksville.mesh.ui.debug.DebugSearchBar +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import androidx.test.platform.app.InstrumentationRegistry +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue + +@RunWith(AndroidJUnit4::class) +class DebugSearchTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun debugSearchBar_showsPlaceholder() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val placeholder = context.getString(R.string.debug_default_search) + composeTestRule.setContent { + DebugSearchBar( + searchState = SearchState(), + onSearchTextChange = {}, + onNextMatch = {}, + onPreviousMatch = {}, + onClearSearch = {} + ) + } + composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + } + + @Test + fun debugSearchBar_showsClearButtonWhenTextEntered() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val placeholder = context.getString(R.string.debug_default_search) + composeTestRule.setContent { + var searchText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("test") } + DebugSearchBar( + searchState = SearchState(searchText = searchText), + onSearchTextChange = { searchText = it }, + onNextMatch = {}, + onPreviousMatch = {}, + onClearSearch = { searchText = "" } + ) + } + composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() + .performClick() + composeTestRule.onNodeWithText(placeholder).assertIsDisplayed() + } + + @Test + fun debugSearchBar_searchFor_showsArrowsClearAndValues() { + val searchText = "test" + val matchCount = 3 + val currentMatchIndex = 1 + + composeTestRule.setContent { + DebugSearchBar( + searchState = SearchState( + searchText = searchText, + currentMatchIndex = currentMatchIndex, + allMatches = List(matchCount) { com.geeksville.mesh.model.LogSearchManager.SearchMatch(it, 0, 6, "Packet") }, + hasMatches = true + ), + onSearchTextChange = {}, + onNextMatch = {}, + onPreviousMatch = {}, + onClearSearch = {} + ) + } + // Check the match count display (e.g., '2/3') + composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed() + // Check the navigation arrows + composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed() + // Check the clear button + composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed() + } + + @Test + fun debugFilterBar_showsFilterButtonAndMenu() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val filterLabel = context.getString(R.string.debug_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) } + var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + val presetFilters = listOf("Error", "Warning", "Info") + com.geeksville.mesh.ui.debug.DebugFilterBar( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it }, + customFilterText = customFilterText, + onCustomFilterTextChange = { customFilterText = it }, + presetFilters = presetFilters + ) + } + // The filter button should be visible + composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed() + } + + @Test + fun debugFilterBar_addCustomFilter_displaysActiveFilter() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activeFiltersLabel = context.getString(R.string.debug_active_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf()) } + var customFilterText by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf("") } + com.geeksville.mesh.ui.debug.DebugActiveFilters( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + com.geeksville.mesh.ui.debug.DebugCustomFilterInput( + customFilterText = customFilterText, + onCustomFilterTextChange = { customFilterText = it }, + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + } + // Add a custom filter + composeTestRule.onNodeWithText("Add custom filter").performTextInput("MyFilter") + composeTestRule.onNodeWithContentDescription("Add filter").performClick() + // The active filters label and the filter chip should be visible + composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("MyFilter").assertIsDisplayed() + } + + @Test + fun debugActiveFilters_clearAllFilters_removesFilters() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val activeFiltersLabel = context.getString(R.string.debug_active_filters) + composeTestRule.setContent { + var filterTexts by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(listOf("A", "B")) } + com.geeksville.mesh.ui.debug.DebugActiveFilters( + filterTexts = filterTexts, + onFilterTextsChange = { filterTexts = it } + ) + } + // The active filters label and chips should be visible + composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed() + composeTestRule.onNodeWithText("A").assertIsDisplayed() + composeTestRule.onNodeWithText("B").assertIsDisplayed() + // Click the clear all filters button + composeTestRule.onNodeWithContentDescription("Clear all filters").performClick() + // The filter chips should no longer be visible + composeTestRule.onNodeWithText("A").assertDoesNotExist() + composeTestRule.onNodeWithText("B").assertDoesNotExist() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt index 801a1df6e..161d2b6dd 100644 --- a/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/DebugViewModel.kt @@ -31,11 +31,126 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.text.DateFormat import java.util.Locale import javax.inject.Inject +import com.geeksville.mesh.Portnums.PortNum +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class SearchMatch( + val logIndex: Int, + val start: Int, + val end: Int, + val field: String +) + +data class SearchState( + val searchText: String = "", + val currentMatchIndex: Int = -1, + val allMatches: List = emptyList(), + val hasMatches: Boolean = false +) + +// --- Search and Filter Managers --- +class LogSearchManager { + data class SearchMatch( + val logIndex: Int, + val start: Int, + val end: Int, + val field: String + ) + + data class SearchState( + val searchText: String = "", + val currentMatchIndex: Int = -1, + val allMatches: List = emptyList(), + val hasMatches: Boolean = false + ) + + private val _searchText = MutableStateFlow("") + val searchText = _searchText.asStateFlow() + + private val _currentMatchIndex = MutableStateFlow(-1) + val currentMatchIndex = _currentMatchIndex.asStateFlow() + + private val _searchState = MutableStateFlow(SearchState()) + val searchState = _searchState.asStateFlow() + + fun setSearchText(text: String) { + _searchText.value = text + _currentMatchIndex.value = -1 + } + + fun goToNextMatch() { + val matches = _searchState.value.allMatches + if (matches.isNotEmpty()) { + val nextIndex = if (_currentMatchIndex.value < matches.lastIndex) _currentMatchIndex.value + 1 else 0 + _currentMatchIndex.value = nextIndex + _searchState.value = _searchState.value.copy(currentMatchIndex = nextIndex) + } + } + + fun goToPreviousMatch() { + val matches = _searchState.value.allMatches + if (matches.isNotEmpty()) { + val prevIndex = if (_currentMatchIndex.value > 0) _currentMatchIndex.value - 1 else matches.lastIndex + _currentMatchIndex.value = prevIndex + _searchState.value = _searchState.value.copy(currentMatchIndex = prevIndex) + } + } + + fun clearSearch() { + setSearchText("") + } + + fun updateMatches(searchText: String, filteredLogs: List) { + val matches = findSearchMatches(searchText, filteredLogs) + val hasMatches = matches.isNotEmpty() + _searchState.value = _searchState.value.copy( + searchText = searchText, + allMatches = matches, + hasMatches = hasMatches, + currentMatchIndex = if (hasMatches) _currentMatchIndex.value.coerceIn(0, matches.lastIndex) else -1 + ) + } + + fun findSearchMatches(searchText: String, filteredLogs: List): List { + if (searchText.isEmpty()) { + return emptyList() + } + return filteredLogs.flatMapIndexed { logIndex, log -> + searchText.split(" ").flatMap { term -> + val messageMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.logMessage) + .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "message") } + val typeMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.messageType) + .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "type") } + val dateMatches = term.toRegex(RegexOption.IGNORE_CASE).findAll(log.formattedReceivedDate) + .map { match -> SearchMatch(logIndex, match.range.first, match.range.last, "date") } + messageMatches + typeMatches + dateMatches + } + }.sortedBy { it.start } + } +} + +class LogFilterManager { + private val _filterTexts = MutableStateFlow>(emptyList()) + val filterTexts = _filterTexts.asStateFlow() + + private val _filteredLogs = MutableStateFlow>(emptyList()) + val filteredLogs = _filteredLogs.asStateFlow() + + fun setFilterTexts(filters: List) { + _filterTexts.value = filters + } + + fun updateFilteredLogs(logs: List) { + _filteredLogs.value = logs + } +} @HiltViewModel class DebugViewModel @Inject constructor( @@ -46,8 +161,33 @@ class DebugViewModel @Inject constructor( .map(::toUiState) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf()) + // --- Managers --- + val searchManager = LogSearchManager() + val filterManager = LogFilterManager() + + val searchText get() = searchManager.searchText + val currentMatchIndex get() = searchManager.currentMatchIndex + val searchState get() = searchManager.searchState + val filterTexts get() = filterManager.filterTexts + val filteredLogs get() = filterManager.filteredLogs + + private val _selectedLogId = MutableStateFlow(null) + val selectedLogId = _selectedLogId.asStateFlow() + + fun updateFilteredLogs(logs: List) { + filterManager.updateFilteredLogs(logs) + searchManager.updateMatches(searchManager.searchText.value, logs) + } + init { debug("DebugViewModel created") + viewModelScope.launch { + combine(searchManager.searchText, filterManager.filteredLogs) { searchText, logs -> + searchManager.findSearchMatches(searchText, logs) + }.collect { matches -> + searchManager.updateMatches(searchManager.searchText.value, filterManager.filteredLogs.value) + } + } } override fun onCleared() { @@ -134,4 +274,11 @@ class DebugViewModel @Inject constructor( companion object { private val TIME_FORMAT = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) } + + val presetFilters = arrayOf( + // "!xxxxxxxx", // Dynamically determine the address of the connected node (i.e., messages to us). + "!ffffffff", // broadcast + ) + PortNum.entries.map { it.name } // all apps + + fun setSelectedLogId(id: String?) { _selectedLogId.value = id } } 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 86373e334..67cd85075 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 @@ -17,7 +17,19 @@ package com.geeksville.mesh.ui.debug +import android.content.Context +import android.os.Environment +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.FileOutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -27,12 +39,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CloudDownload +import androidx.compose.material.icons.twotone.FilterAltOff import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ColorScheme import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -59,6 +80,14 @@ import com.geeksville.mesh.R import com.geeksville.mesh.model.DebugViewModel 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.ui.platform.LocalContext +import androidx.compose.runtime.rememberCoroutineScope +import androidx.datastore.core.IOException +import com.geeksville.mesh.android.BuildUtils.warn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE) @@ -68,24 +97,60 @@ internal fun DebugScreen( ) { val listState = rememberLazyListState() val logs by viewModel.meshLog.collectAsStateWithLifecycle() + val searchState by viewModel.searchState.collectAsStateWithLifecycle() + val filterTexts by viewModel.filterTexts.collectAsStateWithLifecycle() + val selectedLogId by viewModel.selectedLogId.collectAsStateWithLifecycle() + + val filteredLogs = remember(logs, filterTexts) { + logs.filter { log -> + filterTexts.isEmpty() || filterTexts.any { filterText -> + log.logMessage.contains(filterText, ignoreCase = true) || + log.messageType.contains(filterText, ignoreCase = true) || + log.formattedReceivedDate.contains(filterText, ignoreCase = true) + } + }.toImmutableList() + } + + LaunchedEffect(filteredLogs) { + viewModel.updateFilteredLogs(filteredLogs) + } val shouldAutoScroll by remember { derivedStateOf { listState.firstVisibleItemIndex < 3 } } if (shouldAutoScroll) { - LaunchedEffect(logs) { + LaunchedEffect(filteredLogs) { if (!listState.isScrollInProgress) { listState.animateScrollToItem(0) } } } + // Handle search result navigation + LaunchedEffect(searchState) { + if (searchState.currentMatchIndex >= 0 && searchState.currentMatchIndex < searchState.allMatches.size) { + listState.requestScrollToItem(searchState.allMatches[searchState.currentMatchIndex].logIndex) + } + } LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, ) { - items(logs, key = { it.uuid }) { log -> + stickyHeader { + DebugSearchStateviewModelDefaults( + searchState = searchState, + filterTexts = filterTexts, + presetFilters = viewModel.presetFilters.asList(), + ) + } + + items(filteredLogs, key = { it.uuid }) { log -> DebugItem( modifier = Modifier.animateItem(), - log = log + log = log, + searchText = searchState.searchText, + isSelected = selectedLogId == log.uuid, + onLogClick = { + viewModel.setSelectedLogId(if (selectedLogId == log.uuid) null else log.uuid) + } ) } } @@ -95,47 +160,50 @@ internal fun DebugScreen( internal fun DebugItem( log: UiMeshLog, modifier: Modifier = Modifier, + searchText: String = "", + isSelected: Boolean = false, + onLogClick: () -> Unit = {} ) { + val colorScheme = MaterialTheme.colorScheme + Card( modifier = modifier .fillMaxWidth() .padding(4.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + colorScheme.primary.copy(alpha = 0.1f) + } else { + colorScheme.surface + } + ), + border = if (isSelected) { + BorderStroke(2.dp, colorScheme.primary) + } else { + null + } ) { SelectionContainer { Column( - modifier = Modifier.padding(8.dp) + modifier = Modifier + .padding(if (isSelected) 12.dp else 8.dp) + .fillMaxWidth() + .clickable { onLogClick() } ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = log.messageType, - modifier = Modifier.weight(1f), - style = TextStyle(fontWeight = FontWeight.Bold), - ) - Icon( - imageVector = Icons.Outlined.CloudDownload, - contentDescription = stringResource(id = R.string.logs), - tint = Color.Gray.copy(alpha = 0.6f), - modifier = Modifier.padding(end = 8.dp), - ) - Text( - text = log.formattedReceivedDate, - style = TextStyle(fontWeight = FontWeight.Bold), - ) - } - - val annotatedString = rememberAnnotatedLogMessage(log) + DebugItemHeader( + log = log, + searchText = searchText, + isSelected = isSelected, + theme = colorScheme + ) + val messageAnnotatedString = rememberAnnotatedLogMessage(log, searchText) Text( - text = annotatedString, + text = messageAnnotatedString, softWrap = false, style = TextStyle( - fontSize = 9.sp, + fontSize = if (isSelected) 12.sp else 9.sp, fontFamily = FontFamily.Monospace, + color = colorScheme.onSurface ) ) } @@ -144,14 +212,103 @@ internal fun DebugItem( } @Composable -private fun rememberAnnotatedLogMessage(log: UiMeshLog): AnnotatedString { +private fun DebugItemHeader( + log: UiMeshLog, + searchText: String, + isSelected: Boolean, + theme: ColorScheme +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = if (isSelected) 12.dp else 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val typeAnnotatedString = rememberAnnotatedString( + text = log.messageType, + searchText = searchText + ) + Text( + text = typeAnnotatedString, + modifier = Modifier.weight(1f), + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = if (isSelected) 16.sp else 14.sp, + color = theme.onSurface + ), + ) + CopyIconButton( + valueToCopy = log.logMessage, + modifier = Modifier.padding(start = 8.dp) + ) + Icon( + imageVector = Icons.Outlined.CloudDownload, + contentDescription = stringResource(id = R.string.logs), + tint = Color.Gray.copy(alpha = 0.6f), + modifier = Modifier.padding(end = 8.dp), + ) + val dateAnnotatedString = rememberAnnotatedString( + text = log.formattedReceivedDate, + searchText = searchText + ) + Text( + text = dateAnnotatedString, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = if (isSelected) 14.sp else 12.sp, + color = theme.onSurface + ), + ) + } +} + +@Composable +private fun rememberAnnotatedString( + text: String, + searchText: String +): AnnotatedString { + val theme = MaterialTheme.colorScheme + val highlightStyle = SpanStyle( + background = theme.primary.copy(alpha = 0.3f), + color = theme.onSurface + ) + + return remember(text, searchText) { + buildAnnotatedString { + append(text) + if (searchText.isNotEmpty()) { + searchText.split(" ").forEach { term -> + term.toRegex(RegexOption.IGNORE_CASE).findAll(text).forEach { match -> + addStyle( + style = highlightStyle, + start = match.range.first, + end = match.range.last + 1 + ) + } + } + } + } + } +} + +@Composable +private fun rememberAnnotatedLogMessage(log: UiMeshLog, searchText: String): AnnotatedString { + val theme = MaterialTheme.colorScheme val style = SpanStyle( color = colorResource(id = R.color.colorAnnotation), fontStyle = FontStyle.Italic, ) - return remember(log.uuid) { + val highlightStyle = SpanStyle( + background = theme.primary.copy(alpha = 0.3f), + color = theme.onSurface + ) + + return remember(log.uuid, searchText) { buildAnnotatedString { append(log.logMessage) + + // Add node ID annotations REGEX_ANNOTATED_NODE_ID.findAll(log.logMessage).toList().reversed() .forEach { addStyle( @@ -160,13 +317,26 @@ private fun rememberAnnotatedLogMessage(log: UiMeshLog): AnnotatedString { end = it.range.last + 1 ) } + + // Add search highlight annotations + if (searchText.isNotEmpty()) { + searchText.split(" ").forEach { term -> + term.toRegex(RegexOption.IGNORE_CASE).findAll(log.logMessage).forEach { match -> + addStyle( + style = highlightStyle, + start = match.range.first, + end = match.range.last + 1 + ) + } + } + } } } } @PreviewLightDark @Composable -private fun DebugScreenPreview() { +private fun DebugPacketPreview() { AppTheme { DebugItem( UiMeshLog( @@ -193,15 +363,365 @@ private fun DebugScreenPreview() { } } +@PreviewLightDark +@Composable +private fun DebugItemWithSearchHighlightPreview() { + AppTheme { + DebugItem( + UiMeshLog( + uuid = "1", + messageType = "TextMessage", + formattedReceivedDate = "9/27/20, 8:00:58 PM", + logMessage = "Hello world! This is a test message with some keywords to search for." + ), + searchText = "test message" + ) + } +} + +@PreviewLightDark +@Composable +private fun DebugItemPositionPreview() { + AppTheme { + DebugItem( + UiMeshLog( + uuid = "2", + messageType = "Position", + formattedReceivedDate = "9/27/20, 8:01:15 PM", + logMessage = "Position update from node (!a1b2c3d4) at coordinates 40.7128, -74.0060" + ) + ) + } +} + +@PreviewLightDark +@Composable +private fun DebugItemErrorPreview() { + AppTheme { + DebugItem( + UiMeshLog( + uuid = "3", + messageType = "Error", + formattedReceivedDate = "9/27/20, 8:02:30 PM", + logMessage = "Connection failed: timeout after 30 seconds\n" + + "Retry attempt: 3/5\n" + + "Last known position: 40.7128, -74.0060" + ) + ) + } +} + +@PreviewLightDark +@Composable +private fun DebugItemLongMessagePreview() { + AppTheme { + DebugItem( + UiMeshLog( + uuid = "4", + messageType = "Waypoint", + formattedReceivedDate = "9/27/20, 8:03:45 PM", + logMessage = "Waypoint created:\n" + + " Name: Home Base\n" + + " Description: Primary meeting location\n" + + " Latitude: 40.7128\n" + + " Longitude: -74.0060\n" + + " Altitude: 100m\n" + + " Icon: šŸ \n" + + " Created by: (!a1b2c3d4)\n" + + " Expires: 2025-12-31 23:59:59" + ) + ) + } +} + +@PreviewLightDark +@Composable +private fun DebugItemSelectedPreview() { + AppTheme { + DebugItem( + UiMeshLog( + uuid = "5", + messageType = "TextMessage", + formattedReceivedDate = "9/27/20, 8:04:20 PM", + logMessage = "This is a selected log item with larger font sizes for better readability." + ), + isSelected = true + ) + } +} + +@PreviewLightDark +@Composable +private fun DebugMenuActionsPreview() { + AppTheme { + Row( + modifier = Modifier.padding(16.dp) + ) { + Button( + onClick = { /* Preview only */ }, + modifier = Modifier.padding(4.dp) + ) { + Text(text = "Export Logs") + } + Button( + onClick = { /* Preview only */ }, + modifier = Modifier.padding(4.dp) + ) { + Text(text = "Clear All") + } + } + } +} + +@PreviewLightDark +@Composable +@Suppress("detekt:LongMethod") // big preview +private fun DebugScreenEmptyPreview() { + AppTheme { + Surface { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + stickyHeader { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Column( + modifier = Modifier.padding(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = "", + onValueChange = { }, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + placeholder = { Text("Search in logs...") }, + singleLine = true + ) + TextButton( + onClick = { } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "Filters", + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Icon( + imageVector = Icons.TwoTone.FilterAltOff, + contentDescription = "Filter" + ) + } + } + } + } + } + } + } + // Empty state + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No Debug Logs", + style = TextStyle( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + ) + Text( + text = "Debug logs will appear here when available", + style = TextStyle( + fontSize = 14.sp, + color = Color.Gray + ), + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + } + } + } +} + +@PreviewLightDark +@Composable +@Suppress("detekt:LongMethod") // big preview +private fun DebugScreenWithSampleDataPreview() { + AppTheme { + val sampleLogs = listOf( + UiMeshLog( + uuid = "1", + messageType = "NodeInfo", + formattedReceivedDate = "9/27/20, 8:00:58 PM", + logMessage = "from: 2885173132\n" + + "decoded {\n" + + " position {\n" + + " altitude: 60\n" + + " battery_level: 81\n" + + " latitude_i: 411111136\n" + + " longitude_i: -711111805\n" + + " time: 1600390966\n" + + " }\n" + + "}\n" + + "hop_limit: 3\n" + + "id: 1737414295\n" + + "rx_snr: 9.5\n" + + "rx_time: 316400569\n" + + "to: -1409790708" + ), + UiMeshLog( + uuid = "2", + messageType = "TextMessage", + formattedReceivedDate = "9/27/20, 8:01:15 PM", + logMessage = "Hello from node (!a1b2c3d4)! How's the weather today?" + ), + UiMeshLog( + uuid = "3", + messageType = "Position", + formattedReceivedDate = "9/27/20, 8:02:30 PM", + logMessage = "Position update: 40.7128, -74.0060, altitude: 100m, battery: 85%" + ), + UiMeshLog( + uuid = "4", + messageType = "Waypoint", + formattedReceivedDate = "9/27/20, 8:03:45 PM", + logMessage = "New waypoint created: 'Meeting Point' at 40.7589, -73.9851" + ), + UiMeshLog( + uuid = "5", + messageType = "Error", + formattedReceivedDate = "9/27/20, 8:04:20 PM", + logMessage = "Connection timeout - retrying in 5 seconds..." + ) + ) + + // Note: This preview shows the UI structure but won't have actual data + // since the ViewModel isn't injected in previews + Surface { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + stickyHeader { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Column( + modifier = Modifier.padding(8.dp) + ) { + Text( + text = "Debug Screen Preview", + style = TextStyle(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(bottom = 8.dp) + ) + Text( + text = "Search and filter controls would appear here", + style = TextStyle(fontSize = 12.sp, color = Color.Gray) + ) + } + } + } + items(sampleLogs) { log -> + DebugItem(log = log) + } + } + } + } +} + @Composable fun DebugMenuActions( viewModel: DebugViewModel = hiltViewModel(), modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val logs by viewModel.meshLog.collectAsStateWithLifecycle() + + Button( + onClick = { + scope.launch { + exportAllLogs(context, logs) + } + }, + modifier = modifier, + ) { + Text(text = stringResource(R.string.debug_logs_export)) + } Button( onClick = viewModel::deleteAllLogs, modifier = modifier, ) { - Text(text = stringResource(R.string.clear)) + Text(text = stringResource(R.string.debug_clear)) + } +} + +private suspend fun exportAllLogs(context: Context, logs: List) = withContext(Dispatchers.IO) { + try { + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "meshtastic_debug_$timestamp.log" + + // Get the Downloads directory + val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val logFile = File(downloadsDir, fileName) + + // Create the file and write logs + OutputStreamWriter(FileOutputStream(logFile), StandardCharsets.UTF_8).use { writer -> + logs.forEach { log -> + writer.write("${log.formattedReceivedDate} [${log.messageType}]\n") + writer.write(log.logMessage) + writer.write("\n\n") + } + } + + // Notify user of success + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Logs exported to ${logFile.absolutePath}", + Toast.LENGTH_LONG + ).show() + } + } catch (e: SecurityException) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Permission denied: Cannot write to Downloads folder", + Toast.LENGTH_LONG + ).show() + warn("Error:SecurityException: " + e.toString()) + } + } catch (e: IOException) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Failed to write log file: ${e.message}", + Toast.LENGTH_LONG + ).show() + } + warn("Error:IOException: " + e.toString()) } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt new file mode 100644 index 000000000..cdadcc1a8 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugFilters.kt @@ -0,0 +1,280 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.debug + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.twotone.FilterAlt +import androidx.compose.material.icons.twotone.FilterAltOff +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import com.geeksville.mesh.R + +@Composable +fun DebugCustomFilterInput( + customFilterText: String, + onCustomFilterTextChange: (String) -> Unit, + filterTexts: List, + onFilterTextsChange: (List) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = customFilterText, + onValueChange = onCustomFilterTextChange, + modifier = Modifier.weight(1f), + placeholder = { Text("Add custom filter") }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (customFilterText.isNotBlank()) { + onFilterTextsChange(filterTexts + customFilterText) + onCustomFilterTextChange("") + } + } + ) + ) + Spacer(modifier = Modifier.padding(horizontal = 8.dp)) + IconButton( + onClick = { + if (customFilterText.isNotBlank()) { + onFilterTextsChange(filterTexts + customFilterText) + onCustomFilterTextChange("") + } + }, + enabled = customFilterText.isNotBlank() + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Add filter" + ) + } + } +} + +@Composable +internal fun DebugPresetFilters( + presetFilters: List, + filterTexts: List, + onFilterTextsChange: (List) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Text( + text = "Preset Filters", + style = TextStyle(fontWeight = FontWeight.Bold), + modifier = Modifier.padding(vertical = 4.dp) + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + for (filter in presetFilters) { + FilterChip( + selected = filter in filterTexts, + onClick = { + onFilterTextsChange( + if (filter in filterTexts) { + filterTexts - filter + } else { + filterTexts + filter + } + ) + }, + label = { Text(filter) }, + leadingIcon = { if (filter in filterTexts) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = "Done icon", + ) + } + } + ) + } + } + } +} + +@Composable +internal fun DebugFilterBar( + filterTexts: List, + onFilterTextsChange: (List) -> Unit, + customFilterText: String, + onCustomFilterTextChange: (String) -> Unit, + presetFilters: List, + modifier: Modifier = Modifier +) { + var showFilterMenu by remember { mutableStateOf(false) } + Row( + modifier = modifier, + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Box { + TextButton( + onClick = { showFilterMenu = !showFilterMenu } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.debug_filters), + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Icon( + imageVector = if (filterTexts.isNotEmpty()) { + Icons.TwoTone.FilterAlt + } else { + Icons.TwoTone.FilterAltOff + }, + contentDescription = "Filter" + ) + } + } + DropdownMenu( + expanded = showFilterMenu, + onDismissRequest = { showFilterMenu = false }, + offset = DpOffset(0.dp, 8.dp) + ) { + Column( + modifier = Modifier + .padding(8.dp) + .width(300.dp) + ) { + DebugCustomFilterInput( + customFilterText = customFilterText, + onCustomFilterTextChange = onCustomFilterTextChange, + filterTexts = filterTexts, + onFilterTextsChange = onFilterTextsChange + ) + DebugPresetFilters( + presetFilters = presetFilters, + filterTexts = filterTexts, + onFilterTextsChange = onFilterTextsChange + ) + } + } + } + } +} + +@Composable +internal fun DebugActiveFilters( + filterTexts: List, + onFilterTextsChange: (List) -> Unit, + modifier: Modifier = Modifier +) { + val colorScheme = MaterialTheme.colorScheme + + if (filterTexts.isNotEmpty()) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp) + .background(colorScheme.background.copy(alpha = 1.0f)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.debug_active_filters), + style = TextStyle(fontWeight = FontWeight.Bold) + ) + IconButton( + onClick = { onFilterTextsChange(emptyList()) } + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear all filters" + ) + } + } + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 0.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + for (filter in filterTexts) { + FilterChip( + selected = true, + onClick = { + onFilterTextsChange(filterTexts - filter) + }, + label = { Text(filter) }, + leadingIcon = { + Icon( + imageVector = Icons.TwoTone.FilterAlt, + contentDescription = null + ) + }, + trailingIcon = { + Icon( + imageVector = Icons.Filled.Clear, + contentDescription = null + ) + } + ) + } + } + } + } +} 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 new file mode 100644 index 000000000..65420b739 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/DebugSearch.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.ui.debug + +import androidx.compose.foundation.layout.Arrangement +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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.geeksville.mesh.R +import com.geeksville.mesh.model.LogSearchManager.SearchMatch +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 + +@Composable +internal fun DebugSearchNavigation( + searchState: SearchState, + onNextMatch: () -> Unit, + onPreviousMatch: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.width(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${searchState.currentMatchIndex + 1}/${searchState.allMatches.size}", + modifier = Modifier.padding(end = 4.dp), + style = TextStyle(fontSize = 12.sp) + ) + IconButton( + onClick = onPreviousMatch, + enabled = searchState.hasMatches, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = "Previous match", + modifier = Modifier.size(16.dp) + ) + } + IconButton( + onClick = onNextMatch, + enabled = searchState.hasMatches, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = "Next match", + modifier = Modifier.size(16.dp) + ) + } + } +} + +@Composable +internal fun DebugSearchBar( + searchState: SearchState, + onSearchTextChange: (String) -> Unit, + onNextMatch: () -> Unit, + onPreviousMatch: () -> Unit, + onClearSearch: () -> Unit, + modifier: Modifier = Modifier +) { + OutlinedTextField( + value = searchState.searchText, + onValueChange = onSearchTextChange, + modifier = modifier + .padding(end = 8.dp), + placeholder = { Text(stringResource(R.string.debug_default_search)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + // Clear focus when search is performed + } + ), + trailingIcon = { + Row( + modifier = Modifier.width(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (searchState.hasMatches) { + DebugSearchNavigation( + searchState = searchState, + onNextMatch = onNextMatch, + onPreviousMatch = onPreviousMatch + ) + } + if (searchState.searchText.isNotEmpty()) { + IconButton( + onClick = onClearSearch, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear search", + modifier = Modifier.size(16.dp) + ) + } + } + } + } + ) +} + +@Composable +internal fun DebugSearchState( + searchState: SearchState, + filterTexts: List, + presetFilters: List, + onSearchTextChange: (String) -> Unit, + onNextMatch: () -> Unit, + onPreviousMatch: () -> Unit, + onClearSearch: () -> Unit, + onFilterTextsChange: (List) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Column( + modifier = Modifier.padding(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth() + .background(colorScheme.background.copy(alpha = 1.0f)), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + DebugSearchBar( + searchState = searchState, + onSearchTextChange = onSearchTextChange, + onNextMatch = onNextMatch, + onPreviousMatch = onPreviousMatch, + onClearSearch = onClearSearch + ) + DebugFilterBar( + filterTexts = filterTexts, + onFilterTextsChange = onFilterTextsChange, + customFilterText = "", + onCustomFilterTextChange = {}, + presetFilters = presetFilters + ) + } + } + + DebugActiveFilters( + filterTexts = filterTexts, + onFilterTextsChange = onFilterTextsChange + ) +} + +@Composable +fun DebugSearchStateviewModelDefaults( + searchState: SearchState, + filterTexts: List, + presetFilters: List, +) { + val viewModel: DebugViewModel = hiltViewModel() + DebugSearchState( + searchState = searchState, + filterTexts = filterTexts, + presetFilters = presetFilters, + onSearchTextChange = viewModel.searchManager::setSearchText, + onNextMatch = viewModel.searchManager::goToNextMatch, + onPreviousMatch = viewModel.searchManager::goToPreviousMatch, + onClearSearch = viewModel.searchManager::clearSearch, + onFilterTextsChange = viewModel.filterManager::setFilterTexts, + ) +} + +@PreviewLightDark +@Composable +private fun DebugSearchBarEmptyPreview() { + AppTheme { + Surface { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DebugSearchBar( + searchState = SearchState(), + onSearchTextChange = { }, + onNextMatch = { }, + onPreviousMatch = { }, + onClearSearch = { } + ) + } + } + } +} + +@PreviewLightDark +@Composable +@Suppress("detekt:MagicNumber") // fake data +private fun DebugSearchBarWithTextPreview() { + AppTheme { + Surface { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DebugSearchBar( + searchState = SearchState( + searchText = "test message", + currentMatchIndex = 2, + allMatches = List(5) { SearchMatch(it, 0, 10, "message") }, + hasMatches = true + ), + onSearchTextChange = { }, + onNextMatch = { }, + onPreviousMatch = { }, + onClearSearch = { } + ) + } + } + } +} + +@PreviewLightDark +@Composable +@Suppress("detekt:MagicNumber") // fake data +private fun DebugSearchBarWithMatchesPreview() { + AppTheme { + Surface { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + DebugSearchBar( + searchState = SearchState( + searchText = "error", + currentMatchIndex = 0, + allMatches = List(3) { SearchMatch(it, 0, 5, "message") }, + hasMatches = true + ), + onSearchTextChange = { }, + onNextMatch = { }, + onPreviousMatch = { }, + onClearSearch = { } + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e85a43ecd..75ac82e97 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -162,7 +162,12 @@ Text messages This Channel URL is invalid and can not be used Debug Panel + Export Logs 500 last messages + Filters + Active filters + Search in logs... + Clear Logs Clear Updating firmware, wait up to eight minutes… Update successful diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index b27dfe37e..9cc02f625 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -176,6 +176,7 @@ complexity: ignorePrivate: false ignoreOverridden: false ignoreAnnotated: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ] + ignoreAnnotatedFunctions: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ] coroutines: active: true