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 4987dec1..a905cca3 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 @@ -768,6 +768,7 @@ fun SessionsScreen( val info = listState.layoutInfo val lastVisible = info.visibleItemsInfo.lastOrNull()?.index ?: -1 lastVisible >= 0 && + info.totalItemsCount >= AUTO_LOAD_THRESHOLD && lastVisible >= info.totalItemsCount - AUTO_LOAD_THRESHOLD } } 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 f5a10e6d..7b0c9308 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 @@ -64,6 +64,7 @@ data class SessionsUiState( val isLoadingMore: Boolean = false, val sessions: List = emptyList(), val total: Int = 0, + val hasMore: Boolean = false, val errorMessage: String? = null, val stats: SessionStats = SessionStats(), val isLoadingStats: Boolean = false, @@ -89,7 +90,6 @@ data class SessionsUiState( val searchResults: List = emptyList(), val searchError: String? = null, ) { - val hasMore: Boolean get() = total > sessions.size val isSearchMode: Boolean get() = searchQuery.isNotBlank() } @@ -118,9 +118,12 @@ class SessionsViewModel : }, onSuccess = { data -> _uiState.update { + val newSessions = data.sessions.orEmpty().pinnedFirst() + val hasMore = data.sessions.size >= PAGE_SIZE && data.total > newSessions.size it.copy( - sessions = data.sessions.orEmpty().pinnedFirst(), - total = data.total, + sessions = newSessions, + total = if (hasMore) data.total else newSessions.size, + hasMore = hasMore, ) } }, @@ -153,12 +156,15 @@ class SessionsViewModel : }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> + val sessionsList = data.sessions.orEmpty().pinnedFirst() + val hasMore = data.sessions.size >= PAGE_SIZE && data.total > sessionsList.size _uiState.update { it.copy( isLoading = false, isLoadingMore = false, - sessions = data.sessions.orEmpty().pinnedFirst(), - total = data.total, + sessions = sessionsList, + total = if (hasMore) data.total else sessionsList.size, + hasMore = hasMore, selectedIds = emptySet(), ) } @@ -194,6 +200,13 @@ class SessionsViewModel : is NetworkResult.Success -> { val data = result.data _uiState.update { + val newSessions = + (it.sessions + data.sessions) + .distinctBy { s -> s.id } + .pinnedFirst() + val receivedFullPage = data.sessions.size >= PAGE_SIZE + val addedNewItems = newSessions.size > it.sessions.size + val hasMore = receivedFullPage && addedNewItems && data.total > newSessions.size it.copy( isLoadingMore = false, // distinctBy guards offset-pagination churn: if a new @@ -201,11 +214,9 @@ class SessionsViewModel : // shift and the next page can repeat an id we already // have — LazyColumn would crash on the duplicate key. // pinnedFirst keeps pinned sessions above the rest. - sessions = - (it.sessions + data.sessions) - .distinctBy { s -> s.id } - .pinnedFirst(), - total = data.total, + sessions = newSessions, + total = if (hasMore) data.total else newSessions.size, + hasMore = hasMore, ) } } @@ -470,11 +481,14 @@ class SessionsViewModel : when (result) { is NetworkResult.Success -> { _uiState.update { + val updatedSessions = it.sessions.filter { s -> s.id != sessionId } + val newTotal = (it.total - 1).coerceAtLeast(0) it.copy( deletingSessionIds = it.deletingSessionIds - sessionId, - sessions = it.sessions.filter { s -> s.id != sessionId }, + sessions = updatedSessions, searchResults = it.searchResults.filter { it.session_id != sessionId }, - total = (it.total - 1).coerceAtLeast(0), + total = newTotal, + hasMore = it.hasMore && newTotal > updatedSessions.size, toastMessage = "Session deleted", ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index ef2ba99b..94799d90 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -101,37 +101,60 @@ class SessionsViewModelTest { // ── Pagination (fluid load-more) ────────────────────────────────────── @Test - fun `loadMore appends the next page and dedupes overlapping ids`() { + fun `loadSessions with fewer than page size items caps total and sets hasMore false`() { val vm = createViewModel() - // Page 1: 2 sessions of 3 total — hasMore stays true. + // Server reports total = 10 (e.g. cross-profile count), but returns only 2 sessions (< PAGE_SIZE 50). + // hasMore must be false and total must be capped at 2 to prevent infinite auto-load loops. coEvery { mockApi.getSessions(any(), any(), any()) } returns Response.success( SessionListResponse( sessions = listOf(SessionInfo("s-1"), SessionInfo("s-2")), - total = 3, + total = 10, ), ) vm.loadSessions() testDispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf("s-1", "s-2"), vm.uiState.value.sessions.map { it.id }) + assertEquals(2, vm.uiState.value.total) + assertFalse(vm.uiState.value.hasMore) + assertFalse(vm.uiState.value.isLoadingMore) + } + + @Test + fun `loadMore appends the next page and dedupes overlapping ids`() { + val vm = createViewModel() + + val page1Sessions = (1..50).map { SessionInfo("s-$it") } + // Page 1: 50 sessions of 51 total — hasMore stays true. + coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns + Response.success( + SessionListResponse( + sessions = page1Sessions, + total = 51, + ), + ) + vm.loadSessions() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(50, vm.uiState.value.sessions.size) assertTrue(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.isLoadingMore) // Page 2 overlaps page 1 (offset churn: a new session landed on top // between loads) — the duplicate id must not double-append. - coEvery { mockApi.getSessions(any(), any(), any()) } returns + coEvery { mockApi.getSessions(limit = 50, offset = 50, order = any()) } returns Response.success( SessionListResponse( - sessions = listOf(SessionInfo("s-3"), SessionInfo("s-2")), - total = 3, + sessions = listOf(SessionInfo("s-51"), SessionInfo("s-50")), + total = 51, ), ) vm.loadMore() testDispatcher.scheduler.advanceUntilIdle() - assertEquals(listOf("s-1", "s-2", "s-3"), vm.uiState.value.sessions.map { it.id }) - assertEquals(3, vm.uiState.value.total) + assertEquals(51, vm.uiState.value.sessions.size) + assertEquals(51, vm.uiState.value.total) assertFalse(vm.uiState.value.hasMore) assertFalse(vm.uiState.value.isLoadingMore) } @@ -140,11 +163,12 @@ class SessionsViewModelTest { fun `loadMore is a no-op while a load is already running`() { val vm = createViewModel() - coEvery { mockApi.getSessions(any(), any(), any()) } returns + val page1Sessions = (1..50).map { SessionInfo("s-$it") } + coEvery { mockApi.getSessions(limit = 50, offset = 0, order = any()) } returns Response.success( SessionListResponse( - sessions = listOf(SessionInfo("s-1")), - total = 2, + sessions = page1Sessions, + total = 52, ), ) vm.loadSessions() @@ -158,7 +182,6 @@ class SessionsViewModelTest { // loadSessions (1) + exactly one loadMore (1) = 2 API hits total. coVerify(exactly = 2) { mockApi.getSessions(any(), any(), any()) } - assertEquals(listOf("s-1"), vm.uiState.value.sessions.map { it.id }) assertFalse(vm.uiState.value.isLoadingMore) }