fix(#425): live log updates + auto-scroll (#428)

* fix(#425): log screen doesn't auto-scroll on initial load

Replace animateScrollToItem(filteredLogs.size) with
scrollToItem(Int.MAX_VALUE). animateScrollToItem requires the
LazyColumn to have completed layout before it can calculate
animation targets — on initial load the list hasn't laid out
yet, so the scroll silently fails. scrollToItem is instant and
clamps Int.MAX_VALUE to the last item, working reliably even
on first composition.

* feat(#425): add auto-refresh polling for live log updates

LogsViewModel: add @Volatile loadInProgress guard to prevent
concurrent loads, and startAutoRefresh()/stopAutoRefresh() that
polls GET /api/logs every 5 seconds via viewModelScope coroutine.

LogsScreen: wire up DisposableEffect(Unit) to start polling when
the screen is composed and stop when disposed — so polling only
runs while the user is viewing the log screen.

Combined with scrollToItem(Int.MAX_VALUE) from previous commit,
logs now both auto-refresh AND auto-scroll to the latest entry.

* fix(#425): add missing viewModelScope import for CI build

LogsViewModel used viewModelScope directly in startAutoRefresh()
but never imported it — the original code only used it indirectly
via safeLaunchLoad which has its own import in ViewModelExt.kt.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-30 16:29:21 +03:00
1 parent 970f4bfe7c
commit d4320a9289
2 files changed
+40 -1

No files matched your search

@@ -32,6 +32,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -125,12 +126,18 @@ fun LogsScreen(
viewModel.loadLogs()
}
// Auto-refresh logs via polling while the screen is visible
DisposableEffect(Unit) {
viewModel.startAutoRefresh()
onDispose { viewModel.stopAutoRefresh() }
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
// Auto-scroll to bottom when new logs arrive (unless paused)
LaunchedEffect(filteredLogs.size, pauseScroll) {
if (!pauseScroll && filteredLogs.isNotEmpty()) {
listState.animateScrollToItem(filteredLogs.size)
listState.scrollToItem(Int.MAX_VALUE)
}
}
@@ -1,14 +1,19 @@
package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
data class LogsUiState(
val isLoading: Boolean = false,
@@ -21,7 +26,14 @@ class LogsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
@Volatile
private var loadInProgress = false
private var autoRefreshJob: Job? = null
fun loadLogs() {
if (loadInProgress) return
loadInProgress = true
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getLogs(lines = 1000) } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
@@ -29,6 +41,7 @@ class LogsViewModel : ViewModel(), ToastHost {
val body = data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
loadInProgress = false
},
onError = { errorMsg ->
_uiState.update {
@@ -37,10 +50,29 @@ class LogsViewModel : ViewModel(), ToastHost {
errorMessage = "Failed to load logs: $errorMsg",
)
}
loadInProgress = false
},
)
}
/** Start auto-refreshing logs every 5 seconds. */
fun startAutoRefresh() {
if (autoRefreshJob?.isActive == true) return
autoRefreshJob =
viewModelScope.launch {
while (isActive) {
delay(5_000)
loadLogs()
}
}
}
/** Stop auto-refreshing logs. */
fun stopAutoRefresh() {
autoRefreshJob?.cancel()
autoRefreshJob = null
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}