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">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<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(
// Happy path
MatchDto(
beginAt = "2025-07-21T10:30:00Z",
beginAt = null,
opponents = listOf(
OpponentDto(
type = "team",
@ -46,7 +46,7 @@ class MatchLocalDataSource : MatchDataSource {
),
// Empty Opponents
MatchDto(
beginAt = "2025-07-27T10:30:00Z",
beginAt = null,
opponents = emptyList(),
league = LeagueDto(
id = 5078,
@ -62,7 +62,7 @@ class MatchLocalDataSource : MatchDataSource {
// Only 1 opponent
MatchDto(
beginAt = "2025-07-21T10:30:00Z",
beginAt = null,
opponents = listOf(
OpponentDto(
type = "team",
@ -86,7 +86,7 @@ class MatchLocalDataSource : MatchDataSource {
),
// 1 opponent without logo
MatchDto(
beginAt = "2025-07-21T10:30:00Z",
beginAt = null,
opponents = listOf(
OpponentDto(
type = "team",
@ -118,7 +118,7 @@ class MatchLocalDataSource : MatchDataSource {
),
// League without logo
MatchDto(
beginAt = "2025-07-21T10:30:00Z",
beginAt = null,
opponents = listOf(
OpponentDto(
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.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.Serie
fun MatchDto.toDomain(): Match {
return Match(
beginAt = beginAt ?: "",
beginAt = beginAt,
opponents = opponents.map { op ->
Opponent(
id = op.opponent.id,
@ -25,6 +26,9 @@ fun MatchDto.toDomain(): Match {
id = serie.id,
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.Serializable
import xyz.leomurca.csgomatches.data.model.serializer.ZonedDateTimeSerializer
import java.time.ZonedDateTime
@Serializable
data class MatchDto(
@Serializable(with = ZonedDateTimeSerializer::class)
@SerialName("begin_at")
val beginAt: String?,
val beginAt: ZonedDateTime?,
val opponents: List<OpponentDto>,
val league: LeagueDto,
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.http.GET
import retrofit2.http.Query
import xyz.leomurca.csgomatches.data.model.MatchDto
interface MatchesApiService {
@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,
json: Json
): MatchDataSource {
val useRemote = false
val useRemote = true
return if (useRemote) {
MatchRemoteDataSourceImpl(matchesApiService, json)
} else {

View file

@ -1,10 +1,12 @@
package xyz.leomurca.csgomatches.domain.model
import java.time.ZonedDateTime
data class Match(
val beginAt: String,
val beginAt: ZonedDateTime?,
val opponents: List<Opponent>,
val league: League,
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 xyz.leomurca.csgomatches.R
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.theme.LiveRed
import xyz.leomurca.csgomatches.ui.theme.White_20
import xyz.leomurca.csgomatches.ui.theme.White_50
import xyz.leomurca.csgomatches.utils.toFormattedMatchTime
import java.time.ZonedDateTime
@Composable
fun MatchCard(
opponents: List<Opponent>,
league: League,
serieName: String,
match: Match,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
) {
Box(modifier = modifier.fillMaxWidth()) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
MatchupRow(opponents)
LeagueInfoRow(league, serieName)
MatchupRow(match.opponents)
LeagueInfoRow(match.league, match.serie.name)
}
ScheduleBadge()
ScheduleBadge(match.status, match.beginAt)
}
}
@ -140,7 +139,9 @@ private fun LeagueInfoRow(league: League, serieName: String) {
}
@Composable
private fun BoxScope.ScheduleBadge() {
private fun BoxScope.ScheduleBadge(status: MatchStatus, beginAt: ZonedDateTime?) {
when (status) {
MatchStatus.LIVE ->
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@ -149,9 +150,42 @@ private fun BoxScope.ScheduleBadge() {
.padding(8.dp)
) {
Text(
text = "AGORA", style = MaterialTheme.typography.displayMedium, color = Color.White
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 Modifier.topBorder(color: Color, thickness: Dp): Modifier = this.then(
@ -174,3 +208,4 @@ private fun getOrDefaultOpponents(opponents: List<Opponent>): Pair<Opponent, Opp
else -> default to default
}
}

View file

@ -76,7 +76,7 @@ private fun MatchesList(matches: List<Match>) {
contentPadding = PaddingValues(vertical = 24.dp)
) {
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)
}