diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt index adc7b2c..dc63720 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt @@ -176,6 +176,20 @@ data class RustLeaderboardRowDto( val structures: Int = 0, val playtimeSec: Long = 0, val lastSeen: String? = null, + /** + * The chat titles this player holds now (module-rust phase 17, M19) — the + * ones the game shows in chat, after the server's mode. They rank the + * CURRENT wipe whichever wipe the board is showing. Absent from an older + * module, which is why it defaults to empty: nothing is drawn. + */ + val titles: List = emptyList(), +) + +/** One chat title: its text, and the operator's `#rrggbb` colour for it. */ +@Serializable +data class RustTitleDto( + val text: String = "", + val color: String = "", ) /** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */ diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt index 292202b..c18dd3b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt @@ -175,3 +175,31 @@ fun shortSteamId(steamId: String?): String { */ fun playerLabel(name: String?, steamId: String?): String = name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId) + +/** + * A chat title's `#rrggbb` colour as opaque ARGB, or null for anything else — + * a title with a colour this build cannot read is drawn in the theme's own chip + * colours rather than guessed at (M19). + */ +fun titleArgb(hex: String?): Long? { + val m = Regex("^#([0-9a-fA-F]{6})$").find(hex.orEmpty()) ?: return null + return 0xFF000000L or m.groupValues[1].toLong(16) +} + +/** + * Whether a title chip of this colour needs DARK ink — the same rule as the + * website's `contrastInk`: black or white, whichever has the higher WCAG + * contrast. The colour is the operator's, chosen for a dark game chat, and the + * app draws in the reader's theme, so the colour becomes the chip and the ink is + * picked for it. + */ +fun titleInkIsDark(argb: Long): Boolean { + fun linear(channel: Long): Double { + val v = channel / 255.0 + return if (v <= 0.03928) v / 12.92 else Math.pow((v + 0.055) / 1.055, 2.4) + } + val l = 0.2126 * linear((argb shr 16) and 0xFF) + + 0.7152 * linear((argb shr 8) and 0xFF) + + 0.0722 * linear(argb and 0xFF) + return (l + 0.05) / 0.05 >= 1.05 / (l + 0.05) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt index 95cc9d7..5552948 100644 --- a/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/rust/RustServerScreen.kt @@ -3,10 +3,13 @@ */ package com.runicgateway.app.ui.rust +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -15,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ScrollableTabRow @@ -25,6 +29,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -38,6 +44,7 @@ import com.runicgateway.app.data.api.dto.RustEventListDto import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto import com.runicgateway.app.data.api.dto.RustOnlineDto import com.runicgateway.app.data.api.dto.RustServerDto +import com.runicgateway.app.data.api.dto.RustTitleDto import com.runicgateway.app.data.api.dto.RustWipeDto import com.runicgateway.app.ui.ErrorKind import com.runicgateway.app.ui.PollWhileResumed @@ -392,6 +399,7 @@ private val RUST_COLUMNS = listOf( RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) }, ) +@OptIn(ExperimentalLayoutApi::class) @Composable private fun LeaderboardPanel( state: UiState>, @@ -452,13 +460,25 @@ private fun LeaderboardPanel( // Five numeric columns beside an equal-weight name column // left "Brannock" touching its own kill count, which the // walk read as one field. - Text( - text = playerLabel(row.name, row.steamId), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp), - ) + Column(Modifier.weight(NAME_WEIGHT).padding(end = 8.dp)) { + Text( + text = playerLabel(row.name, row.steamId), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // M19: the chat titles this player holds now, under the + // name — the column is too narrow to put them beside it. + if (row.titles.isNotEmpty()) { + FlowRow( + Modifier.padding(top = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + row.titles.forEach { title -> TitleChip(title) } + } + } + } RUST_COLUMNS.forEach { column -> Text( text = column.value(row), @@ -475,6 +495,33 @@ private fun LeaderboardPanel( } } +/** + * One chat title (M19): the operator's colour as the chip, and black or white + * ink chosen for it, as the website draws it. A colour this build cannot read + * falls back to the theme's own chip colours. + */ +@Composable +private fun TitleChip(title: RustTitleDto) { + val argb = titleArgb(title.color) + val background = argb?.let { Color(it) } ?: MaterialTheme.colorScheme.secondaryContainer + val ink = when { + argb == null -> MaterialTheme.colorScheme.onSecondaryContainer + titleInkIsDark(argb) -> Color.Black + else -> Color.White + } + Text( + text = title.text, + style = MaterialTheme.typography.labelSmall, + color = ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(background) + .padding(horizontal = 6.dp, vertical = 1.dp), + ) +} + // ── Online ──────────────────────────────────────────────────────────────── @Composable diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt index 49803ce..a55316a 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust import com.runicgateway.app.data.api.dto.RustEventDto +import com.runicgateway.app.data.api.dto.RustLeaderboardDto import com.runicgateway.app.data.api.dto.RustServerListDto import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals @@ -121,4 +122,28 @@ class RustDtoTest { assertNull(b.nextWipe) assertNull(c.nextWipe) } + + @Test + fun `a leaderboard row carries its chat titles, and an older module's carries none`() { + // M19. The shape is module-rust's `listLeaderboard`: `titles` on every + // row, empty for a player with none; absent altogether from a module + // older than phase 17, which must decode to empty rather than fail. + val board = json.decodeFromString( + """ + {"leaderboard":[ + {"steamId":"1","name":"Brannock","kills":12,"deaths":3,"npcKills":0,"structures":0,"playtimeSec":3600, + "titles":[{"text":"Top Killer","color":"#ff8800"},{"text":"Regular","color":"#00aa55"}]}, + {"steamId":"2","name":"Wren","kills":1,"deaths":0,"npcKills":0,"structures":0,"playtimeSec":60,"titles":[]} + ]} + """.trimIndent(), + ) + assertEquals(listOf("Top Killer", "Regular"), board.leaderboard[0].titles.map { it.text }) + assertEquals("#ff8800", board.leaderboard[0].titles[0].color) + assertTrue(board.leaderboard[1].titles.isEmpty()) + + val older = json.decodeFromString( + """{"leaderboard":[{"steamId":"1","name":"Brannock","kills":12}]}""", + ) + assertTrue(older.leaderboard.single().titles.isEmpty()) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt index b11de19..35a2427 100644 --- a/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt @@ -4,6 +4,7 @@ package com.runicgateway.app.ui.rust import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -153,4 +154,19 @@ class RustFormatTest { assertNull(nextWipeWhen(null, now, utc, uk)) assertNull(nextWipeWhen("soon", now, utc, uk)) } + + @Test + fun `a title colour is read only as #rrggbb, and its ink is whichever reads`() { + assertEquals(0xFFFFAA55L, titleArgb("#ffaa55")) + assertEquals(0xFFFFAA55L, titleArgb("#FFAA55")) + assertNull(titleArgb("red")) + assertNull(titleArgb(null)) + + // The same four the website's contrastInk test asserts, so the two + // clients draw a title the same way. + assertTrue(titleInkIsDark(titleArgb("#ffff00")!!)) + assertTrue(titleInkIsDark(titleArgb("#ffaa55")!!)) + assertTrue(titleInkIsDark(titleArgb("#ff0000")!!)) + assertFalse(titleInkIsDark(titleArgb("#1a1a8c")!!)) + } }