feat(desktop): collapsible info panel in chess deck column

Hide GameInfo/MoveHistory/Actions panel by default in deck view
with a chevron toggle to expand. Add Chess to AddColumnDialog.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-04 09:45:01 +02:00
parent 991ac3b31b
commit 73b8059b55
3 changed files with 198 additions and 172 deletions
@@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
@@ -90,6 +92,7 @@ fun ChessScreen(
relayManager: DesktopRelayConnectionManager, relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn, account: AccountState.LoggedIn,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
compactMode: Boolean = false,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val viewModel = val viewModel =
@@ -274,6 +277,7 @@ fun ChessScreen(
}, },
onResign = { viewModel.resign(gameState.startEventId) }, onResign = { viewModel.resign(gameState.startEventId) },
isSpectatorOverride = isSpectating, isSpectatorOverride = isSpectating,
compactMode = compactMode,
) )
} }
} else { } else {
@@ -593,6 +597,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,
compactMode: Boolean = false,
) { ) {
// Collect state flows to trigger recomposition on changes // Collect state flows to trigger recomposition on changes
val currentPosition by gameState.currentPosition.collectAsState() val currentPosition by gameState.currentPosition.collectAsState()
@@ -604,12 +609,13 @@ private fun DesktopChessGameLayout(
val opponentPubkey = gameState.opponentPubkey val opponentPubkey = gameState.opponentPubkey
val isSpectator = isSpectatorOverride ?: gameState.isSpectator val isSpectator = isSpectatorOverride ?: gameState.isSpectator
var showInfoPanel by remember { mutableStateOf(!compactMode) }
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().padding(16.dp),
) { ) {
// Calculate board size based on available space // Calculate board size based on available space
// Leave room for the info panel (300dp + 24dp spacing) val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp
val infoPanelWidth = 300.dp + 24.dp
val availableWidth = maxWidth - infoPanelWidth val availableWidth = maxWidth - infoPanelWidth
val availableHeight = maxHeight val availableHeight = maxHeight
// Board should fit within available space, maintaining square aspect ratio // Board should fit within available space, maintaining square aspect ratio
@@ -617,11 +623,11 @@ private fun DesktopChessGameLayout(
Row( Row(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(24.dp), horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp),
) { ) {
// Left side: Chess board // Left side: Chess board + toggle
Box( Box(
modifier = Modifier.fillMaxHeight(), modifier = Modifier.weight(1f).fillMaxHeight(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
InteractiveChessBoard( InteractiveChessBoard(
@@ -633,188 +639,206 @@ private fun DesktopChessGameLayout(
positionVersion = moveHistory.size, positionVersion = moveHistory.size,
onMoveMade = onMoveMade, onMoveMade = onMoveMade,
) )
// Toggle button anchored to top-end of board area
IconButton(
onClick = { showInfoPanel = !showInfoPanel },
modifier = Modifier.align(Alignment.TopEnd),
) {
Icon(
if (showInfoPanel) {
Icons.AutoMirrored.Filled.KeyboardArrowRight
} else {
Icons.AutoMirrored.Filled.KeyboardArrowLeft
},
contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} }
// Right side: Game info, moves, controls // Right side: Game info, moves, controls (collapsible)
Column( if (showInfoPanel) {
modifier = Column(
Modifier modifier =
.width(300.dp) Modifier
.fillMaxHeight() .width(300.dp)
.verticalScroll(rememberScrollState()), .fillMaxHeight()
verticalArrangement = Arrangement.spacedBy(16.dp), .verticalScroll(rememberScrollState()),
) { verticalArrangement = Arrangement.spacedBy(16.dp),
// Extract human-readable game name
val gameName =
remember(startEventId) {
ChessGameNameGenerator.extractDisplayName(startEventId)
}
// Game info card
Card(
modifier = Modifier.fillMaxWidth(),
) { ) {
Column( // Extract human-readable game name
modifier = Modifier.padding(16.dp), val gameName =
verticalArrangement = Arrangement.spacedBy(8.dp), remember(startEventId) {
) { ChessGameNameGenerator.extractDisplayName(startEventId)
// Show readable game name if available
if (gameName != null) {
Text(
gameName,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
} }
Row( // Game info card
verticalAlignment = Alignment.CenterVertically, Card(
horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
UserAvatar( // Show readable game name if available
userHex = opponentPubkey, if (gameName != null) {
pictureUrl = opponentPicture, Text(
size = 48.dp, gameName,
) style = MaterialTheme.typography.titleLarge,
Column { fontWeight = FontWeight.Bold,
if (isSpectator) { color = MaterialTheme.colorScheme.primary,
Text( )
"Spectating", } else {
style = MaterialTheme.typography.bodyLarge, Text(
fontWeight = FontWeight.Bold, "Game Info",
color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.titleMedium,
) fontWeight = FontWeight.Bold,
} else { )
Text( }
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge, Row(
) verticalAlignment = Alignment.CenterVertically,
Text( horizontalArrangement = Arrangement.spacedBy(12.dp),
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", ) {
style = MaterialTheme.typography.bodyMedium, UserAvatar(
color = MaterialTheme.colorScheme.onSurfaceVariant, userHex = opponentPubkey,
) pictureUrl = opponentPicture,
size = 48.dp,
)
Column {
if (isSpectator) {
Text(
"Spectating",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary,
)
} else {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
)
Text(
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Use currentPosition to derive turn (triggers recomposition on move)
val currentTurn = currentPosition.activeColor
if (isSpectator) {
Text(
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val isYourTurn = currentTurn == playerColor
Text(
if (isYourTurn) "Your turn" else "Opponent's turn",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
color =
if (isYourTurn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Move history card
if (moveHistory.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Move History",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
// Format moves as numbered pairs
val moveText =
moveHistory
.chunked(2)
.mapIndexed { index, pair ->
"${index + 1}. ${pair.joinToString(" ")}"
}.joinToString("\n")
Text(
moveText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Game controls card (only for participants, not spectators)
if (!isSpectator) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = onResign,
modifier = Modifier.fillMaxWidth(),
) {
Text("Resign")
} }
} }
} }
} else {
// Use currentPosition to derive turn (triggers recomposition on move) // Spectator info card
val currentTurn = currentPosition.activeColor Card(
modifier = Modifier.fillMaxWidth(),
if (isSpectator) { colors =
Text( CardDefaults.cardColors(
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
style = MaterialTheme.typography.bodyMedium, ),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val isYourTurn = currentTurn == playerColor
Text(
if (isYourTurn) "Your turn" else "Opponent's turn",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
color =
if (isYourTurn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
// Move history card
if (moveHistory.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text( Row(
"Move History", modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleMedium, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, horizontalArrangement = Arrangement.spacedBy(8.dp),
)
// Format moves as numbered pairs
val moveText =
moveHistory
.chunked(2)
.mapIndexed { index, pair ->
"${index + 1}. ${pair.joinToString(" ")}"
}.joinToString("\n")
Text(
moveText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Game controls card (only for participants, not spectators)
if (!isSpectator) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
OutlinedButton(
onClick = onResign,
modifier = Modifier.fillMaxWidth(),
) { ) {
Text("Resign") Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
} }
} }
} }
} else {
// Spectator info card
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Default.Visibility, contentDescription = null)
Text(
"Watching game - spectator mode",
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
// Game ID (small footer) // Game ID (small footer)
Text( Text(
"Game: ${startEventId.take(16)}...", "Game: ${startEventId.take(16)}...",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}
} }
} }
} }
@@ -56,6 +56,7 @@ private val COLUMN_OPTIONS =
DeckColumnType.Bookmarks, DeckColumnType.Bookmarks,
DeckColumnType.GlobalFeed, DeckColumnType.GlobalFeed,
DeckColumnType.MyProfile, DeckColumnType.MyProfile,
DeckColumnType.Chess,
) )
@Composable @Composable
@@ -276,6 +276,7 @@ internal fun RootContent(
relayManager = relayManager, relayManager = relayManager,
account = account, account = account,
onBack = {}, onBack = {},
compactMode = true,
) )
} }