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