diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index ef46c576c..6fe10f787 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -295,12 +295,42 @@ fun ChessGameScreen( } else -> { - // Resolve opponent display name + // Resolve opponent display name and avatar + val opponentUser = + remember(gameState.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey) + } val opponentDisplayName = remember(gameState.opponentPubkey) { - accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName() - ?: gameState.opponentPubkey.take(8) + opponentUser?.toBestDisplayName() ?: gameState.opponentPubkey.take(8) } + val opponentAvatarUrl = + remember(gameState.opponentPubkey) { + opponentUser?.profilePicture() + } + + // Resolve player display name and avatar + val playerUser = + remember(gameState.playerPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.playerPubkey) + } + val playerDisplayName = + remember(gameState.playerPubkey) { + playerUser?.toBestDisplayName() ?: gameState.playerPubkey.take(8) + } + val playerAvatarUrl = + remember(gameState.playerPubkey) { + playerUser?.profilePicture() + } + + // Determine white/black pubkeys, names and avatars based on player color + val isPlayerWhite = gameState.playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE + val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey + val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey + val whiteDisplayName = if (isPlayerWhite) playerDisplayName else opponentDisplayName + val blackDisplayName = if (isPlayerWhite) opponentDisplayName else playerDisplayName + val whiteAvatarUrl = if (isPlayerWhite) playerAvatarUrl else opponentAvatarUrl + val blackAvatarUrl = if (isPlayerWhite) opponentAvatarUrl else playerAvatarUrl // Determine spectator status: // 1. If game was accepted locally, user is definitely NOT a spectator @@ -320,6 +350,12 @@ fun ChessGameScreen( onResign = { chessViewModel.resign(gameId) }, isSpectatorOverride = isSpectating, onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, + whiteName = whiteDisplayName, + whiteHex = whitePubkey, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackDisplayName, + blackHex = blackPubkey, + blackAvatarUrl = blackAvatarUrl, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 96a2502eb..42f56380f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars import com.vitorpamplona.amethyst.commons.chess.PublicGameCard import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -359,15 +360,28 @@ fun ChessLobbyContent( } items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> - val displayName = + val opponent = remember(state.opponentPubkey) { - accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) + accountViewModel.checkGetOrCreateUser(state.opponentPubkey) + } + val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8) + val playerUser = + remember(state.playerPubkey) { + accountViewModel.checkGetOrCreateUser(state.playerPubkey) } ActiveGameCard( gameId = gameId, opponentName = displayName, isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, + avatar = { + OverlappingAvatars( + avatar1Hex = state.playerPubkey, + avatar2Hex = state.opponentPubkey, + avatar1Url = playerUser?.profilePicture(), + avatar2Url = opponent?.profilePicture(), + ) + }, ) } } @@ -386,14 +400,32 @@ fun ChessLobbyContent( } items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + val opponentUser = + remember(challenge.opponentPubkey) { + challenge.opponentPubkey?.let { accountViewModel.checkGetOrCreateUser(it) } + } val opponentName = - challenge.opponentPubkey?.let { pubkey -> - accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8) + opponentUser?.toBestDisplayName() + ?: challenge.opponentPubkey?.take(8) + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) } OutgoingChallengeCard( opponentName = opponentName, userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, onClick = { onOpenOwnChallenge(challenge) }, + avatar = + challenge.opponentPubkey?.let { opPubkey -> + { + OverlappingAvatars( + avatar1Hex = userPubkey, + avatar2Hex = opPubkey, + avatar1Url = currentUser?.profilePicture(), + avatar2Url = opponentUser?.profilePicture(), + ) + } + }, ) } } @@ -442,11 +474,23 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = true, onAccept = { onAcceptChallenge(challenge) }, + avatar = { + OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl, + avatar2Url = currentUser?.profilePicture(), + ) + }, ) } } @@ -471,11 +515,23 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, + avatar = { + OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl, + avatar2Url = currentUser?.profilePicture(), + ) + }, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt new file mode 100644 index 000000000..a64db869b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +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 com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import androidx.compose.ui.graphics.Color as ComposeColor + +private val ActiveBorderColor = ComposeColor(0xFF4CAF50) +private val AvatarOuterSize = 36.dp +private val AvatarInnerSize = 32.dp // 4dp inset for border + +/** + * Single player chip showing avatar and name. + * + * @param displayName Player's display name + * @param userHex Player's public key hex (for Robohash fallback) + * @param avatarUrl Optional URL for the player's profile picture + * @param isActive Whether this player's turn is active (shows green border) + * @param mirrored If true, reverses the layout so name appears left of avatar (for right-side player) + * @param modifier Additional modifiers + */ +@Composable +fun ChessPlayerChip( + displayName: String, + userHex: String, + avatarUrl: String?, + isActive: Boolean = false, + mirrored: Boolean = false, + modifier: Modifier = Modifier, +) { + val avatar = + @Composable { + Box( + modifier = + if (isActive) { + Modifier + .size(AvatarOuterSize) + .border(2.dp, ActiveBorderColor, CircleShape) + .clip(CircleShape) + } else { + Modifier + .size(AvatarOuterSize) + .clip(CircleShape) + }, + ) { + UserAvatar( + userHex = userHex, + pictureUrl = avatarUrl, + size = AvatarInnerSize, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + val name = + @Composable { + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = modifier.alpha(if (isActive) 1f else 0.6f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (mirrored) { + name() + avatar() + } else { + avatar() + name() + } + } +} + +/** + * Full "White vs Black" header for the game board. + * + * Layout: + * ``` + * [WhiteAvatar] WhiteName vs BlackName [BlackAvatar] + * White Black + * ``` + * + * Active player (based on isWhiteTurn) gets a green avatar border; + * inactive player is slightly dimmed. + * + * @param whiteName Display name for the white player + * @param whiteHex Public key hex for white player (Robohash fallback) + * @param whiteAvatarUrl Optional avatar URL for white player + * @param blackName Display name for the black player + * @param blackHex Public key hex for black player (Robohash fallback) + * @param blackAvatarUrl Optional avatar URL for black player + * @param isWhiteTurn Whether it is currently white's turn + * @param modifier Additional modifiers + */ +@Composable +fun ChessPlayerVsHeader( + whiteName: String, + whiteHex: String, + whiteAvatarUrl: String?, + blackName: String, + blackHex: String, + blackAvatarUrl: String?, + isWhiteTurn: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + // White player (left side) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + ) { + ChessPlayerChip( + displayName = whiteName, + userHex = whiteHex, + avatarUrl = whiteAvatarUrl, + isActive = isWhiteTurn, + ) + Text( + text = "White", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "vs", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Black player (right side) — mirrored so avatar is on the right + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + ) { + ChessPlayerChip( + displayName = blackName, + userHex = blackHex, + avatarUrl = blackAvatarUrl, + isActive = !isWhiteTurn, + mirrored = true, + ) + Text( + text = "Black", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Two overlapping circular avatars for compact game list cards. + * + * The second avatar is offset to overlap the first by 8dp. + * + * @param avatar1Hex Public key hex for first player (Robohash fallback) + * @param avatar2Hex Public key hex for second player (Robohash fallback) + * @param avatar1Url Optional avatar URL for first player + * @param avatar2Url Optional avatar URL for second player + * @param size Diameter of each avatar circle + * @param modifier Additional modifiers + */ +@Composable +fun OverlappingAvatars( + avatar1Hex: String, + avatar2Hex: String, + avatar1Url: String?, + avatar2Url: String?, + size: Dp = 28.dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.size(width = size + size - 8.dp, height = size), + ) { + UserAvatar( + userHex = avatar1Hex, + pictureUrl = avatar1Url, + size = size, + ) + + Box( + modifier = Modifier.offset(x = size - 8.dp), + ) { + // Thin border to visually separate overlapping avatars + Box( + modifier = + Modifier + .size(size) + .background(MaterialTheme.colorScheme.surface, CircleShape), + ) + UserAvatar( + userHex = avatar2Hex, + pictureUrl = avatar2Url, + size = size, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 3781f0162..5553faa40 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -201,6 +201,12 @@ fun LiveChessGameScreen( onResign: () -> Unit, isSpectatorOverride: Boolean? = null, onGameEndDismiss: (() -> Unit)? = null, + whiteName: String = "White", + whiteHex: String = "", + whiteAvatarUrl: String? = null, + blackName: String = "Black", + blackHex: String = "", + blackAvatarUrl: String? = null, ) { // Observe state flows for automatic recomposition on updates val currentPosition by gameState.currentPosition.collectAsState() @@ -236,6 +242,20 @@ fun LiveChessGameScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // Player vs Player avatar header + if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) { + ChessPlayerVsHeader( + whiteName = whiteName, + whiteHex = whiteHex, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackName, + blackHex = blackHex, + blackAvatarUrl = blackAvatarUrl, + isWhiteTurn = currentPosition.activeColor == Color.WHITE, + modifier = Modifier.fillMaxWidth(), + ) + } + // Game info - use currentPosition.activeColor for turn display GameInfoHeader( gameId = gameState.startEventId, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 2668756b8..0ec8464fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -268,6 +268,10 @@ fun ChessScreen( val wasAccepted = viewModel.wasAccepted(selectedGameId!!) val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId) + val isPlayerWhite = gameState.playerColor == ChessColor.WHITE + val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey + val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey + DesktopChessGameLayout( gameState = gameState, opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), @@ -278,6 +282,12 @@ fun ChessScreen( onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, compactMode = compactMode, + whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey), + whiteHex = whitePubkey, + whiteAvatarUrl = viewModel.userMetadataCache.getPictureUrl(whitePubkey), + blackName = viewModel.userMetadataCache.getDisplayName(blackPubkey), + blackHex = blackPubkey, + blackAvatarUrl = viewModel.userMetadataCache.getPictureUrl(blackPubkey), ) } } else { @@ -398,10 +408,11 @@ private fun ChessLobby( isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, avatar = { - UserAvatar( - userHex = state.opponentPubkey, - pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = state.playerPubkey, + avatar2Hex = state.opponentPubkey, + avatar1Url = metadataCache.getPictureUrl(state.playerPubkey), + avatar2Url = metadataCache.getPictureUrl(state.opponentPubkey), ) }, ) @@ -429,10 +440,11 @@ private fun ChessLobby( avatar = challenge.opponentPubkey?.let { pubkey -> { - UserAvatar( - userHex = pubkey, - pictureUrl = metadataCache.getPictureUrl(pubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = userPubkey, + avatar2Hex = pubkey, + avatar1Url = metadataCache.getPictureUrl(userPubkey), + avatar2Url = metadataCache.getPictureUrl(pubkey), ) } }, @@ -481,10 +493,11 @@ private fun ChessLobby( isIncoming = true, onAccept = { onAcceptChallenge(challenge) }, avatar = { - UserAvatar( - userHex = challenge.challengerPubkey, - pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + avatar2Url = metadataCache.getPictureUrl(userPubkey), ) }, ) @@ -511,10 +524,11 @@ private fun ChessLobby( isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, avatar = { - UserAvatar( - userHex = challenge.challengerPubkey, - pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + avatar2Url = metadataCache.getPictureUrl(userPubkey), ) }, ) @@ -601,6 +615,12 @@ private fun DesktopChessGameLayout( onResign: () -> Unit, isSpectatorOverride: Boolean? = null, compactMode: Boolean = false, + whiteName: String = "White", + whiteHex: String = "", + whiteAvatarUrl: String? = null, + blackName: String = "Black", + blackHex: String = "", + blackAvatarUrl: String? = null, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() @@ -628,20 +648,39 @@ private fun DesktopChessGameLayout( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp), ) { - // Left side: Chess board + toggle + // Left side: Chess board + toggle + vs header Box( modifier = Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center, ) { - InteractiveChessBoard( - engine = engine, - boardSize = boardSize, - flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, - playerColor = playerColor, - isSpectator = isSpectator, - positionVersion = moveHistory.size, - onMoveMade = onMoveMade, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Player vs Player header above board + if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) { + com.vitorpamplona.amethyst.commons.chess.ChessPlayerVsHeader( + whiteName = whiteName, + whiteHex = whiteHex, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackName, + blackHex = blackHex, + blackAvatarUrl = blackAvatarUrl, + isWhiteTurn = currentPosition.activeColor == ChessColor.WHITE, + modifier = Modifier.fillMaxWidth(), + ) + } + + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, + playerColor = playerColor, + isSpectator = isSpectator, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + } // Toggle button anchored to top-end of board area IconButton(