From a1382d0ee4dd7f6e285f445e090fe63d1c33cdd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonardo=20Mur=C3=A7a?= Date: Sat, 19 Jul 2025 17:46:18 -0300 Subject: [PATCH] feat: add pagination support and some refactorings --- .../data/local/MatchLocalDataSource.kt | 2 +- .../data/remote/MatchRemoteDataSourceImpl.kt | 5 +- .../data/remote/MatchesApiService.kt | 12 +++-- .../data/repository/MatchRepositoryImpl.kt | 5 +- .../data/source/MatchDataSource.kt | 2 +- .../domain/repository/MatchRepository.kt | 3 +- .../ui/screens/matches/MatchesScreen.kt | 54 +++++++++++++++++-- .../ui/screens/matches/MatchesViewModel.kt | 39 ++++++++++++-- .../leomurca/csgomatches/utils/Constants.kt | 8 +++ 9 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/xyz/leomurca/csgomatches/utils/Constants.kt diff --git a/app/src/main/java/xyz/leomurca/csgomatches/data/local/MatchLocalDataSource.kt b/app/src/main/java/xyz/leomurca/csgomatches/data/local/MatchLocalDataSource.kt index c7cac3f..8b25c15 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/data/local/MatchLocalDataSource.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/data/local/MatchLocalDataSource.kt @@ -10,7 +10,7 @@ import xyz.leomurca.csgomatches.data.source.MatchDataSource import xyz.leomurca.csgomatches.domain.model.Resource class MatchLocalDataSource : MatchDataSource { - override suspend fun upcomingMatches(): Resource> { + override suspend fun upcomingMatches(page: Int): Resource> { return Resource.Success( data = listOf( // Happy path diff --git a/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchRemoteDataSourceImpl.kt b/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchRemoteDataSourceImpl.kt index 36e074f..c8fc805 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchRemoteDataSourceImpl.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchRemoteDataSourceImpl.kt @@ -1,5 +1,6 @@ package xyz.leomurca.csgomatches.data.remote +import androidx.compose.foundation.pager.PageSize import kotlinx.serialization.json.Json import xyz.leomurca.csgomatches.data.model.ErrorDto import xyz.leomurca.csgomatches.data.model.MatchDto @@ -11,9 +12,9 @@ class MatchRemoteDataSourceImpl( private val matchesApiService: MatchesApiService, private val json: Json ) : MatchDataSource { - override suspend fun upcomingMatches(): Resource> { + override suspend fun upcomingMatches(page: Int): Resource> { return try { - val response = matchesApiService.upcomingMatches() + val response = matchesApiService.upcomingMatches(page = page) if (response.isSuccessful) { Resource.Success(response.body() ?: throw Exception("Empty response body")) diff --git a/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchesApiService.kt b/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchesApiService.kt index 1e5f220..37694b8 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchesApiService.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/data/remote/MatchesApiService.kt @@ -5,14 +5,20 @@ import retrofit2.http.GET import retrofit2.http.Query import xyz.leomurca.csgomatches.data.model.MatchDto import xyz.leomurca.csgomatches.data.model.TeamDetailsDto +import xyz.leomurca.csgomatches.utils.ApiDefaults.ITEMS_PER_PAGE +import xyz.leomurca.csgomatches.utils.ApiDefaults.MATCH_FILTER_STATUS +import xyz.leomurca.csgomatches.utils.ApiDefaults.MATCH_FINISHED +import xyz.leomurca.csgomatches.utils.ApiDefaults.MATCH_SORT interface MatchesApiService { @GET("matches") suspend fun upcomingMatches( - @Query("filter[status]") filterStatus: String = "running, not_started", - @Query("finished") finished: Boolean = false, - @Query("sort") sort: String = "begin_at", + @Query("filter[status]") filterStatus: String = MATCH_FILTER_STATUS, + @Query("finished") finished: Boolean = MATCH_FINISHED, + @Query("sort") sort: String = MATCH_SORT, + @Query("per_page") perPage: Int = ITEMS_PER_PAGE, + @Query("page") page: Int, ): Response> @GET("teams") diff --git a/app/src/main/java/xyz/leomurca/csgomatches/data/repository/MatchRepositoryImpl.kt b/app/src/main/java/xyz/leomurca/csgomatches/data/repository/MatchRepositoryImpl.kt index 044a023..0b6d50b 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/data/repository/MatchRepositoryImpl.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/data/repository/MatchRepositoryImpl.kt @@ -1,5 +1,6 @@ package xyz.leomurca.csgomatches.data.repository +import androidx.compose.foundation.pager.PageSize import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import xyz.leomurca.csgomatches.data.mapper.toDomain @@ -15,9 +16,9 @@ class MatchRepositoryImpl( @Dispatcher(AppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, private val matchRemoteDataSource: MatchDataSource ) : MatchRepository { - override suspend fun upcomingMatches(): Resource> { + override suspend fun upcomingMatches(page: Int): Resource> { return withContext(ioDispatcher) { - when (val result = matchRemoteDataSource.upcomingMatches()) { + when (val result = matchRemoteDataSource.upcomingMatches(page)) { is Resource.Success -> Resource.Success(result.data.map { it.toDomain() }) is Resource.Error -> Resource.Error(result.message) } diff --git a/app/src/main/java/xyz/leomurca/csgomatches/data/source/MatchDataSource.kt b/app/src/main/java/xyz/leomurca/csgomatches/data/source/MatchDataSource.kt index 13cf872..cb40145 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/data/source/MatchDataSource.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/data/source/MatchDataSource.kt @@ -5,6 +5,6 @@ import xyz.leomurca.csgomatches.data.model.TeamDetailsDto import xyz.leomurca.csgomatches.domain.model.Resource interface MatchDataSource { - suspend fun upcomingMatches(): Resource> + suspend fun upcomingMatches(page: Int): Resource> suspend fun teamDetails(teamId: String): Resource> } \ No newline at end of file diff --git a/app/src/main/java/xyz/leomurca/csgomatches/domain/repository/MatchRepository.kt b/app/src/main/java/xyz/leomurca/csgomatches/domain/repository/MatchRepository.kt index 7a7aa9a..b4b1a68 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/domain/repository/MatchRepository.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/domain/repository/MatchRepository.kt @@ -1,12 +1,13 @@ package xyz.leomurca.csgomatches.domain.repository +import androidx.compose.foundation.pager.PageSize import xyz.leomurca.csgomatches.domain.model.Match import xyz.leomurca.csgomatches.domain.model.Resource import xyz.leomurca.csgomatches.domain.model.Team interface MatchRepository { - suspend fun upcomingMatches(): Resource> + suspend fun upcomingMatches(page: Int): Resource> suspend fun teamDetails(teamId: String): Resource> } \ No newline at end of file diff --git a/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesScreen.kt b/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesScreen.kt index 0e65319..e15f952 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesScreen.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesScreen.kt @@ -2,11 +2,16 @@ package xyz.leomurca.csgomatches.ui.screens.matches import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -15,7 +20,10 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp @@ -35,6 +43,17 @@ fun MatchesScreen( ) { val uiState = viewModel.uiState.collectAsState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisibleIndex -> + val totalItems = listState.layoutInfo.totalItemsCount + if (lastVisibleIndex != null && lastVisibleIndex >= totalItems - 3) { + viewModel.loadNextPage() + } + } + } Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -65,7 +84,13 @@ fun MatchesScreen( .background(MaterialTheme.colorScheme.background), ) { when (val value = uiState.value) { - is MatchesUiState.Success -> MatchesList(value.matches, onTapCard) + is MatchesUiState.Success -> MatchesList( + matches = value.matches, + onTapCard = onTapCard, + listState = listState, + isPaginating = value.isPaginating + ) + is MatchesUiState.Error -> ErrorMessage( message = value.message, onRetry = { viewModel.loadUpcomingMatches() } @@ -78,15 +103,34 @@ fun MatchesScreen( } @Composable -private fun MatchesList(matches: List, onTapCard: (MatchDetailsRoute) -> Unit) { +fun MatchesList( + matches: List, + onTapCard: (MatchDetailsRoute) -> Unit, + listState: LazyListState, + isPaginating: Boolean +) { LazyColumn( Modifier .padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(24.dp), - contentPadding = PaddingValues(vertical = 24.dp) + contentPadding = PaddingValues(vertical = 24.dp), + state = listState, ) { - items(matches) { - MatchCard(it, onTapCard) + items(matches) { match -> + MatchCard(match, onTapCard) + } + + if (isPaginating) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesViewModel.kt b/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesViewModel.kt index c5c7e13..c1778a4 100644 --- a/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesViewModel.kt +++ b/app/src/main/java/xyz/leomurca/csgomatches/ui/screens/matches/MatchesViewModel.kt @@ -20,23 +20,56 @@ class MatchesViewModel @Inject constructor( private val _uiState = MutableStateFlow(MatchesUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private var currentPage = 1 + private var isLoadingNextPage = false + init { loadUpcomingMatches() } fun loadUpcomingMatches() { + currentPage = 1 viewModelScope.launch { _uiState.value = MatchesUiState.Loading - _uiState.value = when (val result = matchRepository.upcomingMatches()) { + _uiState.value = when (val result = matchRepository.upcomingMatches(currentPage)) { is Resource.Success -> MatchesUiState.Success(result.data) is Resource.Error -> MatchesUiState.Error(result.message) } } } + fun loadNextPage() { + val current = _uiState.value as? MatchesUiState.Success ?: return + if (isLoadingNextPage || current.endReached) return + + isLoadingNextPage = true + currentPage++ + + viewModelScope.launch { + _uiState.value = current.copy(isPaginating = true) + + val result = matchRepository.upcomingMatches(currentPage) + val newMatches = (result as? Resource.Success)?.data.orEmpty() + + _uiState.value = current.copy( + matches = current.matches + newMatches, + isPaginating = false, + endReached = newMatches.size < 20 + ) + + isLoadingNextPage = false + } + } + + sealed class MatchesUiState { - object Loading : MatchesUiState() - data class Success(val matches: List) : MatchesUiState() + data class Success( + val matches: List, + val isPaginating: Boolean = false, + val endReached: Boolean = false + ) : MatchesUiState() + data class Error(val message: String) : MatchesUiState() + object Loading : MatchesUiState() } } \ No newline at end of file diff --git a/app/src/main/java/xyz/leomurca/csgomatches/utils/Constants.kt b/app/src/main/java/xyz/leomurca/csgomatches/utils/Constants.kt new file mode 100644 index 0000000..30b829a --- /dev/null +++ b/app/src/main/java/xyz/leomurca/csgomatches/utils/Constants.kt @@ -0,0 +1,8 @@ +package xyz.leomurca.csgomatches.utils + +object ApiDefaults { + const val ITEMS_PER_PAGE = 20 + const val MATCH_FILTER_STATUS = "running, not_started" + const val MATCH_FINISHED = false + const val MATCH_SORT = "begin_at" +} \ No newline at end of file