feat: add details screen with navigation and initial structure

This commit is contained in:
Leonardo Murça 2025-07-18 22:33:27 -03:00
parent c5d1bbcbed
commit 07d20aba86
19 changed files with 312 additions and 82 deletions

View file

@ -5,6 +5,7 @@ import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.OpponentDto
import xyz.leomurca.csgomatches.data.model.OpponentRecord
import xyz.leomurca.csgomatches.data.model.SerieDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.domain.model.Resource
@ -151,4 +152,8 @@ class MatchLocalDataSource : MatchDataSource {
)
)
}
override suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>> {
TODO("Not yet implemented")
}
}

View file

@ -1,11 +1,14 @@
package xyz.leomurca.csgomatches.data.mapper
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
import xyz.leomurca.csgomatches.domain.model.League
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.MatchStatus
import xyz.leomurca.csgomatches.domain.model.Opponent
import xyz.leomurca.csgomatches.domain.model.Player
import xyz.leomurca.csgomatches.domain.model.Serie
import xyz.leomurca.csgomatches.domain.model.Team
fun MatchDto.toDomain(): Match {
return Match(
@ -32,3 +35,20 @@ fun MatchDto.toDomain(): Match {
private fun String?.toMatchStatus() =
if (this == "running") MatchStatus.LIVE else MatchStatus.SCHEDULED
fun TeamDetailsDto.toDomain(): Team {
return Team(
id = id,
name = name,
imageUrl = imageUrl,
players = players.map {
Player(
id = it.id,
nickName = it.name,
firstName = it.firstName,
lastName = it.lastName,
imageUrl = it.imageUrl
)
}
)
}

View file

@ -0,0 +1,16 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PlayerDto(
val id: Long,
val name: String?,
@SerialName("first_name")
val firstName: String?,
@SerialName("last_name")
val lastName: String?,
@SerialName("image_url")
val imageUrl: String?
)

View file

@ -0,0 +1,13 @@
package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TeamDetailsDto(
val id: Long,
val name: String?,
@SerialName("image_url")
val imageUrl: String?,
val players: List<PlayerDto>
)

View file

@ -3,6 +3,7 @@ package xyz.leomurca.csgomatches.data.remote
import kotlinx.serialization.json.Json
import xyz.leomurca.csgomatches.data.model.ErrorDto
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
import xyz.leomurca.csgomatches.data.source.MatchDataSource
import xyz.leomurca.csgomatches.domain.model.Resource
@ -25,4 +26,20 @@ class MatchRemoteDataSourceImpl(
Resource.Error(e.message.toString())
}
}
override suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>> {
return try {
val response = matchesApiService.teamDetails(teamId)
if (response.isSuccessful) {
Resource.Success(response.body() ?: throw Exception("Empty response body"))
} else {
val errorBody = response.errorBody()?.string() ?: ""
val networkError = json.decodeFromString<ErrorDto>(errorBody)
Resource.Error(networkError.message)
}
} catch (e: Exception) {
Resource.Error(e.message.toString())
}
}
}

View file

@ -4,6 +4,7 @@ import retrofit2.Response
import retrofit2.http.GET
import retrofit2.http.Query
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
interface MatchesApiService {
@ -13,4 +14,9 @@ interface MatchesApiService {
@Query("finished") finished: Boolean = false,
@Query("sort") sort: String = "begin_at",
): Response<List<MatchDto>>
@GET("teams")
suspend fun teamDetails(
@Query("filter[id]") id: String
): Response<List<TeamDetailsDto>>
}

View file

@ -8,6 +8,7 @@ import xyz.leomurca.csgomatches.di.AppDispatchers
import xyz.leomurca.csgomatches.di.Dispatcher
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.Resource
import xyz.leomurca.csgomatches.domain.model.Team
import xyz.leomurca.csgomatches.domain.repository.MatchRepository
class MatchRepositoryImpl(
@ -22,4 +23,13 @@ class MatchRepositoryImpl(
}
}
}
override suspend fun teamDetails(teamId: String): Resource<List<Team>> {
return withContext(ioDispatcher) {
when (val result = matchRemoteDataSource.teamDetails(teamId)) {
is Resource.Success -> Resource.Success(result.data.map { it.toDomain() })
is Resource.Error -> Resource.Error(result.message)
}
}
}
}

View file

@ -1,8 +1,10 @@
package xyz.leomurca.csgomatches.data.source
import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.data.model.TeamDetailsDto
import xyz.leomurca.csgomatches.domain.model.Resource
interface MatchDataSource {
suspend fun upcomingMatches(): Resource<List<MatchDto>>
suspend fun teamDetails(teamId: String): Resource<List<TeamDetailsDto>>
}

View file

@ -2,7 +2,6 @@ package xyz.leomurca.csgomatches.domain.model
import java.time.ZonedDateTime
data class Match(
val beginAt: ZonedDateTime?,
val opponents: List<Opponent>,

View file

@ -0,0 +1,10 @@
package xyz.leomurca.csgomatches.domain.model
data class Player(
val id: Long,
val nickName: String?,
val firstName: String?,
val lastName: String?,
val imageUrl: String?
)

View file

@ -0,0 +1,8 @@
package xyz.leomurca.csgomatches.domain.model
data class Team(
val id: Long,
val name: String?,
val imageUrl: String?,
val players: List<Player>
)

View file

@ -2,8 +2,11 @@ package xyz.leomurca.csgomatches.domain.repository
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 teamDetails(teamId: String): Resource<List<Team>>
}

View file

@ -33,6 +33,7 @@ import xyz.leomurca.csgomatches.domain.model.League
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.domain.model.MatchStatus
import xyz.leomurca.csgomatches.domain.model.Opponent
import xyz.leomurca.csgomatches.ui.navigation.DetailsRoute
import xyz.leomurca.csgomatches.ui.theme.LiveRed
import xyz.leomurca.csgomatches.ui.theme.White_20
import xyz.leomurca.csgomatches.ui.theme.White_50
@ -42,23 +43,35 @@ import java.time.ZonedDateTime
@Composable
fun MatchCard(
match: Match,
onTapCard: (DetailsRoute) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxWidth()) {
val (leftOpponent, rightOpponent) = getOrDefaultOpponents(match.opponents)
val leagueAndSerieName = "${match.league.name} + ${match.serie.name}"
val scheduleConfig = match.status.scheduleConfigFor(match.beginAt)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), onClick = {
onTapCard(
DetailsRoute(
leftOpponentId = leftOpponent.id,
rightOpponentId = rightOpponent.id,
leagueAndSerieName = leagueAndSerieName,
scheduleString = scheduleConfig.first,
)
)
}, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
MatchupRow(match.opponents)
LeagueInfoRow(match.league, match.serie.name)
MatchupRow(leftOpponent, rightOpponent)
LeagueInfoRow(match.league, leagueAndSerieName)
}
ScheduleBadge(match.status, match.beginAt)
ScheduleBadge(scheduleConfig)
}
}
@Composable
private fun MatchupRow(opponents: List<Opponent>) {
private fun MatchupRow(leftOpponent: Opponent, rightOpponent: Opponent) {
Row(
modifier = Modifier
.fillMaxWidth()
@ -66,7 +79,6 @@ private fun MatchupRow(opponents: List<Opponent>) {
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val (leftOpponent, rightOpponent) = getOrDefaultOpponents(opponents)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AsyncImage(
@ -110,7 +122,7 @@ private fun MatchupRow(opponents: List<Opponent>) {
}
@Composable
private fun LeagueInfoRow(league: League, serieName: String) {
private fun LeagueInfoRow(league: League, leagueAndSerieName: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@ -123,15 +135,14 @@ private fun LeagueInfoRow(league: League, serieName: String) {
contentDescription = "${league.name} logo",
error = painterResource(R.drawable.fallback_image_round),
placeholder = painterResource(R.drawable.fallback_image_round),
onLoading = {
},
onLoading = {},
modifier = Modifier
.height(16.dp)
.wrapContentWidth(),
contentScale = ContentScale.Fit
)
Text(
"${league.name} + $serieName",
leagueAndSerieName,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(start = 8.dp)
)
@ -139,52 +150,20 @@ private fun LeagueInfoRow(league: League, serieName: String) {
}
@Composable
private fun BoxScope.ScheduleBadge(status: MatchStatus, beginAt: ZonedDateTime?) {
when (status) {
MatchStatus.LIVE ->
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp))
.background(LiveRed)
.padding(8.dp)
) {
Text(
text = "AGORA",
style = MaterialTheme.typography.displayMedium,
color = Color.White
)
}
MatchStatus.SCHEDULED ->
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp))
.background(White_20)
.padding(8.dp)
) {
Text(
text = beginAt.toFormattedMatchTime(),
style = MaterialTheme.typography.displayMedium,
color = Color.White
)
}
MatchStatus.UNKNOWN ->
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp))
.background(White_20)
.padding(8.dp)
) {
Text(
text = "A ser definido",
style = MaterialTheme.typography.displayMedium,
color = Color.White
)
}
private fun BoxScope.ScheduleBadge(scheduleConfig: Pair<String, Color>) {
val (scheduleString, scheduleBackgroundColor) = scheduleConfig
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp))
.background(scheduleBackgroundColor)
.padding(8.dp)
) {
Text(
text = scheduleString,
style = MaterialTheme.typography.displayMedium,
color = Color.White
)
}
}
@ -197,8 +176,7 @@ private fun Modifier.topBorder(color: Color, thickness: Dp): Modifier = this.the
end = Offset(size.width, 0f),
strokeWidth = strokeWidth
)
}
)
})
private fun getOrDefaultOpponents(opponents: List<Opponent>): Pair<Opponent, Opponent> {
val default = Opponent(id = 0, name = "A ser definido", imageUrl = "")
@ -209,3 +187,8 @@ private fun getOrDefaultOpponents(opponents: List<Opponent>): Pair<Opponent, Opp
}
}
private fun MatchStatus.scheduleConfigFor(beginAt: ZonedDateTime?) = when (this) {
MatchStatus.LIVE -> "Agora" to LiveRed
MatchStatus.SCHEDULED -> beginAt.toFormattedMatchTime() to White_20
MatchStatus.UNKNOWN -> "A definir" to White_20
}

View file

@ -2,12 +2,21 @@ package xyz.leomurca.csgomatches.ui.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import xyz.leomurca.csgomatches.ui.screens.details.DetailsScreen
const val DETAILS_ROUTE = "details"
@Serializable
data class DetailsRoute(
val leftOpponentId: Long,
val rightOpponentId: Long,
val leagueAndSerieName: String,
val scheduleString: String
)
fun NavGraphBuilder.detailsScreen() {
composable(DETAILS_ROUTE) {
DetailsScreen()
fun NavGraphBuilder.detailsScreen(onBackClick: () -> Unit) {
composable<DetailsRoute> {
val details: DetailsRoute = it.toRoute()
DetailsScreen(details = details, onBackClick = onBackClick)
}
}

View file

@ -2,12 +2,16 @@ package xyz.leomurca.csgomatches.ui.navigation
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.ui.screens.matches.MatchesScreen
const val MATCHES_ROUTE = "matches"
@Serializable
object MatchesRoute
fun NavGraphBuilder.matchesScreen() {
composable(MATCHES_ROUTE) {
MatchesScreen()
fun NavGraphBuilder.matchesScreen(onTapCard: (DetailsRoute) -> Unit) {
composable<MatchesRoute> {
MatchesScreen(onTapCard = onTapCard)
}
}

View file

@ -9,9 +9,13 @@ fun RootNavHost() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = MATCHES_ROUTE
startDestination = MatchesRoute
) {
matchesScreen()
detailsScreen()
matchesScreen {
navController.navigate(it)
}
detailsScreen {
navController.popBackStack()
}
}
}

View file

@ -2,20 +2,84 @@ package xyz.leomurca.csgomatches.ui.screens.details
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import xyz.leomurca.csgomatches.ui.components.LoadingIndicator
import xyz.leomurca.csgomatches.ui.navigation.DetailsRoute
import xyz.leomurca.csgomatches.ui.theme.White
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DetailsScreen() {
fun DetailsScreen(
viewModel: DetailsViewModel = hiltViewModel(),
details: DetailsRoute,
onBackClick: () -> Unit
) {
val uiState = viewModel.uiState.collectAsState()
LaunchedEffect(Unit) {
viewModel.loadTeam(details.leftOpponentId.toString(), isLeft = true)
viewModel.loadTeam(details.rightOpponentId.toString(), isLeft = false)
}
Box(
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Text("Details Screen", fontSize = 24.sp)
TopBar(details.leagueAndSerieName, onBackClick)
val value = uiState.value
when {
value.errorMessage != null -> Text(value.errorMessage)
value.isLoading -> LoadingIndicator()
value.leftTeam != null && value.rightTeam != null -> Column {
Text(value.leftTeam.name.toString())
Text(value.rightTeam.name.toString())
}
}
}
}
@Composable
private fun BoxScope.TopBar(leagueAndSerieName: String, onBackClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.align(Alignment.TopCenter)
.padding(top = 52.dp, start = 24.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onBackClick, modifier = Modifier) {
Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = "", tint = White)
}
Text(
leagueAndSerieName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium,
color = White,
)
}
}

View file

@ -0,0 +1,53 @@
package xyz.leomurca.csgomatches.ui.screens.details
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import xyz.leomurca.csgomatches.domain.model.Resource
import xyz.leomurca.csgomatches.domain.model.Team
import xyz.leomurca.csgomatches.domain.repository.MatchRepository
import javax.inject.Inject
@HiltViewModel
class DetailsViewModel @Inject constructor(
private val matchRepository: MatchRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(TeamUiState())
val uiState: StateFlow<TeamUiState> = _uiState.asStateFlow()
fun loadTeam(teamId: String, isLeft: Boolean) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
when (val result = matchRepository.teamDetails(teamId)) {
is Resource.Success -> {
val team = result.data.firstOrNull()
_uiState.update {
if (isLeft) it.copy(leftTeam = team, isLoading = false)
else it.copy(rightTeam = team, isLoading = false)
}
}
is Resource.Error -> {
_uiState.update {
it.copy(errorMessage = result.message, isLoading = false)
}
}
}
}
}
data class TeamUiState(
val leftTeam: Team? = null,
val rightTeam: Team? = null,
val isLoading: Boolean = true,
val errorMessage: String? = null
)
}

View file

@ -24,12 +24,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import xyz.leomurca.csgomatches.domain.model.Match
import xyz.leomurca.csgomatches.ui.components.LoadingIndicator
import xyz.leomurca.csgomatches.ui.components.MatchCard
import xyz.leomurca.csgomatches.ui.navigation.DetailsRoute
import xyz.leomurca.csgomatches.ui.screens.matches.MatchesViewModel.MatchesUiState
import xyz.leomurca.csgomatches.ui.theme.White
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MatchesScreen(viewModel: MatchesViewModel = hiltViewModel()) {
fun MatchesScreen(
viewModel: MatchesViewModel = hiltViewModel(),
onTapCard: (DetailsRoute) -> Unit
) {
val uiState = viewModel.uiState.collectAsState()
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
@ -41,7 +45,7 @@ fun MatchesScreen(viewModel: MatchesViewModel = hiltViewModel()) {
"Partidas",
style = MaterialTheme.typography.titleLarge,
color = White,
modifier = Modifier.padding(top = 24.dp, start = 24.dp)
modifier = Modifier.padding(top = 24.dp, start = 6.dp)
)
},
scrollBehavior = scrollBehavior,
@ -60,7 +64,7 @@ fun MatchesScreen(viewModel: MatchesViewModel = hiltViewModel()) {
) {
when (val value = uiState.value) {
is MatchesUiState.Loading -> LoadingIndicator()
is MatchesUiState.Success -> MatchesList(value.matches)
is MatchesUiState.Success -> MatchesList(value.matches, onTapCard)
is MatchesUiState.Error -> Text(value.message)
}
}
@ -68,7 +72,7 @@ fun MatchesScreen(viewModel: MatchesViewModel = hiltViewModel()) {
}
@Composable
private fun MatchesList(matches: List<Match>) {
private fun MatchesList(matches: List<Match>, onTapCard: (DetailsRoute) -> Unit) {
LazyColumn(
Modifier
.padding(horizontal = 24.dp),
@ -76,7 +80,7 @@ private fun MatchesList(matches: List<Match>) {
contentPadding = PaddingValues(vertical = 24.dp)
) {
items(matches) {
MatchCard(it)
MatchCard(it, onTapCard)
}
}
}