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"