From d4320a9289f3a4859dc1ea99d55ddfc06335d6c8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:29:21 +0300 Subject: [PATCH] fix(#425): live log updates + auto-scroll (#428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../m57/hermescontrol/ui/logs/LogsScreen.kt | 9 +++++- .../hermescontrol/ui/logs/LogsViewModel.kt | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index ba43cfc1..cd4c827f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index 6d2ab24f..bc9c6473 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -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 = _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) } }