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 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-02 10:07:45 +02:00
parent 42b33ca04c
commit 3c89c442bc
3 changed files with 546 additions and 0 deletions
@@ -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<Map<String, LiveChessGameState>> = logic.state.activeGames
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.spectatingGames
val challenges: StateFlow<List<ChessChallenge>> = logic.state.challenges
val publicGames: StateFlow<List<PublicGame>> = logic.state.publicGames
val completedGames: StateFlow<List<CompletedGame>> = logic.state.completedGames
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
val error: StateFlow<String?> = logic.state.error
val selectedGameId: StateFlow<String?> = 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<ChessChallenge> = logic.state.incomingChallenges()
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
}
@@ -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
}
@@ -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<Map<String, LiveChessGameState>> = logic.state.activeGames
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.spectatingGames
val challenges: StateFlow<List<ChessChallenge>> = logic.state.challenges
val publicGames: StateFlow<List<PublicGame>> = logic.state.publicGames
val completedGames: StateFlow<List<CompletedGame>> = logic.state.completedGames
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
val error: StateFlow<String?> = logic.state.error
val selectedGameId: StateFlow<String?> = 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<ChessChallenge> = logic.state.incomingChallenges()
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
}