* 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:
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) }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user