feat: add pagination support and some refactorings
This commit is contained in:
parent
dd2810f516
commit
a1382d0ee4
9 changed files with 112 additions and 18 deletions
|
@ -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
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>>
|
||||||
}
|
}
|
|
@ -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>>
|
||||||
}
|
}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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"
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue