mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+4
-7
@@ -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()
|
||||
|
||||
+9
-8
@@ -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,
|
||||
|
||||
+3
-2
@@ -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 }
|
||||
|
||||
+13
-5
@@ -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")
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+4
-4
@@ -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(
|
||||
|
||||
+25
-34
@@ -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)
|
||||
}
|
||||
|
||||
+22
-10
@@ -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) },
|
||||
|
||||
-203
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user