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()
// 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
// away is exactly what brings someone back to it.
LifecycleResumeEffect(Unit) {
// and re-read the two drawer counts with it: a tickle that arrived while the app
// was away is exactly what brings someone back to it, and a live player count is
// only live if it is re-read when somebody looks.
LifecycleResumeEffect(capabilities) {
sessionViewModel.revalidate()
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 { }
}
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
// 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
// answer rather than on a timer: a badge is a glance, not a feed, and this is
// the only place that knows whether the module is installed at all. A host
// that has not answered yet asks nothing, so a cold start makes no request
// until it knows there is something to ask about.
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
// timer: a badge is a glance, not a feed. Gated here rather than inside the
// view model because this is the only place that knows whether the module is
// installed at all, and a host that has not answered yet asks nothing.
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 previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }

View File

@@ -24,8 +24,10 @@ import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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)
/** 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(
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
@@ -372,8 +380,15 @@ private fun LeaderboardPanel(
) {
item {
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 ->
// 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(
text = stringResource(column.labelRes),
modifier = Modifier
@@ -384,6 +399,13 @@ private fun LeaderboardPanel(
} else {
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 ->
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 = playerLabel(row.name, row.steamId),
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 ->
Text(
text = column.value(row),
style = MaterialTheme.typography.bodySmall,
color = if (column.sort == sort) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
modifier = Modifier.weight(1f),
)
}
@@ -456,7 +481,10 @@ private fun OnlinePanel(
}
items(s.data, key = { it.steamId }) { player ->
ShardCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = playerLabel(player.name, player.steamId),
style = MaterialTheme.typography.bodyMedium,
@@ -500,7 +528,10 @@ private fun WipesPanel(
ShardCard(
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
style = MaterialTheme.typography.bodyMedium,

View File

@@ -100,6 +100,11 @@ private fun ServerList(
@Composable
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
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(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -144,6 +149,7 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/**