feat: format ScheduleBadge correctly based on local time zone

This commit is contained in:
Leonardo Murça 2025-07-18 18:08:42 -03:00
parent 15707753fc
commit c5d1bbcbed
12 changed files with 169 additions and 35 deletions

1
.idea/misc.xml generated
View file

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" /> <component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View file

@ -14,7 +14,7 @@ class MatchLocalDataSource : MatchDataSource {
data = listOf( data = listOf(
// Happy path // Happy path
MatchDto( MatchDto(
beginAt = "2025-07-21T10:30:00Z", beginAt = null,
opponents = listOf( opponents = listOf(
OpponentDto( OpponentDto(
type = "team", type = "team",
@ -46,7 +46,7 @@ class MatchLocalDataSource : MatchDataSource {
), ),
// Empty Opponents // Empty Opponents
MatchDto( MatchDto(
beginAt = "2025-07-27T10:30:00Z", beginAt = null,
opponents = emptyList(), opponents = emptyList(),
league = LeagueDto( league = LeagueDto(
id = 5078, id = 5078,
@ -62,7 +62,7 @@ class MatchLocalDataSource : MatchDataSource {
// Only 1 opponent // Only 1 opponent
MatchDto( MatchDto(
beginAt = "2025-07-21T10:30:00Z", beginAt = null,
opponents = listOf( opponents = listOf(
OpponentDto( OpponentDto(
type = "team", type = "team",
@ -86,7 +86,7 @@ class MatchLocalDataSource : MatchDataSource {
), ),
// 1 opponent without logo // 1 opponent without logo
MatchDto( MatchDto(
beginAt = "2025-07-21T10:30:00Z", beginAt = null,
opponents = listOf( opponents = listOf(
OpponentDto( OpponentDto(
type = "team", type = "team",
@ -118,7 +118,7 @@ class MatchLocalDataSource : MatchDataSource {
), ),
// League without logo // League without logo
MatchDto( MatchDto(
beginAt = "2025-07-21T10:30:00Z", beginAt = null,
opponents = listOf( opponents = listOf(
OpponentDto( OpponentDto(
type = "team", type = "team",

View file

@ -3,12 +3,13 @@ package xyz.leomurca.csgomatches.data.mapper
import xyz.leomurca.csgomatches.data.model.MatchDto import xyz.leomurca.csgomatches.data.model.MatchDto
import xyz.leomurca.csgomatches.domain.model.League import xyz.leomurca.csgomatches.domain.model.League
import xyz.leomurca.csgomatches.domain.model.Match 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.Opponent
import xyz.leomurca.csgomatches.domain.model.Serie import xyz.leomurca.csgomatches.domain.model.Serie
fun MatchDto.toDomain(): Match { fun MatchDto.toDomain(): Match {
return Match( return Match(
beginAt = beginAt ?: "", beginAt = beginAt,
opponents = opponents.map { op -> opponents = opponents.map { op ->
Opponent( Opponent(
id = op.opponent.id, id = op.opponent.id,
@ -25,6 +26,9 @@ fun MatchDto.toDomain(): Match {
id = serie.id, id = serie.id,
name = serie.fullName ?: "" name = serie.fullName ?: ""
), ),
status = status ?: "" status = status?.toMatchStatus() ?: MatchStatus.UNKNOWN
) )
} }
private fun String?.toMatchStatus() =
if (this == "running") MatchStatus.LIVE else MatchStatus.SCHEDULED

View file

@ -2,11 +2,14 @@ package xyz.leomurca.csgomatches.data.model
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import xyz.leomurca.csgomatches.data.model.serializer.ZonedDateTimeSerializer
import java.time.ZonedDateTime
@Serializable @Serializable
data class MatchDto( data class MatchDto(
@Serializable(with = ZonedDateTimeSerializer::class)
@SerialName("begin_at") @SerialName("begin_at")
val beginAt: String?, val beginAt: ZonedDateTime?,
val opponents: List<OpponentDto>, val opponents: List<OpponentDto>,
val league: LeagueDto, val league: LeagueDto,
val serie: SerieDto, val serie: SerieDto,

View file

@ -0,0 +1,27 @@
package xyz.leomurca.csgomatches.data.model.serializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
object ZonedDateTimeSerializer : KSerializer<ZonedDateTime> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ZonedDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: ZonedDateTime) {
encoder.encodeString(value.withZoneSameInstant(ZoneOffset.UTC).toString())
}
override fun deserialize(decoder: Decoder): ZonedDateTime {
val isoString = decoder.decodeString()
val instant = Instant.parse(isoString)
return instant.atZone(ZoneId.systemDefault())
}
}

View file

@ -2,10 +2,15 @@ package xyz.leomurca.csgomatches.data.remote
import retrofit2.Response import retrofit2.Response
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query
import xyz.leomurca.csgomatches.data.model.MatchDto import xyz.leomurca.csgomatches.data.model.MatchDto
interface MatchesApiService { interface MatchesApiService {
@GET("matches") @GET("matches")
suspend fun upcomingMatches(): Response<List<MatchDto>> suspend fun upcomingMatches(
@Query("filter[status]") filterStatus: String = "running, not_started",
@Query("finished") finished: Boolean = false,
@Query("sort") sort: String = "begin_at",
): Response<List<MatchDto>>
} }

View file

@ -70,7 +70,7 @@ internal object NetworkModule {
matchesApiService: MatchesApiService, matchesApiService: MatchesApiService,
json: Json json: Json
): MatchDataSource { ): MatchDataSource {
val useRemote = false val useRemote = true
return if (useRemote) { return if (useRemote) {
MatchRemoteDataSourceImpl(matchesApiService, json) MatchRemoteDataSourceImpl(matchesApiService, json)
} else { } else {

View file

@ -1,10 +1,12 @@
package xyz.leomurca.csgomatches.domain.model package xyz.leomurca.csgomatches.domain.model
import java.time.ZonedDateTime
data class Match( data class Match(
val beginAt: String, val beginAt: ZonedDateTime?,
val opponents: List<Opponent>, val opponents: List<Opponent>,
val league: League, val league: League,
val serie: Serie, val serie: Serie,
val status: String val status: MatchStatus
) )

View file

@ -0,0 +1,7 @@
package xyz.leomurca.csgomatches.domain.model
enum class MatchStatus {
LIVE,
SCHEDULED,
UNKNOWN,
}

View file

@ -30,31 +30,30 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import xyz.leomurca.csgomatches.R import xyz.leomurca.csgomatches.R
import xyz.leomurca.csgomatches.domain.model.League 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.Opponent
import xyz.leomurca.csgomatches.ui.theme.LiveRed import xyz.leomurca.csgomatches.ui.theme.LiveRed
import xyz.leomurca.csgomatches.ui.theme.White_20 import xyz.leomurca.csgomatches.ui.theme.White_20
import xyz.leomurca.csgomatches.ui.theme.White_50 import xyz.leomurca.csgomatches.ui.theme.White_50
import xyz.leomurca.csgomatches.utils.toFormattedMatchTime
import java.time.ZonedDateTime
@Composable @Composable
fun MatchCard( fun MatchCard(
opponents: List<Opponent>, match: Match,
league: League,
serieName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Box(modifier = modifier.fillMaxWidth()) {
modifier = modifier
.fillMaxWidth()
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) { ) {
MatchupRow(opponents) MatchupRow(match.opponents)
LeagueInfoRow(league, serieName) LeagueInfoRow(match.league, match.serie.name)
} }
ScheduleBadge() ScheduleBadge(match.status, match.beginAt)
} }
} }
@ -140,17 +139,52 @@ private fun LeagueInfoRow(league: League, serieName: String) {
} }
@Composable @Composable
private fun BoxScope.ScheduleBadge() { private fun BoxScope.ScheduleBadge(status: MatchStatus, beginAt: ZonedDateTime?) {
Box( when (status) {
modifier = Modifier MatchStatus.LIVE ->
.align(Alignment.TopEnd) Box(
.clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp)) modifier = Modifier
.background(LiveRed) .align(Alignment.TopEnd)
.padding(8.dp) .clip(RoundedCornerShape(topEnd = 16.dp, bottomStart = 16.dp))
) { .background(LiveRed)
Text( .padding(8.dp)
text = "AGORA", style = MaterialTheme.typography.displayMedium, color = Color.White ) {
) 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
)
}
} }
} }
@ -174,3 +208,4 @@ private fun getOrDefaultOpponents(opponents: List<Opponent>): Pair<Opponent, Opp
else -> default to default else -> default to default
} }
} }

View file

@ -76,7 +76,7 @@ private fun MatchesList(matches: List<Match>) {
contentPadding = PaddingValues(vertical = 24.dp) contentPadding = PaddingValues(vertical = 24.dp)
) { ) {
items(matches) { items(matches) {
MatchCard(it.opponents, it.league, it.serie.name) MatchCard(it)
} }
} }
} }

View file

@ -0,0 +1,52 @@
package xyz.leomurca.csgomatches.utils
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Formats a [ZonedDateTime] instance into a user-friendly, localized string representing the match time.
*
* The formatting rules are as follows:
* - If the date is today, returns: `"Hoje, HH:mm"`
* - If the date is within the current week, returns: `"EEE, HH:mm"` (e.g., `"Ter, 22:00"`)
* - Otherwise, returns: `"dd.MM HH:mm"` (e.g., `"22.04 15:00"`)
*
* The day of the week is formatted in Portuguese (`pt-BR`) and its first letter is capitalized.
*
* @receiver the [ZonedDateTime] instance to format. If null, an empty string is returned.
* @return a formatted string representing the match time, or `"A definir"` if the input is null.
*/
fun ZonedDateTime?.toFormattedMatchTime(): String {
if (this == null) return "A definir"
val targetDate = toLocalDate()
val timeFormatter = DateTimeFormatter.ofPattern("HH:mm")
return when {
targetDate.isToday() -> "Hoje, ${format(timeFormatter)}"
targetDate.isInCurrentWeek() -> {
val dayOfWeekFormatter = DateTimeFormatter.ofPattern("EEE", Locale("pt", "BR"))
val day = format(dayOfWeekFormatter).replaceFirstChar {
it.titlecase(Locale("pt", "BR"))
}
"$day, ${format(timeFormatter)}"
}
else -> {
val fullFormatter = DateTimeFormatter.ofPattern("dd.MM HH:mm")
format(fullFormatter)
}
}
}
private fun LocalDate.isToday() = isEqual(ZonedDateTime.now().toLocalDate())
private fun LocalDate.isInCurrentWeek(): Boolean {
val today = LocalDate.now()
val startOfWeek = today.with(DayOfWeek.MONDAY)
val endOfWeek = today.with(DayOfWeek.SUNDAY)
return !this.isBefore(startOfWeek) && !this.isAfter(endOfWeek)
}