feat(#105): add log controls — severity filter, pause/resume, jump-to-bottom, share/export (#362)

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:
M57
2026-06-26 00:48:10 +03:00
committed by GitHub
parent 066ceba5b0
commit b1fca60511
2 changed files with 255 additions and 36 deletions
@@ -1,15 +1,34 @@
package com.m57.hermescontrol.ui.logs 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.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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons 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.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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -17,8 +36,11 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -26,6 +48,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState 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.NavIcon
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect 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 @Composable
fun LogsScreen( fun LogsScreen(
@@ -46,11 +101,23 @@ fun LogsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
var query by remember { mutableStateOf("") } 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 = val filteredLogs =
remember(query, state.logs) { remember(query, severityFilter, state.logs) {
state.logs.filter { logLine -> state.logs.filterIndexed { index, logLine ->
logLine.contains(query, ignoreCase = true) 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) ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Auto scroll to bottom when new logs arrive // Auto-scroll to bottom when new logs arrive (unless paused)
LaunchedEffect(filteredLogs.size) { LaunchedEffect(filteredLogs.size, pauseScroll) {
if (filteredLogs.isNotEmpty()) { if (!pauseScroll && filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(filteredLogs.size) 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( HermesScaffold(
title = { Text(stringResource(R.string.screen_logs)) }, title = { Text(stringResource(R.string.screen_logs)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadLogs() }, 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 { when {
state.isLoading && state.logs.isEmpty() -> { state.isLoading && state.logs.isEmpty() -> {
@@ -86,8 +185,6 @@ fun LogsScreen(
} }
state.logs.isEmpty() -> { state.logs.isEmpty() -> {
// B5 (Jun 18 2026, kanban t_2322818d): proper empty-state using
// the shared EmptyState component instead of a bare Text.
EmptyState( EmptyState(
title = stringResource(R.string.logs_empty_title), title = stringResource(R.string.logs_empty_title),
subtitle = stringResource(R.string.logs_empty_desc), subtitle = stringResource(R.string.logs_empty_desc),
@@ -96,40 +193,153 @@ fun LogsScreen(
} }
else -> { else -> {
LazyColumn( Box(Modifier.fillMaxSize()) {
state = listState, LazyColumn(
modifier = state = listState,
Modifier modifier =
.fillMaxSize() Modifier
.background(MaterialTheme.colorScheme.surfaceContainerLowest), .fillMaxSize()
contentPadding = .background(MaterialTheme.colorScheme.surfaceContainerLowest),
PaddingValues( contentPadding =
horizontal = spacing.md, PaddingValues(
vertical = spacing.sm, horizontal = spacing.md,
), vertical = spacing.sm,
) { ),
item { ) {
SearchBar( // Search bar
query = query, item {
onQueryChange = { query = it }, SearchBar(
placeholder = "Search logs...", query = query,
modifier = Modifier.padding(bottom = 8.dp), 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( // Jump-to-bottom FAB — only when paused and scrolled up
text = logLine, if (pauseScroll && filteredLogs.isNotEmpty()) {
color = MaterialTheme.colorScheme.onSurface, FloatingActionButton(
fontFamily = FontFamily.Monospace, onClick = {
fontSize = 12.sp, scope.launch {
listState.animateScrollToItem(filteredLogs.size)
}
},
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .align(Alignment.BottomEnd)
.padding(vertical = 2.dp), .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"))
}
+9
View File
@@ -232,6 +232,15 @@
<!-- Logs Screen --> <!-- Logs Screen -->
<string name="logs_empty_title">No logs available</string> <string name="logs_empty_title">No logs available</string>
<string name="logs_empty_desc">Log output from Hermes will appear here.</string> <string name="logs_empty_desc">Log output from Hermes will appear here.</string>
<string name="logs_action_pause">Pause</string>
<string name="logs_action_resume">Resume</string>
<string name="logs_action_export">Share</string>
<string name="logs_content_desc_jump_bottom">Scroll to bottom</string>
<string name="logs_filter_all">All</string>
<string name="logs_filter_error">Error</string>
<string name="logs_filter_warn">Warn</string>
<string name="logs_filter_info">Info</string>
<string name="logs_filter_debug">Debug</string>
<!-- System Screen --> <!-- System Screen -->
<string name="system_empty_title">No system data</string> <string name="system_empty_title">No system data</string>