feat(rust): the Rust server list and one server's page — M14 (module-rust phase 5, Android leg A) #47

Merged
whitlocktech merged 4 commits from feature/rust-p5-android-a into edge 2026-09-17 09:22:02 +00:00
3 changed files with 61 additions and 21 deletions
Showing only changes of commit a6677d5bf9 - Show all commits

View File

@@ -162,26 +162,29 @@ fun RunicApp(
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle() val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3), // Re-validate the cached role each time the app returns to the foreground (§4.3),
// and re-read the unread count with it: a tickle that arrived while the app was // and re-read the two drawer counts with it: a tickle that arrived while the app
// away is exactly what brings someone back to it. // was away is exactly what brings someone back to it, and a live player count is
LifecycleResumeEffect(Unit) { // only live if it is re-read when somebody looks.
LifecycleResumeEffect(capabilities) {
sessionViewModel.revalidate() sessionViewModel.revalidate()
inboxBadgeViewModel.refresh() inboxBadgeViewModel.refresh()
// The Rust count is a LIVE number, so it is re-read on the same clock the
// unread badge is: coming back to the app is exactly when a stale one
// would be noticed. Keyed on the capability answer as well as on resume,
// because the very first resume happens before this host has said whether
// the module is there — and asking then would either make a request on a
// site that has no Rust, or never make one at all.
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
onPauseOrDispose { } onPauseOrDispose { }
} }
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle() val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// How many people are on the Rust fleet, for the drawer row's badge — the // How many people are on the Rust fleet, for the drawer row's badge — the
// phone's answer to D15's footer count (M14). Refreshed with the capability // phone's answer to D15's footer count (M14). Refreshed on resume, never on a
// answer rather than on a timer: a badge is a glance, not a feed, and this is // timer: a badge is a glance, not a feed. Gated here rather than inside the
// the only place that knows whether the module is installed at all. A host // view model because this is the only place that knows whether the module is
// that has not answered yet asks nothing, so a cold start makes no request // installed at all, and a host that has not answered yet asks nothing.
// until it knows there is something to ask about.
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle() val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
LaunchedEffect(capabilities) {
val caps = capabilities
if (caps != null) rustBadgeViewModel.refresh(Capability.RUST in caps)
}
// The badge follows the session, so signing out clears it rather than leaving // The badge follows the session, so signing out clears it rather than leaving
// the previous account's count on the drawer. // the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() } LaunchedEffect(session) { inboxBadgeViewModel.refresh() }

View File

@@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -341,6 +343,12 @@ private fun FeedRow(row: RustEventDto) {
*/ */
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String) private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
/** The name's share of the row against one numeric column's. */
private const val NAME_WEIGHT = 1.7f
/** How far a header that is not the current sort is faded. */
private const val SORTED_AWAY = 0.55f
private val RUST_COLUMNS = listOf( private val RUST_COLUMNS = listOf(
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() }, RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() }, RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
@@ -372,8 +380,15 @@ private fun LeaderboardPanel(
) { ) {
item { item {
Row(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth()) {
SectionLabel(stringResource(R.string.rust_col_player), Modifier.weight(1f)) SectionLabel(
text = stringResource(R.string.rust_col_player),
modifier = Modifier.weight(NAME_WEIGHT),
)
RUST_COLUMNS.forEach { column -> RUST_COLUMNS.forEach { column ->
// The ACTIVE sort is marked on the header, not on the
// values: the header is the control, and tinting a
// column of numbers instead says "these are special"
// rather than "this is what the table is ordered by".
SectionLabel( SectionLabel(
text = stringResource(column.labelRes), text = stringResource(column.labelRes),
modifier = Modifier modifier = Modifier
@@ -384,6 +399,13 @@ private fun LeaderboardPanel(
} else { } else {
Modifier Modifier
}, },
)
.then(
if (column.sort == sort) {
Modifier.alpha(1f)
} else {
Modifier.alpha(SORTED_AWAY)
},
), ),
) )
} }
@@ -391,20 +413,23 @@ private fun LeaderboardPanel(
} }
items(state.data, key = { it.steamId }) { row -> items(state.data, key = { it.steamId }) { row ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
// A wider share for the name, and one line with an ellipsis.
// 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(
text = playerLabel(row.name, row.steamId), text = playerLabel(row.name, row.steamId),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f), maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
) )
RUST_COLUMNS.forEach { column -> RUST_COLUMNS.forEach { column ->
Text( Text(
text = column.value(row), text = column.value(row),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (column.sort == sort) { color = MaterialTheme.colorScheme.onSurfaceVariant,
MaterialTheme.colorScheme.primary maxLines = 1,
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -456,7 +481,10 @@ private fun OnlinePanel(
} }
items(s.data, key = { it.steamId }) { player -> items(s.data, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) { ShardCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
text = playerLabel(player.name, player.steamId), text = playerLabel(player.name, player.steamId),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -500,7 +528,10 @@ private fun WipesPanel(
ShardCard( ShardCard(
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) }, Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
) { ) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text( Text(
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId, text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,

View File

@@ -100,6 +100,11 @@ private fun ServerList(
@Composable @Composable
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) { private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) { ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
// `ShardCard` is the themed Card and nothing more — it carries no padding
// of its own, so every caller pads its own content. Without this the text
// sits flush against the card's edge and the first glyph of each line
// reads as clipped, which is what the walk saw.
Column(Modifier.padding(16.dp)) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@@ -145,6 +150,7 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
) )
} }
} }
}
/** /**
* The world line — the things a Rust player asks first. * The world line — the things a Rust player asks first.