diff --git a/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml b/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml new file mode 100644 index 0000000..6c5519f --- /dev/null +++ b/.idea/misc.sync-conflict-20260504-182215-U4KWF3D.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 3b0be22..a1d4940 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,6 @@ - - + diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..c69b130 --- /dev/null +++ b/.mailmap @@ -0,0 +1,9 @@ +Christopher Budhiawan +Christopher Budhiawan +Emre Orhan +Sofija Sternad-Gugnjak +Alexander Dermutz +Selina Prettner +Selina Prettner +Anna Pschernig +Natasa Jeftic \ No newline at end of file diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt new file mode 100644 index 0000000..a212ee6 --- /dev/null +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -0,0 +1,162 @@ +package com.codenames.frontend + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.data.model.ChatLists +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.ChatTab +import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.screens.GameboardScreen +import org.junit.Rule +import org.junit.Test + +class GameboardScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun gameboardDisplaysGameStateFromParameters() { + val cards = + listOf( + GameCard("BERLIN", CardType.BLUE, revealed = false), + GameCard("ROME", CardType.RED, revealed = true), + GameCard("MOON", CardType.NEUTRAL, revealed = false), + GameCard("VIPER", CardType.ASSASSIN, revealed = false), + ) + + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 3, + currentBlueFound = 0, + currentRedFound = 1, + cards = cards, + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("BERLIN").assertIsDisplayed() + composeRule.onNodeWithText("ROME").assertIsDisplayed() + composeRule.onNodeWithText("Turn: BLUE_OPERATIVE | Guesses: 3").assertIsDisplayed() + composeRule.onNodeWithText("0 FOUND").assertIsDisplayed() + composeRule.onNodeWithText("1 FOUND").assertIsDisplayed() + composeRule.onAllNodesWithText("Hint: EAGLE").assertCountEquals(0) + } + + @Test + fun gameboardDisplaysHintForOperative() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Hint: EAGLE").assertIsDisplayed() + } + + @Test + fun spymasterCanOpenLobbyChat() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + chatLists = + ChatLists( + lobbyMessages = + listOf( + ChatDomainModel( + sender = "Anna", + text = "Lobby message", + isFromMe = false, + ), + ), + ), + availableChatTabs = listOf(ChatTab.GLOBAL), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onNodeWithText("Global Chat").assertIsDisplayed() + composeRule.onNodeWithText("Anna").assertIsDisplayed() + composeRule.onNodeWithText("Lobby message").assertIsDisplayed() + } + + @Test + fun gameboardDisplaysTeamChatMessages() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + chatLists = + ChatLists( + teamMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "Take Berlin", + isFromMe = false, + ), + ), + ), + availableChatTabs = listOf(ChatTab.GLOBAL, ChatTab.TEAM), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onNodeWithText("Team").performClick() + composeRule.onNodeWithText("Max").assertIsDisplayed() + composeRule.onNodeWithText("Take Berlin").assertIsDisplayed() + } + + @Test + fun operativeChatTabIsHiddenWhenNotAvailable() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + availableChatTabs = listOf(ChatTab.GLOBAL, ChatTab.TEAM), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("Chat").performClick() + composeRule.onAllNodesWithText("Operatives").assertCountEquals(0) + } +} diff --git a/app/src/main/java/com/codenames/frontend/MainActivity.kt b/app/src/main/java/com/codenames/frontend/MainActivity.kt index 7e328b3..58679c6 100644 --- a/app/src/main/java/com/codenames/frontend/MainActivity.kt +++ b/app/src/main/java/com/codenames/frontend/MainActivity.kt @@ -5,6 +5,8 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import com.codenames.frontend.ui.navigation.NavGraph import com.codenames.frontend.ui.theme.CodenamesTheme import dagger.hilt.android.AndroidEntryPoint @@ -14,6 +16,14 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + + WindowInsetsControllerCompat(window, window.decorView).apply { + hide(WindowInsetsCompat.Type.systemBars()) + + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + setContent { CodenamesTheme { AppContent() diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt new file mode 100644 index 0000000..39cfa8d --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatDomainModel.kt @@ -0,0 +1,7 @@ +package com.codenames.frontend.data.model + +data class ChatDomainModel( + val sender: String, + val text: String, + val isFromMe: Boolean, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt new file mode 100644 index 0000000..fbbae77 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatLists.kt @@ -0,0 +1,7 @@ +package com.codenames.frontend.data.model + +data class ChatLists( + val lobbyMessages: List = emptyList(), + val teamMessages: List = emptyList(), + val operativeMessages: List = emptyList(), +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt new file mode 100644 index 0000000..9264389 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatMessage.kt @@ -0,0 +1,15 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.ChatTab +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessage( + val id: Int, + val sender: String, + val message: String, + val timestamp: String, + val chatTab: ChatTab, + val isSystemMessage: Boolean = false, + val isFromMe: Boolean = false, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt new file mode 100644 index 0000000..31fb8cb --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/ChatUiState.kt @@ -0,0 +1,6 @@ +package com.codenames.frontend.data.model + +data class ChatUiState( + val messages: List = emptyList(), + val currentInput: String = "", +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt b/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt new file mode 100644 index 0000000..9a40a82 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/GameCard.kt @@ -0,0 +1,9 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.CardType + +data class GameCard( + val word: String, + val type: CardType, + val revealed: Boolean = false, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/GameState.kt b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt new file mode 100644 index 0000000..c1f6cba --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt @@ -0,0 +1,17 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.ChatTab +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.ui.roles.PlayerRoles + +data class GameState( + val currentHint: String = "", + val cards: List = emptyList(), + val currentTurn: PlayerRoles = PlayerRoles.NONE, + val winner: Team? = null, + val remainingGuesses: Int = 0, + val currentRedFound: Int = 0, + val currentBlueFound: Int = 0, + val chatLists: ChatLists = ChatLists(), + val availableChatTabs: List = listOf(ChatTab.GLOBAL), +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt index f21a9c4..195786c 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/LobbyUiState.kt @@ -6,4 +6,8 @@ data class LobbyUiState( val players: List = emptyList(), val error: String? = null, val isGameStarted: Boolean = false, + val blueOperatives: List = emptyList(), + val blueSpymasters: List = emptyList(), + val redOperatives: List = emptyList(), + val redSpymasters: List = emptyList(), ) diff --git a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt index 901c924..e071b38 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/Mapper.kt @@ -1,12 +1,19 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto +import com.codenames.frontend.ui.roles.PlayerRoles fun LobbyResponse.toLobbyState(): LobbyUiState = LobbyUiState( lobbyCode = lobbyCode, players = playerList.map { it.toUi() }, + isGameStarted = isStarted, ) fun PlayerDto.toUi(): Player = @@ -15,5 +22,35 @@ fun PlayerDto.toUi(): Player = role = role, team = team, isHost = isHost, - isReady = false, // if we add this functionality + isReady = false, ) + +fun GameMessage.toGameState(): GameState { + val cards = cardList.map { it.toGameCard() } + + return GameState( + currentHint = currentClue?.word ?: "", + cards = cards, + currentTurn = getCurrentTurn(), + winner = winner, + remainingGuesses = currentClue?.guessAmount ?: 0, + currentRedFound = cards.count { it.type == CardType.RED && it.revealed }, + currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed }, + ) +} + +fun CardDto.toGameCard(): GameCard = + GameCard( + word = word, + type = color, + revealed = isGuessed, + ) + +fun GameMessage.getCurrentTurn(): PlayerRoles { + if (currentTurn == Team.RED) { + if (currentPhase == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER + return PlayerRoles.RED_OPERATIVE + } + if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER + return PlayerRoles.BLUE_OPERATIVE +} diff --git a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt new file mode 100644 index 0000000..95dca09 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -0,0 +1,5 @@ +package com.codenames.frontend.data.model + +data class SessionState( + val username: String, +) diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt new file mode 100644 index 0000000..52faca6 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/CardType.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.data.model.enums + +enum class CardType { + BLUE, + RED, + NEUTRAL, + ASSASSIN, +} diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt new file mode 100644 index 0000000..1ca50f5 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatMessageType.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.data.model.enums + +import kotlinx.serialization.Serializable + +@Serializable +enum class ChatMessageType { + CHAT, +} diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt new file mode 100644 index 0000000..d77829c --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/ChatTab.kt @@ -0,0 +1,9 @@ +package com.codenames.frontend.data.model.enums + +enum class ChatTab( + val title: String, +) { + GLOBAL("Global"), + TEAM("Team"), + OPERATIVES("Operatives"), +} diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt index 56ce7d9..f0e6ba1 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/Role.kt @@ -1,5 +1,8 @@ package com.codenames.frontend.data.model.enums +import kotlinx.serialization.Serializable + +@Serializable enum class Role { OPERATIVE, SPYMASTER, diff --git a/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt b/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt index d7d53c8..08de36d 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/enums/Team.kt @@ -1,5 +1,8 @@ package com.codenames.frontend.data.model.enums +import kotlinx.serialization.Serializable + +@Serializable enum class Team { RED, BLUE, diff --git a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt new file mode 100644 index 0000000..b37fe63 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -0,0 +1,41 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class ChatRepository + @Inject + constructor( + private val webSocketHandler: GameWebSocketHandler, + ) { + fun observeChat( + topic: String, + currentUsername: String, // for UI to map message left or right depending on if it is from the sender themselves + ): Flow = + flow { + val subscriptionFlow = webSocketHandler.subscribeToChat(topic) + // collect is called when we receive a new chat object from websocket, then we collect and emit to ViewModel + subscriptionFlow.collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername, + ), + ) + } + } + + suspend fun sendMessage( + destination: String, + username: String, + text: String, + ) { + val dto = ChatMessageDto(senderUsername = username, content = text) + webSocketHandler.sendChatMessage(destination, dto) + } + } diff --git a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt new file mode 100644 index 0000000..0fc6887 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -0,0 +1,16 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.dto.StartGameMessage +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import javax.inject.Inject + +class GameRepository + @Inject + constructor( + private val webSocketHandler: GameWebSocketHandler, + ) { + suspend fun startGame(lobbyCode: String) { + val msg = StartGameMessage(lobbyCode) + webSocketHandler.startGame(msg) + } + } diff --git a/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt index 622046c..fd0f9f6 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/LobbyRepository.kt @@ -15,14 +15,14 @@ class LobbyRepository suspend fun createLobby(username: String): LobbyResponse = api.createLobby(username) suspend fun leaveLobby( - username: String, lobbyCode: String, - ): LobbyResponse = api.leaveLobby(username, lobbyCode) + username: String, + ): LobbyResponse = api.leaveLobby(lobbyCode, username) suspend fun joinLobby( username: String, lobbyCode: String, - ): LobbyResponse = api.joinLobby(username, lobbyCode) + ): LobbyResponse = api.joinLobby(lobbyCode, username) suspend fun getLobbyInfo(lobbyCode: String): LobbyResponse = api.getLobbyInfo(lobbyCode) @@ -35,4 +35,9 @@ class LobbyRepository val player = PlayerDto(username, role, team, false) return api.changeRole(lobbyCode, player) } + + suspend fun sendStartGame( + lobbyCode: String, + username: String, + ): LobbyResponse = api.startGame(lobbyCode, username) } diff --git a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt index 4057ad7..ccfb313 100644 --- a/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt +++ b/app/src/main/java/com/codenames/frontend/network/api/LobbyApi.kt @@ -10,15 +10,15 @@ import retrofit2.http.Query // will be extended / adapted to different endpoints when backend is ready interface LobbyApi { - @POST("lobby/create") + @GET("lobby/create") suspend fun createLobby( @Query("username") username: String, ): LobbyResponse - @POST("lobby/join") + @GET("lobby/{lobbyCode}/join") suspend fun joinLobby( + @Path("lobbyCode") lobbyCode: String, @Query("username") username: String, - @Query("lobbyCode") lobbyCode: String, ): LobbyResponse @GET("lobby/{lobbyCode}") @@ -26,15 +26,21 @@ interface LobbyApi { @Path("lobbyCode") lobbyCode: String, ): LobbyResponse - @POST("lobby/{lobbyCode}/{username}/leave") + @GET("lobby/{lobbyCode}/leave") suspend fun leaveLobby( - @Path("username") username: String, @Path("lobbyCode") lobbyCode: String, + @Query("username") username: String, ): LobbyResponse - @POST("lobby/{lobbyCode}/roleChange") + @POST("lobby/{lobbyCode}/select-position") suspend fun changeRole( @Path("lobbyCode") lobbyCode: String, @Body playerDto: PlayerDto, ): LobbyResponse + + @GET("lobby/{lobbyCode}/start-game") + suspend fun startGame( + @Path("lobbyCode") lobbyCode: String, + @Query("username") username: String, + ): LobbyResponse } diff --git a/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt index 4fba2a2..5506712 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/CardDto.kt @@ -1,10 +1,11 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.CardType import kotlinx.serialization.Serializable @Serializable data class CardDto( val word: String, - val color: String, + val color: CardType, val isGuessed: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt new file mode 100644 index 0000000..4245e5d --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ChatMessageDto.kt @@ -0,0 +1,11 @@ +package com.codenames.frontend.network.dto + +import com.codenames.frontend.data.model.enums.ChatMessageType +import kotlinx.serialization.Serializable + +@Serializable +data class ChatMessageDto( + val senderUsername: String, + val content: String, + val type: ChatMessageType = ChatMessageType.CHAT, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt new file mode 100644 index 0000000..262fed2 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueDto.kt @@ -0,0 +1,9 @@ +package com.codenames.frontend.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ClueDto( + val word: String, + val guessAmount: Int, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt new file mode 100644 index 0000000..c8b8084 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/ClueMessageDto.kt @@ -0,0 +1,12 @@ +package com.codenames.frontend.network.dto + +import com.codenames.frontend.data.model.enums.Team +import kotlinx.serialization.Serializable + +@Serializable +data class ClueMessageDto( + val lobbyCode: String, + val word: String, + val guessAmount: Int, + val currentTurn: Team, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt index 67a4b8c..bd832c9 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GameMessage.kt @@ -1,14 +1,15 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import kotlinx.serialization.Serializable @Serializable data class GameMessage( - val winner: String = "", - val currentTurn: String = "", - val currentRedFound: Int = 0, - val currentBlueFound: Int = 0, - val currentClue: String = "", - val remainingGuesses: Int = 0, + val winner: Team? = null, + val currentTurn: Team? = null, + val currentPhase: Role? = null, + val currentClue: ClueDto? = null, val cardList: List = emptyList(), + val error: String? = null, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt index 44dac6a..abd147d 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/LobbyResponse.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable data class LobbyResponse( val lobbyCode: String, val playerList: List, + val isStarted: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt b/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt index d3a367b..c663fa7 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/PlayerDto.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.Serializable @Serializable data class PlayerDto( val username: String, - val role: Role?, - val team: Team?, + val role: Role? = null, + val team: Team? = null, val isHost: Boolean, ) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt new file mode 100644 index 0000000..bfd2e50 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/StartGameMessage.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class StartGameMessage( + val lobbyCode: String, +) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt index 8230125..2423f49 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/WebSocketJoinMessage.kt @@ -1,9 +1,12 @@ package com.codenames.frontend.network.dto +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class WebSocketJoinMessage( + @SerialName("name") val username: String, + @SerialName("code") val lobbyCode: String, ) diff --git a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt index 51b2dae..3d1b1f5 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/RetrofitProvider.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://localhost:8080/lobby" +const val BASE_URL = "http://localhost:8080/" @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt index 22ae7c4..41a4800 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt @@ -1,7 +1,12 @@ package com.codenames.frontend.network.websocket +import android.util.Log +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import kotlinx.coroutines.flow.Flow import org.hildan.krossbow.stomp.StompClient @@ -10,9 +15,11 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import javax.inject.Inject +import javax.inject.Singleton -const val BASE_URL = "ws://localhost:8080/ws" +const val BASE_URL = "ws://localhost:8080/ws-fallback" +@Singleton class GameWebSocketHandler @Inject constructor( @@ -20,21 +27,50 @@ class GameWebSocketHandler ) { lateinit var session: StompSessionWithKxSerialization - // called by GameViewModel suspend fun connectStomp() { session = client.connect(BASE_URL).withJsonConversions() + Log.d("WebSocket", "Connected to Websocket, session: $session") + } + + suspend fun startGame(msg: StartGameMessage) { + session.convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) } suspend fun sendGuess(msg: GuessMessage) { - session.convertAndSend("/game/guess", msg, GuessMessage.serializer()) + session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) + } + + @Suppress("kotlin:S6309") + suspend fun subscribeToLobby(lobbyCode: String): Flow = + session.subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) + + suspend fun sendReconnectMessage(msg: WebSocketJoinMessage) { + session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } - // suspend is necessary here, because api calls are suspending functions @Suppress("kotlin:S6309") - suspend fun subscribeToLobby(lobbyCode: String): Flow = session.subscribe("/game/$lobbyCode", GameMessage.serializer()) + suspend fun subscribeToChat(topicPath: String): Flow = session.subscribe(topicPath, ChatMessageDto.serializer()) + + suspend fun sendChatMessage( + destination: String, + msg: ChatMessageDto, + ) { + session.convertAndSend(destination, msg, ChatMessageDto.serializer()) + } - suspend fun sendLobbyJoinMessage(msg: WebSocketJoinMessage) { - val lobbyCode = msg.lobbyCode - session.convertAndSend("app/$lobbyCode/join", msg, WebSocketJoinMessage.serializer()) + suspend fun sendClue( + lobbyCode: String, + word: String, + guessAmount: Int, + currentTurn: Team, + ) { + val msg = + ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = guessAmount, + currentTurn = currentTurn, + ) + session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt new file mode 100644 index 0000000..d0e0bde --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/UiMappers.kt @@ -0,0 +1,33 @@ +package com.codenames.frontend.ui + +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.ui.roles.PlayerRoles + +fun CardDto.toGameCard(): GameCard = + GameCard( + word = word, + type = color, + revealed = isGuessed, + ) + +fun Player.toPlayerRole(): PlayerRoles = + when (team to role) { + Team.BLUE to Role.OPERATIVE -> PlayerRoles.BLUE_OPERATIVE + Team.BLUE to Role.SPYMASTER -> PlayerRoles.BLUE_SPYMASTER + Team.RED to Role.OPERATIVE -> PlayerRoles.RED_OPERATIVE + Team.RED to Role.SPYMASTER -> PlayerRoles.RED_SPYMASTER + else -> PlayerRoles.NONE + } + +fun PlayerRoles.toTeamAndRole(): Pair? = + when (this) { + PlayerRoles.BLUE_OPERATIVE -> Team.BLUE to Role.OPERATIVE + PlayerRoles.BLUE_SPYMASTER -> Team.BLUE to Role.SPYMASTER + PlayerRoles.RED_OPERATIVE -> Team.RED to Role.OPERATIVE + PlayerRoles.RED_SPYMASTER -> Team.RED to Role.SPYMASTER + PlayerRoles.NONE -> null + } diff --git a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt new file mode 100644 index 0000000..843f56d --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt @@ -0,0 +1,78 @@ +package com.codenames.frontend.ui.buttons + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex + +private val cornerButtonColor = Color(0xFF383330) + +@Suppress("ktlint:standard:function-naming") +@Composable +fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 8.dp, end = 8.dp) + .width(56.dp) + .height(56.dp) + .zIndex(1f), + ) { + androidx.compose.material3.IconButton( + onClick = onClick, + modifier = Modifier.fillMaxSize(), + colors = + androidx.compose.material3.IconButtonDefaults.iconButtonColors( + containerColor = Color.Transparent, + contentColor = Color.White, + ), + ) { + Icon( + imageVector = androidx.compose.material.icons.Icons.Default.Settings, + contentDescription = "Settings", + tint = Color.White, + ) + } + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun BoxScope.ReturnCornerButton(onClick: () -> Unit) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(top = 16.dp, end = 16.dp) + .width(140.dp) + .height(56.dp) + .zIndex(1f), + ) { + AppButton( + text = "Return", + onClick = onClick, + modifier = Modifier.fillMaxSize(), + style = cornerButtonStyle(), + ) + } +} + +private fun cornerButtonStyle(): AppButtonStyle = + AppButtonStyle( + containerColor = cornerButtonColor, + contentColor = Color.White, + fontSize = 16.sp, + lineHeight = 18.sp, + ) diff --git a/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt b/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt new file mode 100644 index 0000000..4fc7dea --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt @@ -0,0 +1,76 @@ +package com.codenames.frontend.ui.composables + +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.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.ui.screens.CodenamesCard + +const val BOARD_COLUMNS = 5 + +@Suppress("ktlint:standard:function-naming") +@Composable +fun GameBoardGrid( + cards: List, + scale: Float, + offset: Offset, + isSpymaster: Boolean, + onReveal: (Int) -> Unit, + modifier: Modifier, +) { + Box( + modifier = modifier, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .wrapContentHeight(unbounded = true) + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + cards + .chunked(BOARD_COLUMNS) + .forEachIndexed { rowIndex, row -> + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + row.forEachIndexed { columnIndex, card -> + + val index = + (rowIndex * BOARD_COLUMNS) + columnIndex + + Box( + modifier = Modifier.weight(1f), + ) { + CodenamesCard( + card = card, + isSpymaster = isSpymaster, + onClick = { + if (!isSpymaster && !card.revealed) { + onReveal(index) + } + }, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt index 44b3f7a..4db8e72 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/NavGraph.kt @@ -1,65 +1,71 @@ package com.codenames.frontend.ui.navigation import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.navigation.NavType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController -import androidx.navigation.navArgument -import com.codenames.frontend.ui.roles.PlayerRoles -import com.codenames.frontend.ui.screens.CardType -import com.codenames.frontend.ui.screens.GameCard +import com.codenames.frontend.ui.screens.GameScreenWrapper import com.codenames.frontend.ui.screens.GameSettingsScreen -import com.codenames.frontend.ui.screens.GameTestScreen -import com.codenames.frontend.ui.screens.GameboardScreen import com.codenames.frontend.ui.screens.JoinlobbyScreen import com.codenames.frontend.ui.screens.LobbyScreen +import com.codenames.frontend.ui.screens.OfflineGameStateTestScreen import com.codenames.frontend.ui.screens.SettingsScreen import com.codenames.frontend.ui.screens.StartScreen +import com.codenames.frontend.ui.screens.UserNameScreen +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel @Composable @Suppress("ktlint:standard:function-naming") -fun NavGraph() { +fun NavGraph( + lobbyViewModel: LobbyViewModel = hiltViewModel(), + sessionViewModel: SessionViewModel = hiltViewModel(), + gameViewModel: GameViewModel = hiltViewModel(), +) { val navController = rememberNavController() NavHost( navController = navController, - startDestination = Screen.Start.route, + startDestination = Screen.Username.route, ) { - composable(Screen.Start.route) { - StartScreen(navController) + composable(Screen.Username.route) { + UserNameScreen(navController, sessionViewModel) + } + + composable( + Screen.Start.route, + ) { + StartScreen( + lobbyViewModel = lobbyViewModel, + navController = navController, + sessionViewModel = sessionViewModel, + ) } composable(Screen.Lobby.route) { - LobbyScreen(navController) + LobbyScreen( + navController = navController, + viewModel = lobbyViewModel, + gameViewModel = gameViewModel, + sessionViewModel = sessionViewModel, + ) } composable(Screen.JoinLobby.route) { - JoinlobbyScreen() + JoinlobbyScreen(viewModel = lobbyViewModel, navController = navController, sessionViewModel = sessionViewModel) } - // ---------------- GAME SCREEN ---------------- composable( - route = "${Screen.Gameboard.route}/{role}", - arguments = listOf(navArgument("role") { type = NavType.StringType }), - ) { backStackEntry -> - - val roleString = - backStackEntry.arguments?.getString("role") ?: PlayerRoles.NONE.name - - val passedRole = - try { - PlayerRoles.valueOf(roleString) - } catch (e: IllegalArgumentException) { - PlayerRoles.NONE - } - - GameScreenWrapper(userRole = passedRole) + route = Screen.Gameboard.route, + ) { + GameScreenWrapper( + navController = navController, + lobbyViewModel = lobbyViewModel, + gameViewModel = gameViewModel, + sessionViewModel = sessionViewModel, + ) } composable(Screen.GameSettings.route) { @@ -67,50 +73,11 @@ fun NavGraph() { } composable(Screen.Settings.route) { - SettingsScreen() + SettingsScreen(navController) } composable("game_test") { - GameTestScreen() + OfflineGameStateTestScreen() } } } - -@Composable -@Suppress("ktlint:standard:function-naming") -fun GameScreenWrapper(userRole: PlayerRoles) { - var currentHint by remember { mutableStateOf("Waiting for hint...") } - - val cards = - remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = - when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } - - fun revealCard(index: Int) { - val card = cards[index] - cards[index] = card.copy(revealed = true) - } - - GameboardScreen( - userRole = userRole, - currentHint = currentHint, - onHintChange = { currentHint = it }, - cards = cards, - onReveal = { index -> - revealCard(index) - }, - ) -} diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt index fb7aa2d..05f3c39 100644 --- a/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/navigation/Screen.kt @@ -14,4 +14,6 @@ sealed class Screen( object GameSettings : Screen("game_settings") object Settings : Screen("settings") + + object Username : Screen("username") } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt new file mode 100644 index 0000000..6329512 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -0,0 +1,59 @@ +package com.codenames.frontend.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.navigation.NavHostController +import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel + +@Composable +@Suppress("ktlint:standard:function-naming") +fun GameScreenWrapper( + navController: NavHostController, + lobbyViewModel: LobbyViewModel, + gameViewModel: GameViewModel, + sessionViewModel: SessionViewModel, +) { + val lobbyState by lobbyViewModel.state.collectAsState() + val gameState by gameViewModel.uiState.collectAsState() + val chatState by gameViewModel.chatState.collectAsState() + val usernameState by sessionViewModel.username.collectAsState() + + val username = usernameState.username + val lobbyCode = lobbyState.lobbyCode.orEmpty() + val currentPlayer = lobbyState.players.firstOrNull { it.name == username } + val team = currentPlayer?.team + val userRole = lobbyViewModel.getRoleForUser(username) + val availableChatTabs = lobbyViewModel.getAvailableChatTabsForUser(username) + + GameboardScreen( + userRole = userRole, + gameState = + gameState.copy( + chatLists = chatState, + availableChatTabs = availableChatTabs, + ), + onHintChange = { word, count -> + gameViewModel.submitClue(lobbyCode, word, count) + }, + onReveal = { + // TODO: Send guess through GameViewModel once backend endpoint exists. + }, + onSendChatMessage = { tab, message -> + gameViewModel.sendChatMessage( + tab = tab, + lobbyCode = lobbyCode, + username = username, + team = team, + content = message, + availableChatTabs = availableChatTabs, + ) + }, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, + ) +} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt index d01861c..990460b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameSettingsScreen.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -8,6 +9,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @Suppress("ktlint:standard:function-naming") @@ -17,7 +19,8 @@ fun GameSettingsScreen() { modifier = Modifier .fillMaxSize() - .padding(16.dp), + .padding(16.dp) + .background(Color(0xFFf0d8ce)), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt index 2650692..e8afdee 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameboardScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio @@ -14,13 +15,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -29,307 +32,586 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.data.model.ChatLists +import com.codenames.frontend.data.model.GameCard +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.ChatTab +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.AppButtonType +import com.codenames.frontend.ui.buttons.SettingsCornerButton +import com.codenames.frontend.ui.composables.GameBoardGrid import com.codenames.frontend.ui.inputs.AppTextField import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard import com.codenames.frontend.ui.inputs.AppTextFieldState +import com.codenames.frontend.ui.inputs.AppTextFieldStyle import com.codenames.frontend.ui.roles.PlayerRoles - -enum class CardType { - BLUE, - RED, - NEUTRAL, - ASSASSIN, -} - -data class GameCard( - val word: String, - val type: CardType, - val revealed: Boolean = false, -) - -@Suppress("ktlint:standard:function-naming") -@Composable -fun GameTestScreen() { - var currentHint by remember { mutableStateOf("Waiting for hint...") } - - val cards = - remember { - mutableStateListOf( - *List(25) { - GameCard( - word = "Word ${it + 1}", - type = - when (it) { - 0 -> CardType.ASSASSIN - in 1..8 -> CardType.BLUE - in 9..15 -> CardType.RED - else -> CardType.NEUTRAL - }, - ) - }.toTypedArray(), - ) - } - - fun revealCard(index: Int) { - val card = cards[index] - cards[index] = card.copy(revealed = true) - } - - Row(modifier = Modifier.fillMaxSize()) { - GameboardScreen( - userRole = PlayerRoles.BLUE_SPYMASTER, - currentHint = currentHint, - onHintChange = { currentHint = it }, - cards = cards, - onReveal = {}, - modifier = Modifier.weight(1f), - ) - - GameboardScreen( - userRole = PlayerRoles.BLUE_OPERATIVE, - currentHint = currentHint, - onHintChange = {}, - cards = cards, - onReveal = { index -> revealCard(index) }, - modifier = Modifier.weight(1f), - ) - } -} +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.greenGradient +import com.codenames.frontend.ui.theme.redGradient @Suppress("ktlint:standard:function-naming") @Composable fun GameboardScreen( userRole: PlayerRoles, - currentHint: String, - onHintChange: (String) -> Unit, - cards: List, + gameState: GameState, + onHintChange: (String, Int) -> Unit, onReveal: (Int) -> Unit, modifier: Modifier = Modifier, + onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, + onSettingsClick: (() -> Unit)? = null, ) { + val currentHint = gameState.currentHint + val cards = gameState.cards + val currentTurn = gameState.currentTurn + val winner = gameState.winner + val remainingGuesses = gameState.remainingGuesses + val chatLists = gameState.chatLists + val currentRedFound = gameState.currentRedFound + val currentBlueFound = gameState.currentBlueFound + val availableChatTabs = gameState.availableChatTabs + var hintInput by rememberSaveable { mutableStateOf("") } - val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER + var countInput by rememberSaveable { mutableStateOf("") } + var chatInput by rememberSaveable { mutableStateOf("") } + var isChatOpen by rememberSaveable { mutableStateOf(false) } + var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } + + val activeChatTab = + if (selectedChatTab in availableChatTabs) { + selectedChatTab + } else { + availableChatTabs.firstOrNull() ?: ChatTab.GLOBAL + } + + val isSpymaster = + userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val blueLeft = cards.count { it.type == CardType.BLUE && !it.revealed } - val redLeft = cards.count { it.type == CardType.RED && !it.revealed } var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } - val blueGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFF42A5F5), Color(0xFF1565C0)), - ) - val redGradient = - Brush.verticalGradient( - colors = listOf(Color(0xFFCF5530), Color(0xFFDE8468)), - ) + val onInputChange: (String) -> Unit = { hintInput = it } - Column( - modifier = - modifier - .fillMaxHeight() - .padding(16.dp), - ) { - Row( + Box(modifier = modifier.fillMaxSize()) { + Column( modifier = Modifier - .weight(1f) - .fillMaxWidth(), + .fillMaxSize() + .padding(top = 72.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), ) { - Column( + GameStatusBar( + currentTurn = currentTurn, + winner = winner, + remainingGuesses = remainingGuesses, + ) + + if (availableChatTabs.isNotEmpty()) { + ChatToggleButton( + isChatOpen = isChatOpen, + onClick = { isChatOpen = !isChatOpen }, + modifier = + Modifier + .padding(end = 24.dp, bottom = 24.dp), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( modifier = Modifier - .width(90.dp) - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, + .weight(1f) + .fillMaxWidth(), ) { - Text( - "BLUE TEAM", - fontWeight = FontWeight.Bold, - color = Color(0xFF1565C0), - fontSize = 12.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) - - TeamRoleBox( - title = "OPERATIVES", + TeamSidebar( + userRole, + color = Team.BLUE, + teamFound = currentBlueFound, + textColor = Color(0xFF1565C0), gradient = blueGradient, - isCurrentUser = userRole == PlayerRoles.BLUE_OPERATIVE, - ) - Spacer(modifier = Modifier.height(8.dp)) - TeamRoleBox( - title = "SPYMASTERS", - gradient = blueGradient, - isCurrentUser = userRole == PlayerRoles.BLUE_SPYMASTER, ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "$blueLeft LEFT", - color = Color(0xFF1565C0), - fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp, + if (cards.isEmpty()) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .background(Color(0xFFE0D8C8), RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for game state...", + color = Color(0xFF383330), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } else { + GameBoardGrid( + cards, + scale, + offset, + isSpymaster, + onReveal, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = 8.dp) + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + } + }, + ) + } + + TeamSidebar( + userRole, + color = Team.RED, + teamFound = currentRedFound, + textColor = Color(0xFFCF5530), + gradient = redGradient, ) } - Box( + Spacer(modifier = Modifier.height(12.dp)) + + HintSection( + isSpymaster, + currentHint, + hintInput, + countInput, + onHintChange = onHintChange, + onInputChange, + onCountChange = { countInput = it }, + keyboardController, + focusManager, + ) + } + + if (availableChatTabs.isNotEmpty() && isChatOpen) { + ChatWindow( + chatInput = chatInput, + messages = chatLists, + selectedTab = activeChatTab, + availableTabs = availableChatTabs, + onTabSelected = { selectedChatTab = it }, + onChatInputChange = { chatInput = it }, + onSendClick = { tab, message -> onSendChatMessage(tab, message) }, modifier = Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = 8.dp) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, - ) { - Column( + .align(Alignment.Center) + .padding(end = 24.dp, bottom = 12.dp) + .width(420.dp) + .fillMaxHeight(0.90f), + ) + } + + onSettingsClick?.let { openSettings -> + SettingsCornerButton( + onClick = openSettings, + ) + } + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun GameStatusBar( + currentTurn: PlayerRoles?, + winner: Team?, + remainingGuesses: Int, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + val statusText = + when { + winner != null -> "Winner: $winner" + currentTurn != null -> "Turn: ${currentTurn.name} | Guesses: $remainingGuesses" + else -> "Waiting for turn..." + } + + Text( + text = statusText, + color = Color(0xFF383330), + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatToggleButton( + isChatOpen: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + AppButton( + text = "Chat", + onClick = onClick, + modifier = + modifier + .width(140.dp) + .height(56.dp), + style = + AppButtonStyle( + containerColor = if (isChatOpen) Color(0xFF555555) else Color(0xFF383330), + contentColor = Color.White, + fontSize = 18.sp, + lineHeight = 20.sp, + ), + ) +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatWindow( + chatInput: String, + messages: ChatLists, + selectedTab: ChatTab, + availableTabs: List, + onTabSelected: (ChatTab) -> Unit, + onChatInputChange: (String) -> Unit, + onSendClick: (ChatTab, String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .background( + color = Color(0xE6383330), + shape = RoundedCornerShape(12.dp), + ).padding(12.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + availableTabs.forEach { tab -> + AppButton( + text = tab.title, + onClick = { onTabSelected(tab) }, modifier = Modifier - .fillMaxWidth() - .wrapContentHeight(unbounded = true) - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y, - ), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val columns = 5 - for (i in cards.indices step columns) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - for (j in 0 until columns) { - val index = i + j - if (index < cards.size) { - val card = cards[index] - Box(modifier = Modifier.weight(1f)) { - CodenamesCard( - card = card, - isSpymaster = isSpymaster, - onClick = { - if (!isSpymaster && !card.revealed) { - onReveal(index) - } - }, - ) - } - } else { - Spacer(modifier = Modifier.weight(1f)) - } - } - } - } - } + .weight(1f) + .height(36.dp), + style = + AppButtonStyle( + type = AppButtonType.PRIMARY, + containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, + contentColor = if (selectedTab == tab) Color.Unspecified else Color.LightGray, + fontSize = 11.sp, + lineHeight = 12.sp, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + ), + ) } + } + + Spacer(modifier = Modifier.height(12.dp)) + + ChatMessagesArea( + messages = messages, + selectedTab = selectedTab, + modifier = Modifier.weight(1f).fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(12.dp)) - Column( + Row( + modifier = + Modifier + .fillMaxWidth() + .height(64.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + AppTextField( + value = chatInput, + onValueChange = onChatInputChange, modifier = Modifier - .width(90.dp) + .weight(1f) .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "RED TEAM", - fontWeight = FontWeight.Bold, - color = Color(0xFFCF5530), - fontSize = 12.sp, - ) - Spacer(modifier = Modifier.height(8.dp)) + state = + AppTextFieldState( + label = "Message", + placeholder = "Type message...", + ), + style = + AppTextFieldStyle( + containerColor = Color(0xFFE0D8C8), + contentColor = Color(0xFF383330), + fontSize = 14.sp, + lineHeight = 16.sp, + ), + ) - TeamRoleBox( - title = "OPERATIVES", - gradient = redGradient, - isCurrentUser = userRole == PlayerRoles.RED_OPERATIVE, - ) - Spacer(modifier = Modifier.height(8.dp)) - TeamRoleBox( - title = "SPYMASTERS", - gradient = redGradient, - isCurrentUser = userRole == PlayerRoles.RED_SPYMASTER, - ) + AppButton( + text = "Send", + onClick = { + val trimmedMessage = chatInput.trim() + if (trimmedMessage.isNotBlank()) { + onSendClick(selectedTab, trimmedMessage) + onChatInputChange("") + } + }, + modifier = + Modifier + .width(92.dp) + .fillMaxHeight(), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + lineHeight = 18.sp, + ), + ) + } + } +} - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = "$redLeft LEFT", - color = Color(0xFFCF5530), - fontWeight = FontWeight.ExtraBold, - fontSize = 18.sp, - ) - } +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatMessagesArea( + messages: ChatLists, + selectedTab: ChatTab, + modifier: Modifier = Modifier, +) { + val visibleMessages = + when (selectedTab) { + ChatTab.GLOBAL -> messages.lobbyMessages + ChatTab.TEAM -> messages.teamMessages + ChatTab.OPERATIVES -> messages.operativeMessages } - Spacer(modifier = Modifier.height(12.dp)) + Column( + modifier = + modifier + .background( + color = Color(0xB3E0D8C8), + shape = RoundedCornerShape(8.dp), + ).padding(12.dp), + verticalArrangement = Arrangement.Top, + ) { + Text( + text = "${selectedTab.title} Chat", + color = Color(0xFF383330), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) - if (isSpymaster) { - Row { - AppTextField( - value = hintInput, - onValueChange = { hintInput = it }, - modifier = Modifier.weight(1f), - state = - AppTextFieldState( - label = "HINT", - placeholder = "Enter word...", - ), - keyboard = - AppTextFieldKeyboard( - actions = - KeyboardActions( - onSend = { - if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) - hintInput = "" - focusManager.clearFocus() - keyboardController?.hide() - } - }, - ), - ), - ) + Spacer(modifier = Modifier.height(8.dp)) - AppButton( - text = "SEND", - onClick = { - if (hintInput.isNotBlank()) { - onHintChange(hintInput.uppercase()) - hintInput = "" - } - }, - ) - } + if (visibleMessages.isEmpty()) { + Text( + text = "No messages yet.", + color = Color(0xFF383330), + fontSize = 14.sp, + ) } else { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Text( - text = "Hint: $currentHint", - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(visibleMessages) { message -> + ChatMessageBubble(message = message) + } } } } } +@Suppress("ktlint:standard:function-naming") +@Composable +fun ChatMessageBubble(message: ChatDomainModel) { + val alignment = if (message.isFromMe) Alignment.End else Alignment.Start + val bubbleColor = if (message.isFromMe) Color(0xFF4CAF50) else Color(0xFFE0D8C8) + val textColor = if (message.isFromMe) Color.White else Color(0xFF383330) + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = alignment, + ) { + Text( + text = message.sender, + color = Color(0xFF383330), + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + ) + + Box( + modifier = + Modifier + .fillMaxWidth(0.78f) + .background(bubbleColor, RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + Text( + text = message.text, + color = textColor, + fontSize = 13.sp, + ) + } + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun TeamSidebar( + userRole: PlayerRoles, + color: Team, + teamFound: Int, + textColor: Color, + gradient: Brush, +) { + val isRed = color == Team.RED + val operative = if (isRed) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE + val spymaster = if (isRed) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER + val title = if (isRed) "RED TEAM" else "BLUE TEAM" + + Column( + modifier = + Modifier + .width(90.dp) + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + fontWeight = FontWeight.Bold, + color = textColor, + fontSize = 12.sp, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TeamRoleBox( + title = "OPERATIVES", + gradient = gradient, + isCurrentUser = userRole == operative, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + TeamRoleBox( + title = "SPYMASTERS", + gradient = gradient, + isCurrentUser = userRole == spymaster, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "$teamFound FOUND", + color = textColor, + fontWeight = FontWeight.ExtraBold, + fontSize = 16.sp, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun HintSection( + isSpymaster: Boolean, + currentHint: String, + hintInput: String, + countInput: String, + onHintChange: (String, Int) -> Unit, + onInputChange: (String) -> Unit, + onCountChange: (String) -> Unit, + keyboardController: SoftwareKeyboardController?, + focusManager: FocusManager, +) { + if (isSpymaster) { + Row { + AppTextField( + value = hintInput, + onValueChange = onInputChange, + modifier = Modifier.weight(0.8f), + state = + AppTextFieldState( + label = "HINT", + placeholder = "Enter word...", + ), + keyboard = + AppTextFieldKeyboard( + actions = + KeyboardActions( + onSend = { + val count = countInput.toIntOrNull() ?: 0 + if (hintInput.isNotBlank()) { + onHintChange(hintInput.uppercase(), count) + onInputChange("") + onCountChange("") + focusManager.clearFocus() + keyboardController?.hide() + } + }, + ), + ), + ) + AppTextField( + value = countInput, + onValueChange = onCountChange, + modifier = Modifier.width(80.dp), + state = AppTextFieldState(label = "COUNT", placeholder = "0"), + ) + + AppButton( + text = "SEND", + onClick = { + val count = countInput.toIntOrNull() ?: 0 + if (hintInput.isNotBlank()) { + onHintChange(hintInput.uppercase(), count) + onInputChange("") + onCountChange("") + focusManager.clearFocus() + keyboardController?.hide() + } + }, + ) + } + } else { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = "Hint: $currentHint", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + ) + } + } +} + @Composable @Suppress("ktlint:standard:function-naming") fun TeamRoleBox( @@ -357,7 +639,7 @@ fun TeamRoleBox( if (isCurrentUser) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "👤 You", + text = "You", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold, @@ -380,6 +662,13 @@ fun CodenamesCard( else -> Color(0xFFE0D8C8) } + val contentColor = + if (!card.revealed && !isSpymaster) { + Color(0xFF383330) + } else { + Color.White + } + AppButton( text = card.word, onClick = onClick, @@ -387,7 +676,7 @@ fun CodenamesCard( style = AppButtonStyle( containerColor = backgroundColor, - contentColor = Color.White, + contentColor = contentColor, fontSize = 10.sp, ), ) @@ -400,3 +689,116 @@ fun getColor(type: CardType): Color = CardType.NEUTRAL -> Color(0xFF383330) CardType.ASSASSIN -> Color.Black } + +@Suppress("ktlint:standard:function-naming") +@Composable +fun OfflineGameStateTestScreen() { + var currentHint by rememberSaveable { mutableStateOf("EAGLE") } + var currentTurn by rememberSaveable { mutableStateOf(PlayerRoles.RED_OPERATIVE) } + var remainingGuesses by rememberSaveable { mutableIntStateOf(3) } + + val cards = + remember { + mutableStateListOf( + GameCard("BERLIN", CardType.BLUE), + GameCard("RIVER", CardType.BLUE), + GameCard("MOON", CardType.BLUE), + GameCard("PIANO", CardType.BLUE), + GameCard("FOREST", CardType.BLUE), + GameCard("ROME", CardType.RED), + GameCard("APPLE", CardType.RED), + GameCard("TRAIN", CardType.RED), + GameCard("KING", CardType.RED), + GameCard("GLASS", CardType.RED), + GameCard("CHAIR", CardType.NEUTRAL), + GameCard("STONE", CardType.NEUTRAL), + GameCard("CLOUD", CardType.NEUTRAL), + GameCard("FIELD", CardType.NEUTRAL), + GameCard("WATCH", CardType.NEUTRAL), + GameCard("VIPER", CardType.ASSASSIN), + GameCard("BREAD", CardType.NEUTRAL), + GameCard("LASER", CardType.RED), + GameCard("BRIDGE", CardType.BLUE), + GameCard("QUEEN", CardType.RED), + GameCard("OCEAN", CardType.BLUE), + GameCard("MOUSE", CardType.NEUTRAL), + GameCard("PLANE", CardType.RED), + GameCard("SUN", CardType.BLUE), + GameCard("KEY", CardType.NEUTRAL), + ) + } + + val chatLists = + ChatLists( + lobbyMessages = + listOf( + ChatDomainModel( + sender = "Anna", + text = "Welcome to the lobby chat.", + isFromMe = false, + ), + ), + teamMessages = + listOf( + ChatDomainModel( + sender = "Max", + text = "I think BERLIN fits the hint.", + isFromMe = false, + ), + ChatDomainModel( + sender = "You", + text = "Maybe RIVER too.", + isFromMe = true, + ), + ), + operativeMessages = + listOf( + ChatDomainModel( + sender = "Operative", + text = "Let's avoid VIPER.", + isFromMe = false, + ), + ), + ) + + fun revealCard(index: Int) { + val card = cards[index] + + if (card.revealed) return + + cards[index] = card.copy(revealed = true) + + when (card.type) { + CardType.NEUTRAL -> + currentTurn = + if (currentTurn == PlayerRoles.BLUE_SPYMASTER) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_SPYMASTER + CardType.ASSASSIN -> currentTurn = PlayerRoles.NONE + else -> Unit + } + + if (remainingGuesses > 0) { + remainingGuesses-- + } + } + + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = currentHint, + currentTurn = currentTurn, + remainingGuesses = remainingGuesses, + cards = cards, + currentRedFound = cards.count { it.type == CardType.RED && it.revealed }, + currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed }, + chatLists = chatLists, + availableChatTabs = ChatTab.entries, + ), + onHintChange = { word, count -> + currentHint = word + remainingGuesses = count + }, + onReveal = { index -> revealCard(index) }, + onSendChatMessage = { _, _ -> }, + ) +} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt new file mode 100644 index 0000000..58c4725 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -0,0 +1,188 @@ +package com.codenames.frontend.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +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.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton +import com.codenames.frontend.ui.inputs.AppTextField +import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard +import com.codenames.frontend.ui.inputs.AppTextFieldState +import com.codenames.frontend.ui.inputs.AppTextFieldStyle +import com.codenames.frontend.ui.inputs.AppTextFieldType +import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel + +internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" +internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" +private const val LOBBY_ID_LENGTH = 5 // lobby id hat genau 5 Zeichen + +private fun sanitizeLobbyIdInput(input: String): String = + input + .uppercase() + .filter { it.isLetterOrDigit() } + .take(LOBBY_ID_LENGTH) + +fun isLobbyIdValid(lobbyId: String): Boolean = + lobbyId.isNotBlank() && + lobbyId.length == LOBBY_ID_LENGTH && + lobbyId.matches("^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]+$".toRegex()) + +@Suppress("ktlint:standard:function-naming") +@Composable +fun JoinlobbyScreen( + navController: NavHostController, + viewModel: LobbyViewModel, + sessionViewModel: SessionViewModel, +) { + ForceLandscape() + + var lobbyId by rememberSaveable { mutableStateOf("") } + + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val state by viewModel.state.collectAsState() + val username by sessionViewModel.username.collectAsState() + + val joinEnabled = isLobbyIdValid(lobbyId) + + // Navigation wird ausgeführt, wenn alle notwendigen states im Lobby UI state gesetzt sind. Wird bei jeder rekomposition der UI durchlaufen + LaunchedEffect(state.lobbyCode, state.error, state.isLoading) { + if (!state.isLoading && state.error == null && state.lobbyCode != null) { + navController.navigate(Screen.Lobby.route) + } + } + + fun submitJoin() { + if (!joinEnabled) return + + keyboardController?.hide() + focusManager.clearFocus() + + viewModel.joinLobby(username.username, lobbyId) + } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppTextField( + value = lobbyId, + onValueChange = { newValue -> + lobbyId = sanitizeLobbyIdInput(newValue) + }, + modifier = + Modifier + .fillMaxWidth(0.5f) + .padding(bottom = 16.dp) + .testTag(JOIN_LOBBY_INPUT_TAG), + state = + AppTextFieldState( + label = "Lobby ID", + placeholder = "Enter Lobby ID", + ), + style = + AppTextFieldStyle( + type = AppTextFieldType.SECONDARY, + contentColor = Color.White, + fontSize = 20.sp, + lineHeight = 24.sp, + ), + keyboard = + AppTextFieldKeyboard( + options = + KeyboardOptions( + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + imeAction = ImeAction.Done, + ), + actions = + KeyboardActions( + onDone = { submitJoin() }, + ), + ), + ) + + AppButton( + text = "Join Lobby", + onClick = { submitJoin() }, + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = joinEnabled, + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + + if (state.isLoading) { + Text( + text = "Joining...", + color = Color(0xFF383330), + fontSize = 20.sp, + modifier = Modifier.padding(top = 12.dp), + ) + } + + state.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 18.sp, + modifier = Modifier.padding(top = 12.dp), + ) + } + } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, + ) + } +} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt deleted file mode 100644 index ef7a707..0000000 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinlobbyScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.codenames.frontend.ui.screens - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -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.width -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.codenames.frontend.ui.buttons.AppButton -import com.codenames.frontend.ui.buttons.AppButtonStyle -import com.codenames.frontend.ui.inputs.AppTextField -import com.codenames.frontend.ui.inputs.AppTextFieldKeyboard -import com.codenames.frontend.ui.inputs.AppTextFieldState -import com.codenames.frontend.ui.inputs.AppTextFieldStyle -import com.codenames.frontend.ui.inputs.AppTextFieldType - -internal const val JOIN_LOBBY_INPUT_TAG = "join_lobby_input" -internal const val JOIN_LOBBY_BUTTON_TAG = "join_lobby_button" -private const val LOBBY_ID_MAX_LENGTH = 12 - -internal fun sanitizeLobbyIdInput(input: String): String = - input - .uppercase() - .filter { it.isLetterOrDigit() } - .take(LOBBY_ID_MAX_LENGTH) - -internal fun isLobbyIdValid(lobbyId: String): Boolean = lobbyId.isNotBlank() - -@Suppress("ktlint:standard:function-naming") -@Composable -fun JoinlobbyScreen() { - ForceLandscape() - - var lobbyId by rememberSaveable { mutableStateOf("") } - - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) - - val joinEnabled = isLobbyIdValid(lobbyId) - - fun submitJoin() { - if (!joinEnabled) return - - keyboardController?.hide() - focusManager.clearFocus() - - // Hier später den echten Frontend-Join-Flow anschließen. - } - - Column( - modifier = - Modifier - .fillMaxSize() - .background(Color(0xFF4A403D)) - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AppTextField( - value = lobbyId, - onValueChange = { newValue -> - lobbyId = sanitizeLobbyIdInput(newValue) - }, - modifier = - Modifier - .fillMaxWidth(0.5f) - .padding(bottom = 16.dp) - .testTag(JOIN_LOBBY_INPUT_TAG), - state = - AppTextFieldState( - label = "Lobby ID", - placeholder = "Enter Lobby ID", - ), - style = - AppTextFieldStyle( - type = AppTextFieldType.SECONDARY, - contentColor = Color.White, - fontSize = 20.sp, - lineHeight = 24.sp, - ), - keyboard = - AppTextFieldKeyboard( - options = - KeyboardOptions( - capitalization = KeyboardCapitalization.Characters, - keyboardType = KeyboardType.Ascii, - imeAction = ImeAction.Done, - ), - actions = - KeyboardActions( - onDone = { submitJoin() }, - ), - ), - ) - - AppButton( - text = "Join Lobby", - onClick = { submitJoin() }, - modifier = - Modifier - .width(220.dp) - .height(80.dp) - .testTag(JOIN_LOBBY_BUTTON_TAG), - style = - AppButtonStyle( - enabled = joinEnabled, - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) - } -} diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt index 52e2a9f..9eb2cc3 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/LobbyScreen.kt @@ -1,9 +1,14 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues 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 @@ -12,10 +17,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -23,292 +27,356 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.enums.ConnectionState +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.AppButtonType +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.brownGradient +import com.codenames.frontend.ui.theme.greenGradient +import com.codenames.frontend.ui.theme.redGradient +import com.codenames.frontend.ui.toPlayerRole +import com.codenames.frontend.ui.toTeamAndRole +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel + +private const val JOIN_TEAM: String = "JOIN TEAM" @Suppress("ktlint:standard:function-naming") @Composable -fun LobbyScreen(navController: NavHostController) { - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) +fun LobbyScreen( + navController: NavHostController, + viewModel: LobbyViewModel, + sessionViewModel: SessionViewModel, + gameViewModel: GameViewModel, +) { + val usernameState by sessionViewModel.username.collectAsState() + val lobbyUiState by viewModel.state.collectAsState() + val currentPlayer = lobbyUiState.players.firstOrNull { it.name == usernameState.username } + val currentRole = currentPlayer?.toPlayerRole() ?: PlayerRoles.NONE + val connectionState by gameViewModel.connectionState.collectAsState() - val redGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), - ), - ) + val onStartGame = { + viewModel.sendStartGame(usernameState.username) + } - val brownGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF383330), - Color(0xFF1A1513), - ), - ) + LaunchedEffect(lobbyUiState.isGameStarted) { + if (lobbyUiState.isGameStarted) { + val lobbyCode = lobbyUiState.lobbyCode.orEmpty() + val teamAndRole = currentRole.toTeamAndRole() - val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) + Log.d("LobbyScreen", "Lobby UI state is started, recomposing") + + if (lobbyCode.isNotBlank() && teamAndRole != null) { + val (team, role) = teamAndRole + + gameViewModel.connect( + username = usernameState.username, + lobbyCode = lobbyCode, + team = team.name, + role = role.name, + isHost = viewModel.getIsHost(usernameState.username), + ) + } + } + } - var currentRole by remember { mutableStateOf(PlayerRoles.NONE) } + LaunchedEffect(connectionState) { + if (connectionState == ConnectionState.CONNECTED) { + navController.navigate(Screen.Gameboard.route) + } + } - Row( + Box( modifier = Modifier .fillMaxSize() - .padding(top = 64.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically, + .background(Color(0xFFf0d8ce)), ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + Row( + modifier = + Modifier + .fillMaxSize() + .padding(top = 40.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "BLUE TEAM", - color = Color(0xFF42A5F5), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.BLUE, + gradient = blueGradient, + textColor = Color(0xFF42A5F5), + title = "BLUE TEAM", + onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + lobbyUiState = lobbyUiState, + ) + + GameSettingsColumn( modifier = Modifier - .align(Alignment.Start) - .padding(start = 6.dp) - .padding(bottom = 6.dp), + .padding(horizontal = 24.dp) + .fillMaxHeight(), + navController = navController, + lobbyCode = lobbyUiState.lobbyCode ?: "", + currentRole = currentRole, + onStartGame = onStartGame, + viewModel = viewModel, + sessionViewModel = sessionViewModel, + ) + + TeamColumn( + modifier = Modifier.weight(1f), + color = Team.RED, + gradient = redGradient, + textColor = Color(0xFFDE8468), + title = "RED TEAM", + onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + lobbyUiState = lobbyUiState, ) + } - Column( + lobbyUiState.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 16.sp, modifier = Modifier - .align(Alignment.Start) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(blueGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("OPERATIVES", color = Color.White, fontWeight = FontWeight.Bold) + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) + } - if (currentRole == PlayerRoles.BLUE_OPERATIVE) { - Text( - text = "👤 1 joined", - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } + SettingsCornerButton( + onClick = { + navController.navigate(Screen.Settings.route) + }, + ) + } +} - AppButton( - text = "JOIN TEAM", - onClick = { currentRole = PlayerRoles.BLUE_OPERATIVE }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } +@Suppress("ktlint:standard:function-naming") +@Composable +fun TeamColumn( + modifier: Modifier, + color: Team, + gradient: Brush, + textColor: Color, + title: String, + onRoleSelect: (PlayerRoles) -> Unit, + lobbyUiState: LobbyUiState, +) { + val align = if (color == Team.RED) Alignment.End else Alignment.Start - Column( - modifier = - Modifier - .align(Alignment.Start) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(start = 6.dp, bottom = 12.dp) - .background(blueGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("SPYMASTERS", color = Color.White, fontWeight = FontWeight.Bold) + Column( + modifier = + modifier + .fillMaxWidth(0.5f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val cardModifier = + Modifier + .align(align) + .width(200.dp) + .height(150.dp) + .fillMaxWidth(0.5f) + .padding(start = 6.dp, bottom = 12.dp) + .background(gradient, RoundedCornerShape(12.dp)) + .padding(12.dp) - if (currentRole == PlayerRoles.BLUE_SPYMASTER) { - Text( - text = "👤 1 joined", - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } + Text( + text = title, + color = textColor, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(align) + .padding(start = 6.dp) + .padding(end = 6.dp) + .padding(bottom = 6.dp), + ) - AppButton( - text = "JOIN TEAM", - onClick = { currentRole = PlayerRoles.BLUE_SPYMASTER }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) + RoleCard( + role = if (color == Team.RED) PlayerRoles.RED_OPERATIVE else PlayerRoles.BLUE_OPERATIVE, + onRoleSelect = onRoleSelect, + modifier = cardModifier, + title = "OPERATIVES", + players = if (color == Team.RED) lobbyUiState.redOperatives else lobbyUiState.blueOperatives, + ) + + RoleCard( + role = if (color == Team.RED) PlayerRoles.RED_SPYMASTER else PlayerRoles.BLUE_SPYMASTER, + onRoleSelect = onRoleSelect, + modifier = cardModifier, + title = "SPYMASTERS", + players = if (color == Team.RED) lobbyUiState.redSpymasters else lobbyUiState.blueSpymasters, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +fun RoleCard( + role: PlayerRoles, + onRoleSelect: (PlayerRoles) -> Unit, + modifier: Modifier, + title: String, + players: List = emptyList(), +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween, + ) { + Text(title, color = Color.White, fontWeight = FontWeight.Bold) + if (players.isEmpty()) { + Text( + text = "No players", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp, + ) + } else { + for (player in players) { + Text(player, color = Color.White) } } - Column( - modifier = Modifier.padding(horizontal = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Column( - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(250.dp) - .fillMaxWidth(0.5f) - .background(brownGradient, RoundedCornerShape(12.dp)) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - ) { - Text( - text = "GAME SETTINGS", - color = Color.White, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp), - ) + AppButton( + text = JOIN_TEAM, + onClick = { onRoleSelect(role) }, + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 16.sp, + ), + ) + } +} - AppButton( - text = "TIMER: OFF", - onClick = { /* TODO: Timer Logik */ }, - modifier = - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - style = - AppButtonStyle( - containerColor = Color(0xFF555555), - contentColor = Color.White, - fontSize = 18.sp, - ), - ) - } +@Suppress("ktlint:standard:function-naming") +@Composable +fun GameSettingsColumn( + modifier: Modifier, + navController: NavController, + lobbyCode: String, + viewModel: LobbyViewModel, + sessionViewModel: SessionViewModel, + currentRole: PlayerRoles, + onStartGame: () -> Unit, +) { + val usernameState by sessionViewModel.username.collectAsState() + val canStart = + usernameState.username.isNotBlank() && + lobbyCode.isNotBlank() && + currentRole != PlayerRoles.NONE && + viewModel.getIsHost(usernameState.username) - AppButton( - text = "START GAME", - onClick = { - navController.navigate("${Screen.Gameboard.route}/${currentRole.name}") - }, - modifier = - Modifier - .align(Alignment.CenterHorizontally) - .width(400.dp) - .height(70.dp) - .fillMaxWidth(0.5f) - .padding(top = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 28.sp, - lineHeight = 30.sp, - ), - ) - } + Column( + modifier = modifier, + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "LOBBY CODE: $lobbyCode", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .padding(top = 8.dp), + ) + + Spacer(modifier = Modifier.weight(1f)) Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.Center, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.5f) + .background(brownGradient, RoundedCornerShape(12.dp)) + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, ) { Text( - text = "RED TEAM", - color = Color(0xFFDE8468), - fontSize = 24.sp, + text = "GAME SETTINGS", + color = Color.White, fontWeight = FontWeight.Bold, - modifier = - Modifier - .align(Alignment.End) - .padding(end = 6.dp) - .padding(bottom = 6.dp), + modifier = Modifier.padding(bottom = 16.dp), ) - Column( + AppButton( + text = "TIMER: OFF", + onClick = { /* TODO: Timer Logik */ }, modifier = Modifier - .align(Alignment.End) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(end = 6.dp, bottom = 12.dp) - .background(redGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("OPERATIVES", color = Color.White, fontWeight = FontWeight.Bold) - - if (currentRole == PlayerRoles.RED_OPERATIVE) { - Text( - text = "👤 1 joined", - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) - } + .fillMaxWidth() + .padding(bottom = 8.dp), + style = + AppButtonStyle( + containerColor = Color(0xFF555555), + contentColor = Color.White, + fontSize = 18.sp, + ), + ) + } - AppButton( - text = "JOIN TEAM", - onClick = { currentRole = PlayerRoles.RED_OPERATIVE }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } + Spacer(modifier = Modifier.weight(1f)) - Column( - modifier = - Modifier - .align(Alignment.End) - .width(200.dp) - .height(150.dp) - .fillMaxWidth(0.5f) - .padding(end = 6.dp, bottom = 12.dp) - .background(redGradient, RoundedCornerShape(12.dp)) - .padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.SpaceBetween, - ) { - Text("SPYMASTERS", color = Color.White, fontWeight = FontWeight.Bold) + AppButton( + text = "START GAME", + onClick = onStartGame, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.5f) + .padding(top = 16.dp), + style = + AppButtonStyle( + enabled = canStart, + backgroundBrush = greenGradient, + fontSize = 20.sp, + type = AppButtonType.PRIMARY, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + ), + ) - if (currentRole == PlayerRoles.RED_SPYMASTER) { - Text( - text = "👤 1 joined", - color = Color.White, - modifier = Modifier.padding(vertical = 8.dp), - ) + AppButton( + text = "LEAVE LOBBY", + onClick = { + val onResult = { successful: Boolean -> + if (successful) { + navController.navigate(Screen.Start.route) { + popUpTo(Screen.Start.route) { inclusive = true } + } + } } - - AppButton( - text = "JOIN TEAM", - onClick = { currentRole = PlayerRoles.RED_SPYMASTER }, - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 16.sp, - ), - ) - } - } + viewModel.leaveLobby(username = usernameState.username, onResult = onResult) + }, + modifier = + Modifier + .align(Alignment.CenterHorizontally) + .fillMaxWidth(0.5f) + .padding(top = 16.dp) + .padding(bottom = 16.dp), + style = + AppButtonStyle( + backgroundBrush = brownGradient, + fontSize = 20.sp, + contentColor = Color.Black, + type = AppButtonType.SECONDARY, + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + ), + ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/navigation/ScreenOrientation.kt b/app/src/main/java/com/codenames/frontend/ui/screens/ScreenOrientation.kt similarity index 100% rename from app/src/main/java/com/codenames/frontend/ui/navigation/ScreenOrientation.kt rename to app/src/main/java/com/codenames/frontend/ui/screens/ScreenOrientation.kt diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt index 4db6df1..70cc208 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/SettingsScreen.kt @@ -1,26 +1,86 @@ package com.codenames.frontend.ui.screens +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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 androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.ReturnCornerButton +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.greenGradient @Suppress("ktlint:standard:function-naming") @Composable -fun SettingsScreen() { - Column( +fun SettingsScreen(navController: NavHostController) { + Box( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .background(Color(0xFFf0d8ce)), ) { - Text("Insert Game Settings here") + Text( + text = "SETTINGS", + color = Color(0xFF383330), + fontSize = 36.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(Alignment.TopCenter) + .padding(top = 48.dp), + ) + + Column( + modifier = Modifier.align(Alignment.Center), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppButton( + text = "Toggle SFX", + onClick = {}, + modifier = + Modifier + .width(240.dp) + .height(80.dp), + style = + AppButtonStyle( + backgroundBrush = blueGradient, + fontSize = 24.sp, + lineHeight = 28.sp, + ), + ) + + AppButton( + text = "Toggle Music", + onClick = {}, + modifier = + Modifier + .width(240.dp) + .height(80.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 24.sp, + lineHeight = 28.sp, + ), + ) + } + + ReturnCornerButton( + onClick = { navController.popBackStack() }, + ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt index ff37092..8446760 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt @@ -2,6 +2,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background 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.fillMaxSize @@ -11,146 +12,140 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.ui.buttons.AppButton import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen -import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.ui.theme.greenGradient +import com.codenames.frontend.viewmodel.LobbyViewModel +import com.codenames.frontend.viewmodel.SessionViewModel @Suppress("ktlint:standard:function-naming") @Composable fun StartScreen( navController: NavHostController, - viewModel: GameViewModel = hiltViewModel(), + lobbyViewModel: LobbyViewModel, + sessionViewModel: SessionViewModel, ) { - val state by viewModel.connectionState.collectAsState() ForceLandscape() - val greenGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF4CAF50), - Color(0xFF2E7D32), - ), - ) + val lobbyState by lobbyViewModel.state.collectAsState() + val usernameState by sessionViewModel.username.collectAsState() - val blueGradient = - Brush.verticalGradient( - colors = - listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), - ), - ) + LaunchedEffect(lobbyState.lobbyCode, lobbyState.error, lobbyState.isLoading) { + if (!lobbyState.isLoading && lobbyState.error == null && lobbyState.lobbyCode != null) { + navController.navigate(Screen.Lobby.route) + } + } - Column( + Box( modifier = Modifier .fillMaxSize() - .background(Color(0xFF4A403D)), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, + .background(Color(0xFFf0d8ce)), ) { - Row( - modifier = - Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { - AppButton( - text = "Create Lobby", - onClick = { - navController.navigate(Screen.Lobby.route) - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, end = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) + Text("Welcome to Codenames, ${usernameState.username}!", fontSize = 32.sp, modifier = Modifier.padding(bottom = 48.dp)) - AppButton( - text = "Join Lobby", - onClick = { - navController.navigate(Screen.JoinLobby.route) - }, + Row( modifier = Modifier - .width(200.dp) - .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, start = 12.dp), - style = - AppButtonStyle( - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) - } + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AppButton( + text = "Create Lobby", + onClick = { + lobbyViewModel.createLobby(usernameState.username) + }, + modifier = + Modifier + .width(200.dp) + .height(100.dp) + .fillMaxWidth(0.5f) + .padding(bottom = 12.dp, end = 12.dp), + style = + AppButtonStyle( + enabled = !lobbyState.isLoading, + backgroundBrush = greenGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) - AppButton( - text = "test Mode", - onClick = { - navController.navigate("game_test") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = greenGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) + AppButton( + text = "Join Lobby", + onClick = { + navController.navigate(Screen.JoinLobby.route) + }, + modifier = + Modifier + .width(200.dp) + .height(100.dp) + .fillMaxWidth(0.5f) + .padding(bottom = 12.dp, start = 12.dp), + style = + AppButtonStyle( + enabled = !lobbyState.isLoading, + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) - AppButton( - text = "Connect to Server", - onClick = { - viewModel.connect("TestUser", "12345") - }, - modifier = - Modifier - .width(200.dp) - .height(100.dp) - .padding(bottom = 12.dp), - style = - AppButtonStyle( - backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, - ), - ) + AppButton( + text = "Offline UI Test", + onClick = { + navController.navigate("game_test") + }, + modifier = + Modifier + .width(200.dp) + .height(100.dp) + .padding(bottom = 12.dp, start = 12.dp), + style = + AppButtonStyle( + backgroundBrush = greenGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } - when (state) { - is ConnectionState.CONNECTING -> Text(text = "Connecting...", color = Color.Yellow, fontSize = 25.sp) - is ConnectionState.CONNECTED -> Text("Connected", color = Color.Green, fontSize = 25.sp) - is ConnectionState.Error -> { - Text("Error while connecting: ") - Text((state as ConnectionState.Error).message) + if (lobbyState.isLoading) { + Text( + text = "Loading...", + color = Color(0xFF383330), + fontSize = 22.sp, + ) + } + + lobbyState.error?.let { error -> + Text( + text = error, + color = Color(0xFFCF5530), + fontSize = 18.sp, + modifier = Modifier.padding(top = 12.dp), + ) } - else -> {} } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, + ) } } diff --git a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt new file mode 100644 index 0000000..c723238 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -0,0 +1,94 @@ +package com.codenames.frontend.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.width +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.buttons.SettingsCornerButton +import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.viewmodel.SessionViewModel + +@Suppress("ktlint:standard:function-naming") +@Composable +fun UserNameScreen( + navController: NavController, + viewModel: SessionViewModel, +) { + var username by remember { mutableStateOf("") } + + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color(0xFFf0d8ce)), + ) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Codenames", + fontSize = 48.sp, + modifier = Modifier.padding(bottom = 100.dp), + ) + + TextField( + value = username, + onValueChange = { username = it }, + label = { Text("enter username") }, + modifier = Modifier.fillMaxWidth(0.5f), + ) + + Spacer(modifier = Modifier.height(10.dp)) + + AppButton( + text = "Continue", + onClick = { + if (username.isBlank()) return@AppButton + viewModel.setUsername(username) + navController.navigate(Screen.Start.route) + }, + modifier = + Modifier + .width(220.dp) + .height(80.dp) + .testTag(JOIN_LOBBY_BUTTON_TAG), + style = + AppButtonStyle( + enabled = username.isNotBlank(), + backgroundBrush = blueGradient, + fontSize = 26.sp, + lineHeight = 30.sp, + ), + ) + } + + SettingsCornerButton( + onClick = { navController.navigate(Screen.Settings.route) }, + ) + } +} diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt new file mode 100644 index 0000000..197b01c --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt @@ -0,0 +1,40 @@ +package com.codenames.frontend.ui.theme + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color + +val greenGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF4CAF50), + Color(0xFF2E7D32), + ), + ) + +val blueGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF42A5F5), + Color(0xFF1565C0), + ), + ) + +val redGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFFCF5530), + Color(0xFFDE8468), + ), + ) + +val brownGradient = + Brush.verticalGradient( + colors = + listOf( + Color(0xFF383330), + Color(0xFF1A1513), + ), + ) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..508927b --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -0,0 +1,75 @@ +package com.codenames.frontend.viewmodel + +import androidx.lifecycle.ViewModel +import com.codenames.frontend.data.model.ChatMessage +import com.codenames.frontend.data.model.ChatUiState +import com.codenames.frontend.data.model.enums.ChatTab +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ChatViewModel : ViewModel() { + private val _uiState = + MutableStateFlow( + ChatUiState( + messages = + listOf( + ChatMessage( + id = 1, + sender = "SYSTEM", + message = "Game started", + timestamp = "12:00", + chatTab = ChatTab.GLOBAL, + ), + ChatMessage( + id = 2, + sender = "Anna", + message = "Hello team", + timestamp = "12:01", + chatTab = ChatTab.TEAM, + ), + ChatMessage( + id = 3, + sender = "Max", + message = "Lets win this", + timestamp = "12:02", + chatTab = ChatTab.OPERATIVES, + ), + ), + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + fun updateInput(newInput: String) { + _uiState.value = + _uiState.value.copy( + currentInput = newInput, + ) + } + + fun sendMessage( + username: String, + tab: ChatTab, + ) { + val text = _uiState.value.currentInput.trim() + + if (text.isBlank()) { + return + } + + val newMessage = + ChatMessage( + id = _uiState.value.messages.size + 1, + sender = username, + message = text, + timestamp = "NOW", + chatTab = tab, + ) + + _uiState.value = + _uiState.value.copy( + messages = _uiState.value.messages + newMessage, + currentInput = "", + ) + } +} diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt index 4f5a3ee..68841a1 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -1,15 +1,27 @@ package com.codenames.frontend.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.model.ChatLists +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.ConnectionState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.data.model.toGameState +import com.codenames.frontend.data.repository.ChatRepository +import com.codenames.frontend.data.repository.GameRepository import com.codenames.frontend.network.dto.GameMessage -import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -18,11 +30,16 @@ class GameViewModel @Inject constructor( private val client: GameWebSocketHandler, + private val chatRepository: ChatRepository, + private val gameRepository: GameRepository, ) : ViewModel() { private var job: Job? = null - private val _uiState = MutableStateFlow(GameMessage()) - val uiState: StateFlow = _uiState + private val _uiState = MutableStateFlow(GameState()) + val uiState: StateFlow = _uiState + + private val _chatState = MutableStateFlow(ChatLists()) + val chatState: StateFlow = _chatState private val _connectionState = MutableStateFlow(ConnectionState.IDLE) val connectionState: StateFlow = _connectionState @@ -30,6 +47,9 @@ class GameViewModel fun connect( username: String, lobbyCode: String, + team: String, + role: String, + isHost: Boolean = false, ) { job?.cancel() @@ -40,6 +60,8 @@ class GameViewModel try { client.connectStomp() + Log.d("GameViewModel", "Connection successful") + _connectionState.value = ConnectionState.CONNECTED launch { @@ -47,15 +69,166 @@ class GameViewModel .subscribeToLobby(lobbyCode) .collect { handleMessage(it) } } - client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) + + Log.d("GameViewModel", "Subscribed to Lobby") + + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> + _chatState.update { currentState -> + currentState.copy(lobbyMessages = currentState.lobbyMessages + msg) + } + } + } + + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> + _chatState.update { currentState -> + currentState.copy(teamMessages = currentState.teamMessages + msg) + } + } + } + + if (role == Role.OPERATIVE.name) { + launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + _chatState.update { currentState -> + currentState.copy(operativeMessages = currentState.operativeMessages + msg) + } + } + } + } + + if (isHost) { + delay(2000) + sendGameStart(lobbyCode) + } } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } } } + private fun sendGameStart(lobbyCode: String) { + if (lobbyCode.isBlank()) { + return + } + viewModelScope.launch { + gameRepository.startGame(lobbyCode) + } + } + + fun sendLobbyMessage( + lobbyCode: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + } + } + + fun sendTeamMessage( + lobbyCode: String, + team: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) + } + } + + fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) + } + } + + fun submitClue( + lobbyCode: String, + word: String, + count: Int, + ) { + if (lobbyCode.isBlank()) { + return + } + + val turn = uiState.value.currentTurn + if (turn != PlayerRoles.BLUE_SPYMASTER && turn != PlayerRoles.RED_SPYMASTER) return + + val team = if (turn == PlayerRoles.BLUE_SPYMASTER) Team.BLUE else Team.RED + viewModelScope.launch { + try { + client.sendClue(lobbyCode, word, count, team) + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + } + } + } + + fun sendChatMessage( + tab: ChatTab, + lobbyCode: String, + username: String, + team: Team?, + content: String, + availableChatTabs: List, + ) { + if (lobbyCode.isBlank()) { + return + } + + when (tab) { + ChatTab.GLOBAL -> + sendLobbyMessage( + lobbyCode = lobbyCode, + username = username, + content = content, + ) + + ChatTab.TEAM -> + if (team != null) { + sendTeamMessage( + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, + ) + } + + ChatTab.OPERATIVES -> + if (team != null && ChatTab.OPERATIVES in availableChatTabs) { + sendOperativeMessage( + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, + ) + } + } + } + fun handleMessage(message: GameMessage) { - _uiState.value = message - // Add logic to handle incoming messages + val state = message.toGameState() + _uiState.update { + state + } + Log.d("GameViewModel", "Updated game state: $state") + } + + fun getCurrentFound(team: CardType): Int { + val cards = _uiState.value.cards + var count = 0 + for (card in cards) { + if (card.type == team && card.revealed) { + count++ + } + } + return count } } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt index 9bf0535..c99379b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -1,12 +1,16 @@ package com.codenames.frontend.viewmodel +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.LobbyUiState +import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toLobbyState import com.codenames.frontend.data.repository.LobbyRepository +import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -72,6 +76,7 @@ class LobbyViewModel _state.update { response.toLobbyState() } + updateUiState(_state.value.players) startPolling(response.lobbyCode) } catch (e: Exception) { setError(e.message) @@ -81,11 +86,16 @@ class LobbyViewModel } } - fun leaveLobby(username: String) { + fun leaveLobby( + username: String, + onResult: (Boolean) -> Unit, + ) { val lobbyCode = _state.value.lobbyCode + var successful = false if (lobbyCode.isNullOrBlank()) { setError("Not in a lobby, leaving not possible") + onResult(successful) return } @@ -93,24 +103,28 @@ class LobbyViewModel setLoading(true) try { - val response = repository.leaveLobby(username, lobbyCode) + val response = repository.leaveLobby(lobbyCode, username) _state.update { response.toLobbyState() } stopPolling() + successful = true } catch (e: Exception) { setError(e.message) + successful = false } finally { setLoading(false) + onResult(successful) + if (successful) cleanup() } } } fun changeRole( - username: String, role: Role, team: Team, + username: String, ) { val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { @@ -127,6 +141,7 @@ class LobbyViewModel _state.update { response.toLobbyState() } + updateUiState(_state.value.players) } catch (e: Exception) { setError(e.message) } finally { @@ -135,6 +150,124 @@ class LobbyViewModel } } + fun changeRole( + role: PlayerRoles, + username: String, + ) { + when (role) { + PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username) + PlayerRoles.RED_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.RED, username = username) + PlayerRoles.BLUE_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.BLUE, username = username) + PlayerRoles.RED_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.RED, username = username) + else -> setError("Invalid role") + } + } + + fun getRoleForUser(username: String): PlayerRoles { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return PlayerRoles.NONE + return when (player.role) { + Role.OPERATIVE -> if (player.team == Team.BLUE) PlayerRoles.BLUE_OPERATIVE else PlayerRoles.RED_OPERATIVE + Role.SPYMASTER -> if (player.team == Team.BLUE) PlayerRoles.BLUE_SPYMASTER else PlayerRoles.RED_SPYMASTER + null -> PlayerRoles.NONE + } + } + + fun getAvailableChatTabsForUser(username: String): List { + val player = _state.value.players.firstOrNull { it.name == username } + val team = player?.team + val role = player?.role + + val tabs = mutableListOf(ChatTab.GLOBAL) + + if (team != null) { + tabs.add(ChatTab.TEAM) + } + + if (canUseOperativesChat(team = team, role = role)) { + tabs.add(ChatTab.OPERATIVES) + } + + return tabs + } + + private fun canUseOperativesChat( + team: Team?, + role: Role?, + ): Boolean { + if (team == null || role != Role.OPERATIVE) { + return false + } + + val sameTeamOperativeCount = + _state.value.players.count { player -> + player.team == team && player.role == Role.OPERATIVE + } + + return sameTeamOperativeCount > 1 + } + + fun getIsHost(username: String): Boolean { + val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false + return player.isHost + } + + fun sendStartGame(username: String) { + val lobbyCode = _state.value.lobbyCode.orEmpty() + if (!username.isBlank() && !lobbyCode.isEmpty() && getIsHost(username)) { + viewModelScope.launch { + try { + setLoading(true) + val response = repository.sendStartGame(lobbyCode, username) + _state.update { + response.toLobbyState() + } + updateUiState(_state.value.players) + } catch (e: Exception) { + setError(e.message) + } finally { + Log.d("LobbyViewModel", "Game start sent. Current state: ${_state.value}") + setLoading(false) + } + } + } + } + + private fun cleanup() { + _state.update { + it.copy( + lobbyCode = null, + players = emptyList(), + blueOperatives = emptyList(), + blueSpymasters = emptyList(), + redOperatives = emptyList(), + redSpymasters = emptyList(), + ) + } + } + + private fun updateUiState(players: List) { + _state.update { + it.copy( + blueOperatives = + players + .filter { p -> p.team == Team.BLUE && p.role == Role.OPERATIVE } + .map { p -> p.name }, + blueSpymasters = + players + .filter { p -> p.team == Team.BLUE && p.role == Role.SPYMASTER } + .map { p -> p.name }, + redOperatives = + players + .filter { p -> p.team == Team.RED && p.role == Role.OPERATIVE } + .map { p -> p.name }, + redSpymasters = + players + .filter { p -> p.team == Team.RED && p.role == Role.SPYMASTER } + .map { p -> p.name }, + ) + } + } + private fun startPolling(lobbyCode: String) { if (pollingJob != null) return @@ -147,6 +280,7 @@ class LobbyViewModel _state.update { response.toLobbyState() } + updateUiState(_state.value.players) } catch (e: Exception) { setError(e.message) return@launch diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt new file mode 100644 index 0000000..8a9d666 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -0,0 +1,21 @@ +package com.codenames.frontend.viewmodel + +import androidx.lifecycle.ViewModel +import com.codenames.frontend.data.model.SessionState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject + +@HiltViewModel +class SessionViewModel + @Inject + constructor() : ViewModel() { + private val _username = MutableStateFlow(SessionState("")) + val username: StateFlow = _username + + fun setUsername(username: String) { + _username.update { SessionState(username) } + } + } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..25b13fd 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -6,8 +6,5 @@ See https://developer.android.com/about/versions/12/backup-restore --> - + \ No newline at end of file diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..befb127 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -5,15 +5,7 @@ --> - + - + \ No newline at end of file diff --git a/app/src/test/java/com/codenames/frontend/UiMappersTest.kt b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt new file mode 100644 index 0000000..b5de2f5 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/UiMappersTest.kt @@ -0,0 +1,34 @@ +package com.codenames.frontend + +import com.codenames.frontend.data.model.Player +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.ui.roles.PlayerRoles +import com.codenames.frontend.ui.toGameCard +import com.codenames.frontend.ui.toPlayerRole +import org.junit.Assert.assertEquals +import org.junit.Test + +class UiMappersTest { + @Test + fun cardDtoMapsBackendColorsToGameCards() { + assertEquals(CardType.BLUE, CardDto("A", CardType.BLUE, false).toGameCard().type) + assertEquals(CardType.RED, CardDto("A", CardType.RED, false).toGameCard().type) + assertEquals(CardType.ASSASSIN, CardDto("A", CardType.ASSASSIN, false).toGameCard().type) + assertEquals(CardType.NEUTRAL, CardDto("A", CardType.NEUTRAL, false).toGameCard().type) + } + + @Test + fun playerMapsToPlayerRole() { + val player = + Player( + name = "Max", + role = Role.SPYMASTER, + team = Team.BLUE, + ) + + assertEquals(PlayerRoles.BLUE_SPYMASTER, player.toPlayerRole()) + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt b/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt new file mode 100644 index 0000000..c1ddcb4 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt @@ -0,0 +1,111 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.ClueDto +import com.codenames.frontend.network.dto.GameMessage +import com.codenames.frontend.ui.roles.PlayerRoles +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.test.assertNull + +class MapperTest { + @Test + fun getCurrentTurn_redSpymaster_returnsRedSpymaster() { + val message = + GameMessage( + currentTurn = Team.RED, + currentPhase = Role.SPYMASTER, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + } + + @Test + fun getCurrentTurn_redOperative_returnsRedOperative() { + val message = + GameMessage( + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.RED_OPERATIVE, result) + } + + @Test + fun getCurrentTurn_blueSpymaster_returnsBlueSpymaster() { + val message = + GameMessage( + currentTurn = Team.BLUE, + currentPhase = Role.SPYMASTER, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.BLUE_SPYMASTER, result) + } + + @Test + fun getCurrentTurn_blueOperative_returnsBlueOperative() { + val message = + GameMessage( + currentTurn = Team.BLUE, + currentPhase = Role.OPERATIVE, + ) + + val result = message.getCurrentTurn() + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun toGameState_withCurrentClue_mapsCorrectly() { + val gameMessage = + GameMessage( + currentClue = ClueDto(word = "Animal", guessAmount = 2), + cardList = + listOf( + CardDto( + word = "Dog", + color = CardType.RED, + isGuessed = false, + ), + ), + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + winner = null, + ) + + val result = gameMessage.toGameState() + + assertEquals("Animal", result.currentHint) + assertEquals(PlayerRoles.RED_OPERATIVE, result.currentTurn) + assertEquals(2, result.remainingGuesses) + assertNull(result.winner) + + assertEquals(1, result.cards.size) + assertEquals("Dog", result.cards[0].word) + } + + @Test + fun toGameState_withNullCurrentClue_usesEmptyString() { + val gameMessage = + GameMessage( + currentClue = null, + cardList = emptyList(), + currentTurn = Team.BLUE, + currentPhase = Role.SPYMASTER, + winner = null, + ) + + val result = gameMessage.toGameState() + + assertEquals("", result.currentHint) + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt new file mode 100644 index 0000000..3e6e4fb --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -0,0 +1,140 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFailsWith + +class ChatRepositoryTest { + private lateinit var webSocketHandler: GameWebSocketHandler + private lateinit var repository: ChatRepository + + private val testTopic = "/topic/chat" + private val testUser = "TestUser" + private val testContent = "Hello World" + private val testDto = ChatMessageDto(senderUsername = testUser, content = testContent) + + @Before + fun setup() { + webSocketHandler = mockk() + repository = ChatRepository(webSocketHandler) + // Suspend methods use coroutines (lighter version of thread) -> instead of every, we use coEvery + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns flowOf(testDto) + } + + @Test + fun testObserveChat_correctContent() = + runTest { + // Since we mock the function to return a flow, we can call toList and grab all the contents + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testContent, result[0].text) + } + + @Test + fun testObserveChat_correctUser() = + runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(testUser, result[0].sender) + } + + @Test + fun testObserveChat_fromMe() = + runTest { + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result[0].isFromMe) + } + + @Test + fun testObserveChat_notFromMe() = + runTest { + val result = repository.observeChat(testTopic, "NotTestUser").toList() + assertFalse(result[0].isFromMe) + } + + @Test + fun testSendMessage() = + runTest { + coEvery { webSocketHandler.sendChatMessage(testTopic, testDto) } just Runs + repository.sendMessage(testTopic, testUser, testContent) + coVerify { webSocketHandler.sendChatMessage(testTopic, testDto) } + } + + @Test + fun testObserveChat_emptyFlow() = + runTest { + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns emptyFlow() + val result = repository.observeChat(testTopic, testUser).toList() + assertTrue(result.isEmpty()) + } + + @Test + fun testObserveChat_multipleMessages() = + runTest { + val dtos = + flowOf( + ChatMessageDto(testUser, "First"), + ChatMessageDto("OtherUser", "Second"), + ) + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns dtos + + val result = repository.observeChat(testTopic, testUser).toList() + assertEquals(2, result.size) + } + + @Test + fun testObserveChat_webSocketError() = + runTest { + coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() + assertFailsWith { + repository.observeChat(testTopic, testUser).toList() + } + } + + @Test + fun testObserveChat_errorDuringCollection() = + runTest { + val flowWithError = + flow { + emit(testDto) + throw RuntimeException("Connection Lost") + } + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns flowWithError + + val flow = repository.observeChat(testTopic, testUser) + assertFailsWith { + flow.toList() + } + } + + // Testing early cancellation of flow using .take(1) + @Test + fun testObserveChat_earlyCancellation() = + runTest { + val dtos = + flowOf( + ChatMessageDto(testUser, "First"), + ChatMessageDto("OtherUser", "Second"), + ) + coEvery { webSocketHandler.subscribeToChat(testTopic) } returns dtos + + // take() will consume x elements and cancel the flow + val result = repository.observeChat(testTopic, testUser).take(1).toList() + + assertEquals(1, result.size) + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt new file mode 100644 index 0000000..be20743 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -0,0 +1,34 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.network.websocket.GameWebSocketHandler +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GameRepositoryTest { + private val webSocketHandler: GameWebSocketHandler = mockk() + private lateinit var gameRepository: GameRepository + + @Before + fun setUp() { + this.gameRepository = GameRepository(webSocketHandler) + } + + @Test + fun testStartGame() = + runTest { + val lobbyCode = "ABCDE" + coEvery { webSocketHandler.startGame(any()) } just Runs + + gameRepository.startGame(lobbyCode) + + coVerify { webSocketHandler.startGame(any()) } + } +} diff --git a/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt index 605a3bb..0b215ae 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/LobbyRepositoryTest.kt @@ -4,6 +4,7 @@ import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.api.LobbyApi import com.codenames.frontend.network.dto.LobbyResponse +import com.codenames.frontend.network.dto.PlayerDto import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -33,6 +34,7 @@ class LobbyRepositoryTest { LobbyResponse( lobbyCode = "1234", playerList = emptyList(), + isStarted = false, ) coEvery { api.createLobby(username) } returns response @@ -49,13 +51,13 @@ class LobbyRepositoryTest { val username = "Max" val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) - coEvery { api.joinLobby(username, lobbyCode) } returns response + coEvery { api.joinLobby(lobbyCode, username) } returns response val result = repository.joinLobby(username, lobbyCode) - coVerify { api.joinLobby(username, lobbyCode) } + coVerify { api.joinLobby(lobbyCode, username) } assertEquals(response, result) } @@ -65,7 +67,7 @@ class LobbyRepositoryTest { val username = "Max" val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.leaveLobby(username, lobbyCode) } returns response @@ -80,7 +82,7 @@ class LobbyRepositoryTest { runTest { val lobbyCode = "1234" - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.getLobbyInfo(lobbyCode) } returns response @@ -96,7 +98,7 @@ class LobbyRepositoryTest { val role = Role.OPERATIVE val team = Team.RED - val response = LobbyResponse(lobbyCode, emptyList()) + val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.changeRole(eq(lobbyCode), any()) } returns response @@ -125,4 +127,27 @@ class LobbyRepositoryTest { repository.createLobby(username) } } + + @Test + fun sendStartGame_callsApi() = + runTest { + val lobbyCode = "ABCDE" + val username = "Test" + val list = + listOf( + PlayerDto( + username, + null, + null, + true, + ), + ) + + coEvery { api.startGame(any(), any()) } returns LobbyResponse(lobbyCode, list, false) + + val response = repository.sendStartGame(lobbyCode, username) + + coVerify { api.startGame(any(), any()) } + assertEquals(response.lobbyCode, lobbyCode) + } } diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt index 5df59a3..8e7d3ea 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt @@ -1,11 +1,18 @@ package com.codenames.frontend.network.websocket +import android.util.Log +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.ChatMessageDto +import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.runTest import org.hildan.krossbow.stomp.StompClient @@ -14,9 +21,22 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxS import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe +import org.junit.Before import org.junit.Test class GameWebSocketHandlerTest { + private lateinit var client: StompClient + private lateinit var session: StompSessionWithKxSerialization + private lateinit var wsClient: GameWebSocketHandler + + @Before + fun setup() { + client = mockk() + session = mockk(relaxed = true) + wsClient = GameWebSocketHandler(client) + wsClient.session = session + } + @Test fun testConnectStomp() = runTest { @@ -24,7 +44,10 @@ class GameWebSocketHandlerTest { val session = mockk() val sessionWithJson = mockk() + mockkStatic(Log::class) + coEvery { client.connect(BASE_URL) } returns session + every { Log.d(any(), any()) } returns 0 coEvery { sessionWithJson.subscribe(any(), any()) @@ -54,7 +77,7 @@ class GameWebSocketHandlerTest { wsClient.sendGuess(msg) coVerify { - session.convertAndSend("/game/guess", msg, GuessMessage.serializer()) + session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) } } @@ -65,25 +88,24 @@ class GameWebSocketHandlerTest { val client = mockk() coEvery { - session.subscribe("/game/ABCDE") + session.subscribe("/topic/game/ABCDE") } returns emptyFlow() val wsClient = GameWebSocketHandler(client) wsClient.session = session - wsClient.subscribeToLobby("ABCDE") // warnings can be ignored, only subscribe is tested here, not the return value + wsClient.subscribeToLobby("ABCDE") - // only check destination, ignore headers and payload coVerify { session.subscribe( - match { it.destination == "/game/ABCDE" }, + match { it.destination == "/topic/game/ABCDE" }, GameMessage.serializer(), ) } } @Test - fun testSendJoinMessage_sendsMessage(): Unit = + fun testSendReconnectMessageSendsMessage() = runTest { val session = mockk(relaxed = true) val client = mockk() @@ -93,10 +115,74 @@ class GameWebSocketHandlerTest { val msg = WebSocketJoinMessage("name", "1234") - wsClient.sendLobbyJoinMessage(msg) + wsClient.sendReconnectMessage(msg) coVerify { - session.convertAndSend("app/1234/join", msg, WebSocketJoinMessage.serializer()) + session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) + } + } + + @Test + fun testSubscribeToChat() = + runTest { + val topic = "/topic/chat/123" + + wsClient.subscribeToChat(topic) + + coVerify { + session.subscribe( + match { it.destination == topic }, + ChatMessageDto.serializer(), + ) + } + } + + @Test + fun testSendMessage() = + runTest { + val destination = "app/chat/123" + val msg = ChatMessageDto("TestUser", "TestMsg") + + wsClient.sendChatMessage(destination, msg) + + coVerify { session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } + } + + @Test + fun testStartGame() = + runTest { + val destination = "/app/start-game" + val msg = StartGameMessage(lobbyCode = "ABCDE") + + wsClient.startGame(msg) + + coVerify { session.convertAndSend(destination, msg, StartGameMessage.serializer()) } + } + + @Test + fun testSendClue() = + runTest { + val lobbyCode = "LOBBY123" + val word = "clueWord" + val guessAmount = 2 + val currentTurn = Team.RED + + wsClient.sendClue(lobbyCode, word, guessAmount, currentTurn) + + val expectedMsg = + ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = guessAmount, + currentTurn = currentTurn, + ) + + coVerify { + session.convertAndSend( + "/app/submit-clue", + expectedMsg, + ClueMessageDto.serializer(), + ) } } } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt new file mode 100644 index 0000000..874672e --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -0,0 +1,81 @@ +package com.codenames.frontend.viewmodel + +import com.codenames.frontend.data.model.enums.ChatTab +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class ChatViewModelTest { + private lateinit var viewModel: ChatViewModel + + @Before + fun setup() { + viewModel = ChatViewModel() + } + + @Test + fun updateInput_updatesCurrentInput() { + viewModel.updateInput("Hallo") + + assertEquals( + "Hallo", + viewModel.uiState.value.currentInput, + ) + } + + @Test + fun sendMessage_addsMessage() { + viewModel.updateInput("Neue Nachricht") + + val before = + viewModel.uiState.value.messages.size + + viewModel.sendMessage( + username = "Max", + tab = ChatTab.GLOBAL, + ) + + val state = viewModel.uiState.value + + assertEquals(before + 1, state.messages.size) + + val last = state.messages.last() + + assertEquals("Max", last.sender) + assertEquals("Neue Nachricht", last.message) + assertEquals(ChatTab.GLOBAL, last.chatTab) + } + + @Test + fun sendMessage_clearsInputAfterSend() { + viewModel.updateInput("Text") + + viewModel.sendMessage( + "Max", + ChatTab.GLOBAL, + ) + + assertEquals( + "", + viewModel.uiState.value.currentInput, + ) + } + + @Test + fun sendMessage_blankMessage_doesNothing() { + viewModel.updateInput(" ") + + val before = + viewModel.uiState.value.messages.size + + viewModel.sendMessage( + "Max", + ChatTab.GLOBAL, + ) + + val after = + viewModel.uiState.value.messages.size + + assertEquals(before, after) + } +} diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt index 5fa2e58..1b311aa 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,15 +1,31 @@ package com.codenames.frontend.viewmodel +import android.util.Log +import com.codenames.frontend.data.model.ChatDomainModel +import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.enums.CardType +import com.codenames.frontend.data.model.enums.ChatTab +import com.codenames.frontend.data.model.enums.ConnectionState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.data.repository.ChatRepository +import com.codenames.frontend.data.repository.GameRepository +import com.codenames.frontend.network.dto.CardDto +import com.codenames.frontend.network.dto.ClueDto import com.codenames.frontend.network.dto.GameMessage -import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.ui.roles.PlayerRoles import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.just import io.mockk.mockk +import io.mockk.mockkStatic import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -18,6 +34,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -25,13 +42,43 @@ import org.junit.Test class GameViewModelTest { private val testDispatcher = StandardTestDispatcher() + private val lobbyCode = "12345" + private val username = "user" + private val team = Team.RED.name + private val role = Role.OPERATIVE.name + + private val testState = + GameState( + currentHint = "", + cards = listOf(), + currentTurn = PlayerRoles.RED_SPYMASTER, + winner = null, + remainingGuesses = 0, + ) + private val testMessage = + GameMessage( + winner = null, + Team.RED, + Role.SPYMASTER, + null, + listOf(), + ) + private lateinit var viewModel: GameViewModel - private val client: GameWebSocketHandler = mockk(relaxed = true) + private lateinit var client: GameWebSocketHandler + private lateinit var chatRepository: ChatRepository + private lateinit var gameRepository: GameRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) - viewModel = GameViewModel(client) + client = mockk() + chatRepository = mockk(relaxed = true) + gameRepository = mockk(relaxed = true) + + every { chatRepository.observeChat(any(), any()) } returns emptyFlow() + + viewModel = GameViewModel(client, chatRepository, gameRepository) } @After @@ -42,96 +89,439 @@ class GameViewModelTest { @Test fun connect_shouldCallClientAndUpdateState() = runTest { - val lobbyCode = "1234" - val username = "user" - - val testMessage = - GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), - ) - val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } - assertEquals(testMessage, viewModel.uiState.value) + assertEquals(testState, viewModel.uiState.value) } @Test fun connect_shouldCallClientAndUpdateState_isAlreadyConnected() = runTest { - val lobbyCode = "1234" - val username = "user" - - val testMessage = - GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), - ) - val flow = flowOf(testMessage) coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) advanceUntilIdle() - viewModel.connect(username, lobbyCode) + viewModel.connect(username, lobbyCode, team, role) coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } - assertEquals(testMessage, viewModel.uiState.value) + assertEquals(testState, viewModel.uiState.value) + } + + @Test + fun testSendLobbyMessage() = + runTest { + val content = "Test msg" + viewModel.sendLobbyMessage(lobbyCode, username, content) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + } + } + + @Test + fun testSendTeamMessage() = + runTest { + val content = "Test msg" + viewModel.sendTeamMessage(lobbyCode, team, username, content) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) + } + } + + @Test + fun testSendOperativeMessage() = + runTest { + val content = "Test msg" + viewModel.sendOperativeMessage(lobbyCode, team, username, content) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) + } + } + + @Test + fun testConnectUpdateLobbyChat() = + runTest { + // We use shared instead of state flow, since state requires us to pass an argument and fill the List with an element upon start of the test + // This way we can never start from a clean slate and test with emit + val customLobbyFlow = MutableSharedFlow(replay = 1) + every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns customLobbyFlow + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + every { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) + } returns emptyFlow() + + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() + + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + customLobbyFlow.emit(testChat) + advanceUntilIdle() + + val currentMessageList = viewModel.chatState.value.lobbyMessages + assertEquals("Test msg", currentMessageList[0].text) } @Test - fun connect_shouldSendJoinMessage() = + fun testConnectUpdateOperativeChat() = runTest { - val lobbyCode = "1234" - val username = "user" + val testChat = ChatDomainModel(sender = username, text = "Test msg", isFromMe = false) + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns emptyFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns emptyFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username) } returns flowOf(testChat) - val testMessage = + viewModel.connect(username, lobbyCode, team, role) + advanceUntilIdle() + + val currentMessageList = viewModel.chatState.value.operativeMessages + assertEquals("Test msg", currentMessageList[0].text) + } + + @Test + fun testConnectUpdateOperativeChat_notOperative() = + runTest { + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode", username) } returns emptyFlow() + every { chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username) } returns emptyFlow() + + viewModel.connect(username, lobbyCode, team, Role.SPYMASTER.name) + advanceUntilIdle() + + val currentMessageList = viewModel.chatState.value.operativeMessages + assertTrue(currentMessageList.isEmpty()) + } + + @Test + fun handleMessage_updatesGameState() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + val message = GameMessage( - "", - "red", - 0, - 0, - "", - 0, - emptyList(), + winner = null, + currentTurn = Team.BLUE, + currentPhase = Role.OPERATIVE, + currentClue = ClueDto("EAGLE", 3), + cardList = + listOf( + CardDto("BERLIN", CardType.BLUE, false), + CardDto("ROME", CardType.RED, true), + ), ) - val flow = flowOf(testMessage) + viewModel.handleMessage(message) + + val state = viewModel.uiState.value + + assertEquals(PlayerRoles.BLUE_OPERATIVE, state.currentTurn) + assertEquals("EAGLE", state.currentHint) + assertEquals(3, state.remainingGuesses) + assertEquals(2, state.cards.size) + assertEquals("BERLIN", state.cards[0].word) + } + + @Test + fun connect_shouldUpdateConnectionStateOnError() = + runTest { + coEvery { + client.connectStomp() + } throws RuntimeException("Connection failed") + + viewModel.connect(username, lobbyCode, team, role) + + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + + assertEquals( + "Connection failed", + (state as ConnectionState.Error).message, + ) + } + + @Test + fun getCurrentFound_returnsCorrectCount() { + val message = + GameMessage( + winner = null, + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + currentClue = ClueDto(word = "Animal", guessAmount = 3), + cardList = + listOf( + CardDto("A", CardType.RED, true), + CardDto("B", CardType.RED, false), + CardDto("C", CardType.RED, true), + CardDto("D", CardType.BLUE, true), + ), + ) + + viewModel.handleMessage(message) + + val result = viewModel.getCurrentFound(CardType.RED) + + assertEquals(2, result) + } + + @Test + fun getCurrentFound_returnsZeroWhenNoCardsFound() { + val message = + GameMessage( + winner = null, + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + currentClue = ClueDto(word = "Animal", guessAmount = 3), + cardList = + listOf( + CardDto("A", CardType.RED, false), + CardDto("B", CardType.BLUE, false), + ), + ) + + viewModel.handleMessage(message) + + val result = viewModel.getCurrentFound(CardType.RED) + + assertEquals(0, result) + } + + @Test + fun connect_asHost_shouldStartGame() = + runTest { coEvery { client.connectStomp() } just Runs - coEvery { client.subscribeToLobby(lobbyCode) } returns flow - coEvery { client.sendLobbyJoinMessage(any()) } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + + every { + chatRepository.observeChat(any(), any()) + } returns emptyFlow() + + coEvery { + gameRepository.startGame(lobbyCode) + } just Runs + + viewModel.connect( + username, + lobbyCode, + team, + role, + isHost = true, + ) - viewModel.connect(username, lobbyCode) + advanceUntilIdle() + + coVerify { + gameRepository.startGame(lobbyCode) + } + } + + @Test + fun connect_asHost_blankLobbyCode_shouldNotStartGame() = + runTest { + coEvery { client.connectStomp() } just Runs + coEvery { client.subscribeToLobby(any()) } returns emptyFlow() + + every { + chatRepository.observeChat(any(), any()) + } returns emptyFlow() + + viewModel.connect( + username, + "", + team, + role, + isHost = true, + ) + + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.startGame(any()) + } + } + + @Test + fun handleMessage_withNullClue_setsEmptyHint() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val message = + GameMessage( + winner = null, + currentTurn = Team.RED, + currentPhase = Role.OPERATIVE, + currentClue = null, + cardList = emptyList(), + ) + + viewModel.handleMessage(message) + + assertEquals("", viewModel.uiState.value.currentHint) + } + + @Test + fun testSubmitClue_RedSpymaster_Success() = + runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } just Runs + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + coVerify { client.sendClue(lobbyCode, "EAGLE", 2, Team.RED) } + } + + @Test + fun testSubmitClue_NetworkError_UpdatesConnectionState() = + runTest { + coEvery { + client.sendClue(any(), any(), any(), any()) + } throws Exception("Network connection failed") + + viewModel.handleMessage( + testMessage.copy( + currentTurn = Team.RED, + currentPhase = Role.SPYMASTER, + ), + ) + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } + + @Test + fun testSubmitClue_whenTurnIsNone_doesNotSendClue() = + runTest { + // never call handleMessage so turn is NONE + + viewModel.submitClue(lobbyCode, "EAGLE", 2) + advanceUntilIdle() + + coVerify(exactly = 0) { client.sendClue(any(), any(), any(), any()) } + } + + @Test + fun sendChatMessage_globalTab_sendsLobbyMessage() = + runTest { + val content = "Test msg" + + viewModel.sendChatMessage(ChatTab.GLOBAL, lobbyCode, username, Team.RED, content, listOf(ChatTab.GLOBAL)) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + } + } + + @Test + fun sendChatMessage_blankLobbyCode_doesNotSendMessage() = + runTest { + viewModel.sendChatMessage(ChatTab.GLOBAL, "", username, Team.RED, "Test msg", listOf(ChatTab.GLOBAL)) + advanceUntilIdle() + + coVerify(exactly = 0) { + chatRepository.sendMessage(any(), any(), any()) + } + } + + @Test + fun sendChatMessage_teamTabWithoutTeam_doesNotSendMessage() = + runTest { + viewModel.sendChatMessage(ChatTab.TEAM, lobbyCode, username, null, "Test msg", listOf(ChatTab.GLOBAL, ChatTab.TEAM)) + advanceUntilIdle() + + coVerify(exactly = 0) { + chatRepository.sendMessage(any(), any(), any()) + } + } + + @Test + fun sendChatMessage_teamTabWithTeam_sendsTeamMessage() = + runTest { + val content = "Team msg" + + viewModel.sendChatMessage(ChatTab.TEAM, lobbyCode, username, Team.RED, content, listOf(ChatTab.GLOBAL, ChatTab.TEAM)) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode/RED", username, content) + } + } + + @Test + fun sendChatMessage_operativesTabNotAvailable_doesNotSendMessage() = + runTest { + viewModel.sendChatMessage(ChatTab.OPERATIVES, lobbyCode, username, Team.RED, "Ops msg", listOf(ChatTab.GLOBAL, ChatTab.TEAM)) + advanceUntilIdle() + + coVerify(exactly = 0) { + chatRepository.sendMessage(any(), any(), any()) + } + } + + @Test + fun sendChatMessage_operativesTabAvailable_sendsOperativeMessage() = + runTest { + val content = "Ops msg" + + viewModel.sendChatMessage( + ChatTab.OPERATIVES, + lobbyCode, + username, + Team.RED, + content, + listOf(ChatTab.GLOBAL, ChatTab.TEAM, ChatTab.OPERATIVES), + ) + advanceUntilIdle() + + coVerify { + chatRepository.sendMessage("/app/chat/$lobbyCode/RED/operative", username, content) + } + } + + @Test + fun testSubmitClue_blankLobbyCode_doesNotSendClue() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + viewModel.submitClue("", "EAGLE", 2) advanceUntilIdle() - coVerify { client.sendLobbyJoinMessage(WebSocketJoinMessage(username, lobbyCode)) } + coVerify(exactly = 0) { + client.sendClue(any(), any(), any(), any()) + } } } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt index b812802..55707f6 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -1,13 +1,20 @@ package com.codenames.frontend.viewmodel +import android.util.Log +import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.LobbyRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto +import com.codenames.frontend.ui.roles.PlayerRoles import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -20,6 +27,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -46,6 +54,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + isStarted = false, ) coEvery { repository.createLobby("User") } returns response @@ -96,6 +105,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -103,6 +113,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) val viewModel = LobbyViewModel(repository) @@ -150,23 +161,25 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) val response2 = LobbyResponse( lobbyCode = "", playerList = emptyList(), + false, ) coEvery { repository.joinLobby("User", "1234") } returns response coEvery { repository.getLobbyInfo(any()) } returns response - coEvery { repository.leaveLobby("User", "1234") } returns response2 + coEvery { repository.leaveLobby("1234", "User") } returns response2 val viewModel = LobbyViewModel(repository) viewModel.joinLobby("User", "1234") advanceTimeBy(2000) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceTimeBy(2000) @@ -174,7 +187,7 @@ class LobbyViewModelTest { val state = viewModel.state.value - assertEquals("", state.lobbyCode) + assertEquals(null, state.lobbyCode) assertFalse(state.isLoading) assertNull(state.error) } @@ -188,6 +201,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -199,7 +213,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceTimeBy(2000) @@ -223,12 +237,14 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) val response2 = LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", newRole, newTeam, true)), + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -241,7 +257,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.changeRole("User", newRole, newTeam) + viewModel.changeRole(newRole, newTeam, "User") viewModel.stopPollingForTest() advanceTimeBy(2000) @@ -264,6 +280,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) coEvery { repository.joinLobby("User", "1234") } returns response @@ -282,7 +299,7 @@ class LobbyViewModelTest { advanceTimeBy(2000) - viewModel.changeRole("User", Role.OPERATIVE, Team.RED) + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") advanceTimeBy(2000) @@ -346,6 +363,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) val viewModel = LobbyViewModel(repository) @@ -369,8 +387,8 @@ class LobbyViewModelTest { coEvery { repository.getLobbyInfo(any()) } returnsMany listOf( - LobbyResponse("1", emptyList()), - LobbyResponse("2", emptyList()), + LobbyResponse("1", emptyList(), false), + LobbyResponse("2", emptyList(), false), ) val viewModel = LobbyViewModel(repository) @@ -394,6 +412,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) val viewModel = LobbyViewModel(repository) @@ -424,6 +443,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) coEvery { repository.createLobby("User") } returns response @@ -454,6 +474,7 @@ class LobbyViewModelTest { LobbyResponse( lobbyCode = "1234", playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, ) coEvery { repository.createLobby("User") } returns response @@ -482,7 +503,7 @@ class LobbyViewModelTest { val viewModel = LobbyViewModel(repository) - viewModel.leaveLobby("User") + viewModel.leaveLobby("User", onResult = {}) advanceUntilIdle() @@ -500,7 +521,7 @@ class LobbyViewModelTest { val viewModel = LobbyViewModel(repository) - viewModel.changeRole("User", Role.OPERATIVE, Team.RED) + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") advanceUntilIdle() @@ -510,4 +531,617 @@ class LobbyViewModelTest { assertFalse(state.isLoading) assertEquals("Not in a Lobby", state.error) } + + @Test + fun changeRole_DelegatesCorrectly1() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) + viewModel.changeRole(PlayerRoles.BLUE_SPYMASTER, "Alice") + + verify { + viewModel.changeRole(Role.SPYMASTER, Team.BLUE, "Alice") + } + } + + @Test + fun changeRole_DelegatesCorrectly2() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) + + viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob") + + verify { + viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob") + } + } + + @Test + fun `getRoleForUser returns BLUE_OPERATIVE`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val players = + listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true, + ), + ) + + val response = + LobbyResponse( + lobbyCode = "ABCD", + playerList = players, + false, + ) + + coEvery { + repository.joinLobby("Max", "ABCD") + } returns response + + viewModel.joinLobby("Max", "ABCD") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Max") + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun `getRoleForUser returns NONE when player does not exist`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getRoleForUser("Unknown") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun `changeRole updates player role correctly`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val initialPlayers = + listOf( + PlayerDto( + username = "Max", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = true, + ), + ) + + val updatedPlayers = + listOf( + PlayerDto( + username = "Max", + role = Role.SPYMASTER, + team = Team.RED, + isHost = false, + ), + ) + + val joinResponse = + LobbyResponse( + lobbyCode = "ABCD", + playerList = initialPlayers, + false, + ) + + val changeRoleResponse = + LobbyResponse( + lobbyCode = "ABCD", + playerList = updatedPlayers, + false, + ) + + coEvery { + repository.joinLobby("Max", "ABCD") + } returns joinResponse + + coEvery { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED, + ) + } returns changeRoleResponse + + viewModel.joinLobby("Max", "ABCD") + advanceUntilIdle() + + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max", + ) + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Max") + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + + coVerify(exactly = 1) { + repository.changeRole( + "Max", + "ABCD", + Role.SPYMASTER, + Team.RED, + ) + } + } + + @Test + fun `changeRole sets error when not in lobby`() = + runTest { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + viewModel.changeRole( + role = Role.SPYMASTER, + team = Team.RED, + username = "Max", + ) + + advanceUntilIdle() + + assertTrue( + viewModel.state.value.error + ?.contains("Not in a Lobby") == true, + ) + } + + @Test + fun changeRole_DelegatesCorrectly_redSpymaster() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository)) + + viewModel.changeRole(PlayerRoles.RED_SPYMASTER, "Alice") + + verify { + viewModel.changeRole(Role.SPYMASTER, Team.RED, "Alice") + } + } + + @Test + fun changeRole_DelegatesCorrectly_blueOperative() { + val repository = mockk() + val viewModel = spyk(LobbyViewModel(repository)) + + viewModel.changeRole(PlayerRoles.BLUE_OPERATIVE, "Bob") + + verify { + viewModel.changeRole(Role.OPERATIVE, Team.BLUE, "Bob") + } + } + + @Test + fun changeRole_invalidRole_setsError() = + runTest { + val repository = mockk() + + val viewModel = LobbyViewModel(repository) + + viewModel.changeRole(PlayerRoles.NONE, "User") + + advanceUntilIdle() + + assertEquals( + "Invalid role", + viewModel.state.value.error, + ) + } + + @Test + fun getRoleForUser_userNotFound_returnsNone() { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getRoleForUser("Unknown") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun sendStartGame_blankUsername_doesNothing() = + runTest { + val repository = mockk(relaxed = true) + + val viewModel = LobbyViewModel(repository) + + viewModel.sendStartGame("") + + advanceUntilIdle() + + coVerify(exactly = 0) { + repository.sendStartGame(any(), any()) + } + } + + @Test + fun getRoleForUser_blueOperative_returnsCorrectRole() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Alice", + role = Role.OPERATIVE, + team = Team.BLUE, + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Alice", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Alice") + + assertEquals(PlayerRoles.BLUE_OPERATIVE, result) + } + + @Test + fun getRoleForUser_redSpymaster_returnsCorrectRole() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Bob", + role = Role.SPYMASTER, + team = Team.RED, + isHost = false, + ), + ), + false, + ) + + coEvery { + repository.joinLobby("Bob", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Bob", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Bob") + + assertEquals(PlayerRoles.RED_SPYMASTER, result) + } + + @Test + fun getRoleForUser_nullRole_returnsNone() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Bob", + role = null, + team = Team.RED, + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Bob", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Bob", "12345") + + advanceUntilIdle() + + val result = viewModel.getRoleForUser("Bob") + + assertEquals(PlayerRoles.NONE, result) + } + + @Test + fun getIsHost_returnsTrueForHost() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + role = null, + team = null, + isHost = true, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + assertTrue(viewModel.getIsHost("Host")) + } + + @Test + fun getIsHost_returnsFalseForNonHost() = + runTest { + val repository = mockk() + + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "User", + isHost = false, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("User", "12345") + } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("User", "12345") + + advanceUntilIdle() + + assertFalse(viewModel.getIsHost("User")) + } + + @Test + fun sendStartGame_callsRepository() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val repository = mockk() + + val joinResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + isHost = true, + ), + ), + isStarted = false, + ) + + val startResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = joinResponse.playerList, + isStarted = true, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns joinResponse + + coEvery { + repository.sendStartGame("12345", "Host") + } returns startResponse + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + viewModel.sendStartGame("Host") + + advanceUntilIdle() + + coVerify { + repository.sendStartGame("12345", "Host") + } + } + + @Test + fun sendStartGame_exception_setsError() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + val repository = mockk() + + val joinResponse = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto( + username = "Host", + isHost = true, + ), + ), + isStarted = false, + ) + + coEvery { + repository.joinLobby("Host", "12345") + } returns joinResponse + + coEvery { + repository.sendStartGame(any(), any()) + } throws RuntimeException("Start failed") + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Host", "12345") + + advanceUntilIdle() + + viewModel.sendStartGame("Host") + + advanceUntilIdle() + + assertEquals( + "Start failed", + viewModel.state.value.error, + ) + } + + @Test + fun getAvailableChatTabsForUser_unknownUser_returnsGlobalOnly() { + val repository = mockk() + val viewModel = LobbyViewModel(repository) + + val result = viewModel.getAvailableChatTabsForUser("Unknown") + + assertEquals(listOf(ChatTab.GLOBAL), result) + } + + @Test + fun getAvailableChatTabsForUser_playerWithoutTeam_returnsGlobalOnly() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = listOf(PlayerDto(username = "Alice", role = null, team = null, isHost = false)), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_spymasterWithTeam_returnsGlobalAndTeam() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = listOf(PlayerDto("Alice", Role.SPYMASTER, Team.BLUE, false)), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_singleOperative_returnsGlobalAndTeam() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto("Alice", Role.OPERATIVE, Team.BLUE, false), + PlayerDto("Bob", Role.SPYMASTER, Team.BLUE, false), + ), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) + } + + @Test + fun getAvailableChatTabsForUser_multipleSameTeamOperatives_returnsAllTabs() = + runTest { + val repository = mockk() + val response = + LobbyResponse( + lobbyCode = "12345", + playerList = + listOf( + PlayerDto("Alice", Role.OPERATIVE, Team.BLUE, false), + PlayerDto("Bob", Role.OPERATIVE, Team.BLUE, false), + ), + isStarted = false, + ) + + coEvery { repository.joinLobby("Alice", "12345") } returns response + coEvery { repository.getLobbyInfo("12345") } returns response + + val viewModel = LobbyViewModel(repository) + + viewModel.joinLobby("Alice", "12345") + advanceTimeBy(1) + viewModel.stopPollingForTest() + + assertEquals( + listOf(ChatTab.GLOBAL, ChatTab.TEAM, ChatTab.OPERATIVES), + viewModel.getAvailableChatTabsForUser("Alice"), + ) + } } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt new file mode 100644 index 0000000..7b16c58 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -0,0 +1,33 @@ +package com.codenames.frontend.viewmodel + +import com.codenames.frontend.data.model.SessionState +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionViewModelTest { + private lateinit var viewModel: SessionViewModel + + @Before + fun setup() { + viewModel = SessionViewModel() + } + + @Test + fun `initial username is empty`() { + val result = viewModel.username.value + + assertEquals(SessionState(""), result) + } + + @Test + fun `setUsername updates username state`() { + viewModel.setUsername("Max") + + val result = viewModel.username.value + + assertEquals(SessionState("Max"), result) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2302e13..95aabc3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.0" +agp = "9.1.1" coreKtx = "1.18.0" hiltAndroidCompiler = "2.59.2" hiltNavigationCompose = "1.3.0"