feat(chess): exit spectator mode with Leave Game button

Adds ability to leave a spectated game on both Android and Desktop:
- Added stopSpectating() to ChessLobbyLogic (removes from state + stops polling)
- Both ViewModels now delegate to logic.stopSpectating() instead of bypassing it
- LiveChessGameScreen accepts onLeaveSpectating callback shown in spectator info area
- Android: Leave Game button pops back stack and stops spectating
- Desktop: Leave Game button clears selectedGameId (returns to lobby) and stops spectating

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-03-23 16:28:44 +01:00
parent ed9d942ce0
commit 292c6de08a
6 changed files with 74 additions and 26 deletions
@@ -350,6 +350,15 @@ fun ChessGameScreen(
onResign = { chessViewModel.resign(gameId) }, onResign = { chessViewModel.resign(gameId) },
isSpectatorOverride = isSpectating, isSpectatorOverride = isSpectating,
onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, onGameEndDismiss = { chessViewModel.dismissGame(gameId) },
onLeaveSpectating =
if (isSpectating) {
{
chessViewModel.stopSpectating(gameId)
nav.popBack()
}
} else {
null
},
whiteName = whiteDisplayName, whiteName = whiteDisplayName,
whiteHex = whitePubkey, whiteHex = whitePubkey,
whiteAvatarUrl = whiteAvatarUrl, whiteAvatarUrl = whiteAvatarUrl,
@@ -176,7 +176,7 @@ class ChessViewModelNew(
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
// ============================================ // ============================================
// Utility // Utility
@@ -505,6 +505,11 @@ class ChessLobbyLogic(
// Spectator mode // Spectator mode
// ======================================== // ========================================
fun stopSpectating(gameId: String) {
state.removeSpectatingGame(gameId)
pollingDelegate.removeGameId(gameId)
}
fun loadGameAsSpectator(startEventId: String) { fun loadGameAsSpectator(startEventId: String) {
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
@@ -201,6 +201,7 @@ fun LiveChessGameScreen(
onResign: () -> Unit, onResign: () -> Unit,
isSpectatorOverride: Boolean? = null, isSpectatorOverride: Boolean? = null,
onGameEndDismiss: (() -> Unit)? = null, onGameEndDismiss: (() -> Unit)? = null,
onLeaveSpectating: (() -> Unit)? = null,
whiteName: String = "White", whiteName: String = "White",
whiteHex: String = "", whiteHex: String = "",
whiteAvatarUrl: String? = null, whiteAvatarUrl: String? = null,
@@ -299,7 +300,7 @@ fun LiveChessGameScreen(
} }
isSpectator -> { isSpectator -> {
SpectatorInfo() SpectatorInfo(onLeaveSpectating = onLeaveSpectating)
} }
else -> { else -> {
@@ -547,7 +548,12 @@ private fun GameInfoHeader(
* Info banner shown when spectating a game * Info banner shown when spectating a game
*/ */
@Composable @Composable
private fun SpectatorInfo() { private fun SpectatorInfo(onLeaveSpectating: (() -> Unit)? = null) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box( Box(
modifier = modifier =
Modifier Modifier
@@ -564,6 +570,12 @@ private fun SpectatorInfo() {
color = MaterialTheme.colorScheme.onTertiaryContainer, color = MaterialTheme.colorScheme.onTertiaryContainer,
) )
} }
onLeaveSpectating?.let { onLeave ->
OutlinedButton(onClick = onLeave) {
Text("Leave Game")
}
}
}
} }
/** /**
@@ -281,6 +281,15 @@ fun ChessScreen(
}, },
onResign = { viewModel.resign(gameState.startEventId) }, onResign = { viewModel.resign(gameState.startEventId) },
isSpectatorOverride = isSpectating, isSpectatorOverride = isSpectating,
onLeaveSpectating =
if (isSpectating) {
{
viewModel.stopSpectating(gameState.startEventId)
viewModel.selectGame(null)
}
} else {
null
},
compactMode = compactMode, compactMode = compactMode,
whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey), whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey),
whiteHex = whitePubkey, whiteHex = whitePubkey,
@@ -614,6 +623,7 @@ private fun DesktopChessGameLayout(
onMoveMade: (from: String, to: String, san: String) -> Unit, onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit, onResign: () -> Unit,
isSpectatorOverride: Boolean? = null, isSpectatorOverride: Boolean? = null,
onLeaveSpectating: (() -> Unit)? = null,
compactMode: Boolean = false, compactMode: Boolean = false,
whiteName: String = "White", whiteName: String = "White",
whiteHex: String = "", whiteHex: String = "",
@@ -860,8 +870,11 @@ private fun DesktopChessGameLayout(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
), ),
) { ) {
Row( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -871,6 +884,15 @@ private fun DesktopChessGameLayout(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
onLeaveSpectating?.let { onLeave ->
OutlinedButton(
onClick = onLeave,
modifier = Modifier.fillMaxWidth(),
) {
Text("Leave Game")
}
}
}
} }
} }
@@ -172,7 +172,7 @@ class DesktopChessViewModelNew(
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
// ============================================ // ============================================
// Utility // Utility