Introduction of stable Compose UI State and some simple animations in Debug Panel (#1575)

* Add dependency to KotlinX immutable collections

* Build a Compose-stable UI state vs using a database model. Move appropriate mapping logic for converting database model -> UI state into the view model. Introduce animations to new log placement and automated scroll.

* Center the top card row vertically

* Move log message generation into separate method

---------

Co-authored-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
Joshua Soberg 2025-02-16 07:09:41 -05:00 committed by GitHub
parent e15ad23c46
commit 37489604f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 134 additions and 87 deletions

View file

@ -198,6 +198,9 @@ dependencies {
implementation 'androidx.activity:activity-compose'
implementation 'androidx.compose.runtime:runtime-livedata'
// Immutable Collections (for Compose UI State)
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.8")
// Android Studio Preview support
implementation 'androidx.compose.ui:ui-tooling-preview'
debugImplementation 'androidx.compose.ui:ui-tooling'

View file

@ -17,25 +17,34 @@
package com.geeksville.mesh.model
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.database.MeshLogRepository
import com.geeksville.mesh.database.entity.MeshLog
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.text.DateFormat
import java.util.Locale
import javax.inject.Inject
@HiltViewModel
class DebugViewModel @Inject constructor(
private val meshLogRepository: MeshLogRepository,
) : ViewModel(), Logging {
val meshLog: StateFlow<List<MeshLog>> = meshLogRepository.getAllLogs()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val meshLog: StateFlow<ImmutableList<UiMeshLog>> = meshLogRepository.getAllLogs()
.map(::toUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), persistentListOf())
init {
debug("DebugViewModel created")
@ -46,7 +55,83 @@ class DebugViewModel @Inject constructor(
debug("DebugViewModel cleared")
}
private fun toUiState(databaseLogs: List<MeshLog>) = databaseLogs.map { log ->
UiMeshLog(
uuid = log.uuid,
messageType = log.message_type,
formattedReceivedDate = TIME_FORMAT.format(log.received_date),
logMessage = annotateMeshLogMessage(log),
)
}.toImmutableList()
/**
* Transform the input [MeshLog] by enhancing the raw message with annotations.
*/
private fun annotateMeshLogMessage(meshLog: MeshLog): String {
val annotated = when (meshLog.message_type) {
"Packet" -> meshLog.meshPacket?.let { packet ->
annotateRawMessage(meshLog.raw_message, packet.from, packet.to)
}
"NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.num)
}
"MyNodeInfo" -> meshLog.myNodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum)
}
else -> null
}
return annotated ?: meshLog.raw_message
}
/**
* Annotate the raw message string with the node IDs provided, in hex, if they are present.
*/
private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String {
val msg = StringBuilder(rawMessage)
var mutated = false
nodeIds.forEach { nodeId ->
mutated = mutated or msg.annotateNodeId(nodeId)
}
return if (mutated) {
return msg.toString()
} else {
rawMessage
}
}
/**
* Look for a single node ID integer in the string and annotate it with the hex equivalent
* if found.
*/
private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean {
val nodeIdStr = nodeId.toUInt().toString()
indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx ->
insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})")
return true
}
return false
}
private fun Int.asNodeId(): String {
return "!%08x".format(Locale.getDefault(), this)
}
fun deleteAllLogs() = viewModelScope.launch(Dispatchers.IO) {
meshLogRepository.deleteAll()
}
@Immutable
data class UiMeshLog(
val uuid: String,
val messageType: String,
val formattedReceivedDate: String,
val logMessage: String,
)
companion object {
private val TIME_FORMAT = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
}

View file

@ -44,12 +44,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -63,13 +65,11 @@ import androidx.fragment.app.Fragment
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.R
import com.geeksville.mesh.database.entity.MeshLog
import com.geeksville.mesh.model.DebugViewModel
import com.geeksville.mesh.model.DebugViewModel.UiMeshLog
import com.geeksville.mesh.ui.components.BaseScaffold
import com.geeksville.mesh.ui.theme.AppTheme
import dagger.hilt.android.AndroidEntryPoint
import java.text.DateFormat
import java.util.Locale
@AndroidEntryPoint
class DebugFragment : Fragment() {
@ -91,65 +91,6 @@ class DebugFragment : Fragment() {
private val REGEX_ANNOTATED_NODE_ID = Regex("\\(![0-9a-fA-F]{8}\\)$", RegexOption.MULTILINE)
/**
* Transform the input [MeshLog] by enhancing the raw message with annotations.
*/
private fun annotateMeshLog(meshLog: MeshLog): MeshLog {
val annotated = when (meshLog.message_type) {
"Packet" -> meshLog.meshPacket?.let { packet ->
annotateRawMessage(meshLog.raw_message, packet.from, packet.to)
}
"NodeInfo" -> meshLog.nodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.num)
}
"MyNodeInfo" -> meshLog.myNodeInfo?.let { nodeInfo ->
annotateRawMessage(meshLog.raw_message, nodeInfo.myNodeNum)
}
else -> null
}
return if (annotated == null) {
meshLog
} else {
meshLog.copy(raw_message = annotated)
}
}
/**
* Annotate the raw message string with the node IDs provided, in hex, if they are present.
*/
private fun annotateRawMessage(rawMessage: String, vararg nodeIds: Int): String {
val msg = StringBuilder(rawMessage)
var mutated = false
nodeIds.forEach { nodeId ->
mutated = mutated or msg.annotateNodeId(nodeId)
}
return if (mutated) {
return msg.toString()
} else {
rawMessage
}
}
/**
* Look for a single node ID integer in the string and annotate it with the hex equivalent
* if found.
*/
private fun StringBuilder.annotateNodeId(nodeId: Int): Boolean {
val nodeIdStr = nodeId.toUInt().toString()
indexOf(nodeIdStr).takeIf { it >= 0 }?.let { idx ->
insert(idx + nodeIdStr.length, " (${nodeId.asNodeId()})")
return true
}
return false
}
private fun Int.asNodeId(): String {
return "!%08x".format(Locale.getDefault(), this)
}
@Composable
internal fun DebugScreen(
viewModel: DebugViewModel = hiltViewModel(),
@ -162,7 +103,7 @@ internal fun DebugScreen(
if (shouldAutoScroll) {
LaunchedEffect(logs) {
if (!listState.isScrollInProgress) {
listState.scrollToItem(0)
listState.animateScrollToItem(0)
}
}
}
@ -181,18 +122,24 @@ internal fun DebugScreen(
modifier = Modifier.fillMaxSize(),
state = listState,
) {
items(logs, key = { it.uuid }) { log -> DebugItem(annotateMeshLog(log)) }
items(logs, key = { it.uuid }) { log ->
DebugItem(
modifier = Modifier.animateItem(),
log = log,
)
}
}
}
}
}
@Composable
internal fun DebugItem(log: MeshLog) {
val timeFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
internal fun DebugItem(
log: UiMeshLog,
modifier: Modifier = Modifier,
) {
Card(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.padding(4.dp),
elevation = 4.dp,
@ -207,9 +154,10 @@ internal fun DebugItem(log: MeshLog) {
.fillMaxWidth()
.padding(bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = log.message_type,
text = log.messageType,
modifier = Modifier.weight(1f),
style = TextStyle(fontWeight = FontWeight.Bold),
)
@ -220,22 +168,12 @@ internal fun DebugItem(log: MeshLog) {
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = timeFormat.format(log.received_date),
text = log.formattedReceivedDate,
style = TextStyle(fontWeight = FontWeight.Bold),
)
}
val style = SpanStyle(
color = colorResource(id = R.color.colorAnnotation),
fontStyle = FontStyle.Italic,
)
val annotatedString = buildAnnotatedString {
append(log.raw_message)
REGEX_ANNOTATED_NODE_ID.findAll(log.raw_message).toList().reversed().forEach {
addStyle(style = style, start = it.range.first, end = it.range.last + 1)
}
}
val annotatedString = rememberAnnotatedLogMessage(log)
Text(
text = annotatedString,
softWrap = false,
@ -249,16 +187,37 @@ internal fun DebugItem(log: MeshLog) {
}
}
@Composable
private fun rememberAnnotatedLogMessage(log: UiMeshLog): AnnotatedString {
val style = SpanStyle(
color = colorResource(id = R.color.colorAnnotation),
fontStyle = FontStyle.Italic,
)
return remember(log.uuid) {
buildAnnotatedString {
append(log.logMessage)
REGEX_ANNOTATED_NODE_ID.findAll(log.logMessage).toList().reversed()
.forEach {
addStyle(
style = style,
start = it.range.first,
end = it.range.last + 1
)
}
}
}
}
@PreviewLightDark
@Composable
private fun DebugScreenPreview() {
AppTheme {
DebugItem(
MeshLog(
UiMeshLog(
uuid = "",
message_type = "NodeInfo",
received_date = 1601251258000L,
raw_message = "from: 2885173132\n" +
messageType = "NodeInfo",
formattedReceivedDate = "9/27/20, 8:00:58 PM",
logMessage = "from: 2885173132\n" +
"decoded {\n" +
" position {\n" +
" altitude: 60\n" +