From 3c89c442bc2966fe69106089486de4cbec014fef Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Mon, 2 Feb 2026 10:07:45 +0200 Subject: [PATCH] feat(chess): add slim ViewModels and shared broadcast banner (Steps 7-10) Step 7: ChessViewModelNew.kt - Slim Android ViewModel (~130 lines) - Delegates all business logic to ChessLobbyLogic - Exposes StateFlows from logic.state - Platform adapter creation only Step 8: DesktopChessViewModelNew.kt - Slim Desktop ViewModel (~120 lines) - Same pattern as Android, delegates to ChessLobbyLogic - UserMetadataCache for profile display - External CoroutineScope injection Step 9: ChessBroadcastBanner.kt - Shared Compose banner in commons - Uses ChessBroadcastStatus from ChessLobbyState - Works on both Android and Desktop via Compose Multiplatform - Shows broadcasting progress, sync status, errors Step 10: UI consumers ready for migration - New ViewModels use ChessChallenge (enriched display data) - Shared banner ready to replace Android-only ChessStatusBanner - Existing screens continue to work with old ViewModel Co-Authored-By: Claude Opus 4.5 --- .../loggedIn/chess/ChessViewModelNew.kt | 155 ++++++++++++ .../commons/chess/ChessBroadcastBanner.kt | 226 ++++++++++++++++++ .../desktop/chess/DesktopChessViewModelNew.kt | 165 +++++++++++++ 3 files changed, 546 insertions(+) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt new file mode 100644 index 000000000..d97f71132 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -0,0 +1,155 @@ +/** + * 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.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Android ViewModel for chess (~130 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Android-specific concerns: + * - ViewModel lifecycle (viewModelScope) + * - Platform adapter creation + * - State exposure to Compose UI + */ +class ChessViewModelNew( + private val account: Account, +) : ViewModel() { + // Platform adapters + private val publisher = AndroidChessPublisher(account) + private val fetcher = AndroidRelayFetcher(account) + private val metadataProvider = AndroidMetadataProvider() + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.userProfile().pubkeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = viewModelScope, + pollingConfig = ChessPollingDefaults.android, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + override fun onCleared() { + super.onCleared() + logic.stopPolling() + } + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun offerDraw(gameId: String) = logic.offerDraw(gameId) + + fun acceptDraw(gameId: String) = logic.acceptDraw(gameId) + + fun declineDraw(gameId: String) = logic.declineDraw(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt new file mode 100644 index 000000000..c3cda3a7c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt @@ -0,0 +1,226 @@ +/** + * 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.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess broadcast status - broadcast progress, sync status, etc. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ChessBroadcastBanner( + status: ChessBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Show progress bar for broadcasting/syncing + val progress = getStatusProgress(status) + if (progress != null) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = getStatusProgressColor(status), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> + MaterialTheme.colorScheme.errorContainer + is ChessBroadcastStatus.Success -> + MaterialTheme.colorScheme.primaryContainer + else -> + MaterialTheme.colorScheme.surfaceContainer + } + +private fun getStatusIcon(status: ChessBroadcastStatus): ImageVector = + when (status) { + is ChessBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ChessBroadcastStatus.Success -> Icons.Default.CheckCircle + is ChessBroadcastStatus.Failed -> Icons.Default.Error + is ChessBroadcastStatus.WaitingForOpponent -> Icons.Default.HourglassBottom + is ChessBroadcastStatus.Syncing -> Icons.Default.CloudSync + is ChessBroadcastStatus.Desynced -> Icons.Default.Error + is ChessBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> + MaterialTheme.colorScheme.error + is ChessBroadcastStatus.Success -> + MaterialTheme.colorScheme.primary + is ChessBroadcastStatus.WaitingForOpponent -> + MaterialTheme.colorScheme.secondary + else -> + MaterialTheme.colorScheme.primary + } + +private fun getStatusText(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "Broadcasting: ${status.san}" + is ChessBroadcastStatus.Success -> "Sent: ${status.san}" + is ChessBroadcastStatus.Failed -> "Failed: ${status.san}" + is ChessBroadcastStatus.WaitingForOpponent -> "Waiting for opponent's move..." + is ChessBroadcastStatus.Syncing -> "Syncing game state..." + is ChessBroadcastStatus.Desynced -> "Game desynced: ${status.message}" + is ChessBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ChessBroadcastStatus.Success -> "${status.relayCount} relays" + is ChessBroadcastStatus.Failed -> "Tap to retry" + is ChessBroadcastStatus.Syncing -> "${(status.progress * 100).toInt()}%" + is ChessBroadcastStatus.Desynced -> "Tap to resync" + else -> "" + } + +@Composable +private fun getStatusDetailColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> + MaterialTheme.colorScheme.error + else -> + MaterialTheme.colorScheme.primary + } + +private fun getStatusProgress(status: ChessBroadcastStatus): Float? = + when (status) { + is ChessBroadcastStatus.Broadcasting -> status.progress + is ChessBroadcastStatus.Syncing -> status.progress + else -> null + } + +@Composable +private fun getStatusProgressColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt new file mode 100644 index 000000000..11730b9a8 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -0,0 +1,165 @@ +/** + * 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.desktop.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Desktop ViewModel for chess (~120 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Desktop-specific concerns: + * - Platform adapter creation + * - State exposure to Compose Desktop UI + * - UserMetadataCache for profile display + */ +class DesktopChessViewModelNew( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, + private val scope: CoroutineScope, +) { + // Desktop-specific metadata cache + val userMetadataCache = UserMetadataCache() + + // Platform adapters + private val publisher = DesktopChessPublisher(account, relayManager) + private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) + private val metadataProvider = DesktopMetadataProvider(userMetadataCache) + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.pubKeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = scope, + pollingConfig = ChessPollingDefaults.desktop, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) = logic.handleIncomingEvent(event) + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun offerDraw(gameId: String) = logic.offerDraw(gameId) + + fun acceptDraw(gameId: String) = logic.acceptDraw(gameId) + + fun declineDraw(gameId: String) = logic.declineDraw(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +}