diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 7f7c919335..a9907ff6f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.routeFor import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessGameViewer import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.chess_accept @@ -61,7 +62,6 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.quartz.nip64Chess.challenge.offer.LiveChessGameChallengeEvent import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent @@ -124,10 +124,10 @@ fun RenderLiveChessChallenge( val gameId = event.gameId() val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity - val chessViewModel: ChessViewModelNew = + val chessViewModel: ChessViewModel = viewModel( - key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account, activity.application), + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), ) val isOpenChallenge = event.opponentPubkey() == null 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 d013cb4f1c..2e15512276 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 @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessBroadcastBanner import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessSyncBanner import com.vitorpamplona.amethyst.commons.nip64Chess.ui.LiveChessGameScreen @@ -99,15 +100,11 @@ fun ChessGameScreen( ) { // Scope ViewModel to Activity so it's shared between lobby and game screens val activity = LocalActivity.current as FragmentActivity - val chessViewModel: ChessViewModelNew = + val chessViewModel: ChessViewModel = viewModel( viewModelStoreOwner = activity, - key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = - ChessViewModelFactory( - accountViewModel.account, - activity.application, - ), + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), ) val activeGames by chessViewModel.activeGames.collectAsState() 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 811cbb1c66..ca57f5d7c3 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 @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ActiveGameCard import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChallengeCard import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessSyncBanner @@ -97,11 +98,11 @@ fun ChessLobbyScreen( ) { // Scope ViewModel to Activity so it's shared between lobby and game screens val activity = LocalActivity.current as FragmentActivity - val chessViewModel: ChessViewModelNew = + val chessViewModel: ChessViewModel = viewModel( viewModelStoreOwner = activity, - key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account, activity.application), + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), ) // Subscribe to chess events when screen is visible @@ -133,7 +134,7 @@ fun ChessLobbyScreen( @Composable fun NavigateIfInAGame( - chessViewModel: ChessViewModelNew, + chessViewModel: ChessViewModel, nav: INav, ) { val selectedGameId by chessViewModel.selectedGameId.collectAsState() @@ -156,7 +157,7 @@ fun NavigateIfInAGame( @OptIn(ExperimentalMaterial3Api::class) @Composable fun ChessLobbyScreen( - chessViewModel: ChessViewModelNew, + chessViewModel: ChessViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -256,7 +257,7 @@ fun ChessLobbyScreen( } @Composable -fun ChessSyncBanner(chessViewModel: ChessViewModelNew) { +fun ChessSyncBanner(chessViewModel: ChessViewModel) { val syncStatus by chessViewModel.syncStatus.collectAsState() ChessSyncBanner( @@ -267,7 +268,7 @@ fun ChessSyncBanner(chessViewModel: ChessViewModelNew) { } @Composable -fun ErrorDisplay(chessViewModel: ChessViewModelNew) { +fun ErrorDisplay(chessViewModel: ChessViewModel) { // Error display val error by chessViewModel.error.collectAsState() @@ -292,7 +293,7 @@ fun ErrorDisplay(chessViewModel: ChessViewModelNew) { @Composable fun ChessLobbyContent( - chessViewModel: ChessViewModelNew, + chessViewModel: ChessViewModel, accountViewModel: AccountViewModel, onAcceptChallenge: (ChessChallenge) -> Unit, onOpenOwnChallenge: (ChessChallenge) -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt index d1069687de..faa4bff105 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt @@ -46,6 +46,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.nip64Chess.ChessConfig +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.connected import com.vitorpamplona.amethyst.commons.ui.stringRes @@ -59,7 +60,7 @@ import kotlinx.coroutines.flow.map */ @Composable fun ChessRelaySettingsSheet( - chessViewModel: ChessViewModelNew, + chessViewModel: ChessViewModel, accountViewModel: AccountViewModel, ) { // Get relay information for settings display @@ -226,7 +227,7 @@ fun ChessRelaySettingsSheet( @SuppressLint("StateFlowValueCalledInComposition") @Composable -fun StatRow(chessViewModel: ChessViewModelNew) { +fun StatRow(chessViewModel: ChessViewModel) { Column(horizontalAlignment = Alignment.CenterHorizontally) { val challengeCount by remember { chessViewModel.challenges.map { it.size } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt index ea1e191647..3d69227fd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -22,20 +22,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.model.Account /** - * Factory for creating ChessViewModelNew instances. - * Uses the slim ViewModel that delegates to shared ChessLobbyLogic. + * Factory for the shared [ChessViewModel], wired with the Android adapters. */ class ChessViewModelFactory( private val account: Account, - private val application: android.app.Application, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { - return ChessViewModelNew(account, application) as T + if (modelClass.isAssignableFrom(ChessViewModel::class.java)) { + return ChessViewModel( + userPubkey = account.userProfile().pubkeyHex, + publisher = AndroidChessPublisher(account), + fetcher = AndroidRelayFetcher(account), + metadataProvider = AndroidMetadataProvider(), + pollingConfig = ChessPollingDefaults.android, + dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData), + ) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt index d8d788c180..a9522ae2e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt @@ -25,10 +25,10 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.relayClient.chess.ChessQueryState import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew /** * Subscribe to chess events when the Chess screen is active. @@ -38,7 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew */ @Composable fun ChessSubscription( - chessViewModel: ChessViewModelNew, + chessViewModel: ChessViewModel, accountViewModel: AccountViewModel, ) { // Get active game IDs from the view model for game-specific subscriptions diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index 7c8c77eb6f..df0959e00b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.new_chess_game @@ -40,7 +41,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory -import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew /** * Floating action button for creating new chess game challenges @@ -53,10 +53,10 @@ fun NewChessGameButton( var showDialog by remember { mutableStateOf(false) } val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity - val chessViewModel: ChessViewModelNew = + val chessViewModel: ChessViewModel = viewModel( - key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account, activity.application), + key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), ) FloatingActionButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessViewModel.kt similarity index 77% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessViewModel.kt index 9bd3819de4..bbd7695555 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/nip64Chess/ChessViewModel.kt @@ -18,60 +18,51 @@ * 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 +package com.vitorpamplona.amethyst.commons.nip64Chess import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus -import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame -import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame -import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow /** - * Slim Android ViewModel for chess (~120 lines). + * The chess screen's state holder, shared by Android and Desktop. * - * Delegates all business logic to ChessLobbyLogic. - * Only handles Android-specific concerns: - * - ViewModel lifecycle (viewModelScope) - * - Platform adapter creation - * - State exposure to Compose UI + * All business logic lives in [ChessLobbyLogic]; this class only exposes its state and forwards + * the user's actions. Each platform supplies its own adapters (how to publish, fetch, resolve + * metadata and remember dismissed games) and its polling cadence. + * + * On Android it is a lifecycle-scoped `ViewModel`: [scope] defaults to `viewModelScope` and + * polling stops in [onCleared]. Desktop, which has no `ViewModelStore`, passes the scope of the + * composition that owns it; polling then ends when that scope is cancelled. */ @Stable -class ChessViewModelNew( - private val account: Account, - application: android.app.Application, +class ChessViewModel( + private val userPubkey: HexKey, + publisher: ChessEventPublisher, + fetcher: ChessRelayFetcher, + metadataProvider: IUserMetadataProvider, + pollingConfig: ChessPollingConfig, + dismissedStorage: ChessDismissedGamesStore?, + scope: CoroutineScope? = null, ) : ViewModel() { - // Instance ID for debugging ViewModel sharing - val instanceId = System.identityHashCode(this) - - // Platform adapters - private val publisher = AndroidChessPublisher(account) - private val fetcher = AndroidRelayFetcher(account) - private val metadataProvider = AndroidMetadataProvider() - private val dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData) - // Shared business logic (creates its own ChessLobbyState internally) private val logic = ChessLobbyLogic( - userPubkey = account.userProfile().pubkeyHex, + userPubkey = userPubkey, publisher = publisher, fetcher = fetcher, metadataProvider = metadataProvider, - scope = viewModelScope, - pollingConfig = ChessPollingDefaults.android, + scope = scope ?: viewModelScope, + pollingConfig = pollingConfig, dismissedStorage = dismissedStorage, ) @@ -99,7 +90,7 @@ class ChessViewModelNew( // ============================================ init { - Log.d("chessdebug") { "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}" } + Log.d("chessdebug") { "[ChessVM] init: userPubkey=${userPubkey.take(8)}" } logic.startPolling() } @@ -143,10 +134,10 @@ class ChessViewModelNew( if (event.kind != JesterProtocol.KIND) return val jesterEvent = event.toJesterEvent() ?: run { - Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" } + Log.d("chessdebug") { "[ChessVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" } return } - Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" } + Log.d("chessdebug") { "[ChessVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" } logic.handleIncomingEvent(jesterEvent) } 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 348b47a8a6..271312a224 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 @@ -65,8 +65,11 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.cache.UserMetadataCache import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge import com.vitorpamplona.amethyst.commons.nip64Chess.ChessConfig +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame +import com.vitorpamplona.amethyst.commons.nip64Chess.desktopChessDismissedGamesStore import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ActiveGameCard import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChallengeCard import com.vitorpamplona.amethyst.commons.nip64Chess.ui.ChessBroadcastBanner @@ -100,9 +103,18 @@ fun ChessScreen( compactMode: Boolean = false, ) { val scope = rememberCoroutineScope() + val userMetadataCache = remember(account.pubKeyHex) { UserMetadataCache() } val viewModel = remember(account.pubKeyHex) { - DesktopChessViewModelNew(account, relayManager, scope) + ChessViewModel( + userPubkey = account.pubKeyHex, + publisher = DesktopChessPublisher(account, relayManager), + fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex), + metadataProvider = DesktopMetadataProvider(userMetadataCache), + pollingConfig = ChessPollingDefaults.desktop, + dismissedStorage = desktopChessDismissedGamesStore(), + scope = scope, + ) } val connectedRelays by relayManager.connectedRelays.collectAsState() val broadcastStatus by viewModel.broadcastStatus.collectAsState() @@ -151,7 +163,7 @@ fun ChessScreen( } // Subscribe to user metadata for pubkeys that need it - val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState() + val pubkeysNeeded by userMetadataCache.pubkeysNeeded.collectAsState() rememberSubscription(connectedRelays, pubkeysNeeded, relayManager = relayManager) { if (connectedRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) { createMetadataListSubscription( @@ -171,7 +183,7 @@ fun ChessScreen( val publicGames by viewModel.publicGames.collectAsState() val completedGames by viewModel.completedGames.collectAsState() // Observe metadata changes to trigger recomposition - val userMetadata by viewModel.userMetadataCache.metadata.collectAsState() + val userMetadata by userMetadataCache.metadata.collectAsState() val selectedGameId by viewModel.selectedGameId.collectAsState() val error by viewModel.error.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState() @@ -289,8 +301,8 @@ fun ChessScreen( DesktopChessGameLayout( gameState = gameState, - opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), - opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey), + opponentName = userMetadataCache.getDisplayName(gameState.opponentPubkey), + opponentPicture = userMetadataCache.getPictureUrl(gameState.opponentPubkey), onMoveMade = { from, to, _ -> viewModel.publishMove(gameState.startEventId, from, to) }, @@ -306,12 +318,12 @@ fun ChessScreen( null }, compactMode = compactMode, - whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey), + whiteName = userMetadataCache.getDisplayName(whitePubkey), whiteHex = whitePubkey, - whiteAvatarUrl = viewModel.userMetadataCache.getPictureUrl(whitePubkey), - blackName = viewModel.userMetadataCache.getDisplayName(blackPubkey), + whiteAvatarUrl = userMetadataCache.getPictureUrl(whitePubkey), + blackName = userMetadataCache.getDisplayName(blackPubkey), blackHex = blackPubkey, - blackAvatarUrl = viewModel.userMetadataCache.getPictureUrl(blackPubkey), + blackAvatarUrl = userMetadataCache.getPictureUrl(blackPubkey), ) } } else { @@ -338,7 +350,7 @@ fun ChessScreen( publicGames = publicGames, completedGames = completedGames, userPubkey = account.pubKeyHex, - metadataCache = viewModel.userMetadataCache, + metadataCache = userMetadataCache, onAcceptChallenge = { viewModel.acceptChallenge(it) }, onOpenOwnChallenge = { viewModel.openOwnChallenge(it) }, onWatchGame = { viewModel.loadGameAsSpectator(it) }, 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 deleted file mode 100644 index 3a5123f8b7..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ /dev/null @@ -1,203 +0,0 @@ -/* - * 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.model.cache.UserMetadataCache -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessBroadcastStatus -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessChallenge -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessLobbyLogic -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults -import com.vitorpamplona.amethyst.commons.nip64Chess.ChessSyncStatus -import com.vitorpamplona.amethyst.commons.nip64Chess.CompletedGame -import com.vitorpamplona.amethyst.commons.nip64Chess.PublicGame -import com.vitorpamplona.amethyst.commons.nip64Chess.desktopChessDismissedGamesStore -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 com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent -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) - private val dismissedStorage = desktopChessDismissedGamesStore() - - // 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, - dismissedStorage = dismissedStorage, - ) - - // ============================================ - // 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 - val isRefreshing: StateFlow = logic.state.isRefreshing - val syncStatus: StateFlow = logic.state.syncStatus - val stateVersion: StateFlow = logic.state.stateVersion - - /** 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() - - /** - * Ensure a game ID is being polled for updates. - * Call this when viewing a game. - */ - fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) - - /** - * Set focused game mode - only poll this specific game. - * Call this when viewing a game to avoid refreshing unrelated games. - */ - fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) - - /** - * Clear focused game mode - return to lobby mode (poll all games). - * Call this when returning to the lobby view. - */ - fun clearFocusedGame() = logic.clearFocusedGame() - - // ============================================ - // Incoming event routing (from relay subscriptions) - // ============================================ - - fun handleIncomingEvent(event: Event) { - if (event.kind != JesterProtocol.KIND) return - val jesterEvent = event.toJesterEvent() ?: return - logic.handleIncomingEvent(jesterEvent) - } - - // ============================================ - // 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) - - fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(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 claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) - - fun dismissGame(gameId: String) = logic.dismissGame(gameId) - - fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId) - - fun dismissAllCompletedGames() = logic.dismissAllCompletedGames() - - // ============================================ - // Spectator operations - // ============================================ - - fun loadGame(gameId: String) = logic.loadGame(gameId) - - fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) - - fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) - - fun removeGame(gameId: String) = logic.removeGame(gameId) - - // ============================================ - // Utility - // ============================================ - - fun clearError() = logic.clearError() - - fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) - - /** Check if a game was accepted (prevents loading as spectator during race) */ - fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) - - /** Helper for derived challenge lists */ - fun incomingChallenges(): List = logic.state.incomingChallenges() - - fun outgoingChallenges(): List = logic.state.outgoingChallenges() - - fun openChallenges(): List = logic.state.openChallenges() -}