feat (#2105): debug panel (#2148)

Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
pull/2166/head^2
DaneEvans 2025-06-20 00:04:58 +10:00 zatwierdzone przez GitHub
rodzic e9f95dbf8c
commit 17e3e1a257
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
9 zmienionych plików z 1571 dodań i 35 usunięć

3
.gitignore vendored
Wyświetl plik

@ -26,3 +26,6 @@ keystore.properties
# Kotlin compiler
.kotlin
# VS code
.vscode/settings.json

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()) }
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<String>()) }
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()
}
}

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>()) }
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<String>()) }
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()
}
}

Wyświetl plik

@ -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<SearchMatch> = 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<SearchMatch> = 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<DebugViewModel.UiMeshLog>) {
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<DebugViewModel.UiMeshLog>): List<SearchMatch> {
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<List<String>>(emptyList())
val filterTexts = _filterTexts.asStateFlow()
private val _filteredLogs = MutableStateFlow<List<DebugViewModel.UiMeshLog>>(emptyList())
val filteredLogs = _filteredLogs.asStateFlow()
fun setFilterTexts(filters: List<String>) {
_filterTexts.value = filters
}
fun updateFilteredLogs(logs: List<DebugViewModel.UiMeshLog>) {
_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<String?>(null)
val selectedLogId = _selectedLogId.asStateFlow()
fun updateFilteredLogs(logs: List<UiMeshLog>) {
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 }
}

Wyświetl plik

@ -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<UiMeshLog>) = 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())
}
}

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>,
onFilterTextsChange: (List<String>) -> 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<String>,
filterTexts: List<String>,
onFilterTextsChange: (List<String>) -> 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<String>,
onFilterTextsChange: (List<String>) -> Unit,
customFilterText: String,
onCustomFilterTextChange: (String) -> Unit,
presetFilters: List<String>,
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<String>,
onFilterTextsChange: (List<String>) -> 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
)
}
)
}
}
}
}
}

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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<String>,
presetFilters: List<String>,
onSearchTextChange: (String) -> Unit,
onNextMatch: () -> Unit,
onPreviousMatch: () -> Unit,
onClearSearch: () -> Unit,
onFilterTextsChange: (List<String>) -> 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<String>,
presetFilters: List<String>,
) {
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 = { }
)
}
}
}
}

Wyświetl plik

@ -162,7 +162,12 @@
<string name="text_messages">Text messages</string>
<string name="channel_invalid">This Channel URL is invalid and can not be used</string>
<string name="debug_panel">Debug Panel</string>
<string name="debug_logs_export">Export Logs</string>
<string name="debug_last_messages">500 last messages</string>
<string name="debug_filters">Filters</string>
<string name="debug_active_filters">Active filters</string>
<string name="debug_default_search">Search in logs...</string>
<string name="debug_clear">Clear Logs</string>
<string name="clear">Clear</string>
<string name="updating_firmware">Updating firmware, wait up to eight minutes…</string>
<string name="update_successful">Update successful</string>

Wyświetl plik

@ -176,6 +176,7 @@ complexity:
ignorePrivate: false
ignoreOverridden: false
ignoreAnnotated: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
ignoreAnnotatedFunctions: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
coroutines:
active: true