refactor(chess): one shared ChessViewModel for Android and Desktop

ChessViewModelNew (Android) and DesktopChessViewModelNew forwarded the
same ~40 members to ChessLobbyLogic; only the adapters and the scope
differed. commons/nip64Chess/ChessViewModel now takes the adapters, the
polling config, the dismissed-games store and an optional scope.

- Android: ChessViewModelFactory builds it with the Android adapters and
  no scope, so it runs on viewModelScope and stops polling in onCleared,
  as before. The factory's unused Application parameter is gone.
- Desktop: ChessScreen builds it with the Desktop adapters and its
  rememberCoroutineScope(); the UserMetadataCache the old class owned is
  remembered beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkG4KTqmnprUK9BAcwAyc8
This commit is contained in:
Claude
2026-09-27 03:27:57 +00:00
parent 28bf170f92
commit d7ca63008a
10 changed files with 86 additions and 279 deletions
@@ -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
@@ -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()
@@ -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,
@@ -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 }
@@ -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 <T : ViewModel> create(modelClass: Class<T>): 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")
}
@@ -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
@@ -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(
@@ -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)
}
@@ -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) },
@@ -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<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
val isRefreshing: StateFlow<Boolean> = logic.state.isRefreshing
val syncStatus: StateFlow<ChessSyncStatus> = logic.state.syncStatus
val stateVersion: StateFlow<Long> = 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<ChessChallenge> = logic.state.incomingChallenges()
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
}