feat: format ScheduleBadge correctly based on local time zone
This commit is contained in:
parent
15707753fc
commit
c5d1bbcbed
12 changed files with 169 additions and 35 deletions
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
|
@ -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">
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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>>
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -0,0 +1,7 @@
|
|||
package xyz.leomurca.csgomatches.domain.model
|
||||
|
||||
enum class MatchStatus {
|
||||
LIVE,
|
||||
SCHEDULED,
|
||||
UNKNOWN,
|
||||
}
|
|
@ -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,17 +139,52 @@ private fun LeagueInfoRow(league: League, serieName: String) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.ScheduleBadge() {
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,3 +208,4 @@ private fun getOrDefaultOpponents(opponents: List<Opponent>): Pair<Opponent, Opp
|
|||
else -> default to default
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Add table
Reference in a new issue