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">
|
<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">
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
|
@ -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,
|
||||||
|
|
|
@ -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.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>>
|
||||||
}
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
|
@ -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 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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