refactor impl
This commit is contained in:
+3
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
|
||||||
@@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
val hashtags = HashtagFilterAssembler(client)
|
val hashtags = HashtagFilterAssembler(client)
|
||||||
val geohashes = GeoHashFilterAssembler(client)
|
val geohashes = GeoHashFilterAssembler(client)
|
||||||
val followPacks = FollowPackFeedFilterAssembler(client)
|
val followPacks = FollowPackFeedFilterAssembler(client)
|
||||||
|
val chess = ChessFilterAssembler(client)
|
||||||
|
|
||||||
// active when sending zaps via NWC
|
// active when sending zaps via NWC
|
||||||
val nwc = NWCPaymentFilterAssembler(client)
|
val nwc = NWCPaymentFilterAssembler(client)
|
||||||
@@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator(
|
|||||||
profile,
|
profile,
|
||||||
hashtags,
|
hashtags,
|
||||||
geohashes,
|
geohashes,
|
||||||
|
chess,
|
||||||
nwc,
|
nwc,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
|
|||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
|
||||||
@@ -138,6 +139,7 @@ fun AppNavigation(
|
|||||||
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
|
||||||
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
|
||||||
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
|
||||||
|
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
|
||||||
|
|
||||||
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
|
||||||
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
|
||||||
|
|||||||
+9
@@ -471,6 +471,15 @@ fun ListContent(
|
|||||||
route = Route.Drafts,
|
route = Route.Drafts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRow(
|
||||||
|
title = R.string.route_chess,
|
||||||
|
icon = R.drawable.ic_chess,
|
||||||
|
iconReference = 1,
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
nav = nav,
|
||||||
|
route = Route.Chess,
|
||||||
|
)
|
||||||
|
|
||||||
IconRowRelays(
|
IconRowRelays(
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ sealed class Route {
|
|||||||
|
|
||||||
@Serializable object Notification : Route()
|
@Serializable object Notification : Route()
|
||||||
|
|
||||||
|
@Serializable object Chess : Route()
|
||||||
|
|
||||||
@Serializable object Search : Route()
|
@Serializable object Search : Route()
|
||||||
|
|
||||||
@Serializable object SecurityFilters : Route()
|
@Serializable object SecurityFilters : Route()
|
||||||
@@ -316,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
|
|||||||
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
|
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
|
||||||
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
|
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
|
||||||
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
|
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
|
||||||
|
dest.hasRoute<Route.Chess>() -> entry.toRoute<Route.Chess>()
|
||||||
dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
|
dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
|
||||||
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
|
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
|
||||||
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
|
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ class TopNavFilterState(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow, muteListFollow)
|
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
|
||||||
|
|
||||||
fun mergePeopleLists(
|
fun mergePeopleLists(
|
||||||
peopleLists: List<AddressableNote>,
|
peopleLists: List<AddressableNote>,
|
||||||
@@ -258,7 +258,7 @@ class TopNavFilterState(
|
|||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
emit(
|
emit(
|
||||||
listOf(
|
listOf(
|
||||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
|
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||||
myLivePeopleListsFlow,
|
myLivePeopleListsFlow,
|
||||||
myLiveKind3FollowsFlow,
|
myLiveKind3FollowsFlow,
|
||||||
listOf(muteListFollow),
|
listOf(muteListFollow),
|
||||||
@@ -274,7 +274,7 @@ class TopNavFilterState(
|
|||||||
checkNotInMainThread()
|
checkNotInMainThread()
|
||||||
emit(
|
emit(
|
||||||
listOf(
|
listOf(
|
||||||
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
|
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
|
||||||
myLivePeopleListsFlow,
|
myLivePeopleListsFlow,
|
||||||
listOf(muteListFollow),
|
listOf(muteListFollow),
|
||||||
).flatten().toImmutableList(),
|
).flatten().toImmutableList(),
|
||||||
@@ -413,9 +413,6 @@ val DEFAULT_FEED_KINDS =
|
|||||||
WikiNoteEvent.KIND,
|
WikiNoteEvent.KIND,
|
||||||
NipTextEvent.KIND,
|
NipTextEvent.KIND,
|
||||||
InteractiveStoryPrologueEvent.KIND,
|
InteractiveStoryPrologueEvent.KIND,
|
||||||
ChessGameEvent.KIND,
|
|
||||||
LiveChessGameChallengeEvent.KIND,
|
|
||||||
LiveChessGameEndEvent.KIND,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val DEFAULT_COMMUNITY_FEEDS =
|
val DEFAULT_COMMUNITY_FEEDS =
|
||||||
|
|||||||
+115
-10
@@ -21,17 +21,31 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -40,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
|
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper screen for live chess game
|
* Wrapper screen for live chess game
|
||||||
@@ -50,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
|||||||
* @param accountViewModel Account view model
|
* @param accountViewModel Account view model
|
||||||
* @param nav Navigation interface
|
* @param nav Navigation interface
|
||||||
*/
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ChessGameScreen(
|
fun ChessGameScreen(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
@@ -66,11 +82,78 @@ fun ChessGameScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val activeGames by chessViewModel.activeGames.collectAsState()
|
val activeGames by chessViewModel.activeGames.collectAsState()
|
||||||
val gameState = activeGames[gameId]
|
val error by chessViewModel.error.collectAsState()
|
||||||
|
var isLoading by remember { mutableStateOf(true) }
|
||||||
|
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
|
||||||
|
var loadAttempted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold { paddingValues ->
|
// Try to load game from cache if not in activeGames
|
||||||
if (gameState == null) {
|
LaunchedEffect(gameId, activeGames) {
|
||||||
// Game not found - show error
|
val existingState = activeGames[gameId]
|
||||||
|
if (existingState != null) {
|
||||||
|
loadedGameState = existingState
|
||||||
|
isLoading = false
|
||||||
|
loadAttempted = true
|
||||||
|
} else if (!loadAttempted) {
|
||||||
|
// Try to load from LocalCache
|
||||||
|
loadAttempted = true
|
||||||
|
val loaded = chessViewModel.loadGameFromCache(gameId)
|
||||||
|
loadedGameState = loaded
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state when activeGames changes (e.g., after refresh loads new moves)
|
||||||
|
LaunchedEffect(activeGames[gameId]) {
|
||||||
|
activeGames[gameId]?.let {
|
||||||
|
loadedGameState = it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling when screen is visible
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
chessViewModel.startPolling()
|
||||||
|
onDispose {
|
||||||
|
// Don't stop polling - let ViewModel manage it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameState = loadedGameState ?: activeGames[gameId]
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Chess Game") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
when {
|
||||||
|
isLoading -> {
|
||||||
|
// Loading state
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Loading game...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gameState == null -> {
|
||||||
|
// Game not found - show error with back button
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
@@ -88,18 +171,39 @@ fun ChessGameScreen(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Show specific error if available
|
||||||
Text(
|
Text(
|
||||||
text = "This game may have ended or the ID is incorrect.",
|
text = error ?: "This game may have ended or is waiting for opponent.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Game ID: ${gameId.take(16)}...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Button(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
|
)
|
||||||
|
Text("Go Back")
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
// Show game
|
}
|
||||||
|
else -> {
|
||||||
|
// Show game with proper padding for status bar
|
||||||
|
// Use state-observing version for automatic refresh on polling updates
|
||||||
LiveChessGameScreen(
|
LiveChessGameScreen(
|
||||||
engine = gameState.engine,
|
modifier = Modifier.padding(paddingValues),
|
||||||
playerColor = gameState.playerColor,
|
gameState = gameState,
|
||||||
gameId = gameState.gameId,
|
|
||||||
opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name
|
opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name
|
||||||
onMoveMade = { from, to, san ->
|
onMoveMade = { from, to, san ->
|
||||||
chessViewModel.publishMove(gameId, from, to)
|
chessViewModel.publishMove(gameId, from, to)
|
||||||
@@ -110,3 +214,4 @@ fun ChessGameScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.vitorpamplona.amethyst.R
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||||
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||||
|
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chess lobby screen showing challenges and active games
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ChessLobbyScreen(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
nav: INav,
|
||||||
|
) {
|
||||||
|
val chessViewModel: ChessViewModel =
|
||||||
|
viewModel(
|
||||||
|
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
|
||||||
|
factory = ChessViewModelFactory(accountViewModel.account),
|
||||||
|
)
|
||||||
|
|
||||||
|
val activeGames by chessViewModel.activeGames.collectAsState()
|
||||||
|
val challenges by chessViewModel.challenges.collectAsState()
|
||||||
|
val error by chessViewModel.error.collectAsState()
|
||||||
|
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
|
||||||
|
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
|
||||||
|
|
||||||
|
var showNewGameDialog by remember { mutableStateOf(false) }
|
||||||
|
var isRefreshing by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Subscribe to chess events when screen is visible
|
||||||
|
ChessSubscription(accountViewModel, chessViewModel)
|
||||||
|
|
||||||
|
// Clear any stale game selection on mount
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
chessViewModel.selectGame(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-navigate when a game is selected (e.g., after accepting a challenge)
|
||||||
|
LaunchedEffect(selectedGameId, activeGames) {
|
||||||
|
selectedGameId?.let { gameId ->
|
||||||
|
if (activeGames.containsKey(gameId)) {
|
||||||
|
nav.nav(Route.ChessGame(gameId))
|
||||||
|
chessViewModel.selectGame(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start polling when screen is visible
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
chessViewModel.startPolling()
|
||||||
|
onDispose {
|
||||||
|
chessViewModel.stopPolling()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.route_chess)) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { nav.popBack() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showNewGameDialog = true },
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "New Game")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = isRefreshing,
|
||||||
|
onRefresh = {
|
||||||
|
scope.launch {
|
||||||
|
isRefreshing = true
|
||||||
|
chessViewModel.forceRefresh()
|
||||||
|
delay(500) // Brief delay for visual feedback
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
// Error display
|
||||||
|
error?.let { errorMsg ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
OutlinedButton(onClick = { chessViewModel.clearError() }) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
ChessLobbyContent(
|
||||||
|
challenges = challenges,
|
||||||
|
activeGames = activeGames,
|
||||||
|
userPubkey = userPubkey,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onAcceptChallenge = { note ->
|
||||||
|
chessViewModel.acceptChallenge(note)
|
||||||
|
},
|
||||||
|
onSelectGame = { gameId ->
|
||||||
|
nav.nav(Route.ChessGame(gameId))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New game dialog
|
||||||
|
if (showNewGameDialog) {
|
||||||
|
NewChessGameDialog(
|
||||||
|
onDismiss = { showNewGameDialog = false },
|
||||||
|
onCreateGame = { opponentPubkey, color ->
|
||||||
|
chessViewModel.createChallenge(opponentPubkey, color)
|
||||||
|
showNewGameDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChessLobbyContent(
|
||||||
|
challenges: List<Note>,
|
||||||
|
activeGames: Map<String, LiveChessGameState>,
|
||||||
|
userPubkey: String,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onAcceptChallenge: (Note) -> Unit,
|
||||||
|
onSelectGame: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
if (activeGames.isEmpty() && challenges.isEmpty()) {
|
||||||
|
// Empty state
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"No games or challenges",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Create a new game to get started",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Active games section
|
||||||
|
if (activeGames.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Active Games",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||||
|
ActiveGameCard(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = state.opponentPubkey,
|
||||||
|
isYourTurn = state.isPlayerTurn(),
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onClick = { onSelectGame(gameId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming challenges
|
||||||
|
val incomingChallenges =
|
||||||
|
challenges.filter {
|
||||||
|
val event = it.event as? LiveChessGameChallengeEvent
|
||||||
|
event?.opponentPubkey() == userPubkey
|
||||||
|
}
|
||||||
|
if (incomingChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Incoming Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(incomingChallenges, key = { it.idHex }) { note ->
|
||||||
|
ChallengeCard(
|
||||||
|
note = note,
|
||||||
|
isIncoming = true,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onAccept = { onAcceptChallenge(note) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User's outgoing challenges
|
||||||
|
val outgoingChallenges =
|
||||||
|
challenges.filter {
|
||||||
|
it.author?.pubkeyHex == userPubkey
|
||||||
|
}
|
||||||
|
if (outgoingChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Your Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(outgoingChallenges, key = { it.idHex }) { note ->
|
||||||
|
OutgoingChallengeCard(
|
||||||
|
note = note,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open challenges from others
|
||||||
|
val openChallenges =
|
||||||
|
challenges.filter {
|
||||||
|
val event = it.event as? LiveChessGameChallengeEvent
|
||||||
|
event?.opponentPubkey() == null && it.author?.pubkeyHex != userPubkey
|
||||||
|
}
|
||||||
|
if (openChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Open Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(openChallenges, key = { it.idHex }) { note ->
|
||||||
|
ChallengeCard(
|
||||||
|
note = note,
|
||||||
|
isIncoming = false,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
onAccept = { onAcceptChallenge(note) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom padding for FAB
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(80.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveGameCard(
|
||||||
|
gameId: String,
|
||||||
|
opponentPubkey: String,
|
||||||
|
isYourTurn: Boolean,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
val displayName =
|
||||||
|
remember(opponentPubkey) {
|
||||||
|
accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
border =
|
||||||
|
if (isYourTurn) {
|
||||||
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
"vs $displayName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Game: ${gameId.take(12)}...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
if (isYourTurn) "Your turn" else "Waiting...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChallengeCard(
|
||||||
|
note: Note,
|
||||||
|
isIncoming: Boolean,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
) {
|
||||||
|
val event = note.event as? LiveChessGameChallengeEvent ?: return
|
||||||
|
val challengerPubkey = note.author?.pubkeyHex ?: return
|
||||||
|
val playerColor = event.playerColor()
|
||||||
|
|
||||||
|
val displayName =
|
||||||
|
remember(challengerPubkey) {
|
||||||
|
accountViewModel.checkGetOrCreateUser(challengerPubkey)?.toBestDisplayName() ?: challengerPubkey.take(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
val borderColor =
|
||||||
|
if (isIncoming) {
|
||||||
|
Color(0xFFFF9800) // Orange for incoming
|
||||||
|
} else {
|
||||||
|
Color(0xFF4CAF50) // Green for open
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
border = BorderStroke(2.dp, borderColor),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (isIncoming) "Challenge from $displayName" else "Open challenge by $displayName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = onAccept) {
|
||||||
|
Text("Accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OutgoingChallengeCard(
|
||||||
|
note: Note,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
) {
|
||||||
|
val event = note.event as? LiveChessGameChallengeEvent ?: return
|
||||||
|
val opponentPubkey = event.opponentPubkey()
|
||||||
|
val playerColor = event.playerColor()
|
||||||
|
|
||||||
|
val displayName =
|
||||||
|
remember(opponentPubkey) {
|
||||||
|
opponentPubkey?.let {
|
||||||
|
accountViewModel.checkGetOrCreateUser(it)?.toBestDisplayName() ?: it.take(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not clickable - waiting for opponent to accept
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (displayName != null) {
|
||||||
|
"Challenge to $displayName"
|
||||||
|
} else {
|
||||||
|
"Open challenge (awaiting opponent)"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Waiting...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+270
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
|
||||||
|
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||||
|
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||||
|
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||||
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default "since" window for challenges (24 hours)
|
||||||
|
* Challenges older than this are considered expired anyway
|
||||||
|
*/
|
||||||
|
private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default "since" window for game events when no EOSE cache exists (7 days)
|
||||||
|
* This prevents loading ancient game history on first connection
|
||||||
|
*/
|
||||||
|
private const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to chess events when the Chess tab is active.
|
||||||
|
* Respects the Global/Follows filter from the discovery top bar.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChessSubscription(
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
chessViewModel: ChessViewModel,
|
||||||
|
) {
|
||||||
|
// Observe the current filter selection (Global, All Follows, etc.)
|
||||||
|
val filterSelection by accountViewModel.account.settings.defaultDiscoveryFollowList
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val isGlobal = filterSelection == GLOBAL_FOLLOWS
|
||||||
|
|
||||||
|
// Get active game IDs from the view model for game-specific subscriptions
|
||||||
|
val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle()
|
||||||
|
val activeGameIds = activeGames.keys
|
||||||
|
|
||||||
|
val state =
|
||||||
|
remember(accountViewModel.account, isGlobal, activeGameIds) {
|
||||||
|
ChessQueryState(
|
||||||
|
userPubkey = accountViewModel.account.userProfile().pubkeyHex,
|
||||||
|
// Use notification inbox relays - where others send events TO us
|
||||||
|
inboxRelays = accountViewModel.account.notificationRelays.flow.value,
|
||||||
|
// Use proxy relays for global, outbox relays for follows
|
||||||
|
globalRelays =
|
||||||
|
if (isGlobal) {
|
||||||
|
accountViewModel.account.defaultGlobalRelays.flow.value
|
||||||
|
} else {
|
||||||
|
accountViewModel.account.followOutboxesOrProxy.flow.value
|
||||||
|
},
|
||||||
|
isGlobal = isGlobal,
|
||||||
|
activeGameIds = activeGameIds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(state) {
|
||||||
|
accountViewModel.dataSources().chess.subscribe(state)
|
||||||
|
chessViewModel.refreshChallenges()
|
||||||
|
onDispose {
|
||||||
|
accountViewModel.dataSources().chess.unsubscribe(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query state for chess subscription
|
||||||
|
*/
|
||||||
|
data class ChessQueryState(
|
||||||
|
val userPubkey: String,
|
||||||
|
val inboxRelays: Set<NormalizedRelayUrl>,
|
||||||
|
val globalRelays: Set<NormalizedRelayUrl>,
|
||||||
|
val isGlobal: Boolean,
|
||||||
|
val activeGameIds: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter assembler for chess events
|
||||||
|
*/
|
||||||
|
class ChessFilterAssembler(
|
||||||
|
client: INostrClient,
|
||||||
|
) : ComposeSubscriptionManager<ChessQueryState>() {
|
||||||
|
val group =
|
||||||
|
listOf(
|
||||||
|
ChessFeedFilterSubAssembler(client, ::allKeys),
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun invalidateKeys() = invalidateFilters()
|
||||||
|
|
||||||
|
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||||
|
|
||||||
|
override fun destroy() = group.forEach { it.destroy() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-assembler that creates the actual relay filters
|
||||||
|
*/
|
||||||
|
class ChessFeedFilterSubAssembler(
|
||||||
|
client: INostrClient,
|
||||||
|
allKeys: () -> Set<ChessQueryState>,
|
||||||
|
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
||||||
|
override fun updateFilter(
|
||||||
|
key: ChessQueryState,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
||||||
|
|
||||||
|
override fun id(key: ChessQueryState): String = "chess-${key.userPubkey}-${key.isGlobal}-${key.activeGameIds.hashCode()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create relay filters for chess events.
|
||||||
|
*
|
||||||
|
* Creates filters based on the Global/Follows setting:
|
||||||
|
* - Global: Fetch all chess events from global relays
|
||||||
|
* - Follows: Fetch from followed users' outbox relays
|
||||||
|
*
|
||||||
|
* Also always fetches challenges directed at the user from inbox relays.
|
||||||
|
*
|
||||||
|
* Improvements over basic implementation:
|
||||||
|
* 1. Default "since" timestamps to avoid loading very old events on first connection
|
||||||
|
* 2. Separate challenge filter (shorter window) from game events (longer window)
|
||||||
|
* 3. Game-specific subscriptions for active games using #a tag references
|
||||||
|
* 4. Grouped filters by relay to reduce subscription overhead
|
||||||
|
*/
|
||||||
|
fun filterChessEvents(
|
||||||
|
key: ChessQueryState,
|
||||||
|
since: SincePerRelayMap?,
|
||||||
|
): List<RelayBasedFilter> {
|
||||||
|
val filters = mutableListOf<RelayBasedFilter>()
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
|
||||||
|
// Challenge kinds only (for lobby display)
|
||||||
|
val challengeKinds =
|
||||||
|
listOf(
|
||||||
|
LiveChessGameChallengeEvent.KIND,
|
||||||
|
LiveChessGameAcceptEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move/end kinds (for active games)
|
||||||
|
val gameEventKinds =
|
||||||
|
listOf(
|
||||||
|
LiveChessMoveEvent.KIND,
|
||||||
|
LiveChessGameEndEvent.KIND,
|
||||||
|
)
|
||||||
|
|
||||||
|
// All chess kinds
|
||||||
|
val allChessKinds = challengeKinds + gameEventKinds
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Filter 1: Personal challenges from inbox relays
|
||||||
|
// Uses 24h window for challenges (they expire anyway)
|
||||||
|
// ========================================
|
||||||
|
val inboxFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = allChessKinds,
|
||||||
|
tags = mapOf("p" to listOf(key.userPubkey)),
|
||||||
|
limit = 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (relay in key.inboxRelays) {
|
||||||
|
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
|
||||||
|
filters.add(
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter = inboxFilter.copy(since = sinceTime),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Filter 2: General challenges from global/outbox relays
|
||||||
|
// Uses 24h window to show recent open challenges
|
||||||
|
// ========================================
|
||||||
|
val globalChallengeFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = challengeKinds,
|
||||||
|
limit = 50,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (relay in key.globalRelays) {
|
||||||
|
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
|
||||||
|
filters.add(
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter = globalChallengeFilter.copy(since = sinceTime),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Filter 3: Active game events
|
||||||
|
// Uses longer window (7 days) or no window for active games
|
||||||
|
// Follows jesterui pattern: subscribe to game-specific events via #d tag
|
||||||
|
// ========================================
|
||||||
|
if (key.activeGameIds.isNotEmpty()) {
|
||||||
|
// Create filters for each active game's events
|
||||||
|
// Using #d tag to match the game_id in addressable events
|
||||||
|
val gameSpecificFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = gameEventKinds,
|
||||||
|
tags = mapOf("d" to key.activeGameIds.toList()),
|
||||||
|
limit = 200, // More moves per game
|
||||||
|
)
|
||||||
|
|
||||||
|
// Query all relays for active game events (no since - need full history)
|
||||||
|
val allRelays = key.inboxRelays + key.globalRelays
|
||||||
|
for (relay in allRelays) {
|
||||||
|
filters.add(
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter = gameSpecificFilter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No active games - use general game event filter with window
|
||||||
|
val generalGameFilter =
|
||||||
|
Filter(
|
||||||
|
kinds = gameEventKinds,
|
||||||
|
limit = 100,
|
||||||
|
)
|
||||||
|
|
||||||
|
for (relay in key.globalRelays) {
|
||||||
|
val sinceTime = since?.get(relay)?.time ?: (now - GAME_EVENT_WINDOW_SECONDS)
|
||||||
|
filters.add(
|
||||||
|
RelayBasedFilter(
|
||||||
|
relay = relay,
|
||||||
|
filter = generalGameFilter.copy(since = sinceTime),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters
|
||||||
|
}
|
||||||
+384
-26
@@ -22,12 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
|
||||||
import com.vitorpamplona.amethyst.model.Account
|
import com.vitorpamplona.amethyst.model.Account
|
||||||
import com.vitorpamplona.amethyst.model.LocalCache
|
import com.vitorpamplona.amethyst.model.LocalCache
|
||||||
import com.vitorpamplona.amethyst.model.Note
|
import com.vitorpamplona.amethyst.model.Note
|
||||||
|
import com.vitorpamplona.amethyst.model.filterIntoSet
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||||
@@ -35,6 +39,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
|||||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -47,6 +52,18 @@ import java.util.UUID
|
|||||||
class ChessViewModel(
|
class ChessViewModel(
|
||||||
private val account: Account,
|
private val account: Account,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
companion object {
|
||||||
|
// Challenge expiry: 24 hours
|
||||||
|
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
|
||||||
|
|
||||||
|
// Retry configuration
|
||||||
|
const val MAX_RETRIES = 3
|
||||||
|
const val RETRY_DELAY_MS = 1000L
|
||||||
|
|
||||||
|
// Cleanup interval: check every 5 minutes
|
||||||
|
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
|
||||||
|
}
|
||||||
|
|
||||||
// Active games being played
|
// Active games being played
|
||||||
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
||||||
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
||||||
@@ -67,9 +84,78 @@ class ChessViewModel(
|
|||||||
private val _error = MutableStateFlow<String?>(null)
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
val error: StateFlow<String?> = _error.asStateFlow()
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
// Pending retry operations
|
||||||
|
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
|
||||||
|
val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow()
|
||||||
|
|
||||||
|
// Polling delegate for periodic refresh
|
||||||
|
private val pollingDelegate =
|
||||||
|
ChessPollingDelegate(
|
||||||
|
config = ChessPollingDefaults.android,
|
||||||
|
scope = viewModelScope,
|
||||||
|
onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
|
||||||
|
onRefreshChallenges = { refreshChallenges() },
|
||||||
|
onCleanup = {
|
||||||
|
cleanupExpiredChallenges()
|
||||||
|
checkAbandonedGames()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshChallenges()
|
|
||||||
subscribeToChessEvents()
|
subscribeToChessEvents()
|
||||||
|
// Start polling - will do initial fetch
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for chess events
|
||||||
|
*/
|
||||||
|
fun startPolling() {
|
||||||
|
pollingDelegate.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop polling (call when screen is not visible on Android)
|
||||||
|
*/
|
||||||
|
fun stopPolling() {
|
||||||
|
pollingDelegate.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force immediate refresh
|
||||||
|
*/
|
||||||
|
fun forceRefresh() {
|
||||||
|
pollingDelegate.refreshNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove expired challenges
|
||||||
|
*/
|
||||||
|
private fun cleanupExpiredChallenges() {
|
||||||
|
val now = TimeUtils.now()
|
||||||
|
val validChallenges =
|
||||||
|
_challenges.value.filter { note ->
|
||||||
|
val event = note.event as? LiveChessGameChallengeEvent
|
||||||
|
val createdAt = event?.createdAt ?: 0
|
||||||
|
(now - createdAt) < CHALLENGE_EXPIRY_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validChallenges.size != _challenges.value.size) {
|
||||||
|
_challenges.value = validChallenges
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for abandoned games and notify
|
||||||
|
*/
|
||||||
|
private fun checkAbandonedGames() {
|
||||||
|
_activeGames.value.forEach { (gameId, state) ->
|
||||||
|
if (state.isAbandoned()) {
|
||||||
|
// Game is abandoned, could auto-claim or notify user
|
||||||
|
_error.value = "Game $gameId appears abandoned. You can claim victory."
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,8 +191,9 @@ class ChessViewModel(
|
|||||||
|
|
||||||
val san = event.san() ?: return
|
val san = event.san() ?: return
|
||||||
val fen = event.fen() ?: return
|
val fen = event.fen() ?: return
|
||||||
|
val moveNumber = event.moveNumber()
|
||||||
|
|
||||||
gameState.applyOpponentMove(san, fen)
|
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||||
updateBadgeCount()
|
updateBadgeCount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +216,7 @@ class ChessViewModel(
|
|||||||
// Remove from active games if present
|
// Remove from active games if present
|
||||||
if (_activeGames.value.containsKey(gameId)) {
|
if (_activeGames.value.containsKey(gameId)) {
|
||||||
_activeGames.value = _activeGames.value - gameId
|
_activeGames.value = _activeGames.value - gameId
|
||||||
|
pollingDelegate.removeGameId(gameId)
|
||||||
updateBadgeCount()
|
updateBadgeCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,11 +228,26 @@ class ChessViewModel(
|
|||||||
note: Note,
|
note: Note,
|
||||||
event: LiveChessGameChallengeEvent,
|
event: LiveChessGameChallengeEvent,
|
||||||
) {
|
) {
|
||||||
// Add to challenges if directed at us or is open
|
val gameId = event.gameId() ?: return
|
||||||
|
|
||||||
|
// Skip if this game is already active
|
||||||
|
if (_activeGames.value.containsKey(gameId)) return
|
||||||
|
|
||||||
|
// Add to challenges if directed at us, from us, or is open
|
||||||
val opponentPubkey = event.opponentPubkey()
|
val opponentPubkey = event.opponentPubkey()
|
||||||
if (opponentPubkey == null || opponentPubkey == account.signer.pubKey) {
|
val isFromUs = event.pubKey == account.signer.pubKey
|
||||||
|
val isDirectedAtUs = opponentPubkey == account.signer.pubKey
|
||||||
|
val isOpenChallenge = opponentPubkey == null
|
||||||
|
|
||||||
|
if (isFromUs || isDirectedAtUs || isOpenChallenge) {
|
||||||
val currentChallenges = _challenges.value.toMutableList()
|
val currentChallenges = _challenges.value.toMutableList()
|
||||||
if (currentChallenges.none { it.idHex == note.idHex }) {
|
// Deduplicate by both event ID and game ID
|
||||||
|
val isDuplicateEvent = currentChallenges.any { it.idHex == note.idHex }
|
||||||
|
val isDuplicateGame =
|
||||||
|
currentChallenges.any {
|
||||||
|
(it.event as? LiveChessGameChallengeEvent)?.gameId() == gameId
|
||||||
|
}
|
||||||
|
if (!isDuplicateEvent && !isDuplicateGame) {
|
||||||
currentChallenges.add(note)
|
currentChallenges.add(note)
|
||||||
_challenges.value = currentChallenges
|
_challenges.value = currentChallenges
|
||||||
updateBadgeCount()
|
updateBadgeCount()
|
||||||
@@ -153,7 +256,7 @@ class ChessViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new chess challenge (open or directed)
|
* Create a new chess challenge (open or directed) with retry
|
||||||
*/
|
*/
|
||||||
fun createChallenge(
|
fun createChallenge(
|
||||||
opponentPubkey: String? = null,
|
opponentPubkey: String? = null,
|
||||||
@@ -161,11 +264,11 @@ class ChessViewModel(
|
|||||||
timeControl: String? = null,
|
timeControl: String? = null,
|
||||||
) {
|
) {
|
||||||
val acc = account
|
val acc = account
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val gameId = generateGameId()
|
val gameId = generateGameId()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val success =
|
||||||
|
retryWithBackoff("challenge-$gameId") {
|
||||||
val template =
|
val template =
|
||||||
LiveChessGameChallengeEvent.build(
|
LiveChessGameChallengeEvent.build(
|
||||||
gameId = gameId,
|
gameId = gameId,
|
||||||
@@ -175,10 +278,12 @@ class ChessViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
acc.signAndComputeBroadcast(template)
|
acc.signAndComputeBroadcast(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
_error.value = null
|
_error.value = null
|
||||||
} catch (e: Exception) {
|
} else {
|
||||||
_error.value = "Failed to create challenge: ${e.message}"
|
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +322,7 @@ class ChessViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||||
|
pollingDelegate.addGameId(gameId)
|
||||||
_selectedGameId.value = gameId
|
_selectedGameId.value = gameId
|
||||||
_error.value = null
|
_error.value = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -270,6 +376,7 @@ class ChessViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
_activeGames.value = _activeGames.value + (gameId to gameState)
|
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||||
|
pollingDelegate.addGameId(gameId)
|
||||||
_selectedGameId.value = gameId
|
_selectedGameId.value = gameId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +469,7 @@ class ChessViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer/accept draw
|
* Offer a draw to opponent - sends a draw offer event that opponent can accept or decline
|
||||||
*/
|
*/
|
||||||
fun offerDraw(gameId: String) {
|
fun offerDraw(gameId: String) {
|
||||||
val acc = account
|
val acc = account
|
||||||
@@ -370,22 +477,16 @@ class ChessViewModel(
|
|||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val endData = gameState.offerDraw()
|
val drawOffer = gameState.offerDraw()
|
||||||
|
|
||||||
val template =
|
val template =
|
||||||
LiveChessGameEndEvent.build(
|
LiveChessDrawOfferEvent.build(
|
||||||
gameId = endData.gameId,
|
gameId = drawOffer.gameId,
|
||||||
result = endData.result,
|
opponentPubkey = drawOffer.opponentPubkey,
|
||||||
termination = endData.termination,
|
message = drawOffer.message ?: "",
|
||||||
winnerPubkey = endData.winnerPubkey,
|
|
||||||
opponentPubkey = endData.opponentPubkey,
|
|
||||||
pgn = endData.pgn ?: "",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
acc.signAndComputeBroadcast(template)
|
acc.signAndComputeBroadcast(template)
|
||||||
|
|
||||||
// Remove from active games
|
|
||||||
_activeGames.value = _activeGames.value - gameId
|
|
||||||
_error.value = null
|
_error.value = null
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_error.value = "Failed to offer draw: ${e.message}"
|
_error.value = "Failed to offer draw: ${e.message}"
|
||||||
@@ -408,10 +509,26 @@ class ChessViewModel(
|
|||||||
/**
|
/**
|
||||||
* Refresh challenges from local cache
|
* Refresh challenges from local cache
|
||||||
*/
|
*/
|
||||||
private fun refreshChallenges() {
|
fun refreshChallenges() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
// TODO: Subscribe to challenge events via relay filter
|
val userPubkey = account.signer.pubKey
|
||||||
// For now, this is a placeholder
|
val now = TimeUtils.now()
|
||||||
|
|
||||||
|
// Query LocalCache for chess challenge events
|
||||||
|
val challengeNotes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, note ->
|
||||||
|
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||||
|
|
||||||
|
// Check if challenge is not expired
|
||||||
|
val createdAt = event.createdAt
|
||||||
|
if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false
|
||||||
|
|
||||||
|
// Include challenges directed at us, or open challenges (not from us)
|
||||||
|
val opponentPubkey = event.opponentPubkey()
|
||||||
|
opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
_challenges.value = challengeNotes.toList()
|
||||||
updateBadgeCount()
|
updateBadgeCount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,6 +558,76 @@ class ChessViewModel(
|
|||||||
_error.value = null
|
_error.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim victory for an abandoned game
|
||||||
|
*/
|
||||||
|
fun claimAbandonmentVictory(gameId: String) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val endData = gameState.claimAbandonmentVictory() ?: return@launch
|
||||||
|
|
||||||
|
val success =
|
||||||
|
retryWithBackoff("abandon-$gameId") {
|
||||||
|
val template =
|
||||||
|
LiveChessGameEndEvent.build(
|
||||||
|
gameId = endData.gameId,
|
||||||
|
result = endData.result,
|
||||||
|
termination = endData.termination,
|
||||||
|
winnerPubkey = endData.winnerPubkey,
|
||||||
|
opponentPubkey = endData.opponentPubkey,
|
||||||
|
pgn = endData.pgn ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
account.signAndComputeBroadcast(template)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
_activeGames.value = _activeGames.value - gameId
|
||||||
|
_error.value = null
|
||||||
|
} else {
|
||||||
|
_error.value = "Failed to claim victory"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force resync a game to opponent's position
|
||||||
|
*/
|
||||||
|
fun forceResync(
|
||||||
|
gameId: String,
|
||||||
|
fen: String,
|
||||||
|
) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
gameState.forceResync(fen)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry an operation with exponential backoff
|
||||||
|
*/
|
||||||
|
private suspend fun retryWithBackoff(
|
||||||
|
operationId: String,
|
||||||
|
operation: suspend () -> Unit,
|
||||||
|
): Boolean {
|
||||||
|
var attempt = 0
|
||||||
|
while (attempt < MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
_pendingRetries.value =
|
||||||
|
_pendingRetries.value + (operationId to RetryOperation(operationId, attempt + 1, MAX_RETRIES))
|
||||||
|
operation()
|
||||||
|
_pendingRetries.value = _pendingRetries.value - operationId
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
attempt++
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
delay(RETRY_DELAY_MS * attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_pendingRetries.value = _pendingRetries.value - operationId
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate unique game ID
|
* Generate unique game ID
|
||||||
*/
|
*/
|
||||||
@@ -449,4 +636,175 @@ class ChessViewModel(
|
|||||||
val random = UUID.randomUUID().toString().take(8)
|
val random = UUID.randomUUID().toString().take(8)
|
||||||
return "chess-$timestamp-$random"
|
return "chess-$timestamp-$random"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh game state from LocalCache for specific game IDs
|
||||||
|
* Called periodically by polling delegate
|
||||||
|
*/
|
||||||
|
private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
|
||||||
|
val userPubkey = account.signer.pubKey
|
||||||
|
|
||||||
|
for (gameId in gameIds) {
|
||||||
|
// Find moves for this game
|
||||||
|
val moveNotes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, note ->
|
||||||
|
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||||
|
event.gameId() == gameId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val gameState = _activeGames.value[gameId]
|
||||||
|
if (gameState != null) {
|
||||||
|
// Apply any new moves
|
||||||
|
moveNotes
|
||||||
|
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||||
|
.filter { it.pubKey != userPubkey } // Only opponent moves
|
||||||
|
.sortedBy { it.moveNumber() }
|
||||||
|
.forEach { moveEvent ->
|
||||||
|
val san = moveEvent.san() ?: return@forEach
|
||||||
|
val fen = moveEvent.fen() ?: return@forEach
|
||||||
|
val moveNumber = moveEvent.moveNumber()
|
||||||
|
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load or rebuild game state from LocalCache for a specific gameId
|
||||||
|
* Used when navigating to a game screen
|
||||||
|
*
|
||||||
|
* @return GameLoadResult with either the game state or an error reason
|
||||||
|
*/
|
||||||
|
fun loadGameFromCache(gameId: String): LiveChessGameState? {
|
||||||
|
// Check if already loaded
|
||||||
|
_activeGames.value[gameId]?.let { return it }
|
||||||
|
|
||||||
|
val userPubkey = account.signer.pubKey
|
||||||
|
|
||||||
|
// Find the challenge event for this game
|
||||||
|
val challengeNotes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, note ->
|
||||||
|
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
|
||||||
|
event.gameId() == gameId
|
||||||
|
}
|
||||||
|
val challengeNote = challengeNotes.firstOrNull()
|
||||||
|
|
||||||
|
// Find accept event for this game
|
||||||
|
val acceptNotes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, note ->
|
||||||
|
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
|
||||||
|
event.gameId() == gameId
|
||||||
|
}
|
||||||
|
val acceptNote = acceptNotes.firstOrNull()
|
||||||
|
|
||||||
|
// Need challenge to understand the game
|
||||||
|
val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent
|
||||||
|
if (challengeEvent == null) {
|
||||||
|
_error.value = "Challenge event not found for game $gameId"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val acceptEvent = acceptNote?.event as? LiveChessGameAcceptEvent
|
||||||
|
|
||||||
|
// Determine if we're a participant
|
||||||
|
val challengerPubkey = challengeEvent.pubKey
|
||||||
|
val acceptorPubkey = acceptEvent?.pubKey
|
||||||
|
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||||
|
|
||||||
|
val (playerColor, opponentPubkey) =
|
||||||
|
when {
|
||||||
|
challengerPubkey == userPubkey && acceptorPubkey != null -> {
|
||||||
|
// We created the challenge, someone accepted
|
||||||
|
challengerColor to acceptorPubkey
|
||||||
|
}
|
||||||
|
acceptorPubkey == userPubkey -> {
|
||||||
|
// We accepted someone's challenge
|
||||||
|
challengerColor.opposite() to challengerPubkey
|
||||||
|
}
|
||||||
|
challengerPubkey == userPubkey && acceptorPubkey == null -> {
|
||||||
|
// We created challenge but no one accepted yet
|
||||||
|
_error.value = "Waiting for opponent to accept challenge"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_error.value = "You are not a participant in this game"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build game state
|
||||||
|
val engine = ChessEngine()
|
||||||
|
engine.reset()
|
||||||
|
|
||||||
|
// Find and apply all moves in order
|
||||||
|
val moveNotes =
|
||||||
|
LocalCache.notes.filterIntoSet { _, note ->
|
||||||
|
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
|
||||||
|
event.gameId() == gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
val sortedMoves =
|
||||||
|
moveNotes
|
||||||
|
.mapNotNull { it.event as? LiveChessMoveEvent }
|
||||||
|
.sortedBy { it.moveNumber() ?: Int.MAX_VALUE }
|
||||||
|
|
||||||
|
// Track move numbers we've loaded
|
||||||
|
val loadedMoveNumbers = mutableSetOf<Int>()
|
||||||
|
|
||||||
|
for (moveEvent in sortedMoves) {
|
||||||
|
val san = moveEvent.san() ?: continue
|
||||||
|
val moveNumber = moveEvent.moveNumber()
|
||||||
|
try {
|
||||||
|
engine.makeMove(san)
|
||||||
|
if (moveNumber != null) {
|
||||||
|
loadedMoveNumbers.add(moveNumber)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_error.value = "Error loading move $san: ${e.message}"
|
||||||
|
// Continue trying to load other moves
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameState =
|
||||||
|
LiveChessGameState(
|
||||||
|
gameId = gameId,
|
||||||
|
playerPubkey = userPubkey,
|
||||||
|
opponentPubkey = opponentPubkey,
|
||||||
|
playerColor = playerColor,
|
||||||
|
engine = engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Mark loaded moves as received to prevent re-application during refresh
|
||||||
|
gameState.markMovesAsReceived(loadedMoveNumbers)
|
||||||
|
|
||||||
|
// Add to active games
|
||||||
|
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||||
|
|
||||||
|
// Update polling delegate with this game
|
||||||
|
pollingDelegate.addGameId(gameId)
|
||||||
|
|
||||||
|
// Clear any error since we loaded successfully
|
||||||
|
_error.value = null
|
||||||
|
|
||||||
|
return gameState
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when ViewModel is cleared
|
||||||
|
*/
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
pollingDelegate.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a pending retry operation for UI feedback
|
||||||
|
*/
|
||||||
|
data class RetryOperation(
|
||||||
|
val id: String,
|
||||||
|
val currentAttempt: Int,
|
||||||
|
val maxAttempts: Int,
|
||||||
|
)
|
||||||
|
|||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
|
||||||
|
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
|
||||||
|
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple floating action button for creating new chess game challenges.
|
||||||
|
* Just triggers onClick - the caller handles the dialog.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NewChessGameButton(onClick: () -> Unit) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Size55Modifier,
|
||||||
|
shape = CircleShape,
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Add,
|
||||||
|
contentDescription = "New Chess Game",
|
||||||
|
modifier = Size26Modifier,
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-17
@@ -123,7 +123,7 @@ fun DiscoverScreen(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
val tabs by
|
val feedTabs by
|
||||||
remember(accountViewModel) {
|
remember(accountViewModel) {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
listOf(
|
listOf(
|
||||||
@@ -181,7 +181,7 @@ fun DiscoverScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
|
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { feedTabs.size }
|
||||||
|
|
||||||
WatchAccountForDiscoveryScreen(
|
WatchAccountForDiscoveryScreen(
|
||||||
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
|
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
|
||||||
@@ -204,13 +204,13 @@ fun DiscoverScreen(
|
|||||||
|
|
||||||
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
|
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
|
||||||
|
|
||||||
DiscoverPages(pagerState, tabs, accountViewModel, nav)
|
DiscoverPages(pagerState, feedTabs, accountViewModel, nav)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DiscoverPages(
|
private fun DiscoverPages(
|
||||||
pagerState: PagerState,
|
pagerState: PagerState,
|
||||||
tabs: ImmutableList<TabItem>,
|
feedTabs: ImmutableList<TabItem>,
|
||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
@@ -228,7 +228,7 @@ private fun DiscoverPages(
|
|||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
tabs.forEachIndexed { index, tab ->
|
feedTabs.forEachIndexed { index, tab ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = pagerState.currentPage == index,
|
||||||
text = { Text(text = stringRes(tab.resource)) },
|
text = { Text(text = stringRes(tab.resource)) },
|
||||||
@@ -241,38 +241,44 @@ private fun DiscoverPages(
|
|||||||
bottomBar = {
|
bottomBar = {
|
||||||
AppBottomBar(Route.Discover, accountViewModel) { route ->
|
AppBottomBar(Route.Discover, accountViewModel) { route ->
|
||||||
if (route == Route.Discover) {
|
if (route == Route.Discover) {
|
||||||
tabs[pagerState.currentPage].feedState.sendToTop()
|
val currentPage = pagerState.currentPage
|
||||||
|
if (currentPage >= 0 && currentPage < feedTabs.size) {
|
||||||
|
feedTabs[currentPage].feedState.sendToTop()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
nav.newStack(route)
|
nav.newStack(route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
floatingButton = {
|
floatingButton = {
|
||||||
if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) {
|
val currentPage = pagerState.currentPage
|
||||||
|
if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) {
|
||||||
NewProductButton(accountViewModel, nav)
|
NewProductButton(accountViewModel, nav)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
) {
|
) {
|
||||||
HorizontalPager(state = pagerState, contentPadding = it) { page ->
|
HorizontalPager(state = pagerState, contentPadding = it) { page ->
|
||||||
RefresheableBox(tabs[page].feedState, true) {
|
if (page >= 0 && page < feedTabs.size) {
|
||||||
if (tabs[page].useGridLayout) {
|
val tab = feedTabs[page]
|
||||||
SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
RefresheableBox(tab.feedState, true) {
|
||||||
|
if (tab.useGridLayout) {
|
||||||
|
SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||||
RenderDiscoverFeed(
|
RenderDiscoverFeed(
|
||||||
feedContentState = tabs[page].feedState,
|
feedContentState = tab.feedState,
|
||||||
routeForLastRead = tabs[page].routeForLastRead,
|
routeForLastRead = tab.routeForLastRead,
|
||||||
forceEventKind = tabs[page].forceEventKind,
|
forceEventKind = tab.forceEventKind,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
|
SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
|
||||||
RenderDiscoverFeed(
|
RenderDiscoverFeed(
|
||||||
feedContentState = tabs[page].feedState,
|
feedContentState = tab.feedState,
|
||||||
routeForLastRead = tabs[page].routeForLastRead,
|
routeForLastRead = tab.routeForLastRead,
|
||||||
forceEventKind = tabs[page].forceEventKind,
|
forceEventKind = tab.forceEventKind,
|
||||||
listState = listState,
|
listState = listState,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
@@ -283,6 +289,7 @@ private fun DiscoverPages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RenderDiscoverFeed(
|
private fun RenderDiscoverFeed(
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF9C9C9C"
|
||||||
|
android:pathData="M19,22H5v-2h14v2M17.16,8.26A8.94,8.94 0,0 0,19 3H15.03A5.24,5.24 0,0 1,14.05 4.89L10.08,4.89A5.24,5.24 0,0 1,9.1 3H5A8.94,8.94 0,0 0,6.84 8.26L6.84,18h10.32v-9.74ZM7.5,6c0.28,0 0.5,0.22 0.5,0.5S7.78,7 7.5,7 7,6.78 7,6.5 7.22,6 7.5,6M9,12 L9,9h2v3H9M13,12v-3h2v3H13Z"/>
|
||||||
|
</vector>
|
||||||
@@ -1191,6 +1191,7 @@
|
|||||||
<string name="route_notifications">Notifications</string>
|
<string name="route_notifications">Notifications</string>
|
||||||
<string name="route_global">Global</string>
|
<string name="route_global">Global</string>
|
||||||
<string name="route_video">Shorts</string>
|
<string name="route_video">Shorts</string>
|
||||||
|
<string name="route_chess">Chess</string>
|
||||||
<string name="route_security_filters">Security Filters</string>
|
<string name="route_security_filters">Security Filters</string>
|
||||||
|
|
||||||
<string name="new_post">New Post</string>
|
<string name="new_post">New Post</string>
|
||||||
|
|||||||
+17
-16
@@ -20,23 +20,25 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
package com.vitorpamplona.amethyst.commons.chess
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessPosition
|
import com.vitorpamplona.quartz.nip64Chess.ChessPosition
|
||||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||||
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||||
@@ -103,13 +105,12 @@ private fun ChessSquare(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// Display piece using Unicode chess symbols
|
// Display piece using CBurnett ImageVector icons
|
||||||
piece?.let {
|
piece?.let {
|
||||||
Text(
|
Image(
|
||||||
text = it.toUnicode(),
|
imageVector = it.toImageVector(),
|
||||||
fontSize = (size.value * 0.6).sp,
|
contentDescription = "${it.color} ${it.type}",
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,16 +131,16 @@ private fun ChessSquare(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension function to convert ChessPiece to Unicode chess symbol
|
* Extension function to convert ChessPiece to CBurnett ImageVector
|
||||||
*/
|
*/
|
||||||
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
|
private fun ChessPiece.toImageVector(): ImageVector {
|
||||||
val white = color == ChessColor.WHITE
|
val white = color == ChessColor.WHITE
|
||||||
return when (type) {
|
return when (type) {
|
||||||
PieceType.KING -> if (white) "♔" else "♚"
|
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
|
||||||
PieceType.QUEEN -> if (white) "♕" else "♛"
|
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
|
||||||
PieceType.ROOK -> if (white) "♖" else "♜"
|
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
|
||||||
PieceType.BISHOP -> if (white) "♗" else "♝"
|
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
|
||||||
PieceType.KNIGHT -> if (white) "♘" else "♞"
|
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
|
||||||
PieceType.PAWN -> if (white) "♙" else "♟"
|
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.chess
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for chess event polling
|
||||||
|
*/
|
||||||
|
data class ChessPollingConfig(
|
||||||
|
/** Interval for polling active games (ms) */
|
||||||
|
val activeGamePollInterval: Long = 10_000L,
|
||||||
|
/** Interval for polling challenges (ms) */
|
||||||
|
val challengePollInterval: Long = 30_000L,
|
||||||
|
/** Whether to continue polling in background */
|
||||||
|
val pollInBackground: Boolean = false,
|
||||||
|
/** Challenge expiry time (seconds) */
|
||||||
|
val challengeExpirySeconds: Long = 24 * 60 * 60L,
|
||||||
|
/** Cleanup interval (ms) */
|
||||||
|
val cleanupInterval: Long = 5 * 60 * 1000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-specific defaults
|
||||||
|
*/
|
||||||
|
object ChessPollingDefaults {
|
||||||
|
/** Android: shorter intervals, no background polling */
|
||||||
|
val android =
|
||||||
|
ChessPollingConfig(
|
||||||
|
activeGamePollInterval = 10_000L,
|
||||||
|
challengePollInterval = 30_000L,
|
||||||
|
pollInBackground = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Desktop: can poll in background */
|
||||||
|
val desktop =
|
||||||
|
ChessPollingConfig(
|
||||||
|
activeGamePollInterval = 10_000L,
|
||||||
|
challengePollInterval = 30_000L,
|
||||||
|
pollInBackground = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate for managing chess event polling
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```
|
||||||
|
* class ChessViewModel(...) : ViewModel() {
|
||||||
|
* private val pollingDelegate = ChessPollingDelegate(
|
||||||
|
* config = ChessPollingDefaults.android,
|
||||||
|
* scope = viewModelScope,
|
||||||
|
* onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
|
||||||
|
* onRefreshChallenges = { refreshChallengesFromCache() },
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* fun startPolling() = pollingDelegate.start()
|
||||||
|
* fun stopPolling() = pollingDelegate.stop()
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class ChessPollingDelegate(
|
||||||
|
private val config: ChessPollingConfig,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val onRefreshGames: suspend (Set<String>) -> Unit,
|
||||||
|
private val onRefreshChallenges: suspend () -> Unit,
|
||||||
|
private val onCleanup: suspend () -> Unit = {},
|
||||||
|
) {
|
||||||
|
private var gamePollingJob: Job? = null
|
||||||
|
private var challengePollingJob: Job? = null
|
||||||
|
private var cleanupJob: Job? = null
|
||||||
|
|
||||||
|
private val _isPolling = MutableStateFlow(false)
|
||||||
|
val isPolling: StateFlow<Boolean> = _isPolling.asStateFlow()
|
||||||
|
|
||||||
|
private val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the set of game IDs to poll for
|
||||||
|
*/
|
||||||
|
fun setActiveGameIds(gameIds: Set<String>) {
|
||||||
|
activeGameIdsFlow.value = gameIds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a game ID to poll for
|
||||||
|
*/
|
||||||
|
fun addGameId(gameId: String) {
|
||||||
|
activeGameIdsFlow.value = activeGameIdsFlow.value + gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a game ID from polling
|
||||||
|
*/
|
||||||
|
fun removeGameId(gameId: String) {
|
||||||
|
activeGameIdsFlow.value = activeGameIdsFlow.value - gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start polling for chess events
|
||||||
|
*/
|
||||||
|
fun start() {
|
||||||
|
if (_isPolling.value) return
|
||||||
|
_isPolling.value = true
|
||||||
|
|
||||||
|
// Poll for active games
|
||||||
|
gamePollingJob =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val gameIds = activeGameIdsFlow.value
|
||||||
|
if (gameIds.isNotEmpty()) {
|
||||||
|
onRefreshGames(gameIds)
|
||||||
|
}
|
||||||
|
delay(config.activeGamePollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll for challenges
|
||||||
|
challengePollingJob =
|
||||||
|
scope.launch {
|
||||||
|
// Initial fetch
|
||||||
|
onRefreshChallenges()
|
||||||
|
|
||||||
|
while (isActive) {
|
||||||
|
delay(config.challengePollInterval)
|
||||||
|
onRefreshChallenges()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup job
|
||||||
|
cleanupJob =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(config.cleanupInterval)
|
||||||
|
onCleanup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop polling
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
_isPolling.value = false
|
||||||
|
gamePollingJob?.cancel()
|
||||||
|
challengePollingJob?.cancel()
|
||||||
|
cleanupJob?.cancel()
|
||||||
|
gamePollingJob = null
|
||||||
|
challengePollingJob = null
|
||||||
|
cleanupJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause polling (e.g., when app goes to background on Android)
|
||||||
|
*/
|
||||||
|
fun pause() {
|
||||||
|
if (!config.pollInBackground) {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume polling (e.g., when app comes to foreground on Android)
|
||||||
|
*/
|
||||||
|
fun resume() {
|
||||||
|
if (!_isPolling.value) {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force an immediate refresh
|
||||||
|
*/
|
||||||
|
fun refreshNow() {
|
||||||
|
scope.launch {
|
||||||
|
onRefreshChallenges()
|
||||||
|
val gameIds = activeGameIdsFlow.value
|
||||||
|
if (gameIds.isNotEmpty()) {
|
||||||
|
onRefreshGames(gameIds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for chess event sources that can be refreshed
|
||||||
|
*/
|
||||||
|
interface ChessEventSource {
|
||||||
|
/**
|
||||||
|
* Fetch/refresh challenges from the event source
|
||||||
|
*/
|
||||||
|
suspend fun fetchChallenges(): List<ChessChallengeData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch/refresh game state for specific game IDs
|
||||||
|
*/
|
||||||
|
suspend fun fetchGameUpdates(gameIds: Set<String>): Map<String, ChessGameUpdate>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a chess challenge
|
||||||
|
*/
|
||||||
|
data class ChessChallengeData(
|
||||||
|
val id: String,
|
||||||
|
val gameId: String,
|
||||||
|
val challengerPubkey: String,
|
||||||
|
val opponentPubkey: String?,
|
||||||
|
val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color,
|
||||||
|
val createdAt: Long,
|
||||||
|
val isExpired: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a game update
|
||||||
|
*/
|
||||||
|
data class ChessGameUpdate(
|
||||||
|
val gameId: String,
|
||||||
|
val moves: List<ChessMoveData>,
|
||||||
|
val isEnded: Boolean = false,
|
||||||
|
val endReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data class representing a chess move
|
||||||
|
*/
|
||||||
|
data class ChessMoveData(
|
||||||
|
val san: String,
|
||||||
|
val fen: String,
|
||||||
|
val moveNumber: Int,
|
||||||
|
val playerPubkey: String,
|
||||||
|
val timestamp: Long,
|
||||||
|
)
|
||||||
+1018
File diff suppressed because it is too large
Load Diff
+52
-29
@@ -20,16 +20,17 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
package com.vitorpamplona.amethyst.commons.chess
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -40,12 +41,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.alpha
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
|
||||||
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
import com.vitorpamplona.quartz.nip64Chess.PieceType
|
||||||
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||||
|
|
||||||
@@ -55,55 +57,76 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
|||||||
* @param engine Chess engine for move validation and legal moves
|
* @param engine Chess engine for move validation and legal moves
|
||||||
* @param modifier Modifier for the board
|
* @param modifier Modifier for the board
|
||||||
* @param boardSize Total size of the board in dp
|
* @param boardSize Total size of the board in dp
|
||||||
|
* @param flipped If true, renders from black's perspective (rank 1 at top)
|
||||||
|
* @param playerColor The color the player is playing as (only allows moving these pieces)
|
||||||
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
|
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
|
||||||
|
* NOTE: This callback should make the actual move - this component only validates
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun InteractiveChessBoard(
|
fun InteractiveChessBoard(
|
||||||
engine: ChessEngine,
|
engine: ChessEngine,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
boardSize: Dp = 400.dp,
|
boardSize: Dp = 400.dp,
|
||||||
|
flipped: Boolean = false,
|
||||||
|
playerColor: ChessColor? = null, // null = allow both (for local play)
|
||||||
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
|
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
|
||||||
) {
|
) {
|
||||||
val position = remember(engine) { engine.getPosition() }
|
// Track move count to trigger recomposition when board changes
|
||||||
|
var moveCount by remember { mutableStateOf(0) }
|
||||||
|
val position = remember(engine, moveCount) { engine.getPosition() }
|
||||||
|
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() }
|
||||||
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
|
||||||
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
|
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||||
|
|
||||||
|
// Can only interact if it's your turn (or playerColor is null for local play)
|
||||||
|
val canInteract = playerColor == null || sideToMove == playerColor
|
||||||
|
|
||||||
val squareSize = boardSize / 8
|
val squareSize = boardSize / 8
|
||||||
|
|
||||||
|
// Rank iteration order based on perspective
|
||||||
|
val rankRange = if (flipped) (0..7) else (7 downTo 0)
|
||||||
|
val fileRange = if (flipped) (7 downTo 0) else (0..7)
|
||||||
|
|
||||||
Column(modifier = modifier.size(boardSize)) {
|
Column(modifier = modifier.size(boardSize)) {
|
||||||
// Render ranks 8 down to 1 (from white's perspective)
|
for (rank in rankRange) {
|
||||||
for (rank in 7 downTo 0) {
|
|
||||||
Row {
|
Row {
|
||||||
for (file in 0..7) {
|
for (file in fileRange) {
|
||||||
val piece = position.pieceAt(file, rank)
|
val piece = position.pieceAt(file, rank)
|
||||||
val isLightSquare = (rank + file) % 2 == 0
|
val isLightSquare = (rank + file) % 2 == 0
|
||||||
val square = fileRankToSquare(file, rank)
|
val square = fileRankToSquare(file, rank)
|
||||||
val isSelected = selectedSquare == (file to rank)
|
val isSelected = selectedSquare == (file to rank)
|
||||||
val isLegalMove = legalMoves.contains(square)
|
val isLegalMove = legalMoves.contains(square)
|
||||||
|
|
||||||
|
val showCoord = if (flipped) rank == 7 else rank == 0
|
||||||
|
|
||||||
InteractiveChessSquare(
|
InteractiveChessSquare(
|
||||||
piece = piece,
|
piece = piece,
|
||||||
isLight = isLightSquare,
|
isLight = isLightSquare,
|
||||||
size = squareSize,
|
size = squareSize,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
isLegalMove = isLegalMove,
|
isLegalMove = isLegalMove,
|
||||||
showCoordinate = rank == 0,
|
showCoordinate = showCoord,
|
||||||
file = file,
|
file = file,
|
||||||
rank = rank,
|
rank = rank,
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (!canInteract) return@InteractiveChessSquare
|
||||||
|
|
||||||
if (selectedSquare != null) {
|
if (selectedSquare != null) {
|
||||||
// Attempt to make move
|
// Attempt to make move - validate first, don't actually make it
|
||||||
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
|
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
|
||||||
val to = square
|
val to = square
|
||||||
val result = engine.makeMove(from, to)
|
|
||||||
|
|
||||||
if (result.success) {
|
if (legalMoves.contains(to)) {
|
||||||
onMoveMade(from, to, result.san ?: "$from$to")
|
// Valid move - callback will make the actual move
|
||||||
selectedSquare = null
|
selectedSquare = null
|
||||||
legalMoves = emptyList()
|
legalMoves = emptyList()
|
||||||
|
// Pass empty san - callback will get it from makeMove result
|
||||||
|
onMoveMade(from, to, "")
|
||||||
|
moveCount++ // Trigger recomposition after move is made
|
||||||
} else {
|
} else {
|
||||||
// If move failed, try selecting this square instead
|
// Not a legal move target - try selecting this square instead
|
||||||
if (piece != null && piece.color == engine.getSideToMove()) {
|
val validColor = playerColor ?: sideToMove
|
||||||
|
if (piece != null && piece.color == validColor) {
|
||||||
selectedSquare = file to rank
|
selectedSquare = file to rank
|
||||||
legalMoves = engine.getLegalMovesFrom(square)
|
legalMoves = engine.getLegalMovesFrom(square)
|
||||||
} else {
|
} else {
|
||||||
@@ -112,8 +135,9 @@ fun InteractiveChessBoard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Select piece
|
// Select piece - only allow selecting player's pieces
|
||||||
if (piece != null && piece.color == engine.getSideToMove()) {
|
val validColor = playerColor ?: sideToMove
|
||||||
|
if (piece != null && piece.color == validColor) {
|
||||||
selectedSquare = file to rank
|
selectedSquare = file to rank
|
||||||
legalMoves = engine.getLegalMovesFrom(square)
|
legalMoves = engine.getLegalMovesFrom(square)
|
||||||
}
|
}
|
||||||
@@ -164,13 +188,12 @@ private fun InteractiveChessSquare(
|
|||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
// Display piece using Unicode chess symbols
|
// Display piece using CBurnett ImageVector icons
|
||||||
piece?.let {
|
piece?.let {
|
||||||
Text(
|
Image(
|
||||||
text = it.toUnicode(),
|
imageVector = it.toImageVector(),
|
||||||
fontSize = (size.value * 0.6).sp,
|
contentDescription = "${it.color} ${it.type}",
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.fillMaxSize().padding(2.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,16 +241,16 @@ private fun fileRankToSquare(
|
|||||||
): String = "${('a' + file)}${rank + 1}"
|
): String = "${('a' + file)}${rank + 1}"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension function to convert ChessPiece to Unicode chess symbol
|
* Extension function to convert ChessPiece to CBurnett ImageVector
|
||||||
*/
|
*/
|
||||||
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
|
private fun ChessPiece.toImageVector(): ImageVector {
|
||||||
val white = color == ChessColor.WHITE
|
val white = color == ChessColor.WHITE
|
||||||
return when (type) {
|
return when (type) {
|
||||||
PieceType.KING -> if (white) "♔" else "♚"
|
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
|
||||||
PieceType.QUEEN -> if (white) "♕" else "♛"
|
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
|
||||||
PieceType.ROOK -> if (white) "♖" else "♜"
|
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
|
||||||
PieceType.BISHOP -> if (white) "♗" else "♝"
|
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
|
||||||
PieceType.KNIGHT -> if (white) "♘" else "♞"
|
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
|
||||||
PieceType.PAWN -> if (white) "♙" else "♟"
|
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-9
@@ -20,14 +20,21 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.commons.chess
|
package com.vitorpamplona.amethyst.commons.chess
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -35,6 +42,7 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -43,9 +51,11 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.min
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dialog for creating a new chess game challenge
|
* Dialog for creating a new chess game challenge
|
||||||
@@ -156,6 +166,81 @@ fun NewChessGameDialog(
|
|||||||
/**
|
/**
|
||||||
* Complete live chess game UI with board, controls, and game info
|
* Complete live chess game UI with board, controls, and game info
|
||||||
*
|
*
|
||||||
|
* This version observes LiveChessGameState flows for automatic UI updates
|
||||||
|
* when polling refreshes the game state.
|
||||||
|
*
|
||||||
|
* @param modifier Modifier for the root layout
|
||||||
|
* @param gameState Live chess game state (observed for updates)
|
||||||
|
* @param opponentName Opponent's display name
|
||||||
|
* @param onMoveMade Callback when player makes a move (from, to, san)
|
||||||
|
* @param onResign Callback when player resigns
|
||||||
|
* @param onOfferDraw Callback when player offers draw
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LiveChessGameScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
gameState: LiveChessGameState,
|
||||||
|
opponentName: String,
|
||||||
|
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||||
|
onResign: () -> Unit,
|
||||||
|
onOfferDraw: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Observe state flows for automatic recomposition on updates
|
||||||
|
val currentPosition by gameState.currentPosition.collectAsState()
|
||||||
|
val moveHistory by gameState.moveHistory.collectAsState()
|
||||||
|
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
// Calculate board size based on available space
|
||||||
|
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
|
||||||
|
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
|
||||||
|
val availableHeight = maxHeight - 250.dp // Account for other UI elements
|
||||||
|
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// Game info - use currentPosition.activeColor for turn display
|
||||||
|
GameInfoHeader(
|
||||||
|
gameId = gameState.gameId,
|
||||||
|
opponentName = opponentName,
|
||||||
|
playerColor = gameState.playerColor,
|
||||||
|
currentTurn = currentPosition.activeColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Interactive chess board - flip when playing black
|
||||||
|
// Auto-sized to fit available space
|
||||||
|
InteractiveChessBoard(
|
||||||
|
engine = gameState.engine,
|
||||||
|
boardSize = boardSize,
|
||||||
|
flipped = gameState.playerColor == Color.BLACK,
|
||||||
|
onMoveMade = onMoveMade,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move history (scrollable) - observed from state flow
|
||||||
|
MoveHistoryDisplay(
|
||||||
|
moves = moveHistory,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Game controls
|
||||||
|
GameControls(
|
||||||
|
onResign = onResign,
|
||||||
|
onOfferDraw = onOfferDraw,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy version that takes engine directly (for backwards compatibility)
|
||||||
|
*
|
||||||
|
* @param modifier Modifier for the root layout
|
||||||
* @param engine Chess engine instance
|
* @param engine Chess engine instance
|
||||||
* @param playerColor Color the player is playing as
|
* @param playerColor Color the player is playing as
|
||||||
* @param gameId Unique game identifier
|
* @param gameId Unique game identifier
|
||||||
@@ -166,6 +251,7 @@ fun NewChessGameDialog(
|
|||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LiveChessGameScreen(
|
fun LiveChessGameScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
engine: ChessEngine,
|
engine: ChessEngine,
|
||||||
playerColor: Color,
|
playerColor: Color,
|
||||||
gameId: String,
|
gameId: String,
|
||||||
@@ -174,13 +260,22 @@ fun LiveChessGameScreen(
|
|||||||
onResign: () -> Unit,
|
onResign: () -> Unit,
|
||||||
onOfferDraw: () -> Unit,
|
onOfferDraw: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
BoxWithConstraints(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
// Calculate board size based on available space
|
||||||
|
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
|
||||||
|
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
|
||||||
|
val availableHeight = maxHeight - 250.dp // Account for other UI elements
|
||||||
|
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
// Game info
|
// Game info
|
||||||
GameInfoHeader(
|
GameInfoHeader(
|
||||||
@@ -190,14 +285,16 @@ fun LiveChessGameScreen(
|
|||||||
currentTurn = engine.getSideToMove(),
|
currentTurn = engine.getSideToMove(),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Interactive chess board
|
// Interactive chess board - flip when playing black
|
||||||
|
// Auto-sized to fit available space
|
||||||
InteractiveChessBoard(
|
InteractiveChessBoard(
|
||||||
engine = engine,
|
engine = engine,
|
||||||
boardSize = 400.dp,
|
boardSize = boardSize,
|
||||||
|
flipped = playerColor == Color.BLACK,
|
||||||
onMoveMade = onMoveMade,
|
onMoveMade = onMoveMade,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Move history
|
// Move history (scrollable)
|
||||||
MoveHistoryDisplay(
|
MoveHistoryDisplay(
|
||||||
moves = engine.getMoveHistory(),
|
moves = engine.getMoveHistory(),
|
||||||
)
|
)
|
||||||
@@ -209,6 +306,7 @@ fun LiveChessGameScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game information header showing game ID, opponent, and current turn
|
* Game information header showing game ID, opponent, and current turn
|
||||||
@@ -263,25 +361,91 @@ private fun GameInfoHeader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display move history in SAN notation
|
* Display move history in SAN notation with move numbers
|
||||||
|
* Shows in a horizontally scrollable container
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun MoveHistoryDisplay(moves: List<String>) {
|
private fun MoveHistoryDisplay(moves: List<String>) {
|
||||||
if (moves.isNotEmpty()) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Moves:",
|
text = "Move History",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 4.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
RoundedCornerShape(8.dp),
|
||||||
|
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
if (moves.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = moves.joinToString(" "),
|
text = "No moves yet",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.horizontalScroll(scrollState)
|
||||||
|
.align(Alignment.CenterStart),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Group moves into pairs (white, black)
|
||||||
|
moves.chunked(2).forEachIndexed { index, movePair ->
|
||||||
|
// Move number
|
||||||
|
Text(
|
||||||
|
text = "${index + 1}.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
// White's move
|
||||||
|
Text(
|
||||||
|
text = movePair[0],
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surface,
|
||||||
|
RoundedCornerShape(4.dp),
|
||||||
|
).padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Black's move (if exists)
|
||||||
|
if (movePair.size > 1) {
|
||||||
|
Text(
|
||||||
|
text = movePair[1],
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(4.dp),
|
||||||
|
).padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.amethyst.commons.data
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple data class for cached user display info
|
||||||
|
*/
|
||||||
|
data class UserDisplayInfo(
|
||||||
|
val pubkey: String,
|
||||||
|
val displayName: String?,
|
||||||
|
val pictureUrl: String?,
|
||||||
|
val nip05: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight user metadata cache for UI display.
|
||||||
|
* Can be used by any feature (chess, chat, etc.) that needs user info.
|
||||||
|
*
|
||||||
|
* Thread-safe via StateFlow updates.
|
||||||
|
*/
|
||||||
|
class UserMetadataCache {
|
||||||
|
private val _metadata = MutableStateFlow<Map<String, UserMetadata>>(emptyMap())
|
||||||
|
val metadata: StateFlow<Map<String, UserMetadata>> = _metadata.asStateFlow()
|
||||||
|
|
||||||
|
private val _pubkeysNeeded = MutableStateFlow<Set<String>>(emptySet())
|
||||||
|
val pubkeysNeeded: StateFlow<Set<String>> = _pubkeysNeeded.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or update metadata for a pubkey
|
||||||
|
*/
|
||||||
|
fun put(
|
||||||
|
pubkey: String,
|
||||||
|
userMetadata: UserMetadata,
|
||||||
|
) {
|
||||||
|
_metadata.value = _metadata.value + (pubkey to userMetadata)
|
||||||
|
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkey
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata for a pubkey (may be null if not cached)
|
||||||
|
*/
|
||||||
|
fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if metadata is cached for a pubkey
|
||||||
|
*/
|
||||||
|
fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request metadata for a pubkey (adds to needed set if not cached)
|
||||||
|
*/
|
||||||
|
fun request(pubkey: String) {
|
||||||
|
if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) {
|
||||||
|
_pubkeysNeeded.value = _pubkeysNeeded.value + pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request metadata for multiple pubkeys
|
||||||
|
*/
|
||||||
|
fun requestAll(pubkeys: Collection<String>) {
|
||||||
|
val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value }
|
||||||
|
if (newNeeded.isNotEmpty()) {
|
||||||
|
_pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for a pubkey, falling back to truncated pubkey
|
||||||
|
*/
|
||||||
|
fun getDisplayName(pubkey: String): String {
|
||||||
|
val meta = get(pubkey)
|
||||||
|
return meta?.bestName() ?: formatPubkeyShort(pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile picture URL for a pubkey
|
||||||
|
*/
|
||||||
|
fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display info for a pubkey (for UI binding)
|
||||||
|
*/
|
||||||
|
fun getDisplayInfo(pubkey: String): UserDisplayInfo {
|
||||||
|
val meta = get(pubkey)
|
||||||
|
return UserDisplayInfo(
|
||||||
|
pubkey = pubkey,
|
||||||
|
displayName = meta?.bestName(),
|
||||||
|
pictureUrl = meta?.profilePicture(),
|
||||||
|
nip05 = meta?.nip05,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear metadata that was requested but hasn't been fetched
|
||||||
|
* (call when pubkeys are no longer needed)
|
||||||
|
*/
|
||||||
|
fun clearNeeded(pubkeys: Collection<String>) {
|
||||||
|
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached metadata
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
_metadata.value = emptyMap()
|
||||||
|
_pubkeysNeeded.value = emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Format pubkey for display (npub-style truncation)
|
||||||
|
*/
|
||||||
|
fun formatPubkeyShort(pubkey: String): String =
|
||||||
|
if (pubkey.length > 12) {
|
||||||
|
"${pubkey.take(8)}...${pubkey.takeLast(4)}"
|
||||||
|
} else {
|
||||||
|
pubkey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Article
|
import androidx.compose.material.icons.automirrored.filled.Article
|
||||||
import androidx.compose.material.icons.filled.Email
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material.icons.filled.Extension
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
import androidx.compose.material.icons.filled.Notifications
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
@@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
|
|||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
|
||||||
@@ -120,6 +122,8 @@ sealed class DesktopScreen {
|
|||||||
|
|
||||||
object Notifications : DesktopScreen()
|
object Notifications : DesktopScreen()
|
||||||
|
|
||||||
|
object Chess : DesktopScreen()
|
||||||
|
|
||||||
object MyProfile : DesktopScreen()
|
object MyProfile : DesktopScreen()
|
||||||
|
|
||||||
data class UserProfile(
|
data class UserProfile(
|
||||||
@@ -415,6 +419,13 @@ fun MainContent(
|
|||||||
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
onClick = { onScreenChange(DesktopScreen.Notifications) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
NavigationRailItem(
|
||||||
|
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
|
||||||
|
label = { Text("Chess") },
|
||||||
|
selected = currentScreen == DesktopScreen.Chess,
|
||||||
|
onClick = { onScreenChange(DesktopScreen.Chess) },
|
||||||
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||||
label = { Text("Profile") },
|
label = { Text("Profile") },
|
||||||
@@ -514,6 +525,14 @@ fun MainContent(
|
|||||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DesktopScreen.Chess -> {
|
||||||
|
ChessScreen(
|
||||||
|
relayManager = relayManager,
|
||||||
|
account = account,
|
||||||
|
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
DesktopScreen.MyProfile -> {
|
DesktopScreen.MyProfile -> {
|
||||||
UserProfileScreen(
|
UserProfileScreen(
|
||||||
pubKeyHex = account.pubKeyHex,
|
pubKeyHex = account.pubKeyHex,
|
||||||
|
|||||||
+903
@@ -0,0 +1,903 @@
|
|||||||
|
/**
|
||||||
|
* 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 androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard
|
||||||
|
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||||
|
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription
|
||||||
|
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop chess screen with challenge list and game view
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ChessScreen(
|
||||||
|
relayManager: DesktopRelayConnectionManager,
|
||||||
|
account: AccountState.LoggedIn,
|
||||||
|
onBack: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val viewModel =
|
||||||
|
remember(account.pubKeyHex) {
|
||||||
|
DesktopChessViewModel(account, relayManager, scope)
|
||||||
|
}
|
||||||
|
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||||
|
val refreshKey by viewModel.refreshKey.collectAsState()
|
||||||
|
val isLoading by viewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
// Subscribe to chess events from relays (re-subscribes when refreshKey changes)
|
||||||
|
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) {
|
||||||
|
val configuredRelays = relayStatuses.keys
|
||||||
|
if (configuredRelays.isNotEmpty()) {
|
||||||
|
createChessSubscription(
|
||||||
|
relays = configuredRelays,
|
||||||
|
userPubkey = account.pubKeyHex,
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
viewModel.handleIncomingEvent(event)
|
||||||
|
},
|
||||||
|
onEose = { _, _ ->
|
||||||
|
viewModel.onLoadComplete()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to user metadata for pubkeys that need it
|
||||||
|
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
|
||||||
|
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
|
||||||
|
val configuredRelays = relayStatuses.keys
|
||||||
|
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
|
||||||
|
createMetadataListSubscription(
|
||||||
|
relays = configuredRelays,
|
||||||
|
pubKeys = pubkeysNeeded.toList(),
|
||||||
|
onEvent = { event, _, _, _ ->
|
||||||
|
viewModel.handleIncomingEvent(event)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val activeGames by viewModel.activeGames.collectAsState()
|
||||||
|
val challenges by viewModel.challenges.collectAsState()
|
||||||
|
val completedGames by viewModel.completedGames.collectAsState()
|
||||||
|
// Observe metadata changes to trigger recomposition
|
||||||
|
val userMetadata by viewModel.userMetadataCache.metadata.collectAsState()
|
||||||
|
val selectedGameId by viewModel.selectedGameId.collectAsState()
|
||||||
|
val error by viewModel.error.collectAsState()
|
||||||
|
var showNewGameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (selectedGameId != null) {
|
||||||
|
IconButton(onClick = { viewModel.selectGame(null) }) {
|
||||||
|
Icon(Icons.Default.ArrowBack, "Back to list")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (selectedGameId != null) "Live Game" else "Chess",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedGameId == null) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
// Refresh button
|
||||||
|
IconButton(
|
||||||
|
onClick = { viewModel.refresh() },
|
||||||
|
enabled = !isLoading,
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.Default.Refresh, "Refresh")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Game button
|
||||||
|
if (!account.isReadOnly) {
|
||||||
|
Button(onClick = { showNewGameDialog = true }) {
|
||||||
|
Icon(Icons.Default.Add, "New Game")
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("New Game")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error display
|
||||||
|
error?.let { errorMsg ->
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
|
||||||
|
OutlinedButton(onClick = { viewModel.clearError() }) {
|
||||||
|
Text("Dismiss")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
if (selectedGameId != null) {
|
||||||
|
val gameState = viewModel.getGameState(selectedGameId!!)
|
||||||
|
if (gameState != null) {
|
||||||
|
DesktopChessGameLayout(
|
||||||
|
gameState = gameState,
|
||||||
|
opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey),
|
||||||
|
opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey),
|
||||||
|
onMoveMade = { from, to, _ ->
|
||||||
|
viewModel.publishMove(gameState.gameId, from, to)
|
||||||
|
},
|
||||||
|
onResign = { viewModel.resign(gameState.gameId) },
|
||||||
|
onOfferDraw = { viewModel.offerDraw(gameState.gameId) },
|
||||||
|
onAcceptDraw = { viewModel.acceptDraw(gameState.gameId) },
|
||||||
|
onDeclineDraw = { viewModel.declineDraw(gameState.gameId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ChessLobby(
|
||||||
|
challenges = challenges,
|
||||||
|
activeGames = activeGames,
|
||||||
|
completedGames = completedGames,
|
||||||
|
userPubkey = account.pubKeyHex,
|
||||||
|
isLoading = isLoading,
|
||||||
|
metadataCache = viewModel.userMetadataCache,
|
||||||
|
onAcceptChallenge = { viewModel.acceptChallenge(it) },
|
||||||
|
onSelectGame = { viewModel.selectGame(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New game dialog
|
||||||
|
if (showNewGameDialog) {
|
||||||
|
NewChessGameDialog(
|
||||||
|
onDismiss = { showNewGameDialog = false },
|
||||||
|
onCreateGame = { opponentPubkey, color ->
|
||||||
|
viewModel.createChallenge(opponentPubkey, color)
|
||||||
|
showNewGameDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chess lobby showing challenges and active games
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ChessLobby(
|
||||||
|
challenges: List<LiveChessGameChallengeEvent>,
|
||||||
|
activeGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
|
||||||
|
completedGames: List<CompletedGame>,
|
||||||
|
isLoading: Boolean,
|
||||||
|
userPubkey: String,
|
||||||
|
metadataCache: UserMetadataCache,
|
||||||
|
onAcceptChallenge: (LiveChessGameChallengeEvent) -> Unit,
|
||||||
|
onSelectGame: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
// Active games section
|
||||||
|
if (activeGames.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"Active Games",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
|
||||||
|
ActiveGameCard(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = state.opponentPubkey,
|
||||||
|
opponentName = metadataCache.getDisplayName(state.opponentPubkey),
|
||||||
|
opponentPicture = metadataCache.getPictureUrl(state.opponentPubkey),
|
||||||
|
isYourTurn = state.isPlayerTurn(),
|
||||||
|
onClick = { onSelectGame(gameId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming challenges
|
||||||
|
val incomingChallenges = challenges.filter { it.opponentPubkey() == userPubkey }
|
||||||
|
if (incomingChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Incoming Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(incomingChallenges, key = { it.id }) { challenge ->
|
||||||
|
ChallengeCard(
|
||||||
|
challenge = challenge,
|
||||||
|
challengerName = metadataCache.getDisplayName(challenge.pubKey),
|
||||||
|
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
|
||||||
|
isIncoming = true,
|
||||||
|
onAccept = { onAcceptChallenge(challenge) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// User's outgoing challenges
|
||||||
|
val outgoingChallenges = challenges.filter { it.pubKey == userPubkey }
|
||||||
|
if (outgoingChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Your Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(outgoingChallenges, key = { it.id }) { challenge ->
|
||||||
|
val opponentPubkey = challenge.opponentPubkey()
|
||||||
|
OutgoingChallengeCard(
|
||||||
|
challenge = challenge,
|
||||||
|
opponentName = opponentPubkey?.let { metadataCache.getDisplayName(it) },
|
||||||
|
opponentPicture = opponentPubkey?.let { metadataCache.getPictureUrl(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open challenges from others
|
||||||
|
val openChallenges = challenges.filter { it.opponentPubkey() == null && it.pubKey != userPubkey }
|
||||||
|
if (openChallenges.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Open Challenges",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(openChallenges, key = { it.id }) { challenge ->
|
||||||
|
ChallengeCard(
|
||||||
|
challenge = challenge,
|
||||||
|
challengerName = metadataCache.getDisplayName(challenge.pubKey),
|
||||||
|
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
|
||||||
|
isIncoming = false,
|
||||||
|
onAccept = { onAcceptChallenge(challenge) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completed games history
|
||||||
|
if (completedGames.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Recent Games",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(completedGames.take(10), key = { it.gameId }) { game ->
|
||||||
|
CompletedGameCard(
|
||||||
|
game = game,
|
||||||
|
userPubkey = userPubkey,
|
||||||
|
opponentName = metadataCache.getDisplayName(game.opponentPubkey),
|
||||||
|
opponentPicture = metadataCache.getPictureUrl(game.opponentPubkey),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state or loading
|
||||||
|
if (activeGames.isEmpty() && challenges.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Loading games from relays...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"No games or challenges",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Create a new game or refresh to load from relays",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActiveGameCard(
|
||||||
|
gameId: String,
|
||||||
|
opponentPubkey: String,
|
||||||
|
opponentName: String,
|
||||||
|
opponentPicture: String?,
|
||||||
|
isYourTurn: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||||
|
border =
|
||||||
|
if (isYourTurn) {
|
||||||
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = opponentPubkey,
|
||||||
|
pictureUrl = opponentPicture,
|
||||||
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"vs $opponentName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Game: ${gameId.take(12)}...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
if (isYourTurn) "Your turn" else "Waiting...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OutgoingChallengeCard(
|
||||||
|
challenge: LiveChessGameChallengeEvent,
|
||||||
|
opponentName: String?,
|
||||||
|
opponentPicture: String?,
|
||||||
|
) {
|
||||||
|
val opponentPubkey = challenge.opponentPubkey()
|
||||||
|
val playerColor = challenge.playerColor()
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (opponentPubkey != null) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = opponentPubkey,
|
||||||
|
pictureUrl = opponentPicture,
|
||||||
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
if (opponentName != null) {
|
||||||
|
"Challenge to $opponentName"
|
||||||
|
} else {
|
||||||
|
"Open challenge (awaiting opponent)"
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Waiting...",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChallengeCard(
|
||||||
|
challenge: LiveChessGameChallengeEvent,
|
||||||
|
challengerName: String,
|
||||||
|
challengerPicture: String?,
|
||||||
|
isIncoming: Boolean,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
) {
|
||||||
|
val borderColor =
|
||||||
|
if (isIncoming) {
|
||||||
|
Color(0xFFFF9800) // Orange for incoming
|
||||||
|
} else {
|
||||||
|
Color(0xFF4CAF50) // Green for open
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
border = BorderStroke(2.dp, borderColor),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = challenge.pubKey,
|
||||||
|
pictureUrl = challengerPicture,
|
||||||
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
val playerColor = challenge.playerColor()
|
||||||
|
Text(
|
||||||
|
"Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(onClick = onAccept) {
|
||||||
|
Text("Accept")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompletedGameCard(
|
||||||
|
game: CompletedGame,
|
||||||
|
userPubkey: String,
|
||||||
|
opponentName: String,
|
||||||
|
opponentPicture: String?,
|
||||||
|
) {
|
||||||
|
val resultColor =
|
||||||
|
when (game.didWin(userPubkey)) {
|
||||||
|
true -> Color(0xFF4CAF50) // Green for win
|
||||||
|
false -> Color(0xFFF44336) // Red for loss
|
||||||
|
null -> Color(0xFF9E9E9E) // Gray for draw
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = game.opponentPubkey,
|
||||||
|
pictureUrl = opponentPicture,
|
||||||
|
size = 40.dp,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"vs $opponentName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${game.moveCount} moves - ${game.termination}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
game.resultText(userPubkey),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = resultColor,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop-optimized chess game layout with board on left, info/controls on right
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DesktopChessGameLayout(
|
||||||
|
gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState,
|
||||||
|
opponentName: String,
|
||||||
|
opponentPicture: String?,
|
||||||
|
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||||
|
onResign: () -> Unit,
|
||||||
|
onOfferDraw: () -> Unit,
|
||||||
|
onAcceptDraw: () -> Unit,
|
||||||
|
onDeclineDraw: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Collect state flows to trigger recomposition on changes
|
||||||
|
val currentPosition by gameState.currentPosition.collectAsState()
|
||||||
|
val moveHistory by gameState.moveHistory.collectAsState()
|
||||||
|
val pendingDrawOffer by gameState.pendingDrawOffer.collectAsState()
|
||||||
|
|
||||||
|
val engine = gameState.engine
|
||||||
|
val playerColor = gameState.playerColor
|
||||||
|
val gameId = gameState.gameId
|
||||||
|
val opponentPubkey = gameState.opponentPubkey
|
||||||
|
val hasOpponentDrawOffer = pendingDrawOffer == opponentPubkey
|
||||||
|
val hasOurDrawOffer = pendingDrawOffer == gameState.playerPubkey
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
// Left side: Chess board
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
InteractiveChessBoard(
|
||||||
|
engine = engine,
|
||||||
|
boardSize = 520.dp,
|
||||||
|
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
|
||||||
|
playerColor = playerColor,
|
||||||
|
onMoveMade = onMoveMade,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right side: Game info, moves, controls
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.width(300.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
// Game info card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Game Info",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
UserAvatar(
|
||||||
|
userHex = opponentPubkey,
|
||||||
|
pictureUrl = opponentPicture,
|
||||||
|
size = 48.dp,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
"vs $opponentName",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use currentPosition to derive turn (triggers recomposition on move)
|
||||||
|
val currentTurn = currentPosition.activeColor
|
||||||
|
val isYourTurn = currentTurn == playerColor
|
||||||
|
|
||||||
|
Text(
|
||||||
|
if (isYourTurn) "Your turn" else "Opponent's turn",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color =
|
||||||
|
if (isYourTurn) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move history card
|
||||||
|
if (moveHistory.isNotEmpty()) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Move History",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Format moves as numbered pairs
|
||||||
|
val moveText =
|
||||||
|
moveHistory
|
||||||
|
.chunked(2)
|
||||||
|
.mapIndexed { index, pair ->
|
||||||
|
"${index + 1}. ${pair.joinToString(" ")}"
|
||||||
|
}.joinToString("\n")
|
||||||
|
|
||||||
|
Text(
|
||||||
|
moveText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw offer notification (if opponent offered)
|
||||||
|
if (hasOpponentDrawOffer) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = Color(0xFFFF9800).copy(alpha = 0.15f),
|
||||||
|
),
|
||||||
|
border = BorderStroke(2.dp, Color(0xFFFF9800)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Draw Offered",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color(0xFFFF9800),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"$opponentName has offered a draw",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onAcceptDraw,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Accept")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onDeclineDraw,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Decline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our draw offer pending notification
|
||||||
|
if (hasOurDrawOffer) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors =
|
||||||
|
CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Draw offer sent",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Waiting for $opponentName to respond...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game controls card
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Actions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onOfferDraw,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
enabled = !hasOurDrawOffer && !hasOpponentDrawOffer,
|
||||||
|
) {
|
||||||
|
Text(if (hasOurDrawOffer) "Offer Sent" else "Offer Draw")
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onResign,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
Text("Resign")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game ID (small footer)
|
||||||
|
Text(
|
||||||
|
"Game: ${gameId.take(16)}...",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+813
@@ -0,0 +1,813 @@
|
|||||||
|
/**
|
||||||
|
* 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.data.UserMetadataCache
|
||||||
|
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.GameResult
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.GameTermination
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||||
|
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop ViewModel for managing chess game state and event publishing.
|
||||||
|
* Adapts the Android ChessViewModel patterns for Desktop's relay architecture.
|
||||||
|
*/
|
||||||
|
class DesktopChessViewModel(
|
||||||
|
private val account: AccountState.LoggedIn,
|
||||||
|
private val relayManager: DesktopRelayConnectionManager,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
|
||||||
|
const val MAX_RETRIES = 3
|
||||||
|
const val RETRY_DELAY_MS = 1000L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active games being played
|
||||||
|
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
|
||||||
|
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
|
||||||
|
|
||||||
|
// Pending challenges
|
||||||
|
private val _challenges = MutableStateFlow<List<LiveChessGameChallengeEvent>>(emptyList())
|
||||||
|
val challenges: StateFlow<List<LiveChessGameChallengeEvent>> = _challenges.asStateFlow()
|
||||||
|
|
||||||
|
// Badge count
|
||||||
|
private val _badgeCount = MutableStateFlow(0)
|
||||||
|
val badgeCount: StateFlow<Int> = _badgeCount.asStateFlow()
|
||||||
|
|
||||||
|
// Currently selected game
|
||||||
|
private val _selectedGameId = MutableStateFlow<String?>(null)
|
||||||
|
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
private val _error = MutableStateFlow<String?>(null)
|
||||||
|
val error: StateFlow<String?> = _error.asStateFlow()
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
// Refresh key - incrementing this triggers re-subscription
|
||||||
|
private val _refreshKey = MutableStateFlow(0)
|
||||||
|
val refreshKey: StateFlow<Int> = _refreshKey.asStateFlow()
|
||||||
|
|
||||||
|
// Completed games history
|
||||||
|
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
|
||||||
|
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
|
||||||
|
|
||||||
|
// Shared user metadata cache
|
||||||
|
val userMetadataCache = UserMetadataCache()
|
||||||
|
|
||||||
|
// Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber))
|
||||||
|
private val pendingMoves = mutableMapOf<String, MutableList<Triple<String, String, Int?>>>()
|
||||||
|
|
||||||
|
// Challenge events indexed by gameId for lookup during accept processing
|
||||||
|
private val challengesByGameId = mutableMapOf<String, LiveChessGameChallengeEvent>()
|
||||||
|
|
||||||
|
// Pending accept events waiting for challenge to arrive (gameId -> accept event)
|
||||||
|
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
|
||||||
|
|
||||||
|
// Track event IDs we've already processed to avoid duplicates
|
||||||
|
private val processedEventIds = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// Track games currently being created to prevent race conditions
|
||||||
|
private val gamesBeingCreated = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process incoming chess event from relay subscription
|
||||||
|
*/
|
||||||
|
fun handleIncomingEvent(event: Event) {
|
||||||
|
// Skip already processed events
|
||||||
|
if (processedEventIds.contains(event.id)) return
|
||||||
|
processedEventIds.add(event.id)
|
||||||
|
|
||||||
|
// Check by kind since events may not be cast to specific types
|
||||||
|
when (event.kind) {
|
||||||
|
MetadataEvent.KIND -> {
|
||||||
|
handleMetadataEvent(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LiveChessGameChallengeEvent.KIND -> {
|
||||||
|
if (event is LiveChessGameChallengeEvent) {
|
||||||
|
handleNewChallenge(event)
|
||||||
|
} else {
|
||||||
|
// Manually parse if not already the right type
|
||||||
|
val challenge =
|
||||||
|
LiveChessGameChallengeEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
handleNewChallenge(challenge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LiveChessGameAcceptEvent.KIND -> {
|
||||||
|
if (event is LiveChessGameAcceptEvent) {
|
||||||
|
handleGameAccepted(event)
|
||||||
|
} else {
|
||||||
|
val accept =
|
||||||
|
LiveChessGameAcceptEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
handleGameAccepted(accept)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LiveChessMoveEvent.KIND -> {
|
||||||
|
if (event is LiveChessMoveEvent) {
|
||||||
|
handleIncomingMove(event)
|
||||||
|
} else {
|
||||||
|
val move =
|
||||||
|
LiveChessMoveEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
handleIncomingMove(move)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LiveChessGameEndEvent.KIND -> {
|
||||||
|
if (event is LiveChessGameEndEvent) {
|
||||||
|
handleGameEnded(event)
|
||||||
|
} else {
|
||||||
|
val end =
|
||||||
|
LiveChessGameEndEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
handleGameEnded(end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LiveChessDrawOfferEvent.KIND -> {
|
||||||
|
if (event is LiveChessDrawOfferEvent) {
|
||||||
|
handleDrawOffer(event)
|
||||||
|
} else {
|
||||||
|
val drawOffer =
|
||||||
|
LiveChessDrawOfferEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
handleDrawOffer(drawOffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMetadataEvent(event: Event) {
|
||||||
|
try {
|
||||||
|
val metadataEvent =
|
||||||
|
MetadataEvent(
|
||||||
|
event.id,
|
||||||
|
event.pubKey,
|
||||||
|
event.createdAt,
|
||||||
|
event.tags,
|
||||||
|
event.content,
|
||||||
|
event.sig,
|
||||||
|
)
|
||||||
|
val metadata = metadataEvent.contactMetaData()
|
||||||
|
if (metadata != null) {
|
||||||
|
userMetadataCache.put(event.pubKey, metadata)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingMove(event: LiveChessMoveEvent) {
|
||||||
|
// Don't process our own moves
|
||||||
|
if (event.pubKey == account.pubKeyHex) return
|
||||||
|
|
||||||
|
val gameId = event.gameId() ?: return
|
||||||
|
val san = event.san() ?: return
|
||||||
|
val fen = event.fen() ?: return
|
||||||
|
val moveNumber = event.moveNumber()
|
||||||
|
|
||||||
|
val gameState = _activeGames.value[gameId]
|
||||||
|
|
||||||
|
if (gameState == null) {
|
||||||
|
// Game doesn't exist yet - buffer the move for later
|
||||||
|
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
|
||||||
|
moveList.add(Triple(san, fen, moveNumber))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.pubKey != gameState.opponentPubkey) return
|
||||||
|
|
||||||
|
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
|
||||||
|
val gameId = event.gameId() ?: return
|
||||||
|
|
||||||
|
// Skip if game already exists
|
||||||
|
if (_activeGames.value.containsKey(gameId)) return
|
||||||
|
|
||||||
|
val challengerPubkey = event.challengerPubkey() ?: return
|
||||||
|
val accepterPubkey = event.pubKey
|
||||||
|
|
||||||
|
// Check if we have the challenge event for color info
|
||||||
|
val challengeEvent = challengesByGameId[gameId]
|
||||||
|
if (challengeEvent == null) {
|
||||||
|
// Challenge hasn't arrived yet - buffer this accept for later
|
||||||
|
if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) {
|
||||||
|
pendingAccepts[gameId] = event
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle if user is the challenger or accepter
|
||||||
|
if (challengerPubkey == account.pubKeyHex) {
|
||||||
|
startGameFromAcceptance(event, isChallenger = true)
|
||||||
|
} else if (accepterPubkey == account.pubKeyHex) {
|
||||||
|
startGameFromAcceptance(event, isChallenger = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGameEnded(event: LiveChessGameEndEvent) {
|
||||||
|
val gameId = event.gameId() ?: return
|
||||||
|
|
||||||
|
// Store game in history before removing
|
||||||
|
val gameState = _activeGames.value[gameId]
|
||||||
|
if (gameState != null) {
|
||||||
|
val completedGame =
|
||||||
|
CompletedGame(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = gameState.opponentPubkey,
|
||||||
|
playerColor = gameState.playerColor,
|
||||||
|
result = event.result() ?: "?",
|
||||||
|
termination = event.termination() ?: "unknown",
|
||||||
|
winnerPubkey = event.winnerPubkey(),
|
||||||
|
completedAt = event.createdAt,
|
||||||
|
moveCount = gameState.moveHistory.value.size,
|
||||||
|
)
|
||||||
|
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from active games
|
||||||
|
if (_activeGames.value.containsKey(gameId)) {
|
||||||
|
_activeGames.value = _activeGames.value - gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up challenge references
|
||||||
|
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
|
||||||
|
challengesByGameId.remove(gameId)
|
||||||
|
pendingAccepts.remove(gameId)
|
||||||
|
pendingMoves.remove(gameId)
|
||||||
|
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
|
||||||
|
// Only process draw offers from opponent
|
||||||
|
if (event.pubKey == account.pubKeyHex) return
|
||||||
|
|
||||||
|
val gameId = event.gameId() ?: return
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
|
||||||
|
// Verify this is from our opponent in this game
|
||||||
|
if (event.pubKey != gameState.opponentPubkey) return
|
||||||
|
|
||||||
|
// Pass to game state to track
|
||||||
|
gameState.receiveDrawOffer(event.pubKey)
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleNewChallenge(event: LiveChessGameChallengeEvent) {
|
||||||
|
val gameId = event.gameId() ?: return
|
||||||
|
val opponentPubkey = event.opponentPubkey()
|
||||||
|
val isUserChallenge = event.pubKey == account.pubKeyHex
|
||||||
|
val isOpenChallenge = opponentPubkey == null
|
||||||
|
val isDirectedAtUser = opponentPubkey == account.pubKeyHex
|
||||||
|
|
||||||
|
// Request metadata for challenger
|
||||||
|
userMetadataCache.request(event.pubKey)
|
||||||
|
opponentPubkey?.let { userMetadataCache.request(it) }
|
||||||
|
|
||||||
|
// Always store in lookup map for accept event processing (even if game exists)
|
||||||
|
challengesByGameId[gameId] = event
|
||||||
|
|
||||||
|
// Skip if game already exists (challenge was already accepted)
|
||||||
|
if (_activeGames.value.containsKey(gameId)) return
|
||||||
|
|
||||||
|
// Check if there's a pending accept waiting for this challenge
|
||||||
|
val pendingAccept = pendingAccepts.remove(gameId)
|
||||||
|
if (pendingAccept != null) {
|
||||||
|
// Now we can properly process the accept
|
||||||
|
val isChallenger = event.pubKey == account.pubKeyHex
|
||||||
|
val isAccepter = pendingAccept.pubKey == account.pubKeyHex
|
||||||
|
if (isChallenger || isAccepter) {
|
||||||
|
startGameFromAcceptance(pendingAccept, isChallenger = isChallenger)
|
||||||
|
return // Don't add to challenges list since game is starting
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show challenges that are:
|
||||||
|
// - Created by user (their outgoing challenges)
|
||||||
|
// - Open challenges (anyone can accept)
|
||||||
|
// - Directed at user (incoming challenges)
|
||||||
|
if (isUserChallenge || isOpenChallenge || isDirectedAtUser) {
|
||||||
|
val currentChallenges = _challenges.value.toMutableList()
|
||||||
|
// Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts
|
||||||
|
val isDuplicateEvent = currentChallenges.any { it.id == event.id }
|
||||||
|
val isDuplicateGame = currentChallenges.any { it.gameId() == gameId }
|
||||||
|
if (!isDuplicateEvent && !isDuplicateGame) {
|
||||||
|
currentChallenges.add(event)
|
||||||
|
_challenges.value = currentChallenges
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new chess challenge
|
||||||
|
*/
|
||||||
|
fun createChallenge(
|
||||||
|
opponentPubkey: String? = null,
|
||||||
|
playerColor: Color = Color.WHITE,
|
||||||
|
timeControl: String? = null,
|
||||||
|
) {
|
||||||
|
val gameId = generateGameId()
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessGameChallengeEvent.build(
|
||||||
|
gameId = gameId,
|
||||||
|
playerColor = playerColor,
|
||||||
|
opponentPubkey = opponentPubkey,
|
||||||
|
timeControl = timeControl,
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
_error.value = null
|
||||||
|
} else {
|
||||||
|
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a chess challenge
|
||||||
|
*/
|
||||||
|
fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) {
|
||||||
|
val gameId = challengeEvent.gameId() ?: return
|
||||||
|
val challengerPubkey = challengeEvent.pubKey
|
||||||
|
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
|
||||||
|
val playerColor = challengerColor.opposite()
|
||||||
|
|
||||||
|
// Store in lookup map for consistent access
|
||||||
|
challengesByGameId[gameId] = challengeEvent
|
||||||
|
|
||||||
|
// Mark as being created to prevent duplicate from relay echo
|
||||||
|
if (!gamesBeingCreated.add(gameId)) return // Already being created
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessGameAcceptEvent.build(
|
||||||
|
gameId = gameId,
|
||||||
|
challengeEventId = challengeEvent.id,
|
||||||
|
challengerPubkey = challengerPubkey,
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Check if game was already created by relay echo while we were broadcasting
|
||||||
|
if (_activeGames.value.containsKey(gameId)) {
|
||||||
|
gamesBeingCreated.remove(gameId)
|
||||||
|
_selectedGameId.value = gameId
|
||||||
|
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
|
||||||
|
_error.value = null
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val engine = ChessEngine()
|
||||||
|
engine.reset()
|
||||||
|
|
||||||
|
val gameState =
|
||||||
|
LiveChessGameState(
|
||||||
|
gameId = gameId,
|
||||||
|
playerPubkey = account.pubKeyHex,
|
||||||
|
opponentPubkey = challengerPubkey,
|
||||||
|
playerColor = playerColor,
|
||||||
|
engine = engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||||
|
gamesBeingCreated.remove(gameId)
|
||||||
|
_selectedGameId.value = gameId
|
||||||
|
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
|
||||||
|
_error.value = null
|
||||||
|
} else {
|
||||||
|
gamesBeingCreated.remove(gameId)
|
||||||
|
_error.value = "Failed to accept challenge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startGameFromAcceptance(
|
||||||
|
acceptEvent: LiveChessGameAcceptEvent,
|
||||||
|
isChallenger: Boolean,
|
||||||
|
) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val gameId = acceptEvent.gameId() ?: return@launch
|
||||||
|
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
|
||||||
|
val accepterPubkey = acceptEvent.pubKey
|
||||||
|
|
||||||
|
// Skip if game already exists or is being created
|
||||||
|
if (_activeGames.value.containsKey(gameId)) return@launch
|
||||||
|
if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present
|
||||||
|
|
||||||
|
val opponentPubkey: String
|
||||||
|
val playerColor: Color
|
||||||
|
|
||||||
|
// Use challengesByGameId for reliable lookup (not affected by UI filtering)
|
||||||
|
val challengeEvent = challengesByGameId[gameId]
|
||||||
|
|
||||||
|
if (isChallenger) {
|
||||||
|
// User is challenger, opponent is accepter
|
||||||
|
opponentPubkey = accepterPubkey
|
||||||
|
playerColor = challengeEvent?.playerColor() ?: Color.WHITE
|
||||||
|
} else {
|
||||||
|
// User is accepter, opponent is challenger
|
||||||
|
opponentPubkey = challengerPubkey
|
||||||
|
// Accepter gets opposite color of challenger
|
||||||
|
val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE
|
||||||
|
playerColor = challengerColor.opposite()
|
||||||
|
}
|
||||||
|
|
||||||
|
val engine = ChessEngine()
|
||||||
|
engine.reset()
|
||||||
|
|
||||||
|
val gameState =
|
||||||
|
LiveChessGameState(
|
||||||
|
gameId = gameId,
|
||||||
|
playerPubkey = account.pubKeyHex,
|
||||||
|
opponentPubkey = opponentPubkey,
|
||||||
|
playerColor = playerColor,
|
||||||
|
engine = engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
_activeGames.value = _activeGames.value + (gameId to gameState)
|
||||||
|
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
|
||||||
|
gamesBeingCreated.remove(gameId)
|
||||||
|
|
||||||
|
// Apply any pending moves that arrived before the game was created
|
||||||
|
applyPendingMoves(gameId, gameState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPendingMoves(
|
||||||
|
gameId: String,
|
||||||
|
gameState: LiveChessGameState,
|
||||||
|
) {
|
||||||
|
val moves = pendingMoves.remove(gameId) ?: return
|
||||||
|
|
||||||
|
// Sort by move number if available, then apply in order
|
||||||
|
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
|
||||||
|
|
||||||
|
for ((san, fen, moveNumber) in sortedMoves) {
|
||||||
|
gameState.applyOpponentMove(san, fen, moveNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBadgeCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make and publish a move
|
||||||
|
*/
|
||||||
|
fun publishMove(
|
||||||
|
gameId: String,
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
val moveResult = gameState.makeMove(from, to)
|
||||||
|
if (moveResult != null) {
|
||||||
|
publishMoveEvent(gameId, moveResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishMoveEvent(
|
||||||
|
gameId: String,
|
||||||
|
moveEvent: ChessMoveEvent,
|
||||||
|
) {
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessMoveEvent.build(
|
||||||
|
gameId = moveEvent.gameId,
|
||||||
|
moveNumber = moveEvent.moveNumber,
|
||||||
|
san = moveEvent.san,
|
||||||
|
fen = moveEvent.fen,
|
||||||
|
opponentPubkey = moveEvent.opponentPubkey,
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
_error.value = "Failed to publish move"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resign from a game
|
||||||
|
*/
|
||||||
|
fun resign(gameId: String) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val endData = gameState.resign()
|
||||||
|
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessGameEndEvent.build(
|
||||||
|
gameId = endData.gameId,
|
||||||
|
result = endData.result,
|
||||||
|
termination = endData.termination,
|
||||||
|
winnerPubkey = endData.winnerPubkey,
|
||||||
|
opponentPubkey = endData.opponentPubkey,
|
||||||
|
pgn = endData.pgn ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Store in history
|
||||||
|
val completedGame =
|
||||||
|
CompletedGame(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = gameState.opponentPubkey,
|
||||||
|
playerColor = gameState.playerColor,
|
||||||
|
result = endData.result.notation,
|
||||||
|
termination = endData.termination.name.lowercase(),
|
||||||
|
winnerPubkey = endData.winnerPubkey,
|
||||||
|
completedAt = TimeUtils.now(),
|
||||||
|
moveCount = gameState.moveHistory.value.size,
|
||||||
|
)
|
||||||
|
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||||
|
_activeGames.value = _activeGames.value - gameId
|
||||||
|
_error.value = null
|
||||||
|
} else {
|
||||||
|
_error.value = "Failed to resign"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offer draw - sends a draw offer event that opponent can accept or decline
|
||||||
|
*/
|
||||||
|
fun offerDraw(gameId: String) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val drawOffer = gameState.offerDraw()
|
||||||
|
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessDrawOfferEvent.build(
|
||||||
|
gameId = drawOffer.gameId,
|
||||||
|
opponentPubkey = drawOffer.opponentPubkey,
|
||||||
|
message = drawOffer.message ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
_error.value = "Failed to offer draw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept opponent's draw offer
|
||||||
|
*/
|
||||||
|
fun acceptDraw(gameId: String) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val endData = gameState.acceptDraw()
|
||||||
|
if (endData == null) {
|
||||||
|
_error.value = "No draw offer to accept"
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val success =
|
||||||
|
retryWithBackoff {
|
||||||
|
val template =
|
||||||
|
LiveChessGameEndEvent.build(
|
||||||
|
gameId = endData.gameId,
|
||||||
|
result = endData.result,
|
||||||
|
termination = endData.termination,
|
||||||
|
winnerPubkey = endData.winnerPubkey,
|
||||||
|
opponentPubkey = endData.opponentPubkey,
|
||||||
|
pgn = endData.pgn ?: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
val signedEvent = account.signer.sign(template)
|
||||||
|
relayManager.broadcastToAll(signedEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Store in history and remove from active
|
||||||
|
val completedGame =
|
||||||
|
CompletedGame(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = gameState.opponentPubkey,
|
||||||
|
playerColor = gameState.playerColor,
|
||||||
|
result = GameResult.DRAW.notation,
|
||||||
|
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
|
||||||
|
winnerPubkey = null,
|
||||||
|
completedAt = TimeUtils.now(),
|
||||||
|
moveCount = gameState.moveHistory.value.size,
|
||||||
|
)
|
||||||
|
_completedGames.value = listOf(completedGame) + _completedGames.value
|
||||||
|
_activeGames.value = _activeGames.value - gameId
|
||||||
|
_error.value = null
|
||||||
|
} else {
|
||||||
|
_error.value = "Failed to accept draw"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline opponent's draw offer
|
||||||
|
*/
|
||||||
|
fun declineDraw(gameId: String) {
|
||||||
|
val gameState = _activeGames.value[gameId] ?: return
|
||||||
|
gameState.declineDraw()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectGame(gameId: String?) {
|
||||||
|
_selectedGameId.value = gameId
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId]
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a refresh of chess data from relays.
|
||||||
|
* Preserves existing game state (including local moves) while refreshing challenges.
|
||||||
|
*/
|
||||||
|
fun refresh() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
// Clear challenges - they'll be rebuilt from relay events
|
||||||
|
_challenges.value = emptyList()
|
||||||
|
|
||||||
|
// Clear event tracking caches to allow re-processing
|
||||||
|
processedEventIds.clear()
|
||||||
|
challengesByGameId.clear()
|
||||||
|
pendingAccepts.clear()
|
||||||
|
pendingMoves.clear()
|
||||||
|
gamesBeingCreated.clear()
|
||||||
|
|
||||||
|
// DON'T clear _activeGames - preserve existing game state with local moves
|
||||||
|
// Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers
|
||||||
|
|
||||||
|
_refreshKey.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when EOSE is received from relay, indicating initial load complete
|
||||||
|
*/
|
||||||
|
fun onLoadComplete() {
|
||||||
|
_isLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBadgeCount() {
|
||||||
|
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
|
||||||
|
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
|
||||||
|
_badgeCount.value = incomingChallenges + yourTurnGames
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean {
|
||||||
|
var attempt = 0
|
||||||
|
while (attempt < MAX_RETRIES) {
|
||||||
|
try {
|
||||||
|
operation()
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
attempt++
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
delay(RETRY_DELAY_MS * attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateGameId(): String {
|
||||||
|
val timestamp = TimeUtils.now()
|
||||||
|
val random = UUID.randomUUID().toString().take(8)
|
||||||
|
return "chess-$timestamp-$random"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a completed chess game for history display
|
||||||
|
*/
|
||||||
|
data class CompletedGame(
|
||||||
|
val gameId: String,
|
||||||
|
val opponentPubkey: String,
|
||||||
|
val playerColor: Color,
|
||||||
|
val result: String,
|
||||||
|
val termination: String,
|
||||||
|
val winnerPubkey: String?,
|
||||||
|
val completedAt: Long,
|
||||||
|
val moveCount: Int,
|
||||||
|
) {
|
||||||
|
fun didWin(playerPubkey: String): Boolean? =
|
||||||
|
when {
|
||||||
|
result == "1/2-1/2" -> null // Draw
|
||||||
|
winnerPubkey == playerPubkey -> true
|
||||||
|
winnerPubkey != null -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resultText(playerPubkey: String): String =
|
||||||
|
when (didWin(playerPubkey)) {
|
||||||
|
true -> "Won"
|
||||||
|
false -> "Lost"
|
||||||
|
null -> "Draw"
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription(
|
|||||||
onEose = onEose,
|
onEose = onEose,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for chess events (challenges, moves, etc.).
|
||||||
|
* Subscribes to:
|
||||||
|
* - Open challenges (kind 30064)
|
||||||
|
* - Challenges directed at the user
|
||||||
|
* - Events in games the user is playing
|
||||||
|
*
|
||||||
|
* @param userPubkey The user's public key to filter relevant games
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
*/
|
||||||
|
fun createChessSubscription(
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
userPubkey: String,
|
||||||
|
limit: Int = 100,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig =
|
||||||
|
SubscriptionConfig(
|
||||||
|
subId = generateSubId("chess-${userPubkey.take(8)}"),
|
||||||
|
filters =
|
||||||
|
listOf(
|
||||||
|
// Open challenges and challenges to user
|
||||||
|
FilterBuilders.chessOpenChallenges(limit = limit),
|
||||||
|
// Events where user is tagged (challenges to them, moves in their games)
|
||||||
|
FilterBuilders.chessEventsForUser(userPubkey, limit = limit),
|
||||||
|
// User's own events (their challenges, moves)
|
||||||
|
FilterBuilders.chessEventsByUser(userPubkey, limit = limit),
|
||||||
|
),
|
||||||
|
relays = relays,
|
||||||
|
onEvent = onEvent,
|
||||||
|
onEose = onEose,
|
||||||
|
)
|
||||||
|
|||||||
+97
@@ -200,6 +200,103 @@ object FilterBuilders {
|
|||||||
ids = ids,
|
ids = ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for chess game challenges (kind 30064).
|
||||||
|
* Includes open challenges (no opponent) and challenges directed at a specific user.
|
||||||
|
*
|
||||||
|
* @param userPubkey The user's public key to filter challenges for
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
* @param since Timestamp for events with publication time ≥ this value
|
||||||
|
* @return Filter for chess challenges
|
||||||
|
*/
|
||||||
|
fun chessChallengesToUser(
|
||||||
|
userPubkey: String,
|
||||||
|
limit: Int? = 50,
|
||||||
|
since: Long? = null,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||||
|
tags = mapOf("p" to listOf(userPubkey)),
|
||||||
|
limit = limit,
|
||||||
|
since = since,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for open chess challenges (kind 30064, no specific opponent).
|
||||||
|
*
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
* @param since Timestamp for events with publication time ≥ this value
|
||||||
|
* @return Filter for open chess challenges
|
||||||
|
*/
|
||||||
|
fun chessOpenChallenges(
|
||||||
|
limit: Int? = 50,
|
||||||
|
since: Long? = null,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||||
|
limit = limit,
|
||||||
|
since = since,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers).
|
||||||
|
* Useful for loading the chess lobby.
|
||||||
|
*
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
* @param since Timestamp for events with publication time ≥ this value
|
||||||
|
* @return Filter for all chess events
|
||||||
|
*/
|
||||||
|
fun chessAllEvents(
|
||||||
|
limit: Int? = 100,
|
||||||
|
since: Long? = null,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||||
|
limit = limit,
|
||||||
|
since = since,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for chess events involving a specific user.
|
||||||
|
* Includes challenges to/from user, moves in their games, draw offers, etc.
|
||||||
|
*
|
||||||
|
* @param userPubkey The user's public key
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
* @param since Timestamp for events with publication time ≥ this value
|
||||||
|
* @return Filter for chess events involving the user
|
||||||
|
*/
|
||||||
|
fun chessEventsForUser(
|
||||||
|
userPubkey: String,
|
||||||
|
limit: Int? = 100,
|
||||||
|
since: Long? = null,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||||
|
tags = mapOf("p" to listOf(userPubkey)),
|
||||||
|
limit = limit,
|
||||||
|
since = since,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a filter for chess events by the user (their own events).
|
||||||
|
*
|
||||||
|
* @param userPubkey The user's public key
|
||||||
|
* @param limit Maximum number of events to request
|
||||||
|
* @param since Timestamp for events with publication time ≥ this value
|
||||||
|
* @return Filter for chess events by the user
|
||||||
|
*/
|
||||||
|
fun chessEventsByUser(
|
||||||
|
userPubkey: String,
|
||||||
|
limit: Int? = 100,
|
||||||
|
since: Long? = null,
|
||||||
|
): Filter =
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||||
|
authors = listOf(userPubkey),
|
||||||
|
limit = limit,
|
||||||
|
since = since,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a filter for events tagged with specific p-tags (mentioning users).
|
* Creates a filter for events tagged with specific p-tags (mentioning users).
|
||||||
*
|
*
|
||||||
|
|||||||
+21
@@ -71,6 +71,27 @@ fun createBatchMetadataSubscription(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a subscription config for multiple user metadata (kind 0).
|
||||||
|
* Useful for fetching metadata for a batch of users at once.
|
||||||
|
*/
|
||||||
|
fun createMetadataListSubscription(
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
pubKeys: List<String>,
|
||||||
|
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||||
|
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||||
|
): SubscriptionConfig? {
|
||||||
|
if (pubKeys.isEmpty()) return null
|
||||||
|
|
||||||
|
return SubscriptionConfig(
|
||||||
|
subId = generateSubId("meta-batch-${pubKeys.hashCode()}"),
|
||||||
|
filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)),
|
||||||
|
relays = relays,
|
||||||
|
onEvent = onEvent,
|
||||||
|
onEose = onEose,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a subscription config for user posts (kind 1).
|
* Creates a subscription config for user posts (kind 1).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -584,7 +584,8 @@ fun FeedScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||||
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
|||||||
+1
-1
@@ -226,7 +226,7 @@ fun NotificationsScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(notifications, key = { it.event.id }) { notification ->
|
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
|
||||||
NotificationCard(notification)
|
NotificationCard(notification)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ fun ThreadScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reply notes with level indicators
|
// Reply notes with level indicators
|
||||||
items(replyEvents, key = { it.id }) { event ->
|
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
val level = calculateLevel(event)
|
val level = calculateLevel(event)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|||||||
+1
-1
@@ -569,7 +569,7 @@ fun UserProfileScreen(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||||
FeedNoteCard(
|
FeedNoteCard(
|
||||||
event = event,
|
event = event,
|
||||||
relayManager = relayManager,
|
relayManager = relayManager,
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ kotlin {
|
|||||||
implementation(libs.okhttpCoroutines)
|
implementation(libs.okhttpCoroutines)
|
||||||
|
|
||||||
// Chess engine for move validation and legal move generation
|
// Chess engine for move validation and legal move generation
|
||||||
implementation("io.github.cvb941:kchesslib:1.0.4")
|
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
|
||||||
|
// TODO: Test if 1.0.0 works, or fork library to fix
|
||||||
|
implementation("io.github.cvb941:kchesslib:1.0.0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,6 +112,27 @@ data class ChessGameEnd(
|
|||||||
val pgn: String? = null,
|
val pgn: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw offer event - offer a draw to opponent
|
||||||
|
* Kind: 30068
|
||||||
|
*
|
||||||
|
* Tags:
|
||||||
|
* - d: game_id
|
||||||
|
* - p: opponent pubkey
|
||||||
|
*
|
||||||
|
* Content: Optional message
|
||||||
|
*
|
||||||
|
* Opponent can:
|
||||||
|
* - Accept by sending a GameEnd event with DRAW_AGREEMENT
|
||||||
|
* - Decline implicitly by making their next move
|
||||||
|
* - Decline explicitly (optional, no event needed)
|
||||||
|
*/
|
||||||
|
data class ChessDrawOffer(
|
||||||
|
val gameId: String,
|
||||||
|
val opponentPubkey: String,
|
||||||
|
val message: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Game termination reason
|
* Game termination reason
|
||||||
*/
|
*/
|
||||||
@@ -132,4 +153,5 @@ object LiveChessEventKinds {
|
|||||||
const val GAME_ACCEPT = 30065
|
const val GAME_ACCEPT = 30065
|
||||||
const val CHESS_MOVE = 30066
|
const val CHESS_MOVE = 30066
|
||||||
const val GAME_END = 30067
|
const val GAME_END = 30067
|
||||||
|
const val DRAW_OFFER = 30068
|
||||||
}
|
}
|
||||||
|
|||||||
+45
@@ -247,3 +247,48 @@ class LiveChessGameEndEvent(
|
|||||||
|
|
||||||
fun pgn(): String = content
|
fun pgn(): String = content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live Chess Draw Offer Event (Kind 30068)
|
||||||
|
*
|
||||||
|
* Offer a draw to opponent. Opponent can accept by sending a game end event
|
||||||
|
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
|
||||||
|
*
|
||||||
|
* Tags:
|
||||||
|
* - d: game_id
|
||||||
|
* - p: opponent pubkey
|
||||||
|
*
|
||||||
|
* Content: Optional message
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
class LiveChessDrawOfferEvent(
|
||||||
|
id: HexKey,
|
||||||
|
pubKey: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
tags: Array<Array<String>>,
|
||||||
|
content: String,
|
||||||
|
sig: HexKey,
|
||||||
|
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
|
||||||
|
companion object {
|
||||||
|
const val KIND = 30068
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
gameId: String,
|
||||||
|
opponentPubkey: String,
|
||||||
|
message: String = "",
|
||||||
|
createdAt: Long = TimeUtils.now(),
|
||||||
|
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
|
||||||
|
) = eventTemplate(KIND, message, createdAt) {
|
||||||
|
add(arrayOf("d", gameId))
|
||||||
|
add(arrayOf("p", opponentPubkey))
|
||||||
|
alt("Chess draw offer")
|
||||||
|
initializer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
|
||||||
|
|
||||||
|
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
|
||||||
|
|
||||||
|
fun message(): String = content
|
||||||
|
}
|
||||||
|
|||||||
+186
-5
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.nip64Chess
|
package com.vitorpamplona.quartz.nip64Chess
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -38,19 +39,57 @@ class LiveChessGameState(
|
|||||||
val opponentPubkey: String,
|
val opponentPubkey: String,
|
||||||
val playerColor: Color,
|
val playerColor: Color,
|
||||||
val engine: ChessEngine,
|
val engine: ChessEngine,
|
||||||
|
val createdAt: Long = TimeUtils.now(),
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
// Abandon timeout: 24 hours without a move
|
||||||
|
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
|
||||||
|
|
||||||
|
// Inactivity warning: 1 hour without a move
|
||||||
|
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
|
||||||
|
}
|
||||||
|
|
||||||
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
|
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
|
||||||
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
|
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
|
||||||
|
|
||||||
private val _currentPosition = MutableStateFlow(engine.getPosition())
|
private val _currentPosition = MutableStateFlow(engine.getPosition())
|
||||||
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
|
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
|
||||||
|
|
||||||
private val _moveHistory = MutableStateFlow<List<String>>(emptyList())
|
// Initialize from engine's history so freshly loaded games show all moves
|
||||||
|
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
|
||||||
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
|
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
|
||||||
|
|
||||||
private val _lastError = MutableStateFlow<String?>(null)
|
private val _lastError = MutableStateFlow<String?>(null)
|
||||||
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
val lastError: StateFlow<String?> = _lastError.asStateFlow()
|
||||||
|
|
||||||
|
// Track when last activity occurred (move made or received)
|
||||||
|
private val _lastActivityAt = MutableStateFlow(createdAt)
|
||||||
|
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
|
||||||
|
|
||||||
|
// Track received move numbers to detect duplicates and out-of-order
|
||||||
|
private val receivedMoveNumbers = mutableSetOf<Int>()
|
||||||
|
|
||||||
|
// Pending moves waiting for earlier moves (move number -> move data)
|
||||||
|
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
|
||||||
|
|
||||||
|
// Desync detection
|
||||||
|
private val _isDesynced = MutableStateFlow(false)
|
||||||
|
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
|
||||||
|
|
||||||
|
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
|
||||||
|
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
|
||||||
|
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's a pending draw offer from opponent
|
||||||
|
*/
|
||||||
|
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have an outgoing draw offer
|
||||||
|
*/
|
||||||
|
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if it's the player's turn
|
* Check if it's the player's turn
|
||||||
*/
|
*/
|
||||||
@@ -80,8 +119,12 @@ class LiveChessGameState(
|
|||||||
if (result.success && result.san != null && result.position != null) {
|
if (result.success && result.san != null && result.position != null) {
|
||||||
_currentPosition.value = result.position
|
_currentPosition.value = result.position
|
||||||
_moveHistory.value = engine.getMoveHistory()
|
_moveHistory.value = engine.getMoveHistory()
|
||||||
|
_lastActivityAt.value = TimeUtils.now()
|
||||||
_lastError.value = null
|
_lastError.value = null
|
||||||
|
|
||||||
|
// Making a move implicitly declines any pending draw offer
|
||||||
|
_pendingDrawOffer.value = null
|
||||||
|
|
||||||
// Check for game end conditions
|
// Check for game end conditions
|
||||||
checkGameEnd()
|
checkGameEnd()
|
||||||
|
|
||||||
@@ -105,7 +148,22 @@ class LiveChessGameState(
|
|||||||
fun applyOpponentMove(
|
fun applyOpponentMove(
|
||||||
san: String,
|
san: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
|
moveNumber: Int? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
// Check for duplicate move
|
||||||
|
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
|
||||||
|
// Already processed this move, ignore
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for out-of-order move
|
||||||
|
val expectedMoveNumber = _moveHistory.value.size + 1
|
||||||
|
if (moveNumber != null && moveNumber > expectedMoveNumber) {
|
||||||
|
// Store for later processing
|
||||||
|
pendingMoves[moveNumber] = san to fen
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlayerTurn()) {
|
if (isPlayerTurn()) {
|
||||||
_lastError.value = "Received move but it's not opponent's turn"
|
_lastError.value = "Received move but it's not opponent's turn"
|
||||||
return false
|
return false
|
||||||
@@ -115,22 +173,37 @@ class LiveChessGameState(
|
|||||||
val result = engine.makeMove(san)
|
val result = engine.makeMove(san)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Mark as received
|
||||||
|
if (moveNumber != null) {
|
||||||
|
receivedMoveNumbers.add(moveNumber)
|
||||||
|
}
|
||||||
|
|
||||||
// Verify the resulting FEN matches what opponent sent
|
// Verify the resulting FEN matches what opponent sent
|
||||||
val currentFen = engine.getFen()
|
val currentFen = engine.getFen()
|
||||||
if (currentFen != fen) {
|
if (currentFen != fen) {
|
||||||
// Positions don't match - possible desync
|
// Positions don't match - desync detected
|
||||||
_lastError.value = "Position mismatch after opponent move"
|
_isDesynced.value = true
|
||||||
|
_lastError.value = "Position mismatch - syncing to opponent's position"
|
||||||
// Load the opponent's FEN to stay in sync
|
// Load the opponent's FEN to stay in sync
|
||||||
engine.loadFen(fen)
|
engine.loadFen(fen)
|
||||||
|
} else {
|
||||||
|
_isDesynced.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentPosition.value = engine.getPosition()
|
_currentPosition.value = engine.getPosition()
|
||||||
_moveHistory.value = engine.getMoveHistory()
|
_moveHistory.value = engine.getMoveHistory()
|
||||||
|
_lastActivityAt.value = TimeUtils.now()
|
||||||
_lastError.value = null
|
_lastError.value = null
|
||||||
|
|
||||||
|
// Opponent making a move declines any pending draw offer
|
||||||
|
_pendingDrawOffer.value = null
|
||||||
|
|
||||||
// Check for game end conditions
|
// Check for game end conditions
|
||||||
checkGameEnd()
|
checkGameEnd()
|
||||||
|
|
||||||
|
// Process any pending moves that are now valid
|
||||||
|
processPendingMoves()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
_lastError.value = "Invalid opponent move: $san"
|
_lastError.value = "Invalid opponent move: $san"
|
||||||
@@ -138,6 +211,79 @@ class LiveChessGameState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process any pending out-of-order moves
|
||||||
|
*/
|
||||||
|
private fun processPendingMoves() {
|
||||||
|
val nextMoveNumber = _moveHistory.value.size + 1
|
||||||
|
val pendingMove = pendingMoves.remove(nextMoveNumber)
|
||||||
|
if (pendingMove != null) {
|
||||||
|
val (san, fen) = pendingMove
|
||||||
|
applyOpponentMove(san, fen, nextMoveNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force resync to a specific FEN (manual recovery)
|
||||||
|
*/
|
||||||
|
fun forceResync(fen: String) {
|
||||||
|
engine.loadFen(fen)
|
||||||
|
_currentPosition.value = engine.getPosition()
|
||||||
|
_moveHistory.value = engine.getMoveHistory()
|
||||||
|
_isDesynced.value = false
|
||||||
|
_lastError.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark move numbers as already received.
|
||||||
|
* Used when loading a game from cache to prevent duplicate move application during refresh.
|
||||||
|
*
|
||||||
|
* @param moveNumbers Set of move numbers that have been loaded
|
||||||
|
*/
|
||||||
|
fun markMovesAsReceived(moveNumbers: Set<Int>) {
|
||||||
|
receivedMoveNumbers.addAll(moveNumbers)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if game appears abandoned (opponent hasn't moved in a long time)
|
||||||
|
*/
|
||||||
|
fun isAbandoned(): Boolean {
|
||||||
|
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||||
|
if (isPlayerTurn()) return false // Only check when waiting for opponent
|
||||||
|
|
||||||
|
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||||
|
return elapsed > ABANDON_TIMEOUT_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if opponent is inactive (warning threshold)
|
||||||
|
*/
|
||||||
|
fun isOpponentInactive(): Boolean {
|
||||||
|
if (_gameStatus.value != GameStatus.InProgress) return false
|
||||||
|
if (isPlayerTurn()) return false
|
||||||
|
|
||||||
|
val elapsed = TimeUtils.now() - _lastActivityAt.value
|
||||||
|
return elapsed > INACTIVITY_WARNING_SECONDS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim victory due to abandonment
|
||||||
|
*/
|
||||||
|
fun claimAbandonmentVictory(): ChessGameEnd? {
|
||||||
|
if (!isAbandoned()) return null
|
||||||
|
|
||||||
|
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
|
||||||
|
|
||||||
|
return ChessGameEnd(
|
||||||
|
gameId = gameId,
|
||||||
|
result = GameResult.getResultForWinner(playerColor),
|
||||||
|
termination = GameTermination.ABANDONMENT,
|
||||||
|
winnerPubkey = playerPubkey,
|
||||||
|
opponentPubkey = opponentPubkey,
|
||||||
|
pgn = generatePGN(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer resignation (player resigns)
|
* Offer resignation (player resigns)
|
||||||
*/
|
*/
|
||||||
@@ -155,9 +301,37 @@ class LiveChessGameState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offer or accept draw
|
* Offer a draw to opponent.
|
||||||
|
* Returns the draw offer data to be published to Nostr.
|
||||||
*/
|
*/
|
||||||
fun offerDraw(): ChessGameEnd {
|
fun offerDraw(): ChessDrawOffer {
|
||||||
|
_pendingDrawOffer.value = playerPubkey
|
||||||
|
return ChessDrawOffer(
|
||||||
|
gameId = gameId,
|
||||||
|
opponentPubkey = opponentPubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle receiving a draw offer from opponent (via Nostr event)
|
||||||
|
*/
|
||||||
|
fun receiveDrawOffer(fromPubkey: String): Boolean {
|
||||||
|
if (fromPubkey != opponentPubkey) return false
|
||||||
|
_pendingDrawOffer.value = opponentPubkey
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept opponent's draw offer.
|
||||||
|
* Returns game end data to be published to Nostr.
|
||||||
|
*/
|
||||||
|
fun acceptDraw(): ChessGameEnd? {
|
||||||
|
if (_pendingDrawOffer.value != opponentPubkey) {
|
||||||
|
_lastError.value = "No draw offer to accept"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
_pendingDrawOffer.value = null
|
||||||
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
|
||||||
|
|
||||||
return ChessGameEnd(
|
return ChessGameEnd(
|
||||||
@@ -170,6 +344,13 @@ class LiveChessGameState(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline a draw offer (explicit decline, also happens implicitly on move)
|
||||||
|
*/
|
||||||
|
fun declineDraw() {
|
||||||
|
_pendingDrawOffer.value = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if game has ended (checkmate, stalemate, etc.)
|
* Check if game has ended (checkmate, stalemate, etc.)
|
||||||
*/
|
*/
|
||||||
|
|||||||
+2
-1
@@ -112,6 +112,7 @@ actual class ChessEngine {
|
|||||||
val sq = Square.fromValue(square.uppercase())
|
val sq = Square.fromValue(square.uppercase())
|
||||||
return board
|
return board
|
||||||
.legalMoves()
|
.legalMoves()
|
||||||
|
.filterNotNull()
|
||||||
.filter { it.from == sq }
|
.filter { it.from == sq }
|
||||||
.map { it.to.toString().lowercase() }
|
.map { it.to.toString().lowercase() }
|
||||||
}
|
}
|
||||||
@@ -173,7 +174,7 @@ actual class ChessEngine {
|
|||||||
|
|
||||||
actual fun getMoveHistory(): List<String> {
|
actual fun getMoveHistory(): List<String> {
|
||||||
// kchesslib stores move history
|
// kchesslib stores move history
|
||||||
return board.backup.map { it.move.toString() }
|
return board.backup.mapNotNull { it?.move?.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user