feat(#105): add log controls — severity filter, pause/resume auto-scroll, jump-to-bottom, and share/export - Filter logs by severity (Error, Warn, Info, Debug) - Pause/resume auto-scroll toggle - Jump-to-bottom FAB when paused - Share/export filtered logs via system share sheet + clipboard - Search bar for filtering log lines by text Fixes #105
This commit is contained in:
2 files changed
+255
-36
No files matched your search
@@ -1,15 +1,34 @@
|
||||
package com.m57.hermescontrol.ui.logs
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.HistoryEdu
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -17,8 +36,11 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -26,6 +48,7 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.EmptyState
|
||||
import com.m57.hermescontrol.ui.common.ErrorState
|
||||
@@ -34,6 +57,38 @@ import com.m57.hermescontrol.ui.common.LoadingState
|
||||
import com.m57.hermescontrol.ui.common.NavIcon
|
||||
import com.m57.hermescontrol.ui.common.SearchBar
|
||||
import com.m57.hermescontrol.ui.common.ToastEffect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class LogSeverity {
|
||||
ERROR,
|
||||
WARN,
|
||||
INFO,
|
||||
DEBUG,
|
||||
}
|
||||
|
||||
private fun detectSeverity(line: String): LogSeverity? {
|
||||
val upper = line.uppercase()
|
||||
return when {
|
||||
Regex("\\[ERROR\\]|\\bERROR\\b|\\|ERROR\\||^ERROR[\\s:]").containsMatchIn(upper) ->
|
||||
LogSeverity.ERROR
|
||||
Regex("\\[WARN(?:ING)?\\]|\\bWARN\\b|\\|WARN(?:ING)?\\||^WARN(?:ING)?[\\s:]").containsMatchIn(upper) ->
|
||||
LogSeverity.WARN
|
||||
Regex("\\[INFO\\]|\\bINFO\\b|\\|INFO\\||^INFO[\\s:]").containsMatchIn(upper) ->
|
||||
LogSeverity.INFO
|
||||
Regex("\\[DEBUG\\]|\\bDEBUG\\b|\\|DEBUG\\||^DEBUG[\\s:]").containsMatchIn(upper) ->
|
||||
LogSeverity.DEBUG
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private val LogSeverity.labelRes: Int
|
||||
get() =
|
||||
when (this) {
|
||||
LogSeverity.ERROR -> R.string.logs_filter_error
|
||||
LogSeverity.WARN -> R.string.logs_filter_warn
|
||||
LogSeverity.INFO -> R.string.logs_filter_info
|
||||
LogSeverity.DEBUG -> R.string.logs_filter_debug
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogsScreen(
|
||||
@@ -46,11 +101,23 @@ fun LogsScreen(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
var severityFilter by remember { mutableStateOf<LogSeverity?>(null) }
|
||||
var pauseScroll by remember { mutableStateOf(false) }
|
||||
|
||||
// Build severity-indexed sets for fast lookup
|
||||
val severityMap =
|
||||
remember(state.logs) {
|
||||
state.logs.map { line -> detectSeverity(line) }
|
||||
}
|
||||
|
||||
// Filter by query + severity
|
||||
val filteredLogs =
|
||||
remember(query, state.logs) {
|
||||
state.logs.filter { logLine ->
|
||||
logLine.contains(query, ignoreCase = true)
|
||||
remember(query, severityFilter, state.logs) {
|
||||
state.logs.filterIndexed { index, logLine ->
|
||||
val matchesQuery = logLine.contains(query, ignoreCase = true)
|
||||
val matchesSeverity =
|
||||
severityFilter == null || severityMap.getOrNull(index) == severityFilter
|
||||
matchesQuery && matchesSeverity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,18 +127,50 @@ fun LogsScreen(
|
||||
|
||||
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
|
||||
|
||||
// Auto scroll to bottom when new logs arrive
|
||||
LaunchedEffect(filteredLogs.size) {
|
||||
if (filteredLogs.isNotEmpty()) {
|
||||
// Auto-scroll to bottom when new logs arrive (unless paused)
|
||||
LaunchedEffect(filteredLogs.size, pauseScroll) {
|
||||
if (!pauseScroll && filteredLogs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(filteredLogs.size)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the export text from filtered logs
|
||||
val exportText =
|
||||
remember(filteredLogs) {
|
||||
filteredLogs.joinToString("\n")
|
||||
}
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
HermesScaffold(
|
||||
title = { Text(stringResource(R.string.screen_logs)) },
|
||||
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
|
||||
isRefreshing = state.isLoading,
|
||||
onRefresh = { viewModel.loadLogs() },
|
||||
actions = {
|
||||
// Pause / Resume toggle
|
||||
IconButton(onClick = { pauseScroll = !pauseScroll }) {
|
||||
Icon(
|
||||
imageVector = if (pauseScroll) Icons.Filled.PlayArrow else Icons.Filled.Pause,
|
||||
contentDescription =
|
||||
stringResource(
|
||||
if (pauseScroll) R.string.logs_action_resume else R.string.logs_action_pause,
|
||||
),
|
||||
)
|
||||
}
|
||||
// Share / Export
|
||||
IconButton(
|
||||
onClick = {
|
||||
shareLogs(context, exportText)
|
||||
},
|
||||
enabled = filteredLogs.isNotEmpty(),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Share,
|
||||
contentDescription = stringResource(R.string.logs_action_export),
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
when {
|
||||
state.isLoading && state.logs.isEmpty() -> {
|
||||
@@ -86,8 +185,6 @@ fun LogsScreen(
|
||||
}
|
||||
|
||||
state.logs.isEmpty() -> {
|
||||
// B5 (Jun 18 2026, kanban t_2322818d): proper empty-state using
|
||||
// the shared EmptyState component instead of a bare Text.
|
||||
EmptyState(
|
||||
title = stringResource(R.string.logs_empty_title),
|
||||
subtitle = stringResource(R.string.logs_empty_desc),
|
||||
@@ -96,40 +193,153 @@ fun LogsScreen(
|
||||
}
|
||||
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
horizontal = spacing.md,
|
||||
vertical = spacing.sm,
|
||||
),
|
||||
) {
|
||||
item {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search logs...",
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLowest),
|
||||
contentPadding =
|
||||
PaddingValues(
|
||||
horizontal = spacing.md,
|
||||
vertical = spacing.sm,
|
||||
),
|
||||
) {
|
||||
// Search bar
|
||||
item {
|
||||
SearchBar(
|
||||
query = query,
|
||||
onQueryChange = { query = it },
|
||||
placeholder = "Search logs...",
|
||||
modifier = Modifier.padding(bottom = spacing.xs),
|
||||
)
|
||||
}
|
||||
|
||||
// Severity filter chips
|
||||
item {
|
||||
SeverityFilterRow(
|
||||
selected = severityFilter,
|
||||
onSelected = { severityFilter = it },
|
||||
modifier = Modifier.padding(bottom = spacing.sm),
|
||||
)
|
||||
}
|
||||
|
||||
items(filteredLogs) { logLine ->
|
||||
Text(
|
||||
text = logLine,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
items(filteredLogs) { logLine ->
|
||||
Text(
|
||||
text = logLine,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
|
||||
// Jump-to-bottom FAB — only when paused and scrolled up
|
||||
if (pauseScroll && filteredLogs.isNotEmpty()) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(filteredLogs.size)
|
||||
}
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(16.dp),
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.KeyboardDoubleArrowDown,
|
||||
contentDescription =
|
||||
stringResource(R.string.logs_content_desc_jump_bottom),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeverityFilterRow(
|
||||
selected: LogSeverity?,
|
||||
onSelected: (LogSeverity?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val chipColors = FilterChipDefaults.filterChipColors()
|
||||
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// "All" chip
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelected(null) },
|
||||
label = { Text(stringResource(R.string.logs_filter_all)) },
|
||||
colors = chipColors,
|
||||
)
|
||||
|
||||
LogSeverity.entries.forEach { severity ->
|
||||
val color =
|
||||
when (severity) {
|
||||
LogSeverity.ERROR -> statusColors.error
|
||||
LogSeverity.WARN -> statusColors.warning
|
||||
LogSeverity.INFO -> statusColors.info
|
||||
LogSeverity.DEBUG -> statusColors.success
|
||||
}
|
||||
FilterChip(
|
||||
selected = selected == severity,
|
||||
onClick = {
|
||||
onSelected(if (selected == severity) null else severity)
|
||||
},
|
||||
label = {
|
||||
Text(
|
||||
text = stringResource(severity.labelRes),
|
||||
color = if (selected == severity) color else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
},
|
||||
leadingIcon =
|
||||
if (selected == severity) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = color,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
colors =
|
||||
FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = color.copy(alpha = 0.12f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareLogs(
|
||||
context: Context,
|
||||
text: String,
|
||||
) {
|
||||
// Also copy to clipboard
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
|
||||
clipboard?.setPrimaryClip(ClipData.newPlainText("Hermes Logs", text))
|
||||
|
||||
val sendIntent =
|
||||
Intent().apply {
|
||||
action = Intent.ACTION_SEND
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
type = "text/plain"
|
||||
}
|
||||
context.startActivity(Intent.createChooser(sendIntent, "Share logs"))
|
||||
}
|
||||
Reference in new issue
Block a user