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 import xyz.leomurca.csgomatches.domain.model.Resource
class MatchLocalDataSource : MatchDataSource { class MatchLocalDataSource : MatchDataSource {
override suspend fun upcomingMatches(): Resource<List<MatchDto>> { override suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>> {
return Resource.Success( return Resource.Success(
data = listOf( data = listOf(
// Happy path // Happy path

View file

@ -1,5 +1,6 @@
package xyz.leomurca.csgomatches.data.remote package xyz.leomurca.csgomatches.data.remote
import androidx.compose.foundation.pager.PageSize
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import xyz.leomurca.csgomatches.data.model.ErrorDto import xyz.leomurca.csgomatches.data.model.ErrorDto
import xyz.leomurca.csgomatches.data.model.MatchDto import xyz.leomurca.csgomatches.data.model.MatchDto
@ -11,9 +12,9 @@ class MatchRemoteDataSourceImpl(
private val matchesApiService: MatchesApiService, private val matchesApiService: MatchesApiService,
private val json: Json private val json: Json
) : MatchDataSource { ) : MatchDataSource {
override suspend fun upcomingMatches(): Resource<List<MatchDto>> { override suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>> {
return try { return try {
val response = matchesApiService.upcomingMatches() val response = matchesApiService.upcomingMatches(page = page)
if (response.isSuccessful) { if (response.isSuccessful) {
Resource.Success(response.body() ?: throw Exception("Empty response body")) Resource.Success(response.body() ?: throw Exception("Empty response body"))

View file

@ -5,14 +5,20 @@ import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
import xyz.leomurca.csgomatches.data.model.MatchDto import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto 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 { interface MatchesApiService {
@GET("matches") @GET("matches")
suspend fun upcomingMatches( suspend fun upcomingMatches(
@Query("filter[status]") filterStatus: String = "running, not_started", @Query("filter[status]") filterStatus: String = MATCH_FILTER_STATUS,
@Query("finished") finished: Boolean = false, @Query("finished") finished: Boolean = MATCH_FINISHED,
@Query("sort") sort: String = "begin_at", @Query("sort") sort: String = MATCH_SORT,
@Query("per_page") perPage: Int = ITEMS_PER_PAGE,
@Query("page") page: Int,
): Response<List<MatchDto>> ): Response<List<MatchDto>>
@GET("teams") @GET("teams")

View file

@ -1,5 +1,6 @@
package xyz.leomurca.csgomatches.data.repository package xyz.leomurca.csgomatches.data.repository
import androidx.compose.foundation.pager.PageSize
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import xyz.leomurca.csgomatches.data.mapper.toDomain import xyz.leomurca.csgomatches.data.mapper.toDomain
@ -15,9 +16,9 @@ class MatchRepositoryImpl(
@Dispatcher(AppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Dispatcher(AppDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
private val matchRemoteDataSource: MatchDataSource private val matchRemoteDataSource: MatchDataSource
) : MatchRepository { ) : MatchRepository {
override suspend fun upcomingMatches(): Resource<List<Match>> { override suspend fun upcomingMatches(page: Int): Resource<List<Match>> {
return withContext(ioDispatcher) { 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.Success -> Resource.Success(result.data.map { it.toDomain() })
is Resource.Error -> Resource.Error(result.message) 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 import xyz.leomurca.csgomatches.domain.model.Resource
interface MatchDataSource { interface MatchDataSource {
suspend fun upcomingMatches(): Resource<List<MatchDto>> suspend fun upcomingMatches(page: Int): Resource<List<MatchDto>>
suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>> suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>>
} }

View file

@ -1,12 +1,13 @@
package xyz.leomurca.csgomatches.domain.repository 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.Match
import xyz.leomurca.csgomatches.domain.model.Resource import xyz.leomurca.csgomatches.domain.model.Resource
import xyz.leomurca.csgomatches.domain.model.Team import xyz.leomurca.csgomatches.domain.model.Team
interface MatchRepository { interface MatchRepository {
suspend fun upcomingMatches(): Resource<List<Match>> suspend fun upcomingMatches(page: Int): Resource<List<Match>>
suspend fun teamDetails(teamId: String): Resource<List<Team>> 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.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items 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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -15,7 +20,10 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -35,6 +43,17 @@ fun MatchesScreen(
) { ) {
val uiState = viewModel.uiState.collectAsState() val uiState = viewModel.uiState.collectAsState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() 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( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@ -65,7 +84,13 @@ fun MatchesScreen(
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
when (val value = uiState.value) { 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( is MatchesUiState.Error -> ErrorMessage(
message = value.message, message = value.message,
onRetry = { viewModel.loadUpcomingMatches() } onRetry = { viewModel.loadUpcomingMatches() }
@ -78,15 +103,34 @@ fun MatchesScreen(
} }
@Composable @Composable
private fun MatchesList(matches: List<Match>, onTapCard: (MatchDetailsRoute) -> Unit) { fun MatchesList(
matches: List<Match>,
onTapCard: (MatchDetailsRoute) -> Unit,
listState: LazyListState,
isPaginating: Boolean
) {
LazyColumn( LazyColumn(
Modifier Modifier
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp),
contentPadding = PaddingValues(vertical = 24.dp) contentPadding = PaddingValues(vertical = 24.dp),
state = listState,
) { ) {
items(matches) { items(matches) { match ->
MatchCard(it, onTapCard) 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) private val _uiState = MutableStateFlow<MatchesUiState>(MatchesUiState.Loading)
val uiState: StateFlow<MatchesUiState> = _uiState.asStateFlow() val uiState: StateFlow<MatchesUiState> = _uiState.asStateFlow()
private var currentPage = 1
private var isLoadingNextPage = false
init { init {
loadUpcomingMatches() loadUpcomingMatches()
} }
fun loadUpcomingMatches() { fun loadUpcomingMatches() {
currentPage = 1
viewModelScope.launch { viewModelScope.launch {
_uiState.value = MatchesUiState.Loading _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.Success -> MatchesUiState.Success(result.data)
is Resource.Error -> MatchesUiState.Error(result.message) 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 { sealed class MatchesUiState {
object Loading : MatchesUiState() data class Success(
data class Success(val matches: List<Match>) : MatchesUiState() val matches: List<Match>,
val isPaginating: Boolean = false,
val endReached: Boolean = false
) : MatchesUiState()
data class Error(val message: String) : 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"
}