fix(rust): what the emulator walk found
Some checks failed
PR Checks / android-build (pull_request) Failing after 2s

Three things, none of which a unit test could have seen.

**The drawer's live count resolved once per process.** It was keyed on the
capability answer alone, so it was read at connect and never again — which is
not what "live" means on a row somebody opens the drawer to look at. It now
refreshes on resume, beside the inbox's unread badge and for the same reason:
coming back to the app is exactly when a stale number would be noticed. Still
never on a timer, still nothing at all on a site without the module.

**Every card's text sat flush against its edge.** `ShardCard` is the themed
`Card` and carries no padding of its own — each caller pads its own content, and
these four did not. On a phone the first glyph of each line read as clipped.

**A name touched its own kill count.** Five numeric columns beside an
equal-weight name column left "Brannock" and "50" reading as one field. The name
now takes a wider share and ellipsizes, and the ACTIVE SORT is marked on the
header rather than by tinting a column of numbers — the header is the control,
and tinting the values says "these are special" instead of "this is what the
table is ordered by".

Walked against the phase-4 rig: a core with the module installed, one live
server and one that has never reported. Both halves of the phase criterion hold
on a phone — the Rust site renders every panel with its server unreachable, and
the same app against the UO core shows its five shard rows and no Rust row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-17 03:40:58 -05:00
parent a6b6c92c33
commit a6677d5bf9
3 changed files with 61 additions and 21 deletions

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.