diff --git a/.gitignore b/.gitignore index 0f040ac1..a1a0f7f6 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ local.properties $PWD/ ktlint +.hermes/plans/ diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index 9ddf0ec0..9ff0fd92 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -2,6 +2,9 @@ package com.m57.hermescontrol.data.model data class SessionListResponse( val sessions: List, + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, ) data class SessionInfo( diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 5f8efea2..4b2e8f6e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -67,7 +67,11 @@ interface HermesApiService { suspend fun getStatus(): Response @GET("api/sessions") - suspend fun getSessions(): Response + suspend fun getSessions( + @Query("limit") limit: Int = 20, + @Query("offset") offset: Int = 0, + @Query("order") order: String = "recent", + ): Response @GET("api/sessions/{id}/messages") suspend fun getSessionMessages( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index d54a08a1..fa5ee796 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -1,12 +1,14 @@ package com.m57.hermescontrol.ui.sessions import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -14,6 +16,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.History import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +31,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.ChatScreen @@ -156,6 +160,38 @@ fun SessionsScreen( } } } + + // Load more button + if (state.hasMore || state.isLoadingMore) { + item { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = spacing.sm), + contentAlignment = Alignment.Center, + ) { + if (state.isLoadingMore) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Text( + text = "Load more sessions...", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .testTag("load_more_sessions") + .clickable(role = Role.Button) { + viewModel.loadMore() + }, + ) + } + } + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index 399127ce..1a1fe62f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.sessions import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.safeApiCall @@ -9,26 +10,47 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch data class SessionsUiState( val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, val sessions: List = emptyList(), + val total: Int = 0, val errorMessage: String? = null, -) +) { + val hasMore: Boolean get() = total > sessions.size +} class SessionsViewModel : ViewModel() { private val _uiState = MutableStateFlow(SessionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** Page size sent to the server — matches the default the gateway uses. */ + private companion object { + const val PAGE_SIZE = 20 + } + + /** Load (or reload) sessions from page 0. Used by pull-to-refresh and initial load. */ fun loadSessions() { safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.getSessions() } }, + apiCall = { + safeApiCall { + ApiClient.hermesApi.getSessions( + limit = PAGE_SIZE, + offset = 0, + order = "recent", + ) + } + }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> _uiState.update { it.copy( isLoading = false, + isLoadingMore = false, sessions = data?.sessions.orEmpty(), + total = data?.total ?: 0, ) } }, @@ -36,10 +58,49 @@ class SessionsViewModel : ViewModel() { _uiState.update { it.copy( isLoading = false, + isLoadingMore = false, errorMessage = "Failed to load sessions: $errorMsg", ) } }, ) } + + /** Load the next page and append to the existing session list. */ + fun loadMore() { + val state = _uiState.value + if (state.isLoadingMore || !state.hasMore) return + + _uiState.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.getSessions( + limit = PAGE_SIZE, + offset = state.sessions.size, + order = "recent", + ) + } + when (result) { + is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + val data = result.data + _uiState.update { + it.copy( + isLoadingMore = false, + sessions = it.sessions + data.sessions, + total = data.total, + ) + } + } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoadingMore = false, + errorMessage = "Failed to load more: ${result.error.message}", + ) + } + } + } + } + } } diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 227f2d5d..da43fa30 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -428,7 +428,9 @@ class E2eIntegrationTest { fun testSessionsListing_success() = runTest { val session = SessionInfo("session-123", "Session 1", "2026-06-15T15:10:00Z", 5, "active") - coEvery { mockApiService.getSessions() } returns Response.success(SessionListResponse(listOf(session))) + coEvery { + mockApiService.getSessions(any(), any(), any()) + } returns Response.success(SessionListResponse(listOf(session))) val viewModel = SessionsViewModel() viewModel.loadSessions() @@ -463,7 +465,7 @@ class E2eIntegrationTest { @Test fun testSessionsLoad_failure() = runTest { - coEvery { mockApiService.getSessions() } returns createErrorResponse(500) + coEvery { mockApiService.getSessions(any(), any(), any()) } returns createErrorResponse(500) val viewModel = SessionsViewModel() viewModel.loadSessions() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 3c070465..22ae7b94 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -292,7 +292,7 @@ class ChatViewModelTest { mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) advanceUntilIdle() - coEvery { ApiClient.hermesApi.getSessions() } returns + coEvery { ApiClient.hermesApi.getSessions(any(), any(), any()) } returns mockk(relaxed = true) { every { isSuccessful } returns true every { body() } returns null @@ -301,7 +301,7 @@ class ChatViewModelTest { viewModel.sendMessage("/sessions") advanceUntilIdle() - coVerify { ApiClient.hermesApi.getSessions() } + coVerify { ApiClient.hermesApi.getSessions(any(), any(), any()) } } @Test