feat: add pagination support and some refactorings

This commit is contained in:
Leonardo Murça 2025-07-19 17:46:18 -03:00
parent dd2810f516
commit a1382d0ee4
9 changed files with 112 additions and 18 deletions

View file

@ -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<List<MatchDto>> {
override suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>> {
return Resource.Success(
data = listOf(
// Happy path

View file

@ -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<List<MatchDto>> {
override suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>> {
return try {
val response = matchesApiService.upcomingMatches()
val response = matchesApiService.upcomingMatches(page = page)
if (response.isSuccessful) {
Resource.Success(response.body() ?: throw Exception("Empty response body"))

View file

@ -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<List<MatchDto>>
@GET("teams")

View file

@ -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<List<Match>> {
override suspend fun upcomingMatches(page: Int): Resource<List<Match>> {
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)
}

View file

@ -5,6 +5,6 @@ import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
import xyz.leomurca.csgomatches.domain.model.Resource
interface MatchDataSource {
suspend fun upcomingMatches(): Resource<List<MatchDto>>
suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>>
suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>>
}

View file

@ -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<List<Match>>
suspend fun upcomingMatches(page: Int): Resource<List<Match>>
suspend fun teamDetails(teamId: String): Resource<List<Team>>
}

View file

@ -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<Match>, onTapCard: (MatchDetailsRoute) -> Unit) {
fun MatchesList(
matches: List<Match>,
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()
}
}
}
}
}

View file

@ -20,23 +20,56 @@ class MatchesViewModel @Inject constructor(
private val _uiState = MutableStateFlow<MatchesUiState>(MatchesUiState.Loading)
val uiState: StateFlow<MatchesUiState> = _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<Match>) : MatchesUiState()
data class Success(
val matches: List<Match>,
val isPaginating: Boolean = false,
val endReached: Boolean = false
) : MatchesUiState()
data class Error(val message: String) : MatchesUiState()
object Loading : MatchesUiState()
}
}

View file

@ -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"
}