feat(chess): avatar vs avatar display on board and game list

Add ChessPlayerChip.kt with three shared composables:
- ChessPlayerChip: single player avatar + name with active border
- ChessPlayerVsHeader: mirrored White vs Black header above the board
- OverlappingAvatars: compact overlapping circular avatars for list cards

Wire avatars into:
- LiveChessGameScreen: vs header with active player green border
- DesktopChessGameLayout: vs header above the chess board
- Android & Desktop lobby cards: overlapping avatars in ActiveGameCard,
  ChallengeCard, and OutgoingChallengeCard avatar slots

Uses existing UserAvatar (coil3 AsyncImage + Robohash fallback) from
commons for KMP compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-03-23 16:10:41 +01:00
parent 8c69105fdb
commit 006a2229fe
5 changed files with 432 additions and 33 deletions
@@ -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,
)
}
}
@@ -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(),
)
},
)
}
}
@@ -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,
)
}
}
}
@@ -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,
@@ -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(