fix(rust): what the emulator walk found
Some checks failed
PR Checks / android-build (pull_request) Failing after 2s
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:
@@ -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() }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -144,6 +149,7 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
|||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user