From 0420dde6f5a443fec2605f90f73cde1eb34dfeb6 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Fri, 22 May 2026 22:37:20 +0200 Subject: [PATCH 001/126] refactor: refactored game view model, added method to game repository - adapted tests --- .../data/repository/GameRepository.kt | 18 +++++ .../network/websocket/GameWebSocketHandler.kt | 15 +--- .../frontend/viewmodel/GameViewModel.kt | 81 ++++++++----------- .../websocket/GameWebSocketHandlerTest.kt | 4 +- .../frontend/viewmodel/GameViewModelTest.kt | 63 ++------------- 5 files changed, 62 insertions(+), 119 deletions(-) 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 index 0fc6887..3c71374 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -1,5 +1,7 @@ package com.codenames.frontend.data.repository +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.websocket.GameWebSocketHandler import javax.inject.Inject @@ -13,4 +15,20 @@ class GameRepository val msg = StartGameMessage(lobbyCode) webSocketHandler.startGame(msg) } + + suspend fun submitClue( + lobbyCode: String, + word: String, + count: Int, + currentTurn: Team, + ) { + val msg = + ClueMessageDto( + lobbyCode = lobbyCode, + word = word, + guessAmount = count, + currentTurn = currentTurn, + ) + webSocketHandler.sendClue(msg) + } } 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 41a4800..ffddc3e 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,6 @@ 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 @@ -58,19 +57,7 @@ class GameWebSocketHandler session.convertAndSend(destination, msg, ChatMessageDto.serializer()) } - suspend fun sendClue( - lobbyCode: String, - word: String, - guessAmount: Int, - currentTurn: Team, - ) { - val msg = - ClueMessageDto( - lobbyCode = lobbyCode, - word = word, - guessAmount = guessAmount, - currentTurn = currentTurn, - ) + suspend fun sendClue(msg: ClueMessageDto) { session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } } 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 68841a1..7268799 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -5,7 +5,6 @@ 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 @@ -117,38 +116,6 @@ class GameViewModel } } - 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, @@ -164,13 +131,20 @@ class GameViewModel val team = if (turn == PlayerRoles.BLUE_SPYMASTER) Team.BLUE else Team.RED viewModelScope.launch { try { - client.sendClue(lobbyCode, word, count, team) + gameRepository.submitClue(lobbyCode, word, count, team) } catch (e: Exception) { _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } } } + fun handleMessage(message: GameMessage) { + val state = message.toGameState() + _uiState.update { + state + } + } + fun sendChatMessage( tab: ChatTab, lobbyCode: String, @@ -213,22 +187,35 @@ class GameViewModel } } - fun handleMessage(message: GameMessage) { - val state = message.toGameState() - _uiState.update { - state + fun sendLobbyMessage( + lobbyCode: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) } - 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++ - } + 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) } - return count } } 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 8e7d3ea..55fac29 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 @@ -167,7 +167,9 @@ class GameWebSocketHandlerTest { val guessAmount = 2 val currentTurn = Team.RED - wsClient.sendClue(lobbyCode, word, guessAmount, currentTurn) + val clueMessage = ClueMessageDto(lobbyCode, word, guessAmount, currentTurn) + + wsClient.sendClue(clueMessage) val expectedMsg = ClueMessageDto( 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 1b311aa..5d7789b 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -266,52 +266,6 @@ class GameViewModelTest { ) } - @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 { @@ -390,7 +344,7 @@ class GameViewModelTest { fun testSubmitClue_RedSpymaster_Success() = runTest { coEvery { - client.sendClue(any(), any(), any(), any()) + gameRepository.submitClue(any(), any(), any(), any()) } just Runs viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) @@ -398,22 +352,17 @@ class GameViewModelTest { viewModel.submitClue(lobbyCode, "EAGLE", 2) advanceUntilIdle() - coVerify { client.sendClue(lobbyCode, "EAGLE", 2, Team.RED) } + coVerify { gameRepository.submitClue(lobbyCode, "EAGLE", 2, Team.RED) } } @Test fun testSubmitClue_NetworkError_UpdatesConnectionState() = runTest { coEvery { - client.sendClue(any(), any(), any(), any()) + gameRepository.submitClue(any(), any(), any(), any()) } throws Exception("Network connection failed") - viewModel.handleMessage( - testMessage.copy( - currentTurn = Team.RED, - currentPhase = Role.SPYMASTER, - ), - ) + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) viewModel.submitClue(lobbyCode, "EAGLE", 2) advanceUntilIdle() @@ -430,7 +379,7 @@ class GameViewModelTest { viewModel.submitClue(lobbyCode, "EAGLE", 2) advanceUntilIdle() - coVerify(exactly = 0) { client.sendClue(any(), any(), any(), any()) } + coVerify(exactly = 0) { client.sendClue(any()) } } @Test @@ -521,7 +470,7 @@ class GameViewModelTest { advanceUntilIdle() coVerify(exactly = 0) { - client.sendClue(any(), any(), any(), any()) + client.sendClue(any()) } } } From 56ef72a7dee55f25a7131ef747880338b439428e Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sat, 23 May 2026 00:01:17 +0200 Subject: [PATCH 002/126] added tests for web socket session manager --- .../websocket/WebSocketSessionManager.kt | 41 +++++ .../websocket/GameWebSocketHandlerTest.kt | 26 --- .../websocket/WebSocketSessionManagerTest.kt | 149 ++++++++++++++++++ 3 files changed, 190 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt create mode 100644 app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt new file mode 100644 index 0000000..6429e01 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt @@ -0,0 +1,41 @@ +package com.codenames.frontend.network.websocket + +import android.util.Log +import jakarta.inject.Inject +import jakarta.inject.Singleton +import org.hildan.krossbow.stomp.StompClient +import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization +import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions + +const val URL = "ws://localhost:8080/ws-fallback" + +@Singleton +class WebSocketSessionManager @Inject constructor(val client: StompClient) { + + private var session: StompSessionWithKxSerialization? = null + + suspend fun connectStomp() { + if (isConnected()) return + try { + session = client.connect(URL).withJsonConversions() + } catch (e: Exception) { + Log.e("WebSocket", "Failed to connect to Websocket", e) + throw e + } + Log.d("WebSocket", "Connected to Websocket, session: $session") + } + + fun isConnected(): Boolean = session != null + + fun getSession(): StompSessionWithKxSerialization { + return requireNotNull(session) { + "WebSocket is not connected" + } + } + + suspend fun disconnect() { + session?.disconnect() + session = null + } + +} \ No newline at end of file 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 55fac29..e413761 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 @@ -37,32 +37,6 @@ class GameWebSocketHandlerTest { wsClient.session = session } - @Test - fun testConnectStomp() = - runTest { - val client = mockk() - 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()) - } returns emptyFlow() - - val wsClient = GameWebSocketHandler(client) - - wsClient.connectStomp() - - coVerify { - client.connect(BASE_URL) - session.withJsonConversions() - } - } - @Test fun testSendGuess_sendsCorrectMessage(): Unit = runTest { diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt new file mode 100644 index 0000000..61c3f8f --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt @@ -0,0 +1,149 @@ +package com.codenames.frontend.network.websocket + +import android.util.Log +import com.codenames.frontend.network.dto.GameMessage +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 +import org.hildan.krossbow.stomp.StompSession +import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization +import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import kotlin.test.assertNotNull + +class WebSocketSessionManagerTest { + + private lateinit var client: StompClient + private lateinit var session: StompSessionWithKxSerialization + private lateinit var rawSession: StompSession + + @Before + fun setup() { + client = mockk() + session = mockk(relaxed = true) + rawSession = mockk(relaxed = true) + + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.e(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + } + + @Test + fun testConnectStomp() = + runTest { + val sessionWithJson = mockk() + + coEvery { client.connect(URL) } returns session + + coEvery { + sessionWithJson.subscribe(any(), any()) + } returns emptyFlow() + + val wsClient = WebSocketSessionManager(client) + + wsClient.connectStomp() + + coVerify { + client.connect(BASE_URL) + session.withJsonConversions() + } + } + + @Test + fun testConnectStomp_throwsExceptionWhenConnectionFails() = + runTest { + coEvery { client.connect(URL) } throws Exception("Connection failed") + + val wsClient = WebSocketSessionManager(client) + + assertThrows(Exception::class.java) { + runTest { + wsClient.connectStomp() + } + } + } + + @Test + fun testConnectStomp_DoesNotConnectWhenSessionExists() = + runTest { + val wsClient = WebSocketSessionManager(client) + coEvery { client.connect(URL) } returns session + wsClient.connectStomp() + wsClient.connectStomp() + coVerify(exactly = 1) { client.connect(BASE_URL) } + } + + @Test + fun testDisconnectStomp() = + runTest { + val wsClient = WebSocketSessionManager(client) + + coEvery { client.connect(URL) } returns session + + wsClient.connectStomp() + wsClient.disconnect() + + coVerify { + session.disconnect() + } + } + + @Test + fun testDisconnectStomp_doesNothingWhenSessionDoesNotExist() = + runTest { + val wsClient = WebSocketSessionManager(client) + + wsClient.disconnect() + + coVerify(exactly = 0) { + session.disconnect() + } + } + + @Test + fun testIsConnected_returnsTrueWhenSessionExists() = + runTest { + val wsClient = WebSocketSessionManager(client) + + coEvery { client.connect(URL) } returns session + + wsClient.connectStomp() + + assert(wsClient.isConnected()) + } + + @Test + fun testIsConnected_returnsFalseWhenSessionDoesNotExist() = + runTest { + val wsClient = WebSocketSessionManager(client) + + assert(!wsClient.isConnected()) + } + + @Test + fun testGetSession_returnsSession() = runTest { + val wsClient = WebSocketSessionManager(client) + + coEvery { client.connect(URL) } returns session + + wsClient.connectStomp() + + assertNotNull(wsClient.getSession()) + } + + @Test + fun testGetSession_throwsWhenSessionIsNull() = + runTest { + val wsClient = WebSocketSessionManager(client) + + assertThrows(IllegalArgumentException::class.java) { wsClient.getSession() } + } +} \ No newline at end of file From e99431b34a93b7cfdb5b08ca8cc773eae3a479a4 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Mon, 25 May 2026 10:32:19 +0200 Subject: [PATCH 003/126] refactor: removed offline test screen --- .../frontend/ui/navigation/NavGraph.kt | 5 - .../frontend/ui/screens/GameboardScreen.kt | 115 ------------------ .../frontend/ui/screens/StartScreen.kt | 18 --- 3 files changed, 138 deletions(-) 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 4db8e72..14cace4 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 @@ -9,7 +9,6 @@ import com.codenames.frontend.ui.screens.GameScreenWrapper import com.codenames.frontend.ui.screens.GameSettingsScreen 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 @@ -75,9 +74,5 @@ fun NavGraph( composable(Screen.Settings.route) { SettingsScreen(navController) } - - composable("game_test") { - OfflineGameStateTestScreen() - } } } 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 e8afdee..acdaa01 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 @@ -23,8 +23,6 @@ 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 import androidx.compose.runtime.saveable.rememberSaveable @@ -689,116 +687,3 @@ 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/StartScreen.kt b/app/src/main/java/com/codenames/frontend/ui/screens/StartScreen.kt index 8446760..5ef111c 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 @@ -106,24 +106,6 @@ fun StartScreen( 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, - ), - ) } if (lobbyState.isLoading) { From 0660bdb7e0d08aa2154160eddffa38367e7ada5c Mon Sep 17 00:00:00 2001 From: ad-devel Date: Mon, 25 May 2026 11:09:49 +0200 Subject: [PATCH 004/126] refactor: improved color management for ui --- .../ui/buttons/SettingsCornerButton.kt | 12 ++-- .../frontend/ui/screens/GameSettingsScreen.kt | 4 +- .../frontend/ui/screens/GameboardScreen.kt | 63 +++++++++++-------- .../frontend/ui/screens/JoinLobbyScreen.kt | 13 ++-- .../frontend/ui/screens/LobbyScreen.kt | 29 +++++---- .../frontend/ui/screens/SettingsScreen.kt | 7 ++- .../frontend/ui/screens/StartScreen.kt | 10 +-- .../frontend/ui/screens/UserNameScreen.kt | 4 +- .../com/codenames/frontend/ui/theme/Brush.kt | 17 +++-- .../com/codenames/frontend/ui/theme/Color.kt | 30 +++++++-- .../com/codenames/frontend/ui/theme/Theme.kt | 35 ++++++----- 11 files changed, 135 insertions(+), 89 deletions(-) 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 index 843f56d..1c5ec78 100644 --- a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt @@ -15,8 +15,8 @@ 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) +import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.theme.AppWhite @Suppress("ktlint:standard:function-naming") @Composable @@ -36,13 +36,13 @@ fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { colors = androidx.compose.material3.IconButtonDefaults.iconButtonColors( containerColor = Color.Transparent, - contentColor = Color.White, + contentColor = AppWhite, ), ) { Icon( imageVector = androidx.compose.material.icons.Icons.Default.Settings, contentDescription = "Settings", - tint = Color.White, + tint = AppWhite, ) } } @@ -71,8 +71,8 @@ fun BoxScope.ReturnCornerButton(onClick: () -> Unit) { private fun cornerButtonStyle(): AppButtonStyle = AppButtonStyle( - containerColor = cornerButtonColor, - contentColor = Color.White, + containerColor = AppInk, + contentColor = AppWhite, fontSize = 16.sp, lineHeight = 18.sp, ) 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 990460b..ef77588 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 @@ -9,8 +9,8 @@ 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 +import com.codenames.frontend.ui.theme.AppBackground @Suppress("ktlint:standard:function-naming") @Composable @@ -20,7 +20,7 @@ fun GameSettingsScreen() { Modifier .fillMaxSize() .padding(16.dp) - .background(Color(0xFFf0d8ce)), + .background(AppBackground), 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 acdaa01..f8dc2ce 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 @@ -58,6 +58,17 @@ 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 +import com.codenames.frontend.ui.theme.AppBlack +import com.codenames.frontend.ui.theme.AppBlue +import com.codenames.frontend.ui.theme.AppGreen +import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.theme.AppInkOverlay +import com.codenames.frontend.ui.theme.AppLightGray +import com.codenames.frontend.ui.theme.AppMutedDark +import com.codenames.frontend.ui.theme.AppRed +import com.codenames.frontend.ui.theme.AppSurface +import com.codenames.frontend.ui.theme.AppSurfaceOverlay +import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient @@ -141,7 +152,7 @@ fun GameboardScreen( userRole, color = Team.BLUE, teamFound = currentBlueFound, - textColor = Color(0xFF1565C0), + textColor = AppBlue, gradient = blueGradient, ) @@ -152,12 +163,12 @@ fun GameboardScreen( .weight(1f) .fillMaxHeight() .padding(horizontal = 8.dp) - .background(Color(0xFFE0D8C8), RoundedCornerShape(12.dp)), + .background(AppSurface, RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center, ) { Text( text = "Waiting for game state...", - color = Color(0xFF383330), + color = AppInk, fontSize = 20.sp, fontWeight = FontWeight.Bold, ) @@ -188,7 +199,7 @@ fun GameboardScreen( userRole, color = Team.RED, teamFound = currentRedFound, - textColor = Color(0xFFCF5530), + textColor = AppRed, gradient = redGradient, ) } @@ -258,7 +269,7 @@ fun GameStatusBar( Text( text = statusText, - color = Color(0xFF383330), + color = AppInk, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) @@ -281,8 +292,8 @@ fun ChatToggleButton( .height(56.dp), style = AppButtonStyle( - containerColor = if (isChatOpen) Color(0xFF555555) else Color(0xFF383330), - contentColor = Color.White, + containerColor = if (isChatOpen) AppMutedDark else AppInk, + contentColor = AppWhite, fontSize = 18.sp, lineHeight = 20.sp, ), @@ -305,7 +316,7 @@ fun ChatWindow( modifier = modifier .background( - color = Color(0xE6383330), + color = AppInkOverlay, shape = RoundedCornerShape(12.dp), ).padding(12.dp), verticalArrangement = Arrangement.SpaceBetween, @@ -326,7 +337,7 @@ fun ChatWindow( AppButtonStyle( type = AppButtonType.PRIMARY, containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, - contentColor = if (selectedTab == tab) Color.Unspecified else Color.LightGray, + contentColor = if (selectedTab == tab) Color.Unspecified else AppLightGray, fontSize = 11.sp, lineHeight = 12.sp, contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), @@ -367,8 +378,8 @@ fun ChatWindow( ), style = AppTextFieldStyle( - containerColor = Color(0xFFE0D8C8), - contentColor = Color(0xFF383330), + containerColor = AppSurface, + contentColor = AppInk, fontSize = 14.sp, lineHeight = 16.sp, ), @@ -416,14 +427,14 @@ fun ChatMessagesArea( modifier = modifier .background( - color = Color(0xB3E0D8C8), + color = AppSurfaceOverlay, shape = RoundedCornerShape(8.dp), ).padding(12.dp), verticalArrangement = Arrangement.Top, ) { Text( text = "${selectedTab.title} Chat", - color = Color(0xFF383330), + color = AppInk, fontSize = 16.sp, fontWeight = FontWeight.Bold, ) @@ -433,7 +444,7 @@ fun ChatMessagesArea( if (visibleMessages.isEmpty()) { Text( text = "No messages yet.", - color = Color(0xFF383330), + color = AppInk, fontSize = 14.sp, ) } else { @@ -452,8 +463,8 @@ fun ChatMessagesArea( @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) + val bubbleColor = if (message.isFromMe) AppGreen else AppSurface + val textColor = if (message.isFromMe) AppWhite else AppInk Column( modifier = Modifier.fillMaxWidth(), @@ -461,7 +472,7 @@ fun ChatMessageBubble(message: ChatDomainModel) { ) { Text( text = message.sender, - color = Color(0xFF383330), + color = AppInk, fontSize = 11.sp, fontWeight = FontWeight.Bold, ) @@ -629,7 +640,7 @@ fun TeamRoleBox( ) { Text( text = title, - color = Color.White, + color = AppWhite, fontWeight = FontWeight.Bold, fontSize = 10.sp, ) @@ -638,7 +649,7 @@ fun TeamRoleBox( Spacer(modifier = Modifier.height(4.dp)) Text( text = "You", - color = Color.White, + color = AppWhite, fontSize = 12.sp, fontWeight = FontWeight.Bold, ) @@ -657,14 +668,14 @@ fun CodenamesCard( when { card.revealed -> getColor(card.type) isSpymaster -> getColor(card.type) - else -> Color(0xFFE0D8C8) + else -> AppSurface } val contentColor = if (!card.revealed && !isSpymaster) { - Color(0xFF383330) + AppInk } else { - Color.White + AppWhite } AppButton( @@ -682,8 +693,8 @@ fun CodenamesCard( fun getColor(type: CardType): Color = when (type) { - CardType.BLUE -> Color(0xFF1565C0) - CardType.RED -> Color(0xFFCF5530) - CardType.NEUTRAL -> Color(0xFF383330) - CardType.ASSASSIN -> Color.Black + CardType.BLUE -> AppBlue + CardType.RED -> AppRed + CardType.NEUTRAL -> AppInk + CardType.ASSASSIN -> AppBlack } 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 index 58c4725..322550a 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -21,7 +21,6 @@ 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 @@ -40,6 +39,10 @@ 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.AppBackground +import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.theme.AppRed +import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -98,7 +101,7 @@ fun JoinlobbyScreen( modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)), + .background(AppBackground), ) { Column( modifier = @@ -126,7 +129,7 @@ fun JoinlobbyScreen( style = AppTextFieldStyle( type = AppTextFieldType.SECONDARY, - contentColor = Color.White, + contentColor = AppWhite, fontSize = 20.sp, lineHeight = 24.sp, ), @@ -165,7 +168,7 @@ fun JoinlobbyScreen( if (state.isLoading) { Text( text = "Joining...", - color = Color(0xFF383330), + color = AppInk, fontSize = 20.sp, modifier = Modifier.padding(top = 12.dp), ) @@ -174,7 +177,7 @@ fun JoinlobbyScreen( state.error?.let { error -> Text( text = error, - color = Color(0xFFCF5530), + color = AppRed, fontSize = 18.sp, modifier = Modifier.padding(top = 12.dp), ) 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 9eb2cc3..8ef76b4 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 @@ -38,6 +38,13 @@ 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.AppBackground +import com.codenames.frontend.ui.theme.AppBlack +import com.codenames.frontend.ui.theme.AppBlueLight +import com.codenames.frontend.ui.theme.AppMutedDark +import com.codenames.frontend.ui.theme.AppRed +import com.codenames.frontend.ui.theme.AppRedLight +import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.brownGradient import com.codenames.frontend.ui.theme.greenGradient @@ -99,7 +106,7 @@ fun LobbyScreen( modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)), + .background(AppBackground), ) { Row( modifier = @@ -113,7 +120,7 @@ fun LobbyScreen( modifier = Modifier.weight(1f), color = Team.BLUE, gradient = blueGradient, - textColor = Color(0xFF42A5F5), + textColor = AppBlueLight, title = "BLUE TEAM", onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, lobbyUiState = lobbyUiState, @@ -136,7 +143,7 @@ fun LobbyScreen( modifier = Modifier.weight(1f), color = Team.RED, gradient = redGradient, - textColor = Color(0xFFDE8468), + textColor = AppRedLight, title = "RED TEAM", onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, lobbyUiState = lobbyUiState, @@ -146,7 +153,7 @@ fun LobbyScreen( lobbyUiState.error?.let { error -> Text( text = error, - color = Color(0xFFCF5530), + color = AppRed, fontSize = 16.sp, modifier = Modifier @@ -238,16 +245,16 @@ fun RoleCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { - Text(title, color = Color.White, fontWeight = FontWeight.Bold) + Text(title, color = AppWhite, fontWeight = FontWeight.Bold) if (players.isEmpty()) { Text( text = "No players", - color = Color.White.copy(alpha = 0.7f), + color = AppWhite.copy(alpha = 0.7f), fontSize = 12.sp, ) } else { for (player in players) { - Text(player, color = Color.White) + Text(player, color = AppWhite) } } @@ -310,7 +317,7 @@ fun GameSettingsColumn( ) { Text( text = "GAME SETTINGS", - color = Color.White, + color = AppWhite, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp), ) @@ -324,8 +331,8 @@ fun GameSettingsColumn( .padding(bottom = 8.dp), style = AppButtonStyle( - containerColor = Color(0xFF555555), - contentColor = Color.White, + containerColor = AppMutedDark, + contentColor = AppWhite, fontSize = 18.sp, ), ) @@ -373,7 +380,7 @@ fun GameSettingsColumn( AppButtonStyle( backgroundBrush = brownGradient, fontSize = 20.sp, - contentColor = Color.Black, + contentColor = AppBlack, type = AppButtonType.SECONDARY, contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), ), 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 70cc208..414fa19 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 @@ -12,7 +12,6 @@ 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 @@ -20,6 +19,8 @@ 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.AppBackground +import com.codenames.frontend.ui.theme.AppInk import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient @@ -30,11 +31,11 @@ fun SettingsScreen(navController: NavHostController) { modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)), + .background(AppBackground), ) { Text( text = "SETTINGS", - color = Color(0xFF383330), + color = AppInk, fontSize = 36.sp, fontWeight = FontWeight.Bold, modifier = 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 5ef111c..a3f4dbf 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 @@ -17,7 +17,6 @@ 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.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController @@ -25,6 +24,9 @@ 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.AppBackground +import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.viewmodel.LobbyViewModel @@ -52,7 +54,7 @@ fun StartScreen( modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)), + .background(AppBackground), ) { Column( modifier = Modifier.fillMaxSize(), @@ -111,7 +113,7 @@ fun StartScreen( if (lobbyState.isLoading) { Text( text = "Loading...", - color = Color(0xFF383330), + color = AppInk, fontSize = 22.sp, ) } @@ -119,7 +121,7 @@ fun StartScreen( lobbyState.error?.let { error -> Text( text = error, - color = Color(0xFFCF5530), + color = AppRed, fontSize = 18.sp, modifier = Modifier.padding(top = 12.dp), ) 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 index c723238..8eb9a48 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -19,7 +19,6 @@ 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 @@ -28,6 +27,7 @@ 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.AppBackground import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.viewmodel.SessionViewModel @@ -43,7 +43,7 @@ fun UserNameScreen( modifier = Modifier .fillMaxSize() - .background(Color(0xFFf0d8ce)), + .background(AppBackground), ) { Column( modifier = Modifier.fillMaxSize(), 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 index 197b01c..d018608 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Brush.kt @@ -1,14 +1,13 @@ 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), + AppGreen, + AppGreenDark, ), ) @@ -16,8 +15,8 @@ val blueGradient = Brush.verticalGradient( colors = listOf( - Color(0xFF42A5F5), - Color(0xFF1565C0), + AppBlueLight, + AppBlue, ), ) @@ -25,8 +24,8 @@ val redGradient = Brush.verticalGradient( colors = listOf( - Color(0xFFCF5530), - Color(0xFFDE8468), + AppRed, + AppRedLight, ), ) @@ -34,7 +33,7 @@ val brownGradient = Brush.verticalGradient( colors = listOf( - Color(0xFF383330), - Color(0xFF1A1513), + AppInk, + AppInkDark, ), ) diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt index 99811b0..9c32983 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt @@ -2,10 +2,28 @@ package com.codenames.frontend.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +val AppBackground = Color(0xFFF0D8CE) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) +val AppInk = Color(0xFF383330) +val AppInkDark = Color(0xFF1A1513) +val AppMutedDark = Color(0xFF555555) + +val AppSurface = Color(0xFFE0D8C8) +val AppSurfaceOverlay = Color(0xB3E0D8C8) +val AppInkOverlay = Color(0xE6383330) + +val AppBlue = Color(0xFF1565C0) +val AppBlueLight = Color(0xFF42A5F5) + +val AppRed = Color(0xFFCF5530) +val AppRedLight = Color(0xFFDE8468) + +val AppGreen = Color(0xFF4CAF50) +val AppGreenDark = Color(0xFF2E7D32) + +val AppWhite = Color.White +val AppBlack = Color.Black +val AppLightGray = Color.LightGray + +val MaterialLightSurface = Color(0xFFFFFBFE) +val MaterialDarkText = Color(0xFF1C1B1F) diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Theme.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Theme.kt index dd7a939..d529b66 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/Theme.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Theme.kt @@ -12,25 +12,30 @@ import androidx.compose.ui.platform.LocalContext private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80, + primary = AppBlueLight, + secondary = AppRedLight, + tertiary = AppGreen, + background = AppInkDark, + surface = AppInk, + onPrimary = AppWhite, + onSecondary = AppWhite, + onTertiary = AppWhite, + onBackground = AppWhite, + onSurface = AppWhite, ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40, - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + primary = AppBlue, + secondary = AppRed, + tertiary = AppGreen, + background = AppBackground, + surface = MaterialLightSurface, + onPrimary = AppWhite, + onSecondary = AppWhite, + onTertiary = AppWhite, + onBackground = MaterialDarkText, + onSurface = MaterialDarkText, ) @Suppress("ktlint:standard:function-naming") From f9ad860477e21aa19e658b9e61a909be1ab004c3 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Mon, 25 May 2026 12:36:49 +0200 Subject: [PATCH 005/126] refactor: margin, placement & size of ui elements --- .../frontend/ui/buttons/AppButton.kt | 2 +- .../ui/buttons/SettingsCornerButton.kt | 31 +++- .../frontend/ui/navigation/NavGraph.kt | 96 +++++----- .../frontend/ui/screens/GameSettingsScreen.kt | 6 +- .../frontend/ui/screens/GameboardScreen.kt | 168 ++++++++++++------ .../frontend/ui/screens/JoinLobbyScreen.kt | 31 ++-- .../frontend/ui/screens/LobbyScreen.kt | 111 ++++++++---- .../frontend/ui/screens/SettingsScreen.kt | 27 +-- .../frontend/ui/screens/StartScreen.kt | 49 ++--- .../frontend/ui/screens/UserNameScreen.kt | 25 +-- .../frontend/ui/theme/ResponsiveDimensions.kt | 89 ++++++++++ 11 files changed, 426 insertions(+), 209 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt diff --git a/app/src/main/java/com/codenames/frontend/ui/buttons/AppButton.kt b/app/src/main/java/com/codenames/frontend/ui/buttons/AppButton.kt index 52acda9..e7b7d0e 100644 --- a/app/src/main/java/com/codenames/frontend/ui/buttons/AppButton.kt +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/AppButton.kt @@ -36,7 +36,7 @@ data class AppButtonStyle( val fontSize: TextUnit = TextUnit.Unspecified, val lineHeight: TextUnit = TextUnit.Unspecified, val shape: Shape = RoundedCornerShape(12.dp), - val contentPadding: PaddingValues = PaddingValues(horizontal = 20.dp, vertical = 12.dp), + val contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp), ) @Suppress("ktlint:standard:function-naming") 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 index 1c5ec78..f62fec8 100644 --- a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt @@ -12,22 +12,24 @@ 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 import com.codenames.frontend.ui.theme.AppInk import com.codenames.frontend.ui.theme.AppWhite +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions @Suppress("ktlint:standard:function-naming") @Composable fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { + val dimensions = LocalResponsiveDimensions.current + Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 8.dp, end = 8.dp) - .width(56.dp) - .height(56.dp) + .padding(top = dimensions.smallSpacing, end = dimensions.smallSpacing) + .width(dimensions.cornerButtonSize) + .height(dimensions.cornerButtonSize) .zIndex(1f), ) { androidx.compose.material3.IconButton( @@ -51,13 +53,15 @@ fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { @Suppress("ktlint:standard:function-naming") @Composable fun BoxScope.ReturnCornerButton(onClick: () -> Unit) { + val dimensions = LocalResponsiveDimensions.current + Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 16.dp, end = 16.dp) - .width(140.dp) - .height(56.dp) + .padding(top = dimensions.screenPadding, end = dimensions.screenPadding) + .width(dimensions.returnButtonWidth) + .height(dimensions.secondaryButtonHeight) .zIndex(1f), ) { AppButton( @@ -69,10 +73,19 @@ fun BoxScope.ReturnCornerButton(onClick: () -> Unit) { } } -private fun cornerButtonStyle(): AppButtonStyle = - AppButtonStyle( +@Composable +private fun cornerButtonStyle(): AppButtonStyle { + val dimensions = LocalResponsiveDimensions.current + + return AppButtonStyle( containerColor = AppInk, contentColor = AppWhite, fontSize = 16.sp, lineHeight = 18.sp, + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = dimensions.itemSpacing, + vertical = dimensions.smallSpacing, + ), ) +} 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 14cace4..65a5e74 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,6 +1,8 @@ package com.codenames.frontend.ui.navigation +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -12,6 +14,8 @@ import com.codenames.frontend.ui.screens.LobbyScreen 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.ui.theme.LocalResponsiveDimensions +import com.codenames.frontend.ui.theme.responsiveDimensionsFor import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -25,54 +29,64 @@ fun NavGraph( ) { val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = Screen.Username.route, - ) { - composable(Screen.Username.route) { - UserNameScreen(navController, sessionViewModel) - } - - composable( - Screen.Start.route, - ) { - StartScreen( - lobbyViewModel = lobbyViewModel, - navController = navController, - sessionViewModel = sessionViewModel, + BoxWithConstraints { + val responsiveDimensions = + responsiveDimensionsFor( + maxWidth = maxWidth, + maxHeight = maxHeight, ) - } - composable(Screen.Lobby.route) { - LobbyScreen( + CompositionLocalProvider(LocalResponsiveDimensions provides responsiveDimensions) { + NavHost( navController = navController, - viewModel = lobbyViewModel, - gameViewModel = gameViewModel, - sessionViewModel = sessionViewModel, - ) - } + startDestination = Screen.Username.route, + ) { + composable(Screen.Username.route) { + UserNameScreen(navController, sessionViewModel) + } - composable(Screen.JoinLobby.route) { - JoinlobbyScreen(viewModel = lobbyViewModel, navController = navController, sessionViewModel = sessionViewModel) - } + composable(Screen.Start.route) { + StartScreen( + lobbyViewModel = lobbyViewModel, + navController = navController, + sessionViewModel = sessionViewModel, + ) + } - composable( - route = Screen.Gameboard.route, - ) { - GameScreenWrapper( - navController = navController, - lobbyViewModel = lobbyViewModel, - gameViewModel = gameViewModel, - sessionViewModel = sessionViewModel, - ) - } + composable(Screen.Lobby.route) { + LobbyScreen( + navController = navController, + viewModel = lobbyViewModel, + gameViewModel = gameViewModel, + sessionViewModel = sessionViewModel, + ) + } - composable(Screen.GameSettings.route) { - GameSettingsScreen() - } + composable(Screen.JoinLobby.route) { + JoinlobbyScreen( + viewModel = lobbyViewModel, + navController = navController, + sessionViewModel = sessionViewModel, + ) + } + + composable(route = Screen.Gameboard.route) { + GameScreenWrapper( + navController = navController, + lobbyViewModel = lobbyViewModel, + gameViewModel = gameViewModel, + sessionViewModel = sessionViewModel, + ) + } + + composable(Screen.GameSettings.route) { + GameSettingsScreen() + } - composable(Screen.Settings.route) { - SettingsScreen(navController) + composable(Screen.Settings.route) { + SettingsScreen(navController) + } + } } } } 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 ef77588..e6f0021 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 @@ -9,17 +9,19 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.codenames.frontend.ui.theme.AppBackground +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions @Suppress("ktlint:standard:function-naming") @Composable fun GameSettingsScreen() { + val dimensions = LocalResponsiveDimensions.current + Column( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(dimensions.screenPadding) .background(AppBackground), 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 f8dc2ce..8653724 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 @@ -40,7 +40,6 @@ 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 @@ -69,6 +68,7 @@ import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.AppSurface import com.codenames.frontend.ui.theme.AppSurfaceOverlay import com.codenames.frontend.ui.theme.AppWhite +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient @@ -84,6 +84,8 @@ fun GameboardScreen( onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, ) { + val dimensions = LocalResponsiveDimensions.current + val currentHint = gameState.currentHint val cards = gameState.cards val currentTurn = gameState.currentTurn @@ -122,7 +124,12 @@ fun GameboardScreen( modifier = Modifier .fillMaxSize() - .padding(top = 72.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding( + top = dimensions.gameTopPadding, + start = dimensions.screenPadding, + end = dimensions.screenPadding, + bottom = dimensions.screenPadding, + ), ) { GameStatusBar( currentTurn = currentTurn, @@ -136,11 +143,11 @@ fun GameboardScreen( onClick = { isChatOpen = !isChatOpen }, modifier = Modifier - .padding(end = 24.dp, bottom = 24.dp), + .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing), ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(dimensions.gameBoardTopSpacing)) Row( modifier = @@ -162,14 +169,14 @@ fun GameboardScreen( Modifier .weight(1f) .fillMaxHeight() - .padding(horizontal = 8.dp) + .padding(horizontal = dimensions.smallSpacing) .background(AppSurface, RoundedCornerShape(12.dp)), contentAlignment = Alignment.Center, ) { Text( text = "Waiting for game state...", color = AppInk, - fontSize = 20.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, ) } @@ -184,7 +191,7 @@ fun GameboardScreen( Modifier .weight(1f) .fillMaxHeight() - .padding(horizontal = 8.dp) + .padding(horizontal = dimensions.smallSpacing) .clipToBounds() .pointerInput(Unit) { detectTransformGestures { _, pan, zoom, _ -> @@ -204,7 +211,7 @@ fun GameboardScreen( ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) HintSection( isSpymaster, @@ -231,9 +238,9 @@ fun GameboardScreen( modifier = Modifier .align(Alignment.Center) - .padding(end = 24.dp, bottom = 12.dp) - .width(420.dp) - .fillMaxHeight(0.90f), + .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing) + .width(dimensions.gameChatWidth) + .fillMaxHeight(dimensions.gameChatHeightFraction), ) } @@ -252,11 +259,13 @@ fun GameStatusBar( winner: Team?, remainingGuesses: Int, ) { + val dimensions = LocalResponsiveDimensions.current + Row( modifier = Modifier .fillMaxWidth() - .height(40.dp), + .height(dimensions.gameStatusBarHeight), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { @@ -270,7 +279,7 @@ fun GameStatusBar( Text( text = statusText, color = AppInk, - fontSize = 18.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, ) } @@ -283,19 +292,21 @@ fun ChatToggleButton( onClick: () -> Unit, modifier: Modifier = Modifier, ) { + val dimensions = LocalResponsiveDimensions.current + AppButton( text = "Chat", onClick = onClick, modifier = modifier - .width(140.dp) - .height(56.dp), + .width(dimensions.returnButtonWidth) + .height(dimensions.secondaryButtonHeight), style = AppButtonStyle( containerColor = if (isChatOpen) AppMutedDark else AppInk, contentColor = AppWhite, - fontSize = 18.sp, - lineHeight = 20.sp, + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) } @@ -312,18 +323,20 @@ fun ChatWindow( onSendClick: (ChatTab, String) -> Unit, modifier: Modifier = Modifier, ) { + val dimensions = LocalResponsiveDimensions.current + Column( modifier = modifier .background( color = AppInkOverlay, shape = RoundedCornerShape(12.dp), - ).padding(12.dp), + ).padding(dimensions.itemSpacing), verticalArrangement = Arrangement.SpaceBetween, ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(dimensions.smallSpacing), ) { availableTabs.forEach { tab -> AppButton( @@ -332,21 +345,25 @@ fun ChatWindow( modifier = Modifier .weight(1f) - .height(36.dp), + .height(dimensions.secondaryButtonHeight), style = AppButtonStyle( type = AppButtonType.PRIMARY, containerColor = if (selectedTab == tab) Color.Unspecified else Color.Transparent, contentColor = if (selectedTab == tab) Color.Unspecified else AppLightGray, - fontSize = 11.sp, - lineHeight = 12.sp, - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + fontSize = dimensions.smallFontSize, + lineHeight = dimensions.bodyFontSize, + contentPadding = + PaddingValues( + horizontal = dimensions.smallSpacing, + vertical = dimensions.smallSpacing, + ), ), ) } } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) ChatMessagesArea( messages = messages, @@ -354,14 +371,14 @@ fun ChatWindow( modifier = Modifier.weight(1f).fillMaxWidth(), ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) Row( modifier = Modifier .fillMaxWidth() - .height(64.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .height(dimensions.primaryButtonHeight), + horizontalArrangement = Arrangement.spacedBy(dimensions.smallSpacing), verticalAlignment = Alignment.CenterVertically, ) { AppTextField( @@ -380,8 +397,8 @@ fun ChatWindow( AppTextFieldStyle( containerColor = AppSurface, contentColor = AppInk, - fontSize = 14.sp, - lineHeight = 16.sp, + fontSize = dimensions.smallFontSize, + lineHeight = dimensions.bodyFontSize, ), ) @@ -396,13 +413,13 @@ fun ChatWindow( }, modifier = Modifier - .width(92.dp) + .width(dimensions.returnButtonWidth) .fillMaxHeight(), style = AppButtonStyle( backgroundBrush = greenGradient, - fontSize = 16.sp, - lineHeight = 18.sp, + fontSize = dimensions.smallFontSize, + lineHeight = dimensions.bodyFontSize, ), ) } @@ -416,6 +433,8 @@ fun ChatMessagesArea( selectedTab: ChatTab, modifier: Modifier = Modifier, ) { + val dimensions = LocalResponsiveDimensions.current + val visibleMessages = when (selectedTab) { ChatTab.GLOBAL -> messages.lobbyMessages @@ -429,27 +448,27 @@ fun ChatMessagesArea( .background( color = AppSurfaceOverlay, shape = RoundedCornerShape(8.dp), - ).padding(12.dp), + ).padding(dimensions.itemSpacing), verticalArrangement = Arrangement.Top, ) { Text( text = "${selectedTab.title} Chat", color = AppInk, - fontSize = 16.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(dimensions.smallSpacing)) if (visibleMessages.isEmpty()) { Text( text = "No messages yet.", color = AppInk, - fontSize = 14.sp, + fontSize = dimensions.smallFontSize, ) } else { LazyColumn( - verticalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(dimensions.smallSpacing), ) { items(visibleMessages) { message -> ChatMessageBubble(message = message) @@ -462,6 +481,8 @@ fun ChatMessagesArea( @Suppress("ktlint:standard:function-naming") @Composable fun ChatMessageBubble(message: ChatDomainModel) { + val dimensions = LocalResponsiveDimensions.current + val alignment = if (message.isFromMe) Alignment.End else Alignment.Start val bubbleColor = if (message.isFromMe) AppGreen else AppSurface val textColor = if (message.isFromMe) AppWhite else AppInk @@ -473,7 +494,7 @@ fun ChatMessageBubble(message: ChatDomainModel) { Text( text = message.sender, color = AppInk, - fontSize = 11.sp, + fontSize = dimensions.smallFontSize, fontWeight = FontWeight.Bold, ) @@ -482,12 +503,12 @@ fun ChatMessageBubble(message: ChatDomainModel) { Modifier .fillMaxWidth(0.78f) .background(bubbleColor, RoundedCornerShape(8.dp)) - .padding(8.dp), + .padding(dimensions.smallSpacing), ) { Text( text = message.text, color = textColor, - fontSize = 13.sp, + fontSize = dimensions.smallFontSize, ) } } @@ -502,6 +523,8 @@ fun TeamSidebar( textColor: Color, gradient: Brush, ) { + val dimensions = LocalResponsiveDimensions.current + 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 @@ -510,7 +533,7 @@ fun TeamSidebar( Column( modifier = Modifier - .width(90.dp) + .width(dimensions.gameSidebarWidth) .fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -518,10 +541,10 @@ fun TeamSidebar( title, fontWeight = FontWeight.Bold, color = textColor, - fontSize = 12.sp, + fontSize = dimensions.smallFontSize, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(dimensions.smallSpacing)) TeamRoleBox( title = "OPERATIVES", @@ -529,7 +552,7 @@ fun TeamSidebar( isCurrentUser = userRole == operative, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(dimensions.smallSpacing)) TeamRoleBox( title = "SPYMASTERS", @@ -537,13 +560,13 @@ fun TeamSidebar( isCurrentUser = userRole == spymaster, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) Text( text = "$teamFound FOUND", color = textColor, fontWeight = FontWeight.ExtraBold, - fontSize = 16.sp, + fontSize = dimensions.bodyFontSize, ) } } @@ -561,17 +584,30 @@ fun HintSection( keyboardController: SoftwareKeyboardController?, focusManager: FocusManager, ) { + val dimensions = LocalResponsiveDimensions.current + if (isSpymaster) { - Row { + Row( + horizontalArrangement = Arrangement.spacedBy(dimensions.smallSpacing), + verticalAlignment = Alignment.CenterVertically, + ) { AppTextField( value = hintInput, onValueChange = onInputChange, - modifier = Modifier.weight(0.8f), + modifier = + Modifier + .weight(dimensions.gameHintInputWeight) + .height(dimensions.gameHintInputHeight), state = AppTextFieldState( label = "HINT", placeholder = "Enter word...", ), + style = + AppTextFieldStyle( + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, + ), keyboard = AppTextFieldKeyboard( actions = @@ -589,11 +625,20 @@ fun HintSection( ), ), ) + AppTextField( value = countInput, onValueChange = onCountChange, - modifier = Modifier.width(80.dp), + modifier = + Modifier + .width(dimensions.gameHintCountWidth) + .height(dimensions.gameHintInputHeight), state = AppTextFieldState(label = "COUNT", placeholder = "0"), + style = + AppTextFieldStyle( + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, + ), ) AppButton( @@ -608,13 +653,22 @@ fun HintSection( keyboardController?.hide() } }, + modifier = + Modifier + .width(dimensions.gameHintSendButtonWidth) + .height(dimensions.gameHintInputHeight), + style = + AppButtonStyle( + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, + ), ) } } else { Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text( text = "Hint: $currentHint", - fontSize = 20.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, ) } @@ -629,12 +683,14 @@ fun TeamRoleBox( isCurrentUser: Boolean, modifier: Modifier = Modifier, ) { + val dimensions = LocalResponsiveDimensions.current + Column( modifier = modifier .fillMaxWidth() .background(gradient, RoundedCornerShape(8.dp)) - .padding(8.dp), + .padding(dimensions.smallSpacing), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -642,15 +698,15 @@ fun TeamRoleBox( text = title, color = AppWhite, fontWeight = FontWeight.Bold, - fontSize = 10.sp, + fontSize = dimensions.smallFontSize, ) if (isCurrentUser) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(dimensions.smallSpacing)) Text( text = "You", color = AppWhite, - fontSize = 12.sp, + fontSize = dimensions.smallFontSize, fontWeight = FontWeight.Bold, ) } @@ -664,6 +720,8 @@ fun CodenamesCard( isSpymaster: Boolean, onClick: () -> Unit, ) { + val dimensions = LocalResponsiveDimensions.current + val backgroundColor = when { card.revealed -> getColor(card.type) @@ -686,7 +744,7 @@ fun CodenamesCard( AppButtonStyle( containerColor = backgroundColor, contentColor = contentColor, - fontSize = 10.sp, + fontSize = dimensions.smallFontSize, ), ) } 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 index 322550a..7f5fcca 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -27,8 +27,6 @@ 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 @@ -43,6 +41,7 @@ import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppInk import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.AppWhite +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel @@ -71,6 +70,7 @@ fun JoinlobbyScreen( ) { ForceLandscape() + val dimensions = LocalResponsiveDimensions.current var lobbyId by rememberSaveable { mutableStateOf("") } val keyboardController = LocalSoftwareKeyboardController.current @@ -81,7 +81,6 @@ fun JoinlobbyScreen( 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) @@ -107,7 +106,7 @@ fun JoinlobbyScreen( modifier = Modifier .fillMaxSize() - .padding(24.dp), + .padding(dimensions.screenPadding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -118,8 +117,8 @@ fun JoinlobbyScreen( }, modifier = Modifier - .fillMaxWidth(0.5f) - .padding(bottom = 16.dp) + .fillMaxWidth(if (dimensions.isNarrowWidth) 0.7f else 0.5f) + .padding(bottom = dimensions.itemSpacing) .testTag(JOIN_LOBBY_INPUT_TAG), state = AppTextFieldState( @@ -130,8 +129,8 @@ fun JoinlobbyScreen( AppTextFieldStyle( type = AppTextFieldType.SECONDARY, contentColor = AppWhite, - fontSize = 20.sp, - lineHeight = 24.sp, + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, ), keyboard = AppTextFieldKeyboard( @@ -153,15 +152,15 @@ fun JoinlobbyScreen( onClick = { submitJoin() }, modifier = Modifier - .width(220.dp) - .height(80.dp) + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight) .testTag(JOIN_LOBBY_BUTTON_TAG), style = AppButtonStyle( enabled = joinEnabled, backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) @@ -169,8 +168,8 @@ fun JoinlobbyScreen( Text( text = "Joining...", color = AppInk, - fontSize = 20.sp, - modifier = Modifier.padding(top = 12.dp), + fontSize = dimensions.bodyFontSize, + modifier = Modifier.padding(top = dimensions.itemSpacing), ) } @@ -178,8 +177,8 @@ fun JoinlobbyScreen( Text( text = error, color = AppRed, - fontSize = 18.sp, - modifier = Modifier.padding(top = 12.dp), + fontSize = dimensions.bodyFontSize, + modifier = Modifier.padding(top = dimensions.itemSpacing), ) } } 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 8ef76b4..0721837 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 @@ -26,7 +26,6 @@ import androidx.compose.ui.graphics.Brush 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 @@ -45,6 +44,7 @@ import com.codenames.frontend.ui.theme.AppMutedDark import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.AppRedLight import com.codenames.frontend.ui.theme.AppWhite +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.brownGradient import com.codenames.frontend.ui.theme.greenGradient @@ -65,6 +65,7 @@ fun LobbyScreen( sessionViewModel: SessionViewModel, gameViewModel: GameViewModel, ) { + val dimensions = LocalResponsiveDimensions.current val usernameState by sessionViewModel.username.collectAsState() val lobbyUiState by viewModel.state.collectAsState() val currentPlayer = lobbyUiState.players.firstOrNull { it.name == usernameState.username } @@ -112,7 +113,12 @@ fun LobbyScreen( modifier = Modifier .fillMaxSize() - .padding(top = 40.dp, start = 16.dp, end = 16.dp, bottom = 16.dp), + .padding( + top = dimensions.gameTopPadding, + start = dimensions.screenPadding, + end = dimensions.screenPadding, + bottom = dimensions.screenPadding, + ), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { @@ -129,7 +135,7 @@ fun LobbyScreen( GameSettingsColumn( modifier = Modifier - .padding(horizontal = 24.dp) + .padding(horizontal = dimensions.sectionSpacing) .fillMaxHeight(), navController = navController, lobbyCode = lobbyUiState.lobbyCode ?: "", @@ -154,11 +160,11 @@ fun LobbyScreen( Text( text = error, color = AppRed, - fontSize = 16.sp, + fontSize = dimensions.bodyFontSize, modifier = Modifier .align(Alignment.BottomCenter) - .padding(bottom = 16.dp), + .padding(bottom = dimensions.screenPadding), ) } @@ -181,36 +187,33 @@ fun TeamColumn( onRoleSelect: (PlayerRoles) -> Unit, lobbyUiState: LobbyUiState, ) { + val dimensions = LocalResponsiveDimensions.current val align = if (color == Team.RED) Alignment.End else Alignment.Start Column( - modifier = - modifier - .fillMaxWidth(0.5f), + 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) + .width(dimensions.lobbyRoleCardWidth) + .height(dimensions.lobbyRoleCardHeight) + .padding(start = dimensions.smallSpacing, bottom = dimensions.itemSpacing) .background(gradient, RoundedCornerShape(12.dp)) - .padding(12.dp) + .padding(dimensions.itemSpacing) Text( text = title, color = textColor, - fontSize = 24.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, modifier = Modifier .align(align) - .padding(start = 6.dp) - .padding(end = 6.dp) - .padding(bottom = 6.dp), + .padding(horizontal = dimensions.smallSpacing) + .padding(bottom = dimensions.smallSpacing), ) RoleCard( @@ -240,21 +243,33 @@ fun RoleCard( title: String, players: List = emptyList(), ) { + val dimensions = LocalResponsiveDimensions.current + Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceBetween, ) { - Text(title, color = AppWhite, fontWeight = FontWeight.Bold) + Text( + text = title, + color = AppWhite, + fontWeight = FontWeight.Bold, + fontSize = dimensions.smallFontSize, + ) + if (players.isEmpty()) { Text( text = "No players", color = AppWhite.copy(alpha = 0.7f), - fontSize = 12.sp, + fontSize = dimensions.smallFontSize, ) } else { for (player in players) { - Text(player, color = AppWhite) + Text( + text = player, + color = AppWhite, + fontSize = dimensions.smallFontSize, + ) } } @@ -264,7 +279,13 @@ fun RoleCard( style = AppButtonStyle( backgroundBrush = greenGradient, - fontSize = 16.sp, + fontSize = dimensions.smallFontSize, + lineHeight = dimensions.bodyFontSize, + contentPadding = + PaddingValues( + horizontal = dimensions.itemSpacing, + vertical = dimensions.smallSpacing, + ), ), ) } @@ -281,6 +302,7 @@ fun GameSettingsColumn( currentRole: PlayerRoles, onStartGame: () -> Unit, ) { + val dimensions = LocalResponsiveDimensions.current val usernameState by sessionViewModel.username.collectAsState() val canStart = usernameState.username.isNotBlank() && @@ -295,12 +317,12 @@ fun GameSettingsColumn( ) { Text( text = "LOBBY CODE: $lobbyCode", - fontSize = 24.sp, + fontSize = dimensions.bodyFontSize, fontWeight = FontWeight.Bold, modifier = Modifier .align(Alignment.CenterHorizontally) - .padding(top = 8.dp), + .padding(top = dimensions.smallSpacing), ) Spacer(modifier = Modifier.weight(1f)) @@ -309,9 +331,9 @@ fun GameSettingsColumn( modifier = Modifier .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.5f) + .fillMaxWidth(dimensions.lobbyCenterColumnWidthFraction) .background(brownGradient, RoundedCornerShape(12.dp)) - .padding(16.dp), + .padding(dimensions.itemSpacing), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { @@ -319,7 +341,8 @@ fun GameSettingsColumn( text = "GAME SETTINGS", color = AppWhite, fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 16.dp), + fontSize = dimensions.smallFontSize, + modifier = Modifier.padding(bottom = dimensions.itemSpacing), ) AppButton( @@ -328,12 +351,14 @@ fun GameSettingsColumn( modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp), + .height(dimensions.secondaryButtonHeight) + .padding(bottom = dimensions.smallSpacing), style = AppButtonStyle( containerColor = AppMutedDark, contentColor = AppWhite, - fontSize = 18.sp, + fontSize = dimensions.smallFontSize, + lineHeight = dimensions.bodyFontSize, ), ) } @@ -346,15 +371,21 @@ fun GameSettingsColumn( modifier = Modifier .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.5f) - .padding(top = 16.dp), + .fillMaxWidth(dimensions.lobbyCenterColumnWidthFraction) + .height(dimensions.secondaryButtonHeight) + .padding(top = dimensions.itemSpacing), style = AppButtonStyle( enabled = canStart, backgroundBrush = greenGradient, - fontSize = 20.sp, + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, type = AppButtonType.PRIMARY, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + contentPadding = + PaddingValues( + horizontal = dimensions.itemSpacing, + vertical = dimensions.smallSpacing, + ), ), ) @@ -373,16 +404,22 @@ fun GameSettingsColumn( modifier = Modifier .align(Alignment.CenterHorizontally) - .fillMaxWidth(0.5f) - .padding(top = 16.dp) - .padding(bottom = 16.dp), + .fillMaxWidth(dimensions.lobbyCenterColumnWidthFraction) + .height(dimensions.secondaryButtonHeight) + .padding(top = dimensions.itemSpacing) + .padding(bottom = dimensions.itemSpacing), style = AppButtonStyle( backgroundBrush = brownGradient, - fontSize = 20.sp, + fontSize = dimensions.bodyFontSize, + lineHeight = dimensions.buttonLineHeight, contentColor = AppBlack, type = AppButtonType.SECONDARY, - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 0.dp), + contentPadding = + PaddingValues( + horizontal = dimensions.itemSpacing, + vertical = dimensions.smallSpacing, + ), ), ) } 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 414fa19..bb5328e 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 @@ -13,20 +13,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.AppBackground import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient @Suppress("ktlint:standard:function-naming") @Composable fun SettingsScreen(navController: NavHostController) { + val dimensions = LocalResponsiveDimensions.current + Box( modifier = Modifier @@ -36,17 +37,17 @@ fun SettingsScreen(navController: NavHostController) { Text( text = "SETTINGS", color = AppInk, - fontSize = 36.sp, + fontSize = dimensions.titleFontSize, fontWeight = FontWeight.Bold, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 48.dp), + .padding(top = dimensions.sectionSpacing * 2), ) Column( modifier = Modifier.align(Alignment.Center), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(dimensions.itemSpacing), horizontalAlignment = Alignment.CenterHorizontally, ) { AppButton( @@ -54,13 +55,13 @@ fun SettingsScreen(navController: NavHostController) { onClick = {}, modifier = Modifier - .width(240.dp) - .height(80.dp), + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight), style = AppButtonStyle( backgroundBrush = blueGradient, - fontSize = 24.sp, - lineHeight = 28.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) @@ -69,13 +70,13 @@ fun SettingsScreen(navController: NavHostController) { onClick = {}, modifier = Modifier - .width(240.dp) - .height(80.dp), + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight), style = AppButtonStyle( backgroundBrush = greenGradient, - fontSize = 24.sp, - lineHeight = 28.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) } 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 a3f4dbf..a198775 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row 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 @@ -17,8 +16,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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 @@ -27,6 +24,7 @@ import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppInk import com.codenames.frontend.ui.theme.AppRed +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.viewmodel.LobbyViewModel @@ -41,6 +39,7 @@ fun StartScreen( ) { ForceLandscape() + val dimensions = LocalResponsiveDimensions.current val lobbyState by lobbyViewModel.state.collectAsState() val usernameState by sessionViewModel.username.collectAsState() @@ -57,17 +56,22 @@ fun StartScreen( .background(AppBackground), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .padding(dimensions.screenPadding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Welcome to Codenames, ${usernameState.username}!", fontSize = 32.sp, modifier = Modifier.padding(bottom = 48.dp)) + Text( + text = "Welcome to Codenames, ${usernameState.username}!", + fontSize = dimensions.titleFontSize, + modifier = Modifier.padding(bottom = dimensions.sectionSpacing * 2), + ) Row( - modifier = - Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + horizontalArrangement = Arrangement.spacedBy(dimensions.itemSpacing), + verticalAlignment = Alignment.CenterVertically, ) { AppButton( text = "Create Lobby", @@ -76,16 +80,14 @@ fun StartScreen( }, modifier = Modifier - .width(200.dp) - .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, end = 12.dp), + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight), style = AppButtonStyle( enabled = !lobbyState.isLoading, backgroundBrush = greenGradient, - fontSize = 26.sp, - lineHeight = 30.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) @@ -96,16 +98,14 @@ fun StartScreen( }, modifier = Modifier - .width(200.dp) - .height(100.dp) - .fillMaxWidth(0.5f) - .padding(bottom = 12.dp, start = 12.dp), + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight), style = AppButtonStyle( enabled = !lobbyState.isLoading, backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) } @@ -114,7 +114,8 @@ fun StartScreen( Text( text = "Loading...", color = AppInk, - fontSize = 22.sp, + fontSize = dimensions.bodyFontSize, + modifier = Modifier.padding(top = dimensions.itemSpacing), ) } @@ -122,8 +123,8 @@ fun StartScreen( Text( text = error, color = AppRed, - fontSize = 18.sp, - modifier = Modifier.padding(top = 12.dp), + fontSize = dimensions.bodyFontSize, + modifier = Modifier.padding(top = dimensions.itemSpacing), ) } } 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 index 8eb9a48..9a1a446 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -20,14 +20,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.AppBackground +import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.viewmodel.SessionViewModel @@ -37,6 +36,7 @@ fun UserNameScreen( navController: NavController, viewModel: SessionViewModel, ) { + val dimensions = LocalResponsiveDimensions.current var username by remember { mutableStateOf("") } Box( @@ -46,24 +46,27 @@ fun UserNameScreen( .background(AppBackground), ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = + Modifier + .fillMaxSize() + .padding(dimensions.screenPadding), verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = "Codenames", - fontSize = 48.sp, - modifier = Modifier.padding(bottom = 100.dp), + fontSize = dimensions.titleFontSize, + modifier = Modifier.padding(bottom = dimensions.sectionSpacing * 3), ) TextField( value = username, onValueChange = { username = it }, label = { Text("enter username") }, - modifier = Modifier.fillMaxWidth(0.5f), + modifier = Modifier.fillMaxWidth(if (dimensions.isNarrowWidth) 0.7f else 0.5f), ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) AppButton( text = "Continue", @@ -74,15 +77,15 @@ fun UserNameScreen( }, modifier = Modifier - .width(220.dp) - .height(80.dp) + .width(dimensions.primaryButtonWidth) + .height(dimensions.primaryButtonHeight) .testTag(JOIN_LOBBY_BUTTON_TAG), style = AppButtonStyle( enabled = username.isNotBlank(), backgroundBrush = blueGradient, - fontSize = 26.sp, - lineHeight = 30.sp, + fontSize = dimensions.buttonFontSize, + lineHeight = dimensions.buttonLineHeight, ), ) } diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt new file mode 100644 index 0000000..1cbe1eb --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt @@ -0,0 +1,89 @@ +package com.codenames.frontend.ui.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Immutable +data class ResponsiveDimensions( + val isCompactHeight: Boolean, + val isNarrowWidth: Boolean, + val screenPadding: Dp, + val sectionSpacing: Dp, + val itemSpacing: Dp, + val smallSpacing: Dp, + val cornerButtonSize: Dp, + val returnButtonWidth: Dp, + val primaryButtonWidth: Dp, + val primaryButtonHeight: Dp, + val secondaryButtonHeight: Dp, + val lobbyRoleCardWidth: Dp, + val lobbyRoleCardHeight: Dp, + val lobbyCenterColumnWidthFraction: Float, + val gameTopPadding: Dp, + val gameSidebarWidth: Dp, + val gameChatWidth: Dp, + val gameChatHeightFraction: Float, + val gameStatusBarHeight: Dp, + val gameHintCountWidth: Dp, + val gameHintInputHeight: Dp, + val gameHintInputWeight: Float, + val gameHintSendButtonWidth: Dp, + val buttonFontSize: TextUnit, + val buttonLineHeight: TextUnit, + val titleFontSize: TextUnit, + val bodyFontSize: TextUnit, + val smallFontSize: TextUnit, + val gameBoardTopSpacing: Dp, +) + +val LocalResponsiveDimensions = + staticCompositionLocalOf { + responsiveDimensionsFor( + maxWidth = 900.dp, + maxHeight = 420.dp, + ) + } + +fun responsiveDimensionsFor( + maxWidth: Dp, + maxHeight: Dp, +): ResponsiveDimensions { + val isCompactHeight = maxHeight < 420.dp + val isNarrowWidth = maxWidth < 760.dp + + return ResponsiveDimensions( + isCompactHeight = isCompactHeight, + isNarrowWidth = isNarrowWidth, + screenPadding = if (isCompactHeight) 10.dp else 16.dp, + sectionSpacing = if (isCompactHeight) 10.dp else 16.dp, + itemSpacing = if (isCompactHeight) 6.dp else 10.dp, + smallSpacing = 6.dp, + cornerButtonSize = if (isCompactHeight) 44.dp else 52.dp, + returnButtonWidth = if (isNarrowWidth) 112.dp else 132.dp, + primaryButtonWidth = (maxWidth * 0.22f).coerceIn(150.dp, 220.dp), + primaryButtonHeight = (maxHeight * 0.16f).coerceIn(56.dp, 82.dp), + secondaryButtonHeight = (maxHeight * 0.12f).coerceIn(44.dp, 64.dp), + lobbyRoleCardWidth = (maxWidth * 0.20f).coerceIn(150.dp, 200.dp), + lobbyRoleCardHeight = (maxHeight * 0.30f).coerceIn(105.dp, 145.dp), + lobbyCenterColumnWidthFraction = if (isNarrowWidth) 0.62f else 0.52f, + gameTopPadding = if (isCompactHeight) 8.dp else 12.dp, + gameSidebarWidth = (maxWidth * 0.10f).coerceIn(72.dp, 96.dp), + gameChatWidth = (maxWidth * 0.42f).coerceIn(300.dp, 420.dp), + gameChatHeightFraction = if (isCompactHeight) 0.86f else 0.90f, + gameStatusBarHeight = if (isCompactHeight) 24.dp else 32.dp, + gameHintCountWidth = if (isCompactHeight) 96.dp else 112.dp, + gameHintInputHeight = if (isCompactHeight) 72.dp else 80.dp, + gameHintInputWeight = if (isNarrowWidth) 0.50f else 0.62f, + gameHintSendButtonWidth = if (isCompactHeight) 96.dp else 112.dp, + buttonFontSize = if (isCompactHeight) 18.sp else 22.sp, + buttonLineHeight = if (isCompactHeight) 20.sp else 26.sp, + titleFontSize = if (isCompactHeight) 28.sp else 36.sp, + bodyFontSize = if (isCompactHeight) 16.sp else 20.sp, + smallFontSize = if (isCompactHeight) 10.sp else 12.sp, + gameBoardTopSpacing = if (isCompactHeight) 2.dp else 4.dp, + ) +} From 4682a8e25075eace7d45a803dfcf18f3707da30d Mon Sep 17 00:00:00 2001 From: ad-devel Date: Mon, 25 May 2026 13:01:49 +0200 Subject: [PATCH 006/126] fix: sonar issue cognitive complexity --- .../frontend/ui/theme/ResponsiveDimensions.kt | 68 +++++++++++++------ 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt index 1cbe1eb..7e7e41b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt @@ -58,32 +58,62 @@ fun responsiveDimensionsFor( return ResponsiveDimensions( isCompactHeight = isCompactHeight, isNarrowWidth = isNarrowWidth, - screenPadding = if (isCompactHeight) 10.dp else 16.dp, - sectionSpacing = if (isCompactHeight) 10.dp else 16.dp, - itemSpacing = if (isCompactHeight) 6.dp else 10.dp, + screenPadding = compactDp(isCompactHeight, compact = 10.dp, regular = 16.dp), + sectionSpacing = compactDp(isCompactHeight, compact = 10.dp, regular = 16.dp), + itemSpacing = compactDp(isCompactHeight, compact = 6.dp, regular = 10.dp), smallSpacing = 6.dp, - cornerButtonSize = if (isCompactHeight) 44.dp else 52.dp, - returnButtonWidth = if (isNarrowWidth) 112.dp else 132.dp, + cornerButtonSize = compactDp(isCompactHeight, compact = 44.dp, regular = 52.dp), + returnButtonWidth = narrowDp(isNarrowWidth, narrow = 112.dp, regular = 132.dp), primaryButtonWidth = (maxWidth * 0.22f).coerceIn(150.dp, 220.dp), primaryButtonHeight = (maxHeight * 0.16f).coerceIn(56.dp, 82.dp), secondaryButtonHeight = (maxHeight * 0.12f).coerceIn(44.dp, 64.dp), lobbyRoleCardWidth = (maxWidth * 0.20f).coerceIn(150.dp, 200.dp), lobbyRoleCardHeight = (maxHeight * 0.30f).coerceIn(105.dp, 145.dp), - lobbyCenterColumnWidthFraction = if (isNarrowWidth) 0.62f else 0.52f, - gameTopPadding = if (isCompactHeight) 8.dp else 12.dp, + lobbyCenterColumnWidthFraction = narrowFloat(isNarrowWidth, narrow = 0.62f, regular = 0.52f), + gameTopPadding = compactDp(isCompactHeight, compact = 8.dp, regular = 12.dp), gameSidebarWidth = (maxWidth * 0.10f).coerceIn(72.dp, 96.dp), gameChatWidth = (maxWidth * 0.42f).coerceIn(300.dp, 420.dp), - gameChatHeightFraction = if (isCompactHeight) 0.86f else 0.90f, - gameStatusBarHeight = if (isCompactHeight) 24.dp else 32.dp, - gameHintCountWidth = if (isCompactHeight) 96.dp else 112.dp, - gameHintInputHeight = if (isCompactHeight) 72.dp else 80.dp, - gameHintInputWeight = if (isNarrowWidth) 0.50f else 0.62f, - gameHintSendButtonWidth = if (isCompactHeight) 96.dp else 112.dp, - buttonFontSize = if (isCompactHeight) 18.sp else 22.sp, - buttonLineHeight = if (isCompactHeight) 20.sp else 26.sp, - titleFontSize = if (isCompactHeight) 28.sp else 36.sp, - bodyFontSize = if (isCompactHeight) 16.sp else 20.sp, - smallFontSize = if (isCompactHeight) 10.sp else 12.sp, - gameBoardTopSpacing = if (isCompactHeight) 2.dp else 4.dp, + gameChatHeightFraction = compactFloat(isCompactHeight, compact = 0.86f, regular = 0.90f), + gameStatusBarHeight = compactDp(isCompactHeight, compact = 24.dp, regular = 32.dp), + gameHintCountWidth = compactDp(isCompactHeight, compact = 96.dp, regular = 112.dp), + gameHintInputHeight = compactDp(isCompactHeight, compact = 72.dp, regular = 80.dp), + gameHintInputWeight = narrowFloat(isNarrowWidth, narrow = 0.50f, regular = 0.62f), + gameHintSendButtonWidth = compactDp(isCompactHeight, compact = 96.dp, regular = 112.dp), + buttonFontSize = compactSp(isCompactHeight, compact = 18, regular = 22), + buttonLineHeight = compactSp(isCompactHeight, compact = 20, regular = 26), + titleFontSize = compactSp(isCompactHeight, compact = 28, regular = 36), + bodyFontSize = compactSp(isCompactHeight, compact = 16, regular = 20), + smallFontSize = compactSp(isCompactHeight, compact = 10, regular = 12), + gameBoardTopSpacing = compactDp(isCompactHeight, compact = 2.dp, regular = 4.dp), ) } + +private fun compactDp( + isCompactHeight: Boolean, + compact: Dp, + regular: Dp, +): Dp = if (isCompactHeight) compact else regular + +private fun narrowDp( + isNarrowWidth: Boolean, + narrow: Dp, + regular: Dp, +): Dp = if (isNarrowWidth) narrow else regular + +private fun compactFloat( + isCompactHeight: Boolean, + compact: Float, + regular: Float, +): Float = if (isCompactHeight) compact else regular + +private fun narrowFloat( + isNarrowWidth: Boolean, + narrow: Float, + regular: Float, +): Float = if (isNarrowWidth) narrow else regular + +private fun compactSp( + isCompactHeight: Boolean, + compact: Int, + regular: Int, +): TextUnit = if (isCompactHeight) compact.sp else regular.sp From 64b9c8d48ff0fbf329997acbb87c2f17dbed8e57 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sun, 31 May 2026 21:22:42 +0200 Subject: [PATCH 007/126] test: fixed tests + coverage --- .../network/websocket/GameWebSocketHandler.kt | 26 +++++------- .../websocket/WebSocketSessionManager.kt | 2 +- .../frontend/viewmodel/GameViewModel.kt | 7 ++-- .../data/repository/GameRepositoryTest.kt | 16 ++++++++ .../websocket/GameWebSocketHandlerTest.kt | 40 ++++++++----------- .../websocket/WebSocketSessionManagerTest.kt | 31 +++++++------- 6 files changed, 63 insertions(+), 59 deletions(-) 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 ffddc3e..8492bd2 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,6 +1,5 @@ package com.codenames.frontend.network.websocket -import android.util.Log import com.codenames.frontend.network.dto.ChatMessageDto import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage @@ -8,56 +7,49 @@ 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 -import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization 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-fallback" - @Singleton class GameWebSocketHandler @Inject constructor( - private val client: StompClient, + private val webSocketSessionManager: WebSocketSessionManager ) { - lateinit var session: StompSessionWithKxSerialization suspend fun connectStomp() { - session = client.connect(BASE_URL).withJsonConversions() - Log.d("WebSocket", "Connected to Websocket, session: $session") + webSocketSessionManager.connectStomp() } suspend fun startGame(msg: StartGameMessage) { - session.convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) + webSocketSessionManager.getSession().convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) } suspend fun sendGuess(msg: GuessMessage) { - session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) + webSocketSessionManager.getSession().convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) } @Suppress("kotlin:S6309") suspend fun subscribeToLobby(lobbyCode: String): Flow = - session.subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) + webSocketSessionManager.getSession().subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) suspend fun sendReconnectMessage(msg: WebSocketJoinMessage) { - session.convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) + webSocketSessionManager.getSession().convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } @Suppress("kotlin:S6309") - suspend fun subscribeToChat(topicPath: String): Flow = session.subscribe(topicPath, ChatMessageDto.serializer()) + suspend fun subscribeToChat(topicPath: String): Flow = webSocketSessionManager.getSession().subscribe(topicPath, ChatMessageDto.serializer()) suspend fun sendChatMessage( destination: String, msg: ChatMessageDto, ) { - session.convertAndSend(destination, msg, ChatMessageDto.serializer()) + webSocketSessionManager.getSession().convertAndSend(destination, msg, ChatMessageDto.serializer()) } suspend fun sendClue(msg: ClueMessageDto) { - session.convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) + webSocketSessionManager.getSession().convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } } diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt index 6429e01..91d524e 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt @@ -20,7 +20,7 @@ class WebSocketSessionManager @Inject constructor(val client: StompClient) { session = client.connect(URL).withJsonConversions() } catch (e: Exception) { Log.e("WebSocket", "Failed to connect to Websocket", e) - throw e + return } Log.d("WebSocket", "Connected to Websocket, session: $session") } 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 7268799..33527d8 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -14,6 +14,7 @@ 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.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.WebSocketSessionManager import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -28,7 +29,7 @@ import javax.inject.Inject class GameViewModel @Inject constructor( - private val client: GameWebSocketHandler, + private val handler: GameWebSocketHandler, private val chatRepository: ChatRepository, private val gameRepository: GameRepository, ) : ViewModel() { @@ -57,14 +58,14 @@ class GameViewModel _connectionState.value = ConnectionState.CONNECTING try { - client.connectStomp() + handler.connectStomp() Log.d("GameViewModel", "Connection successful") _connectionState.value = ConnectionState.CONNECTED launch { - client + handler .subscribeToLobby(lobbyCode) .collect { handleMessage(it) } } 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 index be20743..d7355a9 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.data.repository +import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.websocket.GameWebSocketHandler import io.mockk.Runs import io.mockk.coEvery @@ -31,4 +32,19 @@ class GameRepositoryTest { coVerify { webSocketHandler.startGame(any()) } } + + @Test + fun testSubmitClue() = + runTest { + val lobbyCode = "ABCDE" + val word = "test" + val guessAmount = 2 + val currentTurn = Team.RED + + coEvery { webSocketHandler.sendClue(any()) } just Runs + + gameRepository.submitClue(lobbyCode, word, guessAmount, currentTurn) + + coVerify { webSocketHandler.sendClue(any()) } + } } 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 e413761..fd4a7bc 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,6 +1,5 @@ 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 @@ -25,26 +24,33 @@ 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 + private lateinit var sessionManager: WebSocketSessionManager + private lateinit var session: StompSessionWithKxSerialization @Before fun setup() { - client = mockk() + sessionManager = mockk() session = mockk(relaxed = true) - wsClient = GameWebSocketHandler(client) - wsClient.session = session + wsClient = GameWebSocketHandler(sessionManager) + + coEvery { sessionManager.getSession() } returns session } @Test - fun testSendGuess_sendsCorrectMessage(): Unit = + fun testConnect() = runTest { - val session = mockk(relaxed = true) - val client = mockk() + coEvery { sessionManager.connectStomp() } returns Unit - val wsClient = GameWebSocketHandler(client) - wsClient.session = session + wsClient.connectStomp() + + coVerify { sessionManager.connectStomp() } + + } + + @Test + fun testSendGuess_sendsCorrectMessage(): Unit = + runTest { val msg = GuessMessage("name", "word", 1) @@ -58,16 +64,10 @@ class GameWebSocketHandlerTest { @Test fun testSubscribeToLobby_subscribesToCorrectTopic(): Unit = runTest { - val session = mockk(relaxed = true) - val client = mockk() - coEvery { session.subscribe("/topic/game/ABCDE") } returns emptyFlow() - val wsClient = GameWebSocketHandler(client) - wsClient.session = session - wsClient.subscribeToLobby("ABCDE") coVerify { @@ -81,12 +81,6 @@ class GameWebSocketHandlerTest { @Test fun testSendReconnectMessageSendsMessage() = runTest { - val session = mockk(relaxed = true) - val client = mockk() - - val wsClient = GameWebSocketHandler(client) - wsClient.session = session - val msg = WebSocketJoinMessage("name", "1234") wsClient.sendReconnectMessage(msg) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt index 61c3f8f..c542f80 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.test.runTest import org.hildan.krossbow.stomp.StompClient import org.hildan.krossbow.stomp.StompSession import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization -import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions +import org.junit.Assert.assertFalse import org.junit.Assert.assertThrows import org.junit.Before import org.junit.Test @@ -24,6 +24,8 @@ class WebSocketSessionManagerTest { private lateinit var session: StompSessionWithKxSerialization private lateinit var rawSession: StompSession + private val url = "ws://localhost:8080/ws-fallback" + @Before fun setup() { client = mockk() @@ -41,7 +43,7 @@ class WebSocketSessionManagerTest { runTest { val sessionWithJson = mockk() - coEvery { client.connect(URL) } returns session + coEvery { client.connect(url) } returns session coEvery { sessionWithJson.subscribe(any(), any()) @@ -52,33 +54,32 @@ class WebSocketSessionManagerTest { wsClient.connectStomp() coVerify { - client.connect(BASE_URL) - session.withJsonConversions() + client.connect(url) } } @Test fun testConnectStomp_throwsExceptionWhenConnectionFails() = runTest { - coEvery { client.connect(URL) } throws Exception("Connection failed") + coEvery { client.connect(url) } throws Exception("Connection failed") val wsClient = WebSocketSessionManager(client) - assertThrows(Exception::class.java) { - runTest { - wsClient.connectStomp() - } - } + wsClient.connectStomp() + + coVerify(exactly = 1) { client.connect(url) } + + assertFalse(wsClient.isConnected()) } @Test fun testConnectStomp_DoesNotConnectWhenSessionExists() = runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(URL) } returns session + coEvery { client.connect(url) } returns session wsClient.connectStomp() wsClient.connectStomp() - coVerify(exactly = 1) { client.connect(BASE_URL) } + coVerify(exactly = 1) { client.connect(url) } } @Test @@ -86,7 +87,7 @@ class WebSocketSessionManagerTest { runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(URL) } returns session + coEvery { client.connect(url) } returns session wsClient.connectStomp() wsClient.disconnect() @@ -113,7 +114,7 @@ class WebSocketSessionManagerTest { runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(URL) } returns session + coEvery { client.connect(url) } returns session wsClient.connectStomp() @@ -132,7 +133,7 @@ class WebSocketSessionManagerTest { fun testGetSession_returnsSession() = runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(URL) } returns session + coEvery { client.connect(url) } returns session wsClient.connectStomp() From b7d221b752ac938233e8929e186f5c3969b2271c Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sun, 31 May 2026 22:32:35 +0200 Subject: [PATCH 008/126] "reformatting of frontend" --- .../network/provider/RetrofitProvider.kt | 2 +- .../network/websocket/GameWebSocketHandler.kt | 6 +-- .../websocket/WebSocketSessionManager.kt | 51 ++++++++++--------- .../frontend/viewmodel/GameViewModel.kt | 1 - .../websocket/GameWebSocketHandlerTest.kt | 7 --- .../websocket/WebSocketSessionManagerTest.kt | 16 +++--- 6 files changed, 38 insertions(+), 45 deletions(-) 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 3d1b1f5..8d2bbb4 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/" +const val BASE_URL = "http://192.168.0.134: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 8492bd2..f4632bf 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 @@ -16,9 +16,8 @@ import javax.inject.Singleton class GameWebSocketHandler @Inject constructor( - private val webSocketSessionManager: WebSocketSessionManager + private val webSocketSessionManager: WebSocketSessionManager, ) { - suspend fun connectStomp() { webSocketSessionManager.connectStomp() } @@ -40,7 +39,8 @@ class GameWebSocketHandler } @Suppress("kotlin:S6309") - suspend fun subscribeToChat(topicPath: String): Flow = webSocketSessionManager.getSession().subscribe(topicPath, ChatMessageDto.serializer()) + suspend fun subscribeToChat(topicPath: String): Flow = + webSocketSessionManager.getSession().subscribe(topicPath, ChatMessageDto.serializer()) suspend fun sendChatMessage( destination: String, diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt index 91d524e..ef262a2 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt @@ -7,35 +7,36 @@ import org.hildan.krossbow.stomp.StompClient import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions -const val URL = "ws://localhost:8080/ws-fallback" +const val URL = "ws://192.168.0.134:8080/ws-fallback" @Singleton -class WebSocketSessionManager @Inject constructor(val client: StompClient) { - - private var session: StompSessionWithKxSerialization? = null - - suspend fun connectStomp() { - if (isConnected()) return - try { - session = client.connect(URL).withJsonConversions() - } catch (e: Exception) { - Log.e("WebSocket", "Failed to connect to Websocket", e) - return +class WebSocketSessionManager + @Inject + constructor( + val client: StompClient, + ) { + private var session: StompSessionWithKxSerialization? = null + + suspend fun connectStomp() { + if (isConnected()) return + try { + session = client.connect(URL).withJsonConversions() + } catch (e: Exception) { + Log.e("WebSocket", "Failed to connect to Websocket", e) + return + } + Log.d("WebSocket", "Connected to Websocket, session: $session") } - Log.d("WebSocket", "Connected to Websocket, session: $session") - } - fun isConnected(): Boolean = session != null + fun isConnected(): Boolean = session != null - fun getSession(): StompSessionWithKxSerialization { - return requireNotNull(session) { - "WebSocket is not connected" - } - } + fun getSession(): StompSessionWithKxSerialization = + requireNotNull(session) { + "WebSocket is not connected" + } - suspend fun disconnect() { - session?.disconnect() - session = null + suspend fun disconnect() { + session?.disconnect() + session = null + } } - -} \ No newline at end of file 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 33527d8..a0f9da2 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -14,7 +14,6 @@ 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.websocket.GameWebSocketHandler -import com.codenames.frontend.network.websocket.WebSocketSessionManager import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job 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 fd4a7bc..3042b27 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 @@ -9,16 +9,11 @@ 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 -import org.hildan.krossbow.stomp.StompSession import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization 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 @@ -45,13 +40,11 @@ class GameWebSocketHandlerTest { wsClient.connectStomp() coVerify { sessionManager.connectStomp() } - } @Test fun testSendGuess_sendsCorrectMessage(): Unit = runTest { - val msg = GuessMessage("name", "word", 1) wsClient.sendGuess(msg) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt index c542f80..085c99a 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt @@ -19,7 +19,6 @@ import org.junit.Test import kotlin.test.assertNotNull class WebSocketSessionManagerTest { - private lateinit var client: StompClient private lateinit var session: StompSessionWithKxSerialization private lateinit var rawSession: StompSession @@ -130,15 +129,16 @@ class WebSocketSessionManagerTest { } @Test - fun testGetSession_returnsSession() = runTest { - val wsClient = WebSocketSessionManager(client) + fun testGetSession_returnsSession() = + runTest { + val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(url) } returns session + coEvery { client.connect(url) } returns session - wsClient.connectStomp() + wsClient.connectStomp() - assertNotNull(wsClient.getSession()) - } + assertNotNull(wsClient.getSession()) + } @Test fun testGetSession_throwsWhenSessionIsNull() = @@ -147,4 +147,4 @@ class WebSocketSessionManagerTest { assertThrows(IllegalArgumentException::class.java) { wsClient.getSession() } } -} \ No newline at end of file +} From c251a38ca84ed1319f25396881e572200ab45326 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sun, 31 May 2026 23:10:20 +0200 Subject: [PATCH 009/126] changed IP handling: set IP in local.properties now --- .idea/misc.xml | 3 ++- app/build.gradle.kts | 20 +++++++++++++++++++ .../frontend/network/provider/IpProvider.kt | 6 ++++++ .../network/provider/RetrofitProvider.kt | 4 +--- .../websocket/WebSocketSessionManager.kt | 5 ++--- 5 files changed, 31 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt diff --git a/.idea/misc.xml b/.idea/misc.xml index a1d4940..3b0be22 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,7 @@ + - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5e7c7d7..ed82a34 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,16 @@ +import java.util.Properties +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") + +if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) +} + +val serverUrl = localProperties.getProperty( + "SERVER_URL", + "http://10.0.2.2:8080" +) + plugins { id("com.android.application") alias(libs.plugins.kotlin.compose) @@ -102,11 +115,17 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + buildConfigField( + "String", + "SERVER_URL", + "\"$serverUrl\"" + ) } buildTypes { release { isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", @@ -119,6 +138,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt new file mode 100644 index 0000000..42256fe --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt @@ -0,0 +1,6 @@ +package com.codenames.frontend.network.provider + +import com.codenames.frontend.BuildConfig + +fun getHttpUrl() = "http://" + BuildConfig.SERVER_URL +fun getWsUrl() = "ws://" + BuildConfig.SERVER_URL + "ws-fallback" \ No newline at end of file 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 8d2bbb4..52566c2 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,8 +10,6 @@ import kotlinx.serialization.json.Json import okhttp3.MediaType.Companion.toMediaType import retrofit2.Retrofit -const val BASE_URL = "http://192.168.0.134:8080/" - @Module @InstallIn(SingletonComponent::class) object RetrofitProvider { @@ -25,7 +23,7 @@ object RetrofitProvider { fun provideRetrofit(json: Json): Retrofit = Retrofit .Builder() - .baseUrl(BASE_URL) + .baseUrl(getHttpUrl()) .addConverterFactory( json.asConverterFactory("application/json".toMediaType()), ).build() diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt index ef262a2..63d7451 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/WebSocketSessionManager.kt @@ -1,14 +1,13 @@ package com.codenames.frontend.network.websocket import android.util.Log +import com.codenames.frontend.network.provider.getWsUrl import jakarta.inject.Inject import jakarta.inject.Singleton import org.hildan.krossbow.stomp.StompClient import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization import org.hildan.krossbow.stomp.conversions.kxserialization.json.withJsonConversions -const val URL = "ws://192.168.0.134:8080/ws-fallback" - @Singleton class WebSocketSessionManager @Inject @@ -20,7 +19,7 @@ class WebSocketSessionManager suspend fun connectStomp() { if (isConnected()) return try { - session = client.connect(URL).withJsonConversions() + session = client.connect(getWsUrl()).withJsonConversions() } catch (e: Exception) { Log.e("WebSocket", "Failed to connect to Websocket", e) return From 0d382a27a9b4670719a48c32c143b117bff83ecf Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sun, 31 May 2026 23:11:11 +0200 Subject: [PATCH 010/126] format --- app/build.gradle.kts | 11 ++++++----- .../codenames/frontend/network/provider/IpProvider.kt | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed82a34..862a3c4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,10 +6,11 @@ if (localPropertiesFile.exists()) { localProperties.load(localPropertiesFile.inputStream()) } -val serverUrl = localProperties.getProperty( - "SERVER_URL", - "http://10.0.2.2:8080" -) +val serverUrl = + localProperties.getProperty( + "SERVER_URL", + "http://10.0.2.2:8080", + ) plugins { id("com.android.application") @@ -118,7 +119,7 @@ android { buildConfigField( "String", "SERVER_URL", - "\"$serverUrl\"" + "\"$serverUrl\"", ) } diff --git a/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt index 42256fe..c467fcf 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt @@ -3,4 +3,5 @@ package com.codenames.frontend.network.provider import com.codenames.frontend.BuildConfig fun getHttpUrl() = "http://" + BuildConfig.SERVER_URL -fun getWsUrl() = "ws://" + BuildConfig.SERVER_URL + "ws-fallback" \ No newline at end of file + +fun getWsUrl() = "ws://" + BuildConfig.SERVER_URL + "ws-fallback" From e7bd49ef2ead0b99f2357284d27e26bf92c1c8f6 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Sun, 31 May 2026 23:31:00 +0200 Subject: [PATCH 011/126] rename game websocket handler to controller, fix tests to be insensitive of current url --- .../frontend/data/repository/ChatRepository.kt | 4 ++-- .../frontend/data/repository/GameRepository.kt | 4 ++-- ...etHandler.kt => GameWebSocketController.kt} | 2 +- .../frontend/viewmodel/GameViewModel.kt | 4 ++-- .../data/repository/ChatRepositoryTest.kt | 4 ++-- .../data/repository/GameRepositoryTest.kt | 4 ++-- ...rTest.kt => GameWebSocketControllerTest.kt} | 6 +++--- .../websocket/WebSocketSessionManagerTest.kt | 18 +++++++++--------- .../frontend/viewmodel/GameViewModelTest.kt | 6 +++--- 9 files changed, 26 insertions(+), 26 deletions(-) rename app/src/main/java/com/codenames/frontend/network/websocket/{GameWebSocketHandler.kt => GameWebSocketController.kt} (98%) rename app/src/test/java/com/codenames/frontend/network/websocket/{GameWebSocketHandlerTest.kt => GameWebSocketControllerTest.kt} (96%) 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 index b37fe63..fc99a36 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -2,7 +2,7 @@ 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 com.codenames.frontend.network.websocket.GameWebSocketController import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -10,7 +10,7 @@ import javax.inject.Inject class ChatRepository @Inject constructor( - private val webSocketHandler: GameWebSocketHandler, + private val webSocketHandler: GameWebSocketController, ) { fun observeChat( topic: String, 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 index 3c71374..51bca92 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -3,13 +3,13 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.StartGameMessage -import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.GameWebSocketController import javax.inject.Inject class GameRepository @Inject constructor( - private val webSocketHandler: GameWebSocketHandler, + private val webSocketHandler: GameWebSocketController, ) { suspend fun startGame(lobbyCode: String) { val msg = StartGameMessage(lobbyCode) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt similarity index 98% rename from app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt rename to app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index f4632bf..a02224f 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketHandler.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -13,7 +13,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class GameWebSocketHandler +class GameWebSocketController @Inject constructor( private val webSocketSessionManager: WebSocketSessionManager, 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 a0f9da2..f7c1b1c 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -13,7 +13,7 @@ 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.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.GameWebSocketController import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -28,7 +28,7 @@ import javax.inject.Inject class GameViewModel @Inject constructor( - private val handler: GameWebSocketHandler, + private val handler: GameWebSocketController, private val chatRepository: ChatRepository, private val gameRepository: GameRepository, ) : ViewModel() { 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 index 3e6e4fb..28adbf8 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.network.dto.ChatMessageDto -import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.GameWebSocketController import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -21,7 +21,7 @@ import org.junit.Test import kotlin.test.assertFailsWith class ChatRepositoryTest { - private lateinit var webSocketHandler: GameWebSocketHandler + private lateinit var webSocketHandler: GameWebSocketController private lateinit var repository: ChatRepository private val testTopic = "/topic/chat" 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 index d7355a9..a2c86e8 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.model.enums.Team -import com.codenames.frontend.network.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.GameWebSocketController import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -14,7 +14,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class GameRepositoryTest { - private val webSocketHandler: GameWebSocketHandler = mockk() + private val webSocketHandler: GameWebSocketController = mockk() private lateinit var gameRepository: GameRepository @Before diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt similarity index 96% rename from app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt rename to app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index 3042b27..eae518d 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketHandlerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -18,8 +18,8 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import org.junit.Before import org.junit.Test -class GameWebSocketHandlerTest { - private lateinit var wsClient: GameWebSocketHandler +class GameWebSocketControllerTest { + private lateinit var wsClient: GameWebSocketController private lateinit var sessionManager: WebSocketSessionManager private lateinit var session: StompSessionWithKxSerialization @@ -27,7 +27,7 @@ class GameWebSocketHandlerTest { fun setup() { sessionManager = mockk() session = mockk(relaxed = true) - wsClient = GameWebSocketHandler(sessionManager) + wsClient = GameWebSocketController(sessionManager) coEvery { sessionManager.getSession() } returns session } diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt index 085c99a..2d97f97 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/WebSocketSessionManagerTest.kt @@ -42,7 +42,7 @@ class WebSocketSessionManagerTest { runTest { val sessionWithJson = mockk() - coEvery { client.connect(url) } returns session + coEvery { client.connect(any()) } returns session coEvery { sessionWithJson.subscribe(any(), any()) @@ -53,20 +53,20 @@ class WebSocketSessionManagerTest { wsClient.connectStomp() coVerify { - client.connect(url) + client.connect(any()) } } @Test fun testConnectStomp_throwsExceptionWhenConnectionFails() = runTest { - coEvery { client.connect(url) } throws Exception("Connection failed") + coEvery { client.connect(any()) } throws Exception("Connection failed") val wsClient = WebSocketSessionManager(client) wsClient.connectStomp() - coVerify(exactly = 1) { client.connect(url) } + coVerify(exactly = 1) { client.connect(any()) } assertFalse(wsClient.isConnected()) } @@ -75,10 +75,10 @@ class WebSocketSessionManagerTest { fun testConnectStomp_DoesNotConnectWhenSessionExists() = runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(url) } returns session + coEvery { client.connect(any()) } returns session wsClient.connectStomp() wsClient.connectStomp() - coVerify(exactly = 1) { client.connect(url) } + coVerify(exactly = 1) { client.connect(any()) } } @Test @@ -86,7 +86,7 @@ class WebSocketSessionManagerTest { runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(url) } returns session + coEvery { client.connect(any()) } returns session wsClient.connectStomp() wsClient.disconnect() @@ -113,7 +113,7 @@ class WebSocketSessionManagerTest { runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(url) } returns session + coEvery { client.connect(any()) } returns session wsClient.connectStomp() @@ -133,7 +133,7 @@ class WebSocketSessionManagerTest { runTest { val wsClient = WebSocketSessionManager(client) - coEvery { client.connect(url) } returns session + coEvery { client.connect(any()) } returns session wsClient.connectStomp() 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 5d7789b..ae24210 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -13,7 +13,7 @@ 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.websocket.GameWebSocketHandler +import com.codenames.frontend.network.websocket.GameWebSocketController import com.codenames.frontend.ui.roles.PlayerRoles import io.mockk.Runs import io.mockk.coEvery @@ -65,14 +65,14 @@ class GameViewModelTest { ) private lateinit var viewModel: GameViewModel - private lateinit var client: GameWebSocketHandler + private lateinit var client: GameWebSocketController private lateinit var chatRepository: ChatRepository private lateinit var gameRepository: GameRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) - client = mockk() + client = mockk() chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) From 92c065e3fcb52ff45775fba3cc984004f6fed7f5 Mon Sep 17 00:00:00 2001 From: 5eli Date: Tue, 2 Jun 2026 14:03:16 +0200 Subject: [PATCH 012/126] refactor: extract chat logic into ChatViewModel --- .idea/misc.xml | 2 +- .../frontend/ui/navigation/NavGraph.kt | 5 + .../frontend/ui/screens/GameScreenWrapper.kt | 6 +- .../frontend/ui/screens/LobbyScreen.kt | 8 + .../frontend/viewmodel/ChatViewModel.kt | 188 ++++++++++++------ .../frontend/viewmodel/GameViewModel.kt | 117 +---------- 6 files changed, 154 insertions(+), 172 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index a1d4940..6c5519f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,6 @@ - + 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 65a5e74..aea42b8 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 @@ -19,6 +19,7 @@ import com.codenames.frontend.ui.theme.responsiveDimensionsFor import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel +import com.codenames.frontend.viewmodel.ChatViewModel @Composable @Suppress("ktlint:standard:function-naming") @@ -26,9 +27,11 @@ fun NavGraph( lobbyViewModel: LobbyViewModel = hiltViewModel(), sessionViewModel: SessionViewModel = hiltViewModel(), gameViewModel: GameViewModel = hiltViewModel(), + chatViewModel: ChatViewModel = hiltViewModel(), ) { val navController = rememberNavController() + @Suppress("UnusedBoxWithConstraintsScope") BoxWithConstraints { val responsiveDimensions = responsiveDimensionsFor( @@ -58,6 +61,7 @@ fun NavGraph( navController = navController, viewModel = lobbyViewModel, gameViewModel = gameViewModel, + chatViewModel = chatViewModel, sessionViewModel = sessionViewModel, ) } @@ -75,6 +79,7 @@ fun NavGraph( navController = navController, lobbyViewModel = lobbyViewModel, gameViewModel = gameViewModel, + chatViewModel = chatViewModel, sessionViewModel = sessionViewModel, ) } 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 index 6329512..1377c26 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -8,6 +8,7 @@ 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 +import com.codenames.frontend.viewmodel.ChatViewModel @Composable @Suppress("ktlint:standard:function-naming") @@ -15,11 +16,12 @@ fun GameScreenWrapper( navController: NavHostController, lobbyViewModel: LobbyViewModel, gameViewModel: GameViewModel, + chatViewModel: ChatViewModel, sessionViewModel: SessionViewModel, ) { val lobbyState by lobbyViewModel.state.collectAsState() val gameState by gameViewModel.uiState.collectAsState() - val chatState by gameViewModel.chatState.collectAsState() + val chatState by chatViewModel.chatState.collectAsState() val usernameState by sessionViewModel.username.collectAsState() val username = usernameState.username @@ -43,7 +45,7 @@ fun GameScreenWrapper( // TODO: Send guess through GameViewModel once backend endpoint exists. }, onSendChatMessage = { tab, message -> - gameViewModel.sendChatMessage( + chatViewModel.sendChatMessage( tab = tab, lobbyCode = lobbyCode, username = username, 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 0721837..3b175d5 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 @@ -54,6 +54,7 @@ import com.codenames.frontend.ui.toTeamAndRole import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel +import com.codenames.frontend.viewmodel.ChatViewModel private const val JOIN_TEAM: String = "JOIN TEAM" @@ -64,6 +65,7 @@ fun LobbyScreen( viewModel: LobbyViewModel, sessionViewModel: SessionViewModel, gameViewModel: GameViewModel, + chatViewModel: ChatViewModel, ) { val dimensions = LocalResponsiveDimensions.current val usernameState by sessionViewModel.username.collectAsState() @@ -93,6 +95,12 @@ fun LobbyScreen( role = role.name, isHost = viewModel.getIsHost(usernameState.username), ) + chatViewModel.subscribeToChats( + username = usernameState.username, + lobbyCode = lobbyCode, + team = team.name, + role = role.name, + ) } } } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 508927b..c131a13 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -1,75 +1,151 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel -import com.codenames.frontend.data.model.ChatMessage -import com.codenames.frontend.data.model.ChatUiState +import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.model.ChatLists 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.ChatRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject -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() +@HiltViewModel +class ChatViewModel +@Inject +constructor( + private val chatRepository: ChatRepository, +) : ViewModel() { - fun updateInput(newInput: String) { - _uiState.value = - _uiState.value.copy( - currentInput = newInput, + private val _chatState = MutableStateFlow(ChatLists()) + val chatState: StateFlow = _chatState + + fun subscribeToChats( + username: String, + lobbyCode: String, + team: String, + role: String, + ) { + viewModelScope.launch { + chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> + _chatState.update { + it.copy( + lobbyMessages = it.lobbyMessages + msg, + ) + } + } + } + + viewModelScope.launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> + _chatState.update { + it.copy( + teamMessages = it.teamMessages + msg, + ) + } + } + } + + if (role == Role.OPERATIVE.name) { + viewModelScope.launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + _chatState.update { + it.copy( + operativeMessages = it.operativeMessages + msg, + ) + } + } + } + } + } + + fun sendLobbyMessage( + lobbyCode: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode", + username, + content, ) + } } - fun sendMessage( + fun sendTeamMessage( + lobbyCode: String, + team: String, username: String, - tab: ChatTab, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode/$team", + username, + content, + ) + } + } + + fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + content: String, ) { - val text = _uiState.value.currentInput.trim() + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode/$team/operative", + username, + content, + ) + } + } - if (text.isBlank()) { + fun sendChatMessage( + tab: ChatTab, + lobbyCode: String, + username: String, + team: Team?, + content: String, + availableChatTabs: List, + ) { + if (lobbyCode.isBlank()) { return } - val newMessage = - ChatMessage( - id = _uiState.value.messages.size + 1, - sender = username, - message = text, - timestamp = "NOW", - chatTab = tab, - ) + when (tab) { + ChatTab.GLOBAL -> + sendLobbyMessage( + lobbyCode, + username, + content, + ) - _uiState.value = - _uiState.value.copy( - messages = _uiState.value.messages + newMessage, - currentInput = "", - ) + ChatTab.TEAM -> + if (team != null) { + sendTeamMessage( + lobbyCode, + team.name, + username, + content, + ) + } + + ChatTab.OPERATIVES -> + if (team != null && ChatTab.OPERATIVES in availableChatTabs) { + sendOperativeMessage( + lobbyCode, + team.name, + username, + content, + ) + } + } } -} +} \ No newline at end of file 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 68841a1..3e6bb04 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -3,15 +3,11 @@ 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.websocket.GameWebSocketHandler @@ -30,17 +26,12 @@ 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(GameState()) val uiState: StateFlow = _uiState - - private val _chatState = MutableStateFlow(ChatLists()) - val chatState: StateFlow = _chatState - private val _connectionState = MutableStateFlow(ConnectionState.IDLE) val connectionState: StateFlow = _connectionState @@ -72,32 +63,6 @@ class GameViewModel 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) @@ -116,39 +81,6 @@ class GameViewModel 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, @@ -170,53 +102,12 @@ class GameViewModel } } } - - 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) { val state = message.toGameState() - _uiState.update { - state + _uiState.update { current -> + state.copy( + chatLists = current.chatLists + ) } Log.d("GameViewModel", "Updated game state: $state") } From abed34f510a9de3e6b8f7920b2f372da9ed2ff75 Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 2 Jun 2026 20:03:09 +0200 Subject: [PATCH 013/126] removed placeholder sendGuess endpoint, will be replaced by real endpoint in future PR --- .idea/misc.xml | 1 - .../frontend/network/websocket/GameWebSocketController.kt | 4 ---- 2 files changed, 5 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 3b0be22..6c5519f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index a02224f..8acf930 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -26,10 +26,6 @@ class GameWebSocketController webSocketSessionManager.getSession().convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) } - suspend fun sendGuess(msg: GuessMessage) { - webSocketSessionManager.getSession().convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) - } - @Suppress("kotlin:S6309") suspend fun subscribeToLobby(lobbyCode: String): Flow = webSocketSessionManager.getSession().subscribe("/topic/game/$lobbyCode", GameMessage.serializer()) From 4355a53248e1b69fa8e8850234821008aa90f06b Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 2 Jun 2026 20:07:05 +0200 Subject: [PATCH 014/126] removed test case for removed method --- .../network/websocket/GameWebSocketControllerTest.kt | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index eae518d..6ba53c8 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -42,18 +42,6 @@ class GameWebSocketControllerTest { coVerify { sessionManager.connectStomp() } } - @Test - fun testSendGuess_sendsCorrectMessage(): Unit = - runTest { - val msg = GuessMessage("name", "word", 1) - - wsClient.sendGuess(msg) - - coVerify { - session.convertAndSend("/app/game/guess", msg, GuessMessage.serializer()) - } - } - @Test fun testSubscribeToLobby_subscribesToCorrectTopic(): Unit = runTest { From 9f33e98caea14baf6e9bcd5fda4c13d3be55c72c Mon Sep 17 00:00:00 2001 From: the-only-queen-anna Date: Tue, 2 Jun 2026 20:48:15 +0200 Subject: [PATCH 015/126] formatting --- .../frontend/network/websocket/GameWebSocketController.kt | 1 - .../frontend/network/websocket/GameWebSocketControllerTest.kt | 1 - 2 files changed, 2 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index 8acf930..be0a97f 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -3,7 +3,6 @@ package com.codenames.frontend.network.websocket 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 diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index 6ba53c8..8b0e46c 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -4,7 +4,6 @@ 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 From 563efa9b9e366536fdd40fc6a4c31ac95b743ee8 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 17:20:23 +0200 Subject: [PATCH 016/126] refactor: align guess message DTO with backend reveal payload --- .../java/com/codenames/frontend/network/dto/GuessMessage.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt index 1039325..def5394 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class GuessMessage( - val username: String, - val word: String, - val number: Int, + val lobbyCode: String, + val position: Int, + val currentTurn: String, ) From e85ce486c4c88c62af487f0b305720341538b3f7 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 17:25:35 +0200 Subject: [PATCH 017/126] refactor: align guess message DTO with backend reveal payload --- .../java/com/codenames/frontend/network/dto/GuessMessage.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt index def5394..5dae2ea 100644 --- a/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt +++ b/app/src/main/java/com/codenames/frontend/network/dto/GuessMessage.kt @@ -1,10 +1,11 @@ package com.codenames.frontend.network.dto +import com.codenames.frontend.data.model.enums.Team import kotlinx.serialization.Serializable @Serializable data class GuessMessage( val lobbyCode: String, val position: Int, - val currentTurn: String, + val currentTurn: Team, ) From 235232ddf3ce2142a8c75e8f8b6ff4ca5e77ddf5 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 18:53:14 +0200 Subject: [PATCH 018/126] feat: add websocket guess submission --- .../frontend/network/websocket/GameWebSocketController.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index be0a97f..ce8024b 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -3,6 +3,7 @@ package com.codenames.frontend.network.websocket 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 @@ -47,4 +48,8 @@ class GameWebSocketController suspend fun sendClue(msg: ClueMessageDto) { webSocketSessionManager.getSession().convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } + + suspend fun sendGuess(msg: GuessMessage) { + webSocketSessionManager.getSession().convertAndSend("/app/reveal-card", msg, GuessMessage.serializer()) + } } From 01f8b982b1943951a87e2b81ee54a8a7a5535c7c Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 18:54:03 +0200 Subject: [PATCH 019/126] test: cover websocket guess submission --- .../websocket/GameWebSocketControllerTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index 8b0e46c..05dcee4 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -4,6 +4,7 @@ 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 @@ -135,4 +136,20 @@ class GameWebSocketControllerTest { ) } } + + @Test + fun testSendGuess() = + runTest { + val guessMessage = GuessMessage("LOBBY123", 3, Team.RED) + + wsClient.sendGuess(guessMessage) + + coVerify { + session.convertAndSend( + "/app/reveal-card", + guessMessage, + GuessMessage.serializer(), + ) + } + } } From 056eed27ca66ba6a40e33add81652cdcf81a0338 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 19:04:24 +0200 Subject: [PATCH 020/126] feat: add repository guess submission --- .../frontend/data/repository/GameRepository.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 index 51bca92..7ee3c50 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -2,6 +2,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.ClueMessageDto +import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.websocket.GameWebSocketController import javax.inject.Inject @@ -31,4 +32,18 @@ class GameRepository ) webSocketHandler.sendClue(msg) } + + suspend fun submitGuess( + lobbyCode: String, + position: Int, + currentTurn: Team, + ) { + val msg = + GuessMessage( + lobbyCode = lobbyCode, + position = position, + currentTurn = currentTurn, + ) + webSocketHandler.sendGuess(msg) + } } From c974454a982e295ffd46c7d6c9f3c33bb537c62d Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 5 Jun 2026 19:05:02 +0200 Subject: [PATCH 021/126] test: cover repository guess submission --- .../frontend/data/repository/GameRepositoryTest.kt | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 index a2c86e8..c084062 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -47,4 +47,18 @@ class GameRepositoryTest { coVerify { webSocketHandler.sendClue(any()) } } + + @Test + fun testSubmitGuess() = + runTest { + val lobbyCode = "ABCDE" + val position = 3 + val currentTurn = Team.RED + + coEvery { webSocketHandler.sendGuess(any()) } just Runs + + gameRepository.submitGuess(lobbyCode, position, currentTurn) + + coVerify { webSocketHandler.sendGuess(any()) } + } } From 071198ddcd9adfac2677707ef66fabf20e942449 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sat, 6 Jun 2026 16:32:26 +0200 Subject: [PATCH 022/126] feat: add viewmodel guess submission --- .../frontend/viewmodel/GameViewModel.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 f7c1b1c..1d695a6 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -138,6 +138,27 @@ class GameViewModel } } + fun submitGuess( + lobbyCode: String, + position: Int, + ) { + if (lobbyCode.isBlank()) { + return + } + + val turn = uiState.value.currentTurn + if (turn != PlayerRoles.BLUE_OPERATIVE && turn != PlayerRoles.RED_OPERATIVE) return + + val team = if (turn == PlayerRoles.BLUE_OPERATIVE) Team.BLUE else Team.RED + viewModelScope.launch { + try { + gameRepository.submitGuess(lobbyCode, position, team) + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + } + } + } + fun handleMessage(message: GameMessage) { val state = message.toGameState() _uiState.update { From 989f8eb0ee330830665a5c0b27304cadba68f869 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sat, 6 Jun 2026 16:33:00 +0200 Subject: [PATCH 023/126] test: cover viewmodel guess submission --- .../frontend/viewmodel/GameViewModelTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) 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 ae24210..fa9a970 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -473,4 +473,61 @@ class GameViewModelTest { client.sendClue(any()) } } + + @Test + fun testSubmitGuess_RedOperative_Success() = + runTest { + coEvery { + gameRepository.submitGuess(any(), any(), any()) + } just Runs + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuess(lobbyCode, 3) + advanceUntilIdle() + + coVerify { gameRepository.submitGuess(lobbyCode, 3, Team.RED) } + } + + @Test + fun testSubmitGuess_whenTurnIsSpymaster_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitGuess(lobbyCode, 3) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun testSubmitGuess_blankLobbyCode_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuess("", 3) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun testSubmitGuess_NetworkError_UpdatesConnectionState() = + runTest { + coEvery { + gameRepository.submitGuess(any(), any(), any()) + } throws Exception("Network connection failed") + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuess(lobbyCode, 3) + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } } From 3211917ce6d60b2477421c756c85db5ea757f1ff Mon Sep 17 00:00:00 2001 From: 5eli Date: Sat, 6 Jun 2026 17:37:24 +0200 Subject: [PATCH 024/126] test: adapt tests after chat logic refactor to ChatViewModel --- .../frontend/ui/navigation/NavGraph.kt | 2 +- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../frontend/ui/screens/LobbyScreen.kt | 2 +- .../frontend/viewmodel/ChatViewModel.kt | 207 ++++++++-------- .../frontend/viewmodel/GameViewModel.kt | 4 +- .../frontend/viewmodel/ChatViewModelTest.kt | 228 +++++++++++++----- .../frontend/viewmodel/GameViewModelTest.kt | 193 +-------------- 7 files changed, 286 insertions(+), 352 deletions(-) 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 aea42b8..4a08737 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 @@ -16,10 +16,10 @@ import com.codenames.frontend.ui.screens.StartScreen import com.codenames.frontend.ui.screens.UserNameScreen import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.responsiveDimensionsFor +import com.codenames.frontend.viewmodel.ChatViewModel import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel -import com.codenames.frontend.viewmodel.ChatViewModel @Composable @Suppress("ktlint:standard:function-naming") 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 index 1377c26..ad241cd 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -5,10 +5,10 @@ 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.ChatViewModel import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel -import com.codenames.frontend.viewmodel.ChatViewModel @Composable @Suppress("ktlint:standard:function-naming") 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 3b175d5..29484cc 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 @@ -51,10 +51,10 @@ 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.ChatViewModel import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel -import com.codenames.frontend.viewmodel.ChatViewModel private const val JOIN_TEAM: String = "JOIN TEAM" diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index c131a13..5ddc19b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -16,136 +16,135 @@ import javax.inject.Inject @HiltViewModel class ChatViewModel -@Inject -constructor( - private val chatRepository: ChatRepository, -) : ViewModel() { + @Inject + constructor( + private val chatRepository: ChatRepository, + ) : ViewModel() { + private val _chatState = MutableStateFlow(ChatLists()) + val chatState: StateFlow = _chatState - private val _chatState = MutableStateFlow(ChatLists()) - val chatState: StateFlow = _chatState - - fun subscribeToChats( - username: String, - lobbyCode: String, - team: String, - role: String, - ) { - viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> - _chatState.update { - it.copy( - lobbyMessages = it.lobbyMessages + msg, - ) - } - } - } - - viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> - _chatState.update { - it.copy( - teamMessages = it.teamMessages + msg, - ) + fun subscribeToChats( + username: String, + lobbyCode: String, + team: String, + role: String, + ) { + viewModelScope.launch { + chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> + _chatState.update { + it.copy( + lobbyMessages = it.lobbyMessages + msg, + ) + } } } - } - if (role == Role.OPERATIVE.name) { viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> _chatState.update { it.copy( - operativeMessages = it.operativeMessages + msg, + teamMessages = it.teamMessages + msg, ) } } } - } - } - - 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, - ) + if (role == Role.OPERATIVE.name) { + viewModelScope.launch { + chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + _chatState.update { + it.copy( + operativeMessages = it.operativeMessages + msg, + ) + } + } + } + } } - } - fun sendOperativeMessage( - lobbyCode: String, - team: String, - username: String, - content: String, - ) { - viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode/$team/operative", - username, - content, - ) + fun sendLobbyMessage( + lobbyCode: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode", + username, + content, + ) + } } - } - fun sendChatMessage( - tab: ChatTab, - lobbyCode: String, - username: String, - team: Team?, - content: String, - availableChatTabs: List, - ) { - if (lobbyCode.isBlank()) { - return + fun sendTeamMessage( + lobbyCode: String, + team: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode/$team", + username, + content, + ) + } } - when (tab) { - ChatTab.GLOBAL -> - sendLobbyMessage( - lobbyCode, + fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + content: String, + ) { + viewModelScope.launch { + chatRepository.sendMessage( + "/app/chat/$lobbyCode/$team/operative", username, content, ) + } + } - ChatTab.TEAM -> - if (team != null) { - sendTeamMessage( - lobbyCode, - team.name, - username, - content, - ) - } + fun sendChatMessage( + tab: ChatTab, + lobbyCode: String, + username: String, + team: Team?, + content: String, + availableChatTabs: List, + ) { + if (lobbyCode.isBlank()) { + return + } - ChatTab.OPERATIVES -> - if (team != null && ChatTab.OPERATIVES in availableChatTabs) { - sendOperativeMessage( + when (tab) { + ChatTab.GLOBAL -> + sendLobbyMessage( lobbyCode, - team.name, username, content, ) - } + + ChatTab.TEAM -> + if (team != null) { + sendTeamMessage( + lobbyCode, + team.name, + username, + content, + ) + } + + ChatTab.OPERATIVES -> + if (team != null && ChatTab.OPERATIVES in availableChatTabs) { + sendOperativeMessage( + lobbyCode, + team.name, + username, + content, + ) + } + } } } -} \ No newline at end of file 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 3e6bb04..431bcc6 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -81,6 +81,7 @@ class GameViewModel gameRepository.startGame(lobbyCode) } } + fun submitClue( lobbyCode: String, word: String, @@ -102,11 +103,12 @@ class GameViewModel } } } + fun handleMessage(message: GameMessage) { val state = message.toGameState() _uiState.update { current -> state.copy( - chatLists = current.chatLists + chatLists = current.chatLists, ) } Log.d("GameViewModel", "Updated game state: $state") diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt index 874672e..ca14149 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -1,81 +1,197 @@ package com.codenames.frontend.viewmodel +import com.codenames.frontend.data.model.ChatDomainModel 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.ChatRepository +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test class ChatViewModelTest { + private lateinit var repository: ChatRepository private lateinit var viewModel: ChatViewModel @Before fun setup() { - viewModel = ChatViewModel() - } + repository = mockk(relaxed = true) - @Test - fun updateInput_updatesCurrentInput() { - viewModel.updateInput("Hallo") + every { + repository.observeChat(any(), any()) + } returns emptyFlow() - assertEquals( - "Hallo", - viewModel.uiState.value.currentInput, - ) + viewModel = ChatViewModel(repository) } @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) - } + fun sendChatMessage_global_sendsLobbyMessage() = + runTest { + viewModel.sendChatMessage( + tab = ChatTab.GLOBAL, + lobbyCode = "ABCD", + username = "Max", + team = Team.RED, + content = "Hallo", + availableChatTabs = listOf(ChatTab.GLOBAL), + ) + + coVerify { + repository.sendMessage( + "/app/chat/ABCD", + "Max", + "Hallo", + ) + } + } @Test - fun sendMessage_clearsInputAfterSend() { - viewModel.updateInput("Text") - - viewModel.sendMessage( - "Max", - ChatTab.GLOBAL, - ) - - assertEquals( - "", - viewModel.uiState.value.currentInput, - ) - } + fun sendChatMessage_team_sendsTeamMessage() = + runTest { + viewModel.sendChatMessage( + tab = ChatTab.TEAM, + lobbyCode = "ABCD", + username = "Max", + team = Team.RED, + content = "Hallo Team", + availableChatTabs = + listOf( + ChatTab.GLOBAL, + ChatTab.TEAM, + ), + ) + + coVerify { + repository.sendMessage( + "/app/chat/ABCD/RED", + "Max", + "Hallo Team", + ) + } + } @Test - fun sendMessage_blankMessage_doesNothing() { - viewModel.updateInput(" ") + fun sendChatMessage_operatives_sendsOperativeMessage() = + runTest { + viewModel.sendChatMessage( + tab = ChatTab.OPERATIVES, + lobbyCode = "ABCD", + username = "Max", + team = Team.RED, + content = "Secret", + availableChatTabs = + listOf( + ChatTab.GLOBAL, + ChatTab.TEAM, + ChatTab.OPERATIVES, + ), + ) + + coVerify { + repository.sendMessage( + "/app/chat/ABCD/RED/operative", + "Max", + "Secret", + ) + } + } - val before = - viewModel.uiState.value.messages.size - - viewModel.sendMessage( - "Max", - ChatTab.GLOBAL, - ) + @Test + fun sendChatMessage_operativesNotAvailable_doesNothing() = + runTest { + viewModel.sendChatMessage( + tab = ChatTab.OPERATIVES, + lobbyCode = "ABCD", + username = "Max", + team = Team.RED, + content = "Secret", + availableChatTabs = + listOf( + ChatTab.GLOBAL, + ChatTab.TEAM, + ), + ) + + coVerify(exactly = 0) { + repository.sendMessage(any(), any(), any()) + } + } - val after = - viewModel.uiState.value.messages.size + @Test + fun sendChatMessage_blankLobbyCode_doesNothing() = + runTest { + viewModel.sendChatMessage( + tab = ChatTab.GLOBAL, + lobbyCode = "", + username = "Max", + team = Team.RED, + content = "Hallo", + availableChatTabs = listOf(ChatTab.GLOBAL), + ) + + coVerify(exactly = 0) { + repository.sendMessage(any(), any(), any()) + } + } - assertEquals(before, after) - } + @Test + fun subscribeToChats_lobbyMessage_updatesLobbyMessages() = + runTest { + val msg = + ChatDomainModel( + sender = "Anna", + text = "Hallo", + isFromMe = false, + ) + + every { + repository.observeChat( + "/topic/chat/ABCD", + "Max", + ) + } returns flowOf(msg) + + every { + repository.observeChat( + "/topic/chat/ABCD/RED", + "Max", + ) + } returns emptyFlow() + + every { + repository.observeChat( + "/topic/chat/ABCD/RED/operative", + "Max", + ) + } returns emptyFlow() + + viewModel.subscribeToChats( + username = "Max", + lobbyCode = "ABCD", + team = "RED", + role = Role.OPERATIVE.name, + ) + + advanceUntilIdle() + + val messages = viewModel.chatState.value.lobbyMessages + if (messages.isNotEmpty()) { + println("First sender: ${messages.first().sender}") + println("First text: ${messages.first().text}") + } + + assertEquals(1, messages.size) + + val first = messages.first() + + assertEquals("Anna", first.sender) + assertEquals("Hallo", first.text) + } } 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 1b311aa..5375c13 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -1,14 +1,11 @@ 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 @@ -24,7 +21,6 @@ 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 @@ -66,19 +62,19 @@ class GameViewModelTest { private lateinit var viewModel: GameViewModel private lateinit var client: GameWebSocketHandler - private lateinit var chatRepository: ChatRepository private lateinit var gameRepository: GameRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) client = mockk() - chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) - every { chatRepository.observeChat(any(), any()) } returns emptyFlow() - - viewModel = GameViewModel(client, chatRepository, gameRepository) + viewModel = + GameViewModel( + client, + gameRepository, + ) } @After @@ -124,98 +120,6 @@ class GameViewModelTest { 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 testConnectUpdateOperativeChat() = - runTest { - 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) - - 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 { @@ -318,10 +222,6 @@ class GameViewModelTest { coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(any()) } returns emptyFlow() - every { - chatRepository.observeChat(any(), any()) - } returns emptyFlow() - coEvery { gameRepository.startGame(lobbyCode) } just Runs @@ -347,10 +247,6 @@ class GameViewModelTest { coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(any()) } returns emptyFlow() - every { - chatRepository.observeChat(any(), any()) - } returns emptyFlow() - viewModel.connect( username, "", @@ -433,85 +329,6 @@ class GameViewModelTest { 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 { From fa141afb10f21bd4ad385f6686dab6abb6ca90fc Mon Sep 17 00:00:00 2001 From: 5eli Date: Sat, 6 Jun 2026 18:12:08 +0200 Subject: [PATCH 025/126] efactor: resolve merge conflicts and finalize ChatViewModel extraction --- .../frontend/viewmodel/ChatViewModel.kt | 40 ++++------ .../frontend/viewmodel/GameViewModel.kt | 76 ------------------- .../frontend/viewmodel/GameViewModelTest.kt | 12 +-- 3 files changed, 16 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 5ddc19b..09c9405 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -68,11 +68,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) } } @@ -83,11 +79,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode/$team", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) } } @@ -98,11 +90,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode/$team/operative", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) } } @@ -121,28 +109,28 @@ class ChatViewModel when (tab) { ChatTab.GLOBAL -> sendLobbyMessage( - lobbyCode, - username, - content, + lobbyCode = lobbyCode, + username = username, + content = content, ) ChatTab.TEAM -> if (team != null) { sendTeamMessage( - lobbyCode, - team.name, - username, - content, + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, ) } ChatTab.OPERATIVES -> if (team != null && ChatTab.OPERATIVES in availableChatTabs) { sendOperativeMessage( - lobbyCode, - team.name, - username, - content, + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, ) } } 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 3ca492c..d0b6c72 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.GameState -import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toGameState @@ -26,7 +25,6 @@ class GameViewModel @Inject constructor( private val handler: GameWebSocketController, - private val chatRepository: ChatRepository, private val gameRepository: GameRepository, ) : ViewModel() { private var job: Job? = null @@ -111,78 +109,4 @@ class GameViewModel state } } - - 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 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) - } - } } 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 6cdc55f..7dd2b2f 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -61,17 +61,13 @@ class GameViewModelTest { ) private lateinit var viewModel: GameViewModel - private lateinit var client: GameWebSocketHandler private lateinit var client: GameWebSocketController - private lateinit var chatRepository: ChatRepository private lateinit var gameRepository: GameRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) - client = mockk() client = mockk() - chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) viewModel = @@ -274,12 +270,9 @@ class GameViewModelTest { @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()) } + coVerify(exactly = 0) { gameRepository.submitClue(any(), any(), any(), any()) } } @Test @@ -289,9 +282,8 @@ class GameViewModelTest { viewModel.submitClue("", "EAGLE", 2) advanceUntilIdle() - coVerify(exactly = 0) { - client.sendClue(any()) + gameRepository.submitClue(any(), any(), any(), any()) } } } From 962b01ea5a2c2a49b7c5726b1d1539685d9106c3 Mon Sep 17 00:00:00 2001 From: 5eli Date: Sat, 6 Jun 2026 18:12:08 +0200 Subject: [PATCH 026/126] refactor: resolve merge conflicts and finalize ChatViewModel extraction --- .../frontend/viewmodel/ChatViewModel.kt | 40 ++++------ .../frontend/viewmodel/GameViewModel.kt | 76 ------------------- .../frontend/viewmodel/GameViewModelTest.kt | 12 +-- 3 files changed, 16 insertions(+), 112 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 5ddc19b..09c9405 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -68,11 +68,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) } } @@ -83,11 +79,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode/$team", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) } } @@ -98,11 +90,7 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage( - "/app/chat/$lobbyCode/$team/operative", - username, - content, - ) + chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) } } @@ -121,28 +109,28 @@ class ChatViewModel when (tab) { ChatTab.GLOBAL -> sendLobbyMessage( - lobbyCode, - username, - content, + lobbyCode = lobbyCode, + username = username, + content = content, ) ChatTab.TEAM -> if (team != null) { sendTeamMessage( - lobbyCode, - team.name, - username, - content, + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, ) } ChatTab.OPERATIVES -> if (team != null && ChatTab.OPERATIVES in availableChatTabs) { sendOperativeMessage( - lobbyCode, - team.name, - username, - content, + lobbyCode = lobbyCode, + team = team.name, + username = username, + content = content, ) } } 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 3ca492c..d0b6c72 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.GameState -import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toGameState @@ -26,7 +25,6 @@ class GameViewModel @Inject constructor( private val handler: GameWebSocketController, - private val chatRepository: ChatRepository, private val gameRepository: GameRepository, ) : ViewModel() { private var job: Job? = null @@ -111,78 +109,4 @@ class GameViewModel state } } - - 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 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) - } - } } 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 6cdc55f..7dd2b2f 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -61,17 +61,13 @@ class GameViewModelTest { ) private lateinit var viewModel: GameViewModel - private lateinit var client: GameWebSocketHandler private lateinit var client: GameWebSocketController - private lateinit var chatRepository: ChatRepository private lateinit var gameRepository: GameRepository @Before fun setup() { Dispatchers.setMain(testDispatcher) - client = mockk() client = mockk() - chatRepository = mockk(relaxed = true) gameRepository = mockk(relaxed = true) viewModel = @@ -274,12 +270,9 @@ class GameViewModelTest { @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()) } + coVerify(exactly = 0) { gameRepository.submitClue(any(), any(), any(), any()) } } @Test @@ -289,9 +282,8 @@ class GameViewModelTest { viewModel.submitClue("", "EAGLE", 2) advanceUntilIdle() - coVerify(exactly = 0) { - client.sendClue(any()) + gameRepository.submitClue(any(), any(), any(), any()) } } } From 97d72ed268fb5bceef1128add2ab72ab8e1c8879 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 9 Jun 2026 23:03:57 +0200 Subject: [PATCH 027/126] feat: wire guess submission from game screen --- .../frontend/ui/screens/GameScreenWrapper.kt | 4 ++-- .../frontend/viewmodel/GameViewModel.kt | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) 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 index 6329512..a189372 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -39,8 +39,8 @@ fun GameScreenWrapper( onHintChange = { word, count -> gameViewModel.submitClue(lobbyCode, word, count) }, - onReveal = { - // TODO: Send guess through GameViewModel once backend endpoint exists. + onReveal = { positions -> + gameViewModel.submitGuesses(lobbyCode, positions) }, onSendChatMessage = { tab, message -> gameViewModel.sendChatMessage( 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 1d695a6..7820203 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -159,6 +159,29 @@ class GameViewModel } } + fun submitGuesses( + lobbyCode: String, + positions: List, + ) { + if (lobbyCode.isBlank() || positions.isEmpty()) { + return + } + + val turn = uiState.value.currentTurn + if (turn != PlayerRoles.BLUE_OPERATIVE && turn != PlayerRoles.RED_OPERATIVE) return + + val team = if (turn == PlayerRoles.BLUE_OPERATIVE) Team.BLUE else Team.RED + viewModelScope.launch { + try { + positions.forEach { position -> + gameRepository.submitGuess(lobbyCode, position, team) + } + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + } + } + } + fun handleMessage(message: GameMessage) { val state = message.toGameState() _uiState.update { From bc27511828b6b4cb0f467ab104b1f711e69cd7e1 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Tue, 9 Jun 2026 23:04:51 +0200 Subject: [PATCH 028/126] feat: add selectable game board cards --- .../frontend/ui/composables/GameBoardGrid.kt | 6 ++- .../frontend/ui/screens/GameboardScreen.kt | 41 +++++++++++++++++-- 2 files changed, 42 insertions(+), 5 deletions(-) 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 index 4fc7dea..0e95abd 100644 --- a/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt +++ b/app/src/main/java/com/codenames/frontend/ui/composables/GameBoardGrid.kt @@ -23,7 +23,8 @@ fun GameBoardGrid( scale: Float, offset: Offset, isSpymaster: Boolean, - onReveal: (Int) -> Unit, + selectedCardPositions: Set, + onCardClick: (Int) -> Unit, modifier: Modifier, ) { Box( @@ -61,9 +62,10 @@ fun GameBoardGrid( CodenamesCard( card = card, isSpymaster = isSpymaster, + isSelected = index in selectedCardPositions, onClick = { if (!isSpymaster && !card.revealed) { - onReveal(index) + onCardClick(index) } }, ) 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 8653724..7ea3388 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 @@ -1,6 +1,7 @@ package com.codenames.frontend.ui.screens import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,6 +22,7 @@ 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.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -79,7 +81,7 @@ fun GameboardScreen( userRole: PlayerRoles, gameState: GameState, onHintChange: (String, Int) -> Unit, - onReveal: (Int) -> Unit, + onReveal: (List) -> Unit, modifier: Modifier = Modifier, onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, @@ -101,6 +103,7 @@ fun GameboardScreen( var chatInput by rememberSaveable { mutableStateOf("") } var isChatOpen by rememberSaveable { mutableStateOf(false) } var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } + var selectedCardPositions by remember { mutableStateOf(emptyList()) } val activeChatTab = if (selectedChatTab in availableChatTabs) { @@ -111,6 +114,7 @@ fun GameboardScreen( val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER + val canSelectCards = !isSpymaster && remainingGuesses > 0 val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -119,6 +123,13 @@ fun GameboardScreen( val onInputChange: (String) -> Unit = { hintInput = it } + LaunchedEffect(cards, currentTurn, remainingGuesses) { + selectedCardPositions = + selectedCardPositions + .filter { position -> cards.getOrNull(position)?.revealed == false } + .take(remainingGuesses.coerceAtLeast(0)) + } + Box(modifier = modifier.fillMaxSize()) { Column( modifier = @@ -186,7 +197,19 @@ fun GameboardScreen( scale, offset, isSpymaster, - onReveal, + selectedCardPositions.toSet(), + onCardClick = { position -> + if (!canSelectCards) { + return@GameBoardGrid + } + + if (position in selectedCardPositions) { + selectedCardPositions = selectedCardPositions - position + onReveal(listOf(position)) + } else if (selectedCardPositions.size < remainingGuesses) { + selectedCardPositions = selectedCardPositions + position + } + }, modifier = Modifier .weight(1f) @@ -718,9 +741,11 @@ fun TeamRoleBox( fun CodenamesCard( card: GameCard, isSpymaster: Boolean, + isSelected: Boolean = false, onClick: () -> Unit, ) { val dimensions = LocalResponsiveDimensions.current + val cardShape = RoundedCornerShape(12.dp) val backgroundColor = when { @@ -739,12 +764,22 @@ fun CodenamesCard( AppButton( text = card.word, onClick = onClick, - modifier = Modifier.aspectRatio(2f), + modifier = + Modifier + .aspectRatio(2f) + .then( + if (isSelected) { + Modifier.border(3.dp, AppGreen, cardShape) + } else { + Modifier + }, + ), style = AppButtonStyle( containerColor = backgroundColor, contentColor = contentColor, fontSize = dimensions.smallFontSize, + shape = cardShape, ), ) } From a65314674dc39912d300fad575fa296a372ae427 Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 10 Jun 2026 14:47:21 +0200 Subject: [PATCH 029/126] refactor: move websocket destination from ViewModel to Repository --- .../data/repository/ChatRepository.kt | 125 ++++++++++++++---- .../frontend/viewmodel/ChatViewModel.kt | 37 +++++- 2 files changed, 128 insertions(+), 34 deletions(-) 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 index fc99a36..8a59899 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -8,34 +8,103 @@ import kotlinx.coroutines.flow.flow import javax.inject.Inject class ChatRepository - @Inject - constructor( - private val webSocketHandler: GameWebSocketController, - ) { - 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, - ), - ) - } - } +@Inject +constructor( + private val webSocketHandler: GameWebSocketController, +) { + fun observeLobbyChat( + lobbyCode: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode", + currentUsername = currentUsername, + ) + + fun observeTeamChat( + lobbyCode: String, + team: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode/$team", + currentUsername = currentUsername, + ) - suspend fun sendMessage( - destination: String, - username: String, - text: String, - ) { - val dto = ChatMessageDto(senderUsername = username, content = text) - webSocketHandler.sendChatMessage(destination, dto) + fun observeOperativeChat( + lobbyCode: String, + team: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode/$team/operative", + currentUsername = currentUsername, + ) + + private fun observeChat( + topic: String, + currentUsername: String, + ): Flow = + flow { + webSocketHandler.subscribeToChat(topic).collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername, + ), + ) + } } + + suspend fun sendLobbyMessage( + lobbyCode: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode", + username = username, + text = text, + ) + } + + suspend fun sendTeamMessage( + lobbyCode: String, + team: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode/$team", + username = username, + text = text, + ) + } + + suspend fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode/$team/operative", + username = username, + text = text, + ) + } + + private suspend fun sendMessage( + destination: String, + username: String, + text: String, + ) { + val dto = ChatMessageDto( + senderUsername = username, + content = text, + ) + + webSocketHandler.sendChatMessage(destination, dto) } +} \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 09c9405..9c25be9 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -30,7 +30,10 @@ class ChatViewModel role: String, ) { viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode", username).collect { msg -> + chatRepository.observeLobbyChat( + lobbyCode = lobbyCode, + currentUsername = username, + ).collect { msg -> _chatState.update { it.copy( lobbyMessages = it.lobbyMessages + msg, @@ -40,7 +43,11 @@ class ChatViewModel } viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode/$team", username).collect { msg -> + chatRepository.observeTeamChat( + lobbyCode = lobbyCode, + team = team, + currentUsername = username, + ).collect { msg -> _chatState.update { it.copy( teamMessages = it.teamMessages + msg, @@ -51,7 +58,11 @@ class ChatViewModel if (role == Role.OPERATIVE.name) { viewModelScope.launch { - chatRepository.observeChat("/topic/chat/$lobbyCode/$team/operative", username).collect { msg -> + chatRepository.observeOperativeChat( + lobbyCode = lobbyCode, + team = team, + currentUsername = username, + ).collect { msg -> _chatState.update { it.copy( operativeMessages = it.operativeMessages + msg, @@ -68,7 +79,11 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage("/app/chat/$lobbyCode", username, content) + chatRepository.sendLobbyMessage( + lobbyCode = lobbyCode, + username = username, + text = content, + ) } } @@ -79,7 +94,12 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage("/app/chat/$lobbyCode/$team", username, content) + chatRepository.sendTeamMessage( + lobbyCode = lobbyCode, + team = team, + username = username, + text = content, + ) } } @@ -90,7 +110,12 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendMessage("/app/chat/$lobbyCode/$team/operative", username, content) + chatRepository.sendOperativeMessage( + lobbyCode = lobbyCode, + team = team, + username = username, + text = content, + ) } } From 4d326f07a5ee61fd140d4a5a6d7cd7f09689603a Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 10 Jun 2026 15:13:00 +0200 Subject: [PATCH 030/126] test: update tests and format affected files --- .../data/repository/ChatRepository.kt | 181 +++++++++--------- .../frontend/viewmodel/ChatViewModel.kt | 55 +++--- .../data/repository/ChatRepositoryTest.kt | 122 +++++++++--- .../frontend/viewmodel/ChatViewModelTest.kt | 55 ++++-- 4 files changed, 260 insertions(+), 153 deletions(-) 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 index 8a59899..ab74c49 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -8,103 +8,104 @@ import kotlinx.coroutines.flow.flow import javax.inject.Inject class ChatRepository -@Inject -constructor( - private val webSocketHandler: GameWebSocketController, -) { - fun observeLobbyChat( - lobbyCode: String, - currentUsername: String, - ): Flow = - observeChat( - topic = "/topic/chat/$lobbyCode", - currentUsername = currentUsername, - ) + @Inject + constructor( + private val webSocketHandler: GameWebSocketController, + ) { + fun observeLobbyChat( + lobbyCode: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode", + currentUsername = currentUsername, + ) - fun observeTeamChat( - lobbyCode: String, - team: String, - currentUsername: String, - ): Flow = - observeChat( - topic = "/topic/chat/$lobbyCode/$team", - currentUsername = currentUsername, - ) + fun observeTeamChat( + lobbyCode: String, + team: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode/$team", + currentUsername = currentUsername, + ) - fun observeOperativeChat( - lobbyCode: String, - team: String, - currentUsername: String, - ): Flow = - observeChat( - topic = "/topic/chat/$lobbyCode/$team/operative", - currentUsername = currentUsername, - ) + fun observeOperativeChat( + lobbyCode: String, + team: String, + currentUsername: String, + ): Flow = + observeChat( + topic = "/topic/chat/$lobbyCode/$team/operative", + currentUsername = currentUsername, + ) - private fun observeChat( - topic: String, - currentUsername: String, - ): Flow = - flow { - webSocketHandler.subscribeToChat(topic).collect { dto -> - emit( - ChatDomainModel( - sender = dto.senderUsername, - text = dto.content, - isFromMe = dto.senderUsername == currentUsername, - ), - ) + private fun observeChat( + topic: String, + currentUsername: String, + ): Flow = + flow { + webSocketHandler.subscribeToChat(topic).collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername, + ), + ) + } } - } - suspend fun sendLobbyMessage( - lobbyCode: String, - username: String, - text: String, - ) { - sendMessage( - destination = "/app/chat/$lobbyCode", - username = username, - text = text, - ) - } + suspend fun sendLobbyMessage( + lobbyCode: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode", + username = username, + text = text, + ) + } - suspend fun sendTeamMessage( - lobbyCode: String, - team: String, - username: String, - text: String, - ) { - sendMessage( - destination = "/app/chat/$lobbyCode/$team", - username = username, - text = text, - ) - } + suspend fun sendTeamMessage( + lobbyCode: String, + team: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode/$team", + username = username, + text = text, + ) + } - suspend fun sendOperativeMessage( - lobbyCode: String, - team: String, - username: String, - text: String, - ) { - sendMessage( - destination = "/app/chat/$lobbyCode/$team/operative", - username = username, - text = text, - ) - } + suspend fun sendOperativeMessage( + lobbyCode: String, + team: String, + username: String, + text: String, + ) { + sendMessage( + destination = "/app/chat/$lobbyCode/$team/operative", + username = username, + text = text, + ) + } - private suspend fun sendMessage( - destination: String, - username: String, - text: String, - ) { - val dto = ChatMessageDto( - senderUsername = username, - content = text, - ) + private suspend fun sendMessage( + destination: String, + username: String, + text: String, + ) { + val dto = + ChatMessageDto( + senderUsername = username, + content = text, + ) - webSocketHandler.sendChatMessage(destination, dto) + webSocketHandler.sendChatMessage(destination, dto) + } } -} \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 9c25be9..617b58f 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -30,45 +30,48 @@ class ChatViewModel role: String, ) { viewModelScope.launch { - chatRepository.observeLobbyChat( - lobbyCode = lobbyCode, - currentUsername = username, - ).collect { msg -> - _chatState.update { - it.copy( - lobbyMessages = it.lobbyMessages + msg, - ) + chatRepository + .observeLobbyChat( + lobbyCode = lobbyCode, + currentUsername = username, + ).collect { msg -> + _chatState.update { + it.copy( + lobbyMessages = it.lobbyMessages + msg, + ) + } } - } } viewModelScope.launch { - chatRepository.observeTeamChat( - lobbyCode = lobbyCode, - team = team, - currentUsername = username, - ).collect { msg -> - _chatState.update { - it.copy( - teamMessages = it.teamMessages + msg, - ) - } - } - } - - if (role == Role.OPERATIVE.name) { - viewModelScope.launch { - chatRepository.observeOperativeChat( + chatRepository + .observeTeamChat( lobbyCode = lobbyCode, team = team, currentUsername = username, ).collect { msg -> _chatState.update { it.copy( - operativeMessages = it.operativeMessages + msg, + teamMessages = it.teamMessages + msg, ) } } + } + + if (role == Role.OPERATIVE.name) { + viewModelScope.launch { + chatRepository + .observeOperativeChat( + lobbyCode = lobbyCode, + team = team, + currentUsername = username, + ).collect { msg -> + _chatState.update { + it.copy( + operativeMessages = it.operativeMessages + msg, + ) + } + } } } } 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 index 28adbf8..d024567 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -24,7 +24,12 @@ class ChatRepositoryTest { private lateinit var webSocketHandler: GameWebSocketController private lateinit var repository: ChatRepository - private val testTopic = "/topic/chat" + private val testLobbyCode = "ABCD" + private val testTeam = "RED" + + private val lobbyTopic = "/topic/chat/$testLobbyCode" + private val teamTopic = "/topic/chat/$testLobbyCode/$testTeam" + private val operativeTopic = "/topic/chat/$testLobbyCode/$testTeam/operative" private val testUser = "TestUser" private val testContent = "Hello World" private val testDto = ChatMessageDto(senderUsername = testUser, content = testContent) @@ -34,51 +39,96 @@ class ChatRepositoryTest { 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) + coEvery { webSocketHandler.subscribeToChat(any()) } 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() + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() assertEquals(testContent, result[0].text) } @Test fun testObserveChat_correctUser() = runTest { - val result = repository.observeChat(testTopic, testUser).toList() + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() assertEquals(testUser, result[0].sender) } @Test fun testObserveChat_fromMe() = runTest { - val result = repository.observeChat(testTopic, testUser).toList() + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() assertTrue(result[0].isFromMe) } @Test fun testObserveChat_notFromMe() = runTest { - val result = repository.observeChat(testTopic, "NotTestUser").toList() + val result = + repository + .observeLobbyChat( + testLobbyCode, + "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) } + coEvery { + webSocketHandler.sendChatMessage( + "/app/chat/$testLobbyCode", + testDto, + ) + } just Runs + + repository.sendLobbyMessage( + testLobbyCode, + testUser, + testContent, + ) + + coVerify { + webSocketHandler.sendChatMessage( + "/app/chat/$testLobbyCode", + testDto, + ) + } } @Test fun testObserveChat_emptyFlow() = runTest { - coEvery { webSocketHandler.subscribeToChat(testTopic) } returns emptyFlow() - val result = repository.observeChat(testTopic, testUser).toList() + coEvery { + webSocketHandler.subscribeToChat(lobbyTopic) + } returns emptyFlow() + + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() + assertTrue(result.isEmpty()) } @@ -90,18 +140,33 @@ class ChatRepositoryTest { ChatMessageDto(testUser, "First"), ChatMessageDto("OtherUser", "Second"), ) - coEvery { webSocketHandler.subscribeToChat(testTopic) } returns dtos + coEvery { + webSocketHandler.subscribeToChat(lobbyTopic) + } returns dtos + + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() - val result = repository.observeChat(testTopic, testUser).toList() assertEquals(2, result.size) } @Test fun testObserveChat_webSocketError() = runTest { - coEvery { webSocketHandler.subscribeToChat(any()) } throws RuntimeException() + coEvery { + webSocketHandler.subscribeToChat(any()) + } throws RuntimeException() + assertFailsWith { - repository.observeChat(testTopic, testUser).toList() + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).toList() } } @@ -113,9 +178,15 @@ class ChatRepositoryTest { emit(testDto) throw RuntimeException("Connection Lost") } - coEvery { webSocketHandler.subscribeToChat(testTopic) } returns flowWithError - - val flow = repository.observeChat(testTopic, testUser) + coEvery { + webSocketHandler.subscribeToChat(lobbyTopic) + } returns flowWithError + + val flow = + repository.observeLobbyChat( + testLobbyCode, + testUser, + ) assertFailsWith { flow.toList() } @@ -130,10 +201,17 @@ class ChatRepositoryTest { 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() + coEvery { + webSocketHandler.subscribeToChat(lobbyTopic) + } returns dtos + + val result = + repository + .observeLobbyChat( + testLobbyCode, + testUser, + ).take(1) + .toList() assertEquals(1, result.size) } diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt index ca14149..020111e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -25,7 +25,15 @@ class ChatViewModelTest { repository = mockk(relaxed = true) every { - repository.observeChat(any(), any()) + repository.observeLobbyChat(any(), any()) + } returns emptyFlow() + + every { + repository.observeTeamChat(any(), any(), any()) + } returns emptyFlow() + + every { + repository.observeOperativeChat(any(), any(), any()) } returns emptyFlow() viewModel = ChatViewModel(repository) @@ -44,8 +52,8 @@ class ChatViewModelTest { ) coVerify { - repository.sendMessage( - "/app/chat/ABCD", + repository.sendLobbyMessage( + "ABCD", "Max", "Hallo", ) @@ -69,8 +77,9 @@ class ChatViewModelTest { ) coVerify { - repository.sendMessage( - "/app/chat/ABCD/RED", + repository.sendTeamMessage( + "ABCD", + "RED", "Max", "Hallo Team", ) @@ -95,8 +104,9 @@ class ChatViewModelTest { ) coVerify { - repository.sendMessage( - "/app/chat/ABCD/RED/operative", + repository.sendOperativeMessage( + "ABCD", + "RED", "Max", "Secret", ) @@ -120,7 +130,12 @@ class ChatViewModelTest { ) coVerify(exactly = 0) { - repository.sendMessage(any(), any(), any()) + repository.sendOperativeMessage( + any(), + any(), + any(), + any(), + ) } } @@ -137,7 +152,15 @@ class ChatViewModelTest { ) coVerify(exactly = 0) { - repository.sendMessage(any(), any(), any()) + repository.sendLobbyMessage(any(), any(), any()) + } + + coVerify(exactly = 0) { + repository.sendTeamMessage(any(), any(), any(), any()) + } + + coVerify(exactly = 0) { + repository.sendOperativeMessage(any(), any(), any(), any()) } } @@ -152,22 +175,24 @@ class ChatViewModelTest { ) every { - repository.observeChat( - "/topic/chat/ABCD", + repository.observeLobbyChat( + "ABCD", "Max", ) } returns flowOf(msg) every { - repository.observeChat( - "/topic/chat/ABCD/RED", + repository.observeTeamChat( + "ABCD", + "RED", "Max", ) } returns emptyFlow() every { - repository.observeChat( - "/topic/chat/ABCD/RED/operative", + repository.observeOperativeChat( + "ABCD", + "RED", "Max", ) } returns emptyFlow() From 0a2c4fed58df28f36cbc69e672588bfd1c0344eb Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 10 Jun 2026 15:22:04 +0200 Subject: [PATCH 031/126] refactor: introduce ChatWebSocketController --- .../data/repository/ChatRepository.kt | 8 ++--- .../websocket/ChatWebSocketController.kt | 33 +++++++++++++++++++ .../websocket/GameWebSocketController.kt | 12 ------- 3 files changed, 37 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt 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 index ab74c49..14a994a 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -2,7 +2,7 @@ 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.GameWebSocketController +import com.codenames.frontend.network.websocket.ChatWebSocketController import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import javax.inject.Inject @@ -10,7 +10,7 @@ import javax.inject.Inject class ChatRepository @Inject constructor( - private val webSocketHandler: GameWebSocketController, + private val chatWebSocketController: ChatWebSocketController, ) { fun observeLobbyChat( lobbyCode: String, @@ -46,7 +46,7 @@ class ChatRepository currentUsername: String, ): Flow = flow { - webSocketHandler.subscribeToChat(topic).collect { dto -> + chatWebSocketController.subscribeToChat(topic).collect { dto -> emit( ChatDomainModel( sender = dto.senderUsername, @@ -106,6 +106,6 @@ class ChatRepository content = text, ) - webSocketHandler.sendChatMessage(destination, dto) + chatWebSocketController.sendChatMessage(destination, dto) } } diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt new file mode 100644 index 0000000..315662f --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt @@ -0,0 +1,33 @@ +package com.codenames.frontend.network.websocket + +import com.codenames.frontend.network.dto.ChatMessageDto +import kotlinx.coroutines.flow.Flow +import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend +import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ChatWebSocketController + @Inject + constructor( + private val webSocketSessionManager: WebSocketSessionManager, + ) { + @Suppress("kotlin:S6309") + suspend fun subscribeToChat(topicPath: String): Flow = + webSocketSessionManager.getSession().subscribe( + topicPath, + ChatMessageDto.serializer(), + ) + + suspend fun sendChatMessage( + destination: String, + msg: ChatMessageDto, + ) { + webSocketSessionManager.getSession().convertAndSend( + destination, + msg, + ChatMessageDto.serializer(), + ) + } + } diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index be0a97f..7359c86 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -1,6 +1,5 @@ package com.codenames.frontend.network.websocket -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.StartGameMessage @@ -33,17 +32,6 @@ class GameWebSocketController webSocketSessionManager.getSession().convertAndSend("/app/join", msg, WebSocketJoinMessage.serializer()) } - @Suppress("kotlin:S6309") - suspend fun subscribeToChat(topicPath: String): Flow = - webSocketSessionManager.getSession().subscribe(topicPath, ChatMessageDto.serializer()) - - suspend fun sendChatMessage( - destination: String, - msg: ChatMessageDto, - ) { - webSocketSessionManager.getSession().convertAndSend(destination, msg, ChatMessageDto.serializer()) - } - suspend fun sendClue(msg: ClueMessageDto) { webSocketSessionManager.getSession().convertAndSend("/app/submit-clue", msg, ClueMessageDto.serializer()) } From bfc549a0c76b625e625dc591084072d42b9ccf98 Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 10 Jun 2026 15:49:19 +0200 Subject: [PATCH 032/126] test: update Tests affected from implementing ChatWebSocketController --- .../data/repository/ChatRepositoryTest.kt | 24 +++---- .../websocket/ChatWebSocketControllerTest.kt | 63 +++++++++++++++++++ .../websocket/GameWebSocketControllerTest.kt | 27 -------- 3 files changed, 75 insertions(+), 39 deletions(-) create mode 100644 app/src/test/java/com/codenames/frontend/network/websocket/ChatWebSocketControllerTest.kt 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 index d024567..900bf4c 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.network.dto.ChatMessageDto -import com.codenames.frontend.network.websocket.GameWebSocketController +import com.codenames.frontend.network.websocket.ChatWebSocketController import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -21,7 +21,7 @@ import org.junit.Test import kotlin.test.assertFailsWith class ChatRepositoryTest { - private lateinit var webSocketHandler: GameWebSocketController + private lateinit var chatSocketHandler: ChatWebSocketController private lateinit var repository: ChatRepository private val testLobbyCode = "ABCD" @@ -36,10 +36,10 @@ class ChatRepositoryTest { @Before fun setup() { - webSocketHandler = mockk() - repository = ChatRepository(webSocketHandler) + chatSocketHandler = mockk() + repository = ChatRepository(chatSocketHandler) // Suspend methods use coroutines (lighter version of thread) -> instead of every, we use coEvery - coEvery { webSocketHandler.subscribeToChat(any()) } returns flowOf(testDto) + coEvery { chatSocketHandler.subscribeToChat(any()) } returns flowOf(testDto) } @Test @@ -95,7 +95,7 @@ class ChatRepositoryTest { fun testSendMessage() = runTest { coEvery { - webSocketHandler.sendChatMessage( + chatSocketHandler.sendChatMessage( "/app/chat/$testLobbyCode", testDto, ) @@ -108,7 +108,7 @@ class ChatRepositoryTest { ) coVerify { - webSocketHandler.sendChatMessage( + chatSocketHandler.sendChatMessage( "/app/chat/$testLobbyCode", testDto, ) @@ -119,7 +119,7 @@ class ChatRepositoryTest { fun testObserveChat_emptyFlow() = runTest { coEvery { - webSocketHandler.subscribeToChat(lobbyTopic) + chatSocketHandler.subscribeToChat(lobbyTopic) } returns emptyFlow() val result = @@ -141,7 +141,7 @@ class ChatRepositoryTest { ChatMessageDto("OtherUser", "Second"), ) coEvery { - webSocketHandler.subscribeToChat(lobbyTopic) + chatSocketHandler.subscribeToChat(lobbyTopic) } returns dtos val result = @@ -158,7 +158,7 @@ class ChatRepositoryTest { fun testObserveChat_webSocketError() = runTest { coEvery { - webSocketHandler.subscribeToChat(any()) + chatSocketHandler.subscribeToChat(any()) } throws RuntimeException() assertFailsWith { @@ -179,7 +179,7 @@ class ChatRepositoryTest { throw RuntimeException("Connection Lost") } coEvery { - webSocketHandler.subscribeToChat(lobbyTopic) + chatSocketHandler.subscribeToChat(lobbyTopic) } returns flowWithError val flow = @@ -202,7 +202,7 @@ class ChatRepositoryTest { ChatMessageDto("OtherUser", "Second"), ) coEvery { - webSocketHandler.subscribeToChat(lobbyTopic) + chatSocketHandler.subscribeToChat(lobbyTopic) } returns dtos val result = diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/ChatWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/ChatWebSocketControllerTest.kt new file mode 100644 index 0000000..85170da --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/network/websocket/ChatWebSocketControllerTest.kt @@ -0,0 +1,63 @@ +package com.codenames.frontend.network.websocket + +import com.codenames.frontend.network.dto.ChatMessageDto +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runTest +import org.hildan.krossbow.stomp.conversions.kxserialization.StompSessionWithKxSerialization +import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend +import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe +import org.junit.Before +import org.junit.Test + +class ChatWebSocketControllerTest { + private lateinit var wsClient: ChatWebSocketController + private lateinit var sessionManager: WebSocketSessionManager + private lateinit var session: StompSessionWithKxSerialization + + @Before + fun setup() { + sessionManager = mockk() + session = mockk(relaxed = true) + wsClient = ChatWebSocketController(sessionManager) + + coEvery { sessionManager.getSession() } returns session + } + + @Test + fun testSubscribeToChat() = + runTest { + val topic = "/topic/chat/123" + + coEvery { + session.subscribe(topic) + } returns emptyFlow() + + 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(), + ) + } + } +} diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index 8b0e46c..3f87b06 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -1,7 +1,6 @@ package com.codenames.frontend.network.websocket 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.StartGameMessage @@ -70,32 +69,6 @@ class GameWebSocketControllerTest { } } - @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 { From b45e35bfeec92ff0796c9f0d977248ebd3c30a53 Mon Sep 17 00:00:00 2001 From: 5eli Date: Wed, 10 Jun 2026 16:28:07 +0200 Subject: [PATCH 033/126] test: add tests for more coverage --- .../data/repository/ChatRepositoryTest.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) 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 index 900bf4c..d0deaec 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -215,4 +215,104 @@ class ChatRepositoryTest { assertEquals(1, result.size) } + + @Test + fun testObserveTeamChat_correctMapping() = + runTest { + val dto = ChatMessageDto(testUser, testContent) + + coEvery { chatSocketHandler.subscribeToChat(teamTopic) } returns flowOf(dto) + + val result = + repository + .observeTeamChat(testLobbyCode, testTeam, testUser) + .toList() + + assertEquals(testContent, result[0].text) + assertEquals(testUser, result[0].sender) + } + + @Test + fun testObserveOperativeChat_correctTopicAndMapping() = + runTest { + val dto = ChatMessageDto(testUser, testContent) + + coEvery { chatSocketHandler.subscribeToChat(operativeTopic) } returns flowOf(dto) + + val result = + repository + .observeOperativeChat(testLobbyCode, testTeam, testUser) + .toList() + + assertEquals(testContent, result[0].text) + assertEquals(testUser, result[0].sender) + } + + @Test + fun testObserveTeamChat_usesCorrectTopic() = + runTest { + coEvery { chatSocketHandler.subscribeToChat(teamTopic) } returns flowOf(testDto) + + repository.observeTeamChat(testLobbyCode, testTeam, testUser).toList() + + coVerify { chatSocketHandler.subscribeToChat(teamTopic) } + } + + @Test + fun testObserveOperativeChat_usesCorrectTopic() = + runTest { + coEvery { chatSocketHandler.subscribeToChat(operativeTopic) } returns flowOf(testDto) + + repository.observeOperativeChat(testLobbyCode, testTeam, testUser).toList() + + coVerify { chatSocketHandler.subscribeToChat(operativeTopic) } + } + + @Test + fun testSendTeamMessage_callsCorrectDestination() = + runTest { + val dto = ChatMessageDto(testUser, testContent) + + coEvery { + chatSocketHandler.sendChatMessage(teamTopic.replace("/topic", "/app"), dto) + } just Runs + + repository.sendTeamMessage(testLobbyCode, testTeam, testUser, testContent) + + coVerify { + chatSocketHandler.sendChatMessage( + "/app/chat/$testLobbyCode/$testTeam", + dto, + ) + } + } + + @Test + fun testSendOperativeMessage_callsCorrectDestination() = + runTest { + val dto = ChatMessageDto(testUser, testContent) + + coEvery { + chatSocketHandler.sendChatMessage("/app/chat/$testLobbyCode/$testTeam/operative", dto) + } just Runs + + repository.sendOperativeMessage(testLobbyCode, testTeam, testUser, testContent) + + coVerify { + chatSocketHandler.sendChatMessage( + "/app/chat/$testLobbyCode/$testTeam/operative", + dto, + ) + } + } + + @Test + fun testObserveTeamChat_emptyFlow() = + runTest { + coEvery { chatSocketHandler.subscribeToChat(teamTopic) } returns emptyFlow() + + val result = repository.observeTeamChat(testLobbyCode, testTeam, testUser).toList() + + assertTrue(result.isEmpty()) + } } From 0e5b80ede4d4f87bf157962f5b413ed1b6e97d00 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 10 Jun 2026 20:12:08 +0200 Subject: [PATCH 034/126] feat: add deselect all button for selected cards --- .../frontend/ui/screens/GameboardScreen.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 7ea3388..0e40654 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 @@ -236,6 +236,25 @@ fun GameboardScreen( Spacer(modifier = Modifier.height(dimensions.itemSpacing)) + if (canSelectCards && selectedCardPositions.isNotEmpty()) { + AppButton( + text = "Deselect all", + onClick = { selectedCardPositions = emptyList() }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = dimensions.screenPadding), + style = + AppButtonStyle( + containerColor = AppGreen, + contentColor = AppWhite, + fontSize = dimensions.bodyFontSize, + ), + ) + + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) + } + HintSection( isSpymaster, currentHint, From 3ab081c13a5e834f91edb194909ebabe857ed284 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 10 Jun 2026 20:26:40 +0200 Subject: [PATCH 035/126] test: cover guess selection flow --- .../codenames/frontend/GameboardScreenTest.kt | 63 +++++++++++++++++++ .../frontend/viewmodel/GameViewModelTest.kt | 61 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index a212ee6..b8245b5 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -14,6 +14,7 @@ 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.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -159,4 +160,66 @@ class GameboardScreenTest { composeRule.onNodeWithText("Chat").performClick() composeRule.onAllNodesWithText("Operatives").assertCountEquals(0) } + + @Test + fun operativeCanSelectAndDeselectCards() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = + listOf( + GameCard("BERLIN", CardType.BLUE), + GameCard("ROME", CardType.RED), + ), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onAllNodesWithText("Deselect all").assertCountEquals(0) + + composeRule.onNodeWithText("BERLIN").performClick() + composeRule.onNodeWithText("Deselect all").assertIsDisplayed() + + composeRule.onNodeWithText("Deselect all").performClick() + composeRule.onAllNodesWithText("Deselect all").assertCountEquals(0) + } + + @Test + fun clickingSelectedCardRevealsOnlyThatCard() { + var revealedPositions = emptyList() + + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = + listOf( + GameCard("BERLIN", CardType.BLUE), + GameCard("ROME", CardType.RED), + ), + ), + onHintChange = { _, _ -> }, + onReveal = { positions -> revealedPositions = positions }, + ) + } + + composeRule.onNodeWithText("BERLIN").performClick() + composeRule.onNodeWithText("ROME").performClick() + composeRule.onNodeWithText("BERLIN").performClick() + + composeRule.runOnIdle { + assertEquals(listOf(0), revealedPositions) + } + } } 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 fa9a970..7bdf455 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -530,4 +530,65 @@ class GameViewModelTest { val state = viewModel.connectionState.value assertTrue(state is ConnectionState.Error) } + + @Test + fun submitGuesses_redOperative_sendsEachGuessInOrder() = + runTest { + coEvery { + gameRepository.submitGuess(any(), any(), any()) + } just Runs + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, listOf(2, 4)) + advanceUntilIdle() + + coVerify { + gameRepository.submitGuess(lobbyCode, 2, Team.RED) + gameRepository.submitGuess(lobbyCode, 4, Team.RED) + } + } + + @Test + fun submitGuesses_blueOperative_sendsBlueTeamGuess() = + runTest { + coEvery { + gameRepository.submitGuess(any(), any(), any()) + } just Runs + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.BLUE, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, listOf(1)) + advanceUntilIdle() + + coVerify { + gameRepository.submitGuess(lobbyCode, 1, Team.BLUE) + } + } + + @Test + fun submitGuesses_emptyPositions_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, emptyList()) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun submitGuesses_whenTurnIsSpymaster_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitGuesses(lobbyCode, listOf(3)) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } } From 0a3566f4b643625323b0618d544fb60bf578b6ba Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 10 Jun 2026 20:55:18 +0200 Subject: [PATCH 036/126] refactor: reduce game screen complexity --- .../frontend/ui/screens/GameboardScreen.kt | 384 ++++++++++++------ 1 file changed, 264 insertions(+), 120 deletions(-) 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 0e40654..47e5b4b 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 @@ -105,13 +105,6 @@ fun GameboardScreen( var selectedChatTab by rememberSaveable { mutableStateOf(ChatTab.GLOBAL) } var selectedCardPositions by remember { mutableStateOf(emptyList()) } - val activeChatTab = - if (selectedChatTab in availableChatTabs) { - selectedChatTab - } else { - availableChatTabs.firstOrNull() ?: ChatTab.GLOBAL - } - val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val canSelectCards = !isSpymaster && remainingGuesses > 0 @@ -148,112 +141,43 @@ fun GameboardScreen( remainingGuesses = remainingGuesses, ) - if (availableChatTabs.isNotEmpty()) { - ChatToggleButton( - isChatOpen = isChatOpen, - onClick = { isChatOpen = !isChatOpen }, - modifier = - Modifier - .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing), - ) - } + ChatToggle( + isVisible = availableChatTabs.isNotEmpty(), + isChatOpen = isChatOpen, + onClick = { isChatOpen = !isChatOpen }, + ) Spacer(modifier = Modifier.height(dimensions.gameBoardTopSpacing)) - Row( + GameBoardSection( + userRole = userRole, + cards = cards, + currentBlueFound = currentBlueFound, + currentRedFound = currentRedFound, + isSpymaster = isSpymaster, + canSelectCards = canSelectCards, + selectedCardPositions = selectedCardPositions, + remainingGuesses = remainingGuesses, + scale = scale, + offset = offset, + onTransform = { pan, zoom -> + scale = (scale * zoom).coerceIn(0.5f, 3f) + offset += pan + }, + onSelectionChange = { selectedCardPositions = it }, + onReveal = onReveal, modifier = Modifier .weight(1f) .fillMaxWidth(), - ) { - TeamSidebar( - userRole, - color = Team.BLUE, - teamFound = currentBlueFound, - textColor = AppBlue, - gradient = blueGradient, - ) - - if (cards.isEmpty()) { - Box( - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = dimensions.smallSpacing) - .background(AppSurface, RoundedCornerShape(12.dp)), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Waiting for game state...", - color = AppInk, - fontSize = dimensions.bodyFontSize, - fontWeight = FontWeight.Bold, - ) - } - } else { - GameBoardGrid( - cards, - scale, - offset, - isSpymaster, - selectedCardPositions.toSet(), - onCardClick = { position -> - if (!canSelectCards) { - return@GameBoardGrid - } - - if (position in selectedCardPositions) { - selectedCardPositions = selectedCardPositions - position - onReveal(listOf(position)) - } else if (selectedCardPositions.size < remainingGuesses) { - selectedCardPositions = selectedCardPositions + position - } - }, - modifier = - Modifier - .weight(1f) - .fillMaxHeight() - .padding(horizontal = dimensions.smallSpacing) - .clipToBounds() - .pointerInput(Unit) { - detectTransformGestures { _, pan, zoom, _ -> - scale = (scale * zoom).coerceIn(0.5f, 3f) - offset += pan - } - }, - ) - } - - TeamSidebar( - userRole, - color = Team.RED, - teamFound = currentRedFound, - textColor = AppRed, - gradient = redGradient, - ) - } + ) Spacer(modifier = Modifier.height(dimensions.itemSpacing)) - if (canSelectCards && selectedCardPositions.isNotEmpty()) { - AppButton( - text = "Deselect all", - onClick = { selectedCardPositions = emptyList() }, - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = dimensions.screenPadding), - style = - AppButtonStyle( - containerColor = AppGreen, - contentColor = AppWhite, - fontSize = dimensions.bodyFontSize, - ), - ) - - Spacer(modifier = Modifier.height(dimensions.itemSpacing)) - } + DeselectAllButton( + isVisible = canSelectCards && selectedCardPositions.isNotEmpty(), + onClick = { selectedCardPositions = emptyList() }, + ) HintSection( isSpymaster, @@ -268,23 +192,22 @@ fun GameboardScreen( ) } - 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 - .align(Alignment.Center) - .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing) - .width(dimensions.gameChatWidth) - .fillMaxHeight(dimensions.gameChatHeightFraction), - ) - } + GameChatOverlay( + isVisible = availableChatTabs.isNotEmpty() && isChatOpen, + chatInput = chatInput, + chatLists = chatLists, + selectedChatTab = selectedChatTab, + availableChatTabs = availableChatTabs, + onTabSelected = { selectedChatTab = it }, + onChatInputChange = { chatInput = it }, + onSendChatMessage = onSendChatMessage, + modifier = + Modifier + .align(Alignment.Center) + .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing) + .width(dimensions.gameChatWidth) + .fillMaxHeight(dimensions.gameChatHeightFraction), + ) onSettingsClick?.let { openSettings -> SettingsCornerButton( @@ -294,6 +217,227 @@ fun GameboardScreen( } } +private fun updateSelectedCardPositions( + position: Int, + selectedCardPositions: List, + remainingGuesses: Int, + onReveal: (List) -> Unit, +): List = + when { + position in selectedCardPositions -> { + onReveal(listOf(position)) + selectedCardPositions - position + } + selectedCardPositions.size < remainingGuesses -> selectedCardPositions + position + else -> selectedCardPositions + } + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun ChatToggle( + isVisible: Boolean, + isChatOpen: Boolean, + onClick: () -> Unit, +) { + val dimensions = LocalResponsiveDimensions.current + + if (isVisible) { + ChatToggleButton( + isChatOpen = isChatOpen, + onClick = onClick, + modifier = + Modifier + .padding(end = dimensions.itemSpacing, bottom = dimensions.itemSpacing), + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun GameBoardSection( + userRole: PlayerRoles, + cards: List, + currentBlueFound: Int, + currentRedFound: Int, + isSpymaster: Boolean, + canSelectCards: Boolean, + selectedCardPositions: List, + remainingGuesses: Int, + scale: Float, + offset: Offset, + onTransform: (Offset, Float) -> Unit, + onSelectionChange: (List) -> Unit, + onReveal: (List) -> Unit, + modifier: Modifier = Modifier, +) { + val dimensions = LocalResponsiveDimensions.current + + Row(modifier = modifier) { + TeamSidebar( + userRole, + color = Team.BLUE, + teamFound = currentBlueFound, + textColor = AppBlue, + gradient = blueGradient, + ) + + BoardContent( + cards = cards, + scale = scale, + offset = offset, + isSpymaster = isSpymaster, + selectedCardPositions = selectedCardPositions, + onCardClick = { position -> + if (canSelectCards) { + onSelectionChange( + updateSelectedCardPositions( + position = position, + selectedCardPositions = selectedCardPositions, + remainingGuesses = remainingGuesses, + onReveal = onReveal, + ), + ) + } + }, + onTransform = onTransform, + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .padding(horizontal = dimensions.smallSpacing), + ) + + TeamSidebar( + userRole, + color = Team.RED, + teamFound = currentRedFound, + textColor = AppRed, + gradient = redGradient, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun BoardContent( + cards: List, + scale: Float, + offset: Offset, + isSpymaster: Boolean, + selectedCardPositions: List, + onCardClick: (Int) -> Unit, + onTransform: (Offset, Float) -> Unit, + modifier: Modifier = Modifier, +) { + val dimensions = LocalResponsiveDimensions.current + + if (cards.isEmpty()) { + WaitingForGameState(modifier = modifier) + } else { + GameBoardGrid( + cards = cards, + scale = scale, + offset = offset, + isSpymaster = isSpymaster, + selectedCardPositions = selectedCardPositions.toSet(), + onCardClick = onCardClick, + modifier = + modifier + .clipToBounds() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + onTransform(pan, zoom) + } + }, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun WaitingForGameState(modifier: Modifier = Modifier) { + val dimensions = LocalResponsiveDimensions.current + + Box( + modifier = + modifier + .background(AppSurface, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for game state...", + color = AppInk, + fontSize = dimensions.bodyFontSize, + fontWeight = FontWeight.Bold, + ) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun DeselectAllButton( + isVisible: Boolean, + onClick: () -> Unit, +) { + val dimensions = LocalResponsiveDimensions.current + + if (isVisible) { + AppButton( + text = "Deselect all", + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = dimensions.screenPadding), + style = + AppButtonStyle( + containerColor = AppGreen, + contentColor = AppWhite, + fontSize = dimensions.bodyFontSize, + ), + ) + + Spacer(modifier = Modifier.height(dimensions.itemSpacing)) + } +} + +@Suppress("ktlint:standard:function-naming") +@Composable +private fun GameChatOverlay( + isVisible: Boolean, + chatInput: String, + chatLists: ChatLists, + selectedChatTab: ChatTab, + availableChatTabs: List, + onTabSelected: (ChatTab) -> Unit, + onChatInputChange: (String) -> Unit, + onSendChatMessage: (ChatTab, String) -> Unit, + modifier: Modifier = Modifier, +) { + if (isVisible) { + ChatWindow( + chatInput = chatInput, + messages = chatLists, + selectedTab = getActiveChatTab(selectedChatTab, availableChatTabs), + availableTabs = availableChatTabs, + onTabSelected = onTabSelected, + onChatInputChange = onChatInputChange, + onSendClick = onSendChatMessage, + modifier = modifier, + ) + } +} + +private fun getActiveChatTab( + selectedChatTab: ChatTab, + availableChatTabs: List, +): ChatTab = + if (selectedChatTab in availableChatTabs) { + selectedChatTab + } else { + availableChatTabs.firstOrNull() ?: ChatTab.GLOBAL + } + @Suppress("ktlint:standard:function-naming") @Composable fun GameStatusBar( From dd161bcc2a66c809fa22d37c3c4381c854810e6d Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 10 Jun 2026 20:55:38 +0200 Subject: [PATCH 037/126] refactor: extract connection error message constant --- .../com/codenames/frontend/viewmodel/GameViewModel.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 7820203..7bd0fd4 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +private const val CONNECTION_ERROR_MESSAGE = "Connection error" + @HiltViewModel class GameViewModel @Inject @@ -102,7 +104,7 @@ class GameViewModel sendGameStart(lobbyCode) } } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) } } } @@ -133,7 +135,7 @@ class GameViewModel try { gameRepository.submitClue(lobbyCode, word, count, team) } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) } } } @@ -154,7 +156,7 @@ class GameViewModel try { gameRepository.submitGuess(lobbyCode, position, team) } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) } } } @@ -177,7 +179,7 @@ class GameViewModel gameRepository.submitGuess(lobbyCode, position, team) } } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") + _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) } } } From 9c023f0d41956843bce32bd80d86ffe0ca8bd52f Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Wed, 10 Jun 2026 21:14:25 +0200 Subject: [PATCH 038/126] refactor: reduce game screen parameter count --- .../frontend/ui/screens/GameboardScreen.kt | 194 ++++++++++-------- 1 file changed, 114 insertions(+), 80 deletions(-) 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 47e5b4b..1283285 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 @@ -75,6 +75,39 @@ import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient +private data class BoardSectionState( + val userRole: PlayerRoles, + val cards: List, + val currentBlueFound: Int, + val currentRedFound: Int, + val isSpymaster: Boolean, +) + +private data class BoardSelectionState( + val canSelectCards: Boolean, + val selectedCardPositions: List, + val remainingGuesses: Int, +) + +private data class BoardTransformState( + val scale: Float, + val offset: Offset, +) + +private data class ChatOverlayState( + val isVisible: Boolean, + val input: String, + val messages: ChatLists, + val selectedTab: ChatTab, + val availableTabs: List, +) + +private data class ChatOverlayActions( + val onTabSelected: (ChatTab) -> Unit, + val onInputChange: (String) -> Unit, + val onSendMessage: (ChatTab, String) -> Unit, +) + @Suppress("ktlint:standard:function-naming") @Composable fun GameboardScreen( @@ -150,16 +183,25 @@ fun GameboardScreen( Spacer(modifier = Modifier.height(dimensions.gameBoardTopSpacing)) GameBoardSection( - userRole = userRole, - cards = cards, - currentBlueFound = currentBlueFound, - currentRedFound = currentRedFound, - isSpymaster = isSpymaster, - canSelectCards = canSelectCards, - selectedCardPositions = selectedCardPositions, - remainingGuesses = remainingGuesses, - scale = scale, - offset = offset, + state = + BoardSectionState( + userRole = userRole, + cards = cards, + currentBlueFound = currentBlueFound, + currentRedFound = currentRedFound, + isSpymaster = isSpymaster, + ), + selectionState = + BoardSelectionState( + canSelectCards = canSelectCards, + selectedCardPositions = selectedCardPositions, + remainingGuesses = remainingGuesses, + ), + transformState = + BoardTransformState( + scale = scale, + offset = offset, + ), onTransform = { pan, zoom -> scale = (scale * zoom).coerceIn(0.5f, 3f) offset += pan @@ -172,13 +214,6 @@ fun GameboardScreen( .fillMaxWidth(), ) - Spacer(modifier = Modifier.height(dimensions.itemSpacing)) - - DeselectAllButton( - isVisible = canSelectCards && selectedCardPositions.isNotEmpty(), - onClick = { selectedCardPositions = emptyList() }, - ) - HintSection( isSpymaster, currentHint, @@ -193,14 +228,20 @@ fun GameboardScreen( } GameChatOverlay( - isVisible = availableChatTabs.isNotEmpty() && isChatOpen, - chatInput = chatInput, - chatLists = chatLists, - selectedChatTab = selectedChatTab, - availableChatTabs = availableChatTabs, - onTabSelected = { selectedChatTab = it }, - onChatInputChange = { chatInput = it }, - onSendChatMessage = onSendChatMessage, + state = + ChatOverlayState( + isVisible = availableChatTabs.isNotEmpty() && isChatOpen, + input = chatInput, + messages = chatLists, + selectedTab = selectedChatTab, + availableTabs = availableChatTabs, + ), + actions = + ChatOverlayActions( + onTabSelected = { selectedChatTab = it }, + onInputChange = { chatInput = it }, + onSendMessage = onSendChatMessage, + ), modifier = Modifier .align(Alignment.Center) @@ -209,6 +250,19 @@ fun GameboardScreen( .fillMaxHeight(dimensions.gameChatHeightFraction), ) + DeselectAllButton( + isVisible = canSelectCards && selectedCardPositions.isNotEmpty(), + onClick = { selectedCardPositions = emptyList() }, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + start = dimensions.screenPadding, + end = dimensions.screenPadding, + bottom = dimensions.screenPadding, + ), + ) + onSettingsClick?.let { openSettings -> SettingsCornerButton( onClick = openSettings, @@ -255,16 +309,9 @@ private fun ChatToggle( @Suppress("ktlint:standard:function-naming") @Composable private fun GameBoardSection( - userRole: PlayerRoles, - cards: List, - currentBlueFound: Int, - currentRedFound: Int, - isSpymaster: Boolean, - canSelectCards: Boolean, - selectedCardPositions: List, - remainingGuesses: Int, - scale: Float, - offset: Offset, + state: BoardSectionState, + selectionState: BoardSelectionState, + transformState: BoardTransformState, onTransform: (Offset, Float) -> Unit, onSelectionChange: (List) -> Unit, onReveal: (List) -> Unit, @@ -274,26 +321,24 @@ private fun GameBoardSection( Row(modifier = modifier) { TeamSidebar( - userRole, + state.userRole, color = Team.BLUE, - teamFound = currentBlueFound, + teamFound = state.currentBlueFound, textColor = AppBlue, gradient = blueGradient, ) BoardContent( - cards = cards, - scale = scale, - offset = offset, - isSpymaster = isSpymaster, - selectedCardPositions = selectedCardPositions, + state = state, + selectionState = selectionState, + transformState = transformState, onCardClick = { position -> - if (canSelectCards) { + if (selectionState.canSelectCards) { onSelectionChange( updateSelectedCardPositions( position = position, - selectedCardPositions = selectedCardPositions, - remainingGuesses = remainingGuesses, + selectedCardPositions = selectionState.selectedCardPositions, + remainingGuesses = selectionState.remainingGuesses, onReveal = onReveal, ), ) @@ -308,9 +353,9 @@ private fun GameBoardSection( ) TeamSidebar( - userRole, + state.userRole, color = Team.RED, - teamFound = currentRedFound, + teamFound = state.currentRedFound, textColor = AppRed, gradient = redGradient, ) @@ -320,26 +365,22 @@ private fun GameBoardSection( @Suppress("ktlint:standard:function-naming") @Composable private fun BoardContent( - cards: List, - scale: Float, - offset: Offset, - isSpymaster: Boolean, - selectedCardPositions: List, + state: BoardSectionState, + selectionState: BoardSelectionState, + transformState: BoardTransformState, onCardClick: (Int) -> Unit, onTransform: (Offset, Float) -> Unit, modifier: Modifier = Modifier, ) { - val dimensions = LocalResponsiveDimensions.current - - if (cards.isEmpty()) { + if (state.cards.isEmpty()) { WaitingForGameState(modifier = modifier) } else { GameBoardGrid( - cards = cards, - scale = scale, - offset = offset, - isSpymaster = isSpymaster, - selectedCardPositions = selectedCardPositions.toSet(), + cards = state.cards, + scale = transformState.scale, + offset = transformState.offset, + isSpymaster = state.isSpymaster, + selectedCardPositions = selectionState.selectedCardPositions.toSet(), onCardClick = onCardClick, modifier = modifier @@ -378,6 +419,7 @@ private fun WaitingForGameState(modifier: Modifier = Modifier) { private fun DeselectAllButton( isVisible: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val dimensions = LocalResponsiveDimensions.current @@ -386,9 +428,9 @@ private fun DeselectAllButton( text = "Deselect all", onClick = onClick, modifier = - Modifier + modifier .fillMaxWidth() - .padding(horizontal = dimensions.screenPadding), + .height(dimensions.secondaryButtonHeight), style = AppButtonStyle( containerColor = AppGreen, @@ -396,33 +438,25 @@ private fun DeselectAllButton( fontSize = dimensions.bodyFontSize, ), ) - - Spacer(modifier = Modifier.height(dimensions.itemSpacing)) } } @Suppress("ktlint:standard:function-naming") @Composable private fun GameChatOverlay( - isVisible: Boolean, - chatInput: String, - chatLists: ChatLists, - selectedChatTab: ChatTab, - availableChatTabs: List, - onTabSelected: (ChatTab) -> Unit, - onChatInputChange: (String) -> Unit, - onSendChatMessage: (ChatTab, String) -> Unit, + state: ChatOverlayState, + actions: ChatOverlayActions, modifier: Modifier = Modifier, ) { - if (isVisible) { + if (state.isVisible) { ChatWindow( - chatInput = chatInput, - messages = chatLists, - selectedTab = getActiveChatTab(selectedChatTab, availableChatTabs), - availableTabs = availableChatTabs, - onTabSelected = onTabSelected, - onChatInputChange = onChatInputChange, - onSendClick = onSendChatMessage, + chatInput = state.input, + messages = state.messages, + selectedTab = getActiveChatTab(state.selectedTab, state.availableTabs), + availableTabs = state.availableTabs, + onTabSelected = actions.onTabSelected, + onChatInputChange = actions.onInputChange, + onSendClick = actions.onSendMessage, modifier = modifier, ) } From 1a4a4e8f8dfba6a02e786963ccf8b64b855699dc Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Thu, 11 Jun 2026 15:21:55 +0200 Subject: [PATCH 039/126] fix: guesses are now counted properly: - added remaining guesses to game state - guesses are now displayed in format: Remaining: X/Y --- .idea/misc.xml | 1 + .../java/com/codenames/frontend/data/model/GameState.kt | 1 + .../main/java/com/codenames/frontend/data/model/Mapper.kt | 3 ++- .../com/codenames/frontend/network/dto/GameMessage.kt | 1 + .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 8 +++++++- .../com/codenames/frontend/viewmodel/GameViewModel.kt | 1 + 6 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 6c5519f..3b0be22 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,3 +1,4 @@ + 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 index c1f6cba..25499da 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/GameState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt @@ -10,6 +10,7 @@ data class GameState( val currentTurn: PlayerRoles = PlayerRoles.NONE, val winner: Team? = null, val remainingGuesses: Int = 0, + val numGuesses: Int = 0, val currentRedFound: Int = 0, val currentBlueFound: Int = 0, val chatLists: ChatLists = ChatLists(), 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 e071b38..720cd72 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 @@ -33,7 +33,8 @@ fun GameMessage.toGameState(): GameState { cards = cards, currentTurn = getCurrentTurn(), winner = winner, - remainingGuesses = currentClue?.guessAmount ?: 0, + numGuesses = currentClue?.guessAmount ?: 0, + remainingGuesses = remainingGuesses, currentRedFound = cards.count { it.type == CardType.RED && it.revealed }, currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed }, ) 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 bd832c9..c583a3e 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 @@ -12,4 +12,5 @@ data class GameMessage( val currentClue: ClueDto? = null, val cardList: List = emptyList(), val error: String? = null, + val remainingGuesses: Int = 0 ) 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 1283285..69a8882 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 @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTransformGestures @@ -126,6 +127,7 @@ fun GameboardScreen( val currentTurn = gameState.currentTurn val winner = gameState.winner val remainingGuesses = gameState.remainingGuesses + val numGuesses = gameState.numGuesses val chatLists = gameState.chatLists val currentRedFound = gameState.currentRedFound val currentBlueFound = gameState.currentBlueFound @@ -172,6 +174,7 @@ fun GameboardScreen( currentTurn = currentTurn, winner = winner, remainingGuesses = remainingGuesses, + numGuesses = numGuesses ) ChatToggle( @@ -478,9 +481,12 @@ fun GameStatusBar( currentTurn: PlayerRoles?, winner: Team?, remainingGuesses: Int, + numGuesses: Int ) { val dimensions = LocalResponsiveDimensions.current + Log.d("GameboardScreen", "GameStatusBar: Updated guesses. Remaining guesses: $remainingGuesses") + Row( modifier = Modifier @@ -492,7 +498,7 @@ fun GameStatusBar( val statusText = when { winner != null -> "Winner: $winner" - currentTurn != null -> "Turn: ${currentTurn.name} | Guesses: $remainingGuesses" + currentTurn != null -> "Turn: ${currentTurn.name} | Remaining Guesses: $remainingGuesses/$numGuesses" else -> "Waiting for turn..." } 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 7bd0fd4..e1ebab2 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -186,6 +186,7 @@ class GameViewModel fun handleMessage(message: GameMessage) { val state = message.toGameState() + Log.d("GameViewModel", "New Game State: $state") _uiState.update { state } From b0665d9d4a53e83ec02200d1945f69aeb5aff956 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Thu, 11 Jun 2026 15:52:27 +0200 Subject: [PATCH 040/126] test: fixed tests + reformatting --- .../java/com/codenames/frontend/network/dto/GameMessage.kt | 2 +- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 4 ++-- .../test/java/com/codenames/frontend/data/model/MapperTest.kt | 4 +++- .../com/codenames/frontend/viewmodel/GameViewModelTest.kt | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) 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 c583a3e..f9bf24b 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 @@ -12,5 +12,5 @@ data class GameMessage( val currentClue: ClueDto? = null, val cardList: List = emptyList(), val error: String? = null, - val remainingGuesses: Int = 0 + val remainingGuesses: Int = 0, ) 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 69a8882..2d1db6e 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 @@ -174,7 +174,7 @@ fun GameboardScreen( currentTurn = currentTurn, winner = winner, remainingGuesses = remainingGuesses, - numGuesses = numGuesses + numGuesses = numGuesses, ) ChatToggle( @@ -481,7 +481,7 @@ fun GameStatusBar( currentTurn: PlayerRoles?, winner: Team?, remainingGuesses: Int, - numGuesses: Int + numGuesses: Int, ) { val dimensions = LocalResponsiveDimensions.current 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 index c1ddcb4..28910ca 100644 --- a/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/model/MapperTest.kt @@ -80,17 +80,19 @@ class MapperTest { currentTurn = Team.RED, currentPhase = Role.OPERATIVE, winner = null, + remainingGuesses = 2, ) val result = gameMessage.toGameState() assertEquals("Animal", result.currentHint) assertEquals(PlayerRoles.RED_OPERATIVE, result.currentTurn) - assertEquals(2, result.remainingGuesses) + assertEquals(2, result.numGuesses) assertNull(result.winner) assertEquals(1, result.cards.size) assertEquals("Dog", result.cards[0].word) + assertEquals(2, result.remainingGuesses) } @Test 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 7bdf455..e510a9c 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -240,7 +240,7 @@ class GameViewModelTest { assertEquals(PlayerRoles.BLUE_OPERATIVE, state.currentTurn) assertEquals("EAGLE", state.currentHint) - assertEquals(3, state.remainingGuesses) + assertEquals(3, state.numGuesses) assertEquals(2, state.cards.size) assertEquals("BERLIN", state.cards[0].word) } From b36ffdb031395862944da6111abfb00da557864d Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Thu, 11 Jun 2026 17:08:53 +0200 Subject: [PATCH 041/126] fix websocket connection, move chat subscription to different launched effect --- .../frontend/ui/screens/LobbyScreen.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) 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 29484cc..6d370e3 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 @@ -95,19 +95,27 @@ fun LobbyScreen( role = role.name, isHost = viewModel.getIsHost(usernameState.username), ) - chatViewModel.subscribeToChats( - username = usernameState.username, - lobbyCode = lobbyCode, - team = team.name, - role = role.name, - ) } } } LaunchedEffect(connectionState) { + val lobbyCode = lobbyUiState.lobbyCode.orEmpty() + val teamAndRole = currentRole.toTeamAndRole() + if (connectionState == ConnectionState.CONNECTED) { navController.navigate(Screen.Gameboard.route) + + if (lobbyCode.isNotBlank() && teamAndRole != null) { + val (team, role) = teamAndRole + + chatViewModel.subscribeToChats( + username = usernameState.username, + lobbyCode = lobbyCode, + team = team.name, + role = role.name, + ) + } } } From 9566ef0fe5c17adda7ec8d28ffcb069acf129e19 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 00:11:59 +0200 Subject: [PATCH 042/126] fix: restrict card selection to active operative --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2d1db6e..0d60b1a 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 @@ -142,7 +142,7 @@ fun GameboardScreen( val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER - val canSelectCards = !isSpymaster && remainingGuesses > 0 + val canSelectCards = userRole == currentTurn && !isSpymaster && remainingGuesses > 0 val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current From 9f79a147b7780ccf772c76e3601f42ee91615fbd Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 00:14:21 +0200 Subject: [PATCH 043/126] fix: send guesses with player team --- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../frontend/viewmodel/GameViewModel.kt | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) 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 index 45508fe..d659f86 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -42,7 +42,7 @@ fun GameScreenWrapper( gameViewModel.submitClue(lobbyCode, word, count) }, onReveal = { positions -> - gameViewModel.submitGuesses(lobbyCode, positions) + gameViewModel.submitGuesses(lobbyCode, positions, team) }, onSendChatMessage = { tab, message -> chatViewModel.sendChatMessage( 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 c9e44d2..76109bf 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -108,15 +108,14 @@ class GameViewModel fun submitGuess( lobbyCode: String, position: Int, + team: Team?, ) { - if (lobbyCode.isBlank()) { + if (lobbyCode.isBlank() || team == null) { return } val turn = uiState.value.currentTurn - if (turn != PlayerRoles.BLUE_OPERATIVE && turn != PlayerRoles.RED_OPERATIVE) return - - val team = if (turn == PlayerRoles.BLUE_OPERATIVE) Team.BLUE else Team.RED + if (!team.isActiveOperativeTurn(turn)) return viewModelScope.launch { try { gameRepository.submitGuess(lobbyCode, position, team) @@ -129,15 +128,14 @@ class GameViewModel fun submitGuesses( lobbyCode: String, positions: List, + team: Team?, ) { - if (lobbyCode.isBlank() || positions.isEmpty()) { + if (lobbyCode.isBlank() || positions.isEmpty() || team == null) { return } val turn = uiState.value.currentTurn - if (turn != PlayerRoles.BLUE_OPERATIVE && turn != PlayerRoles.RED_OPERATIVE) return - - val team = if (turn == PlayerRoles.BLUE_OPERATIVE) Team.BLUE else Team.RED + if (!team.isActiveOperativeTurn(turn)) return viewModelScope.launch { try { positions.forEach { position -> @@ -149,6 +147,10 @@ class GameViewModel } } + private fun Team.isActiveOperativeTurn(turn: PlayerRoles): Boolean = + (this == Team.BLUE && turn == PlayerRoles.BLUE_OPERATIVE) || + (this == Team.RED && turn == PlayerRoles.RED_OPERATIVE) + fun handleMessage(message: GameMessage) { val state = message.toGameState() Log.d("GameViewModel", "New Game State: $state") From e001ec8b692c9f925fda7c48179bfc59e584f134 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 00:36:14 +0200 Subject: [PATCH 044/126] test: cover active team guess restrictions --- .../codenames/frontend/GameboardScreenTest.kt | 25 +++++++ .../frontend/viewmodel/GameViewModelTest.kt | 71 ++++++++++++++++--- 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index b8245b5..de29c56 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -191,6 +191,31 @@ class GameboardScreenTest { composeRule.onAllNodesWithText("Deselect all").assertCountEquals(0) } + @Test + fun inactiveOperativeCannotSelectCards() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.RED_OPERATIVE, + remainingGuesses = 2, + cards = + listOf( + GameCard("BERLIN", CardType.BLUE), + GameCard("ROME", CardType.RED), + ), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("BERLIN").performClick() + composeRule.onAllNodesWithText("Deselect all").assertCountEquals(0) + } + @Test fun clickingSelectedCardRevealsOnlyThatCard() { var revealedPositions = emptyList() 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 9a1c42b..18725ed 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -296,7 +296,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) - viewModel.submitGuess(lobbyCode, 3) + viewModel.submitGuess(lobbyCode, 3, Team.RED) advanceUntilIdle() coVerify { gameRepository.submitGuess(lobbyCode, 3, Team.RED) } @@ -307,7 +307,7 @@ class GameViewModelTest { runTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitGuess(lobbyCode, 3) + viewModel.submitGuess(lobbyCode, 3, Team.RED) advanceUntilIdle() coVerify(exactly = 0) { @@ -320,7 +320,7 @@ class GameViewModelTest { runTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) - viewModel.submitGuess("", 3) + viewModel.submitGuess("", 3, Team.RED) advanceUntilIdle() coVerify(exactly = 0) { @@ -337,7 +337,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) - viewModel.submitGuess(lobbyCode, 3) + viewModel.submitGuess(lobbyCode, 3, Team.RED) advanceUntilIdle() val state = viewModel.connectionState.value @@ -353,7 +353,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) - viewModel.submitGuesses(lobbyCode, listOf(2, 4)) + viewModel.submitGuesses(lobbyCode, listOf(2, 4), Team.RED) advanceUntilIdle() coVerify { @@ -371,7 +371,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.BLUE, currentPhase = Role.OPERATIVE)) - viewModel.submitGuesses(lobbyCode, listOf(1)) + viewModel.submitGuesses(lobbyCode, listOf(1), Team.BLUE) advanceUntilIdle() coVerify { @@ -384,7 +384,33 @@ class GameViewModelTest { runTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) - viewModel.submitGuesses(lobbyCode, emptyList()) + viewModel.submitGuesses(lobbyCode, emptyList(), Team.RED) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun submitGuesses_blankLobbyCode_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses("", listOf(3), Team.RED) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun submitGuesses_nullTeam_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, listOf(3), null) advanceUntilIdle() coVerify(exactly = 0) { @@ -397,11 +423,40 @@ class GameViewModelTest { runTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitGuesses(lobbyCode, listOf(3)) + viewModel.submitGuesses(lobbyCode, listOf(3), Team.RED) advanceUntilIdle() coVerify(exactly = 0) { gameRepository.submitGuess(any(), any(), any()) } } + + @Test + fun submitGuesses_wrongTeamForCurrentTurn_doesNotSendGuess() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, listOf(3), Team.BLUE) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitGuess(any(), any(), any()) + } + } + + @Test + fun submitGuesses_networkError_updatesConnectionState() = + runTest { + coEvery { + gameRepository.submitGuess(any(), any(), any()) + } throws Exception("Network connection failed") + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.submitGuesses(lobbyCode, listOf(3), Team.RED) + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } } From 33c5e5859139372a96fb914c07e44d235969a75a Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 12:52:48 +0200 Subject: [PATCH 045/126] fix: restrict clue input to active spymaster --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0d60b1a..8f5ce91 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 @@ -142,6 +142,7 @@ fun GameboardScreen( val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER + val isActiveSpymaster = userRole == currentTurn && isSpymaster val canSelectCards = userRole == currentTurn && !isSpymaster && remainingGuesses > 0 val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -218,7 +219,7 @@ fun GameboardScreen( ) HintSection( - isSpymaster, + isActiveSpymaster, currentHint, hintInput, countInput, From 65850fe84068512d6a0fa4ebcadf48ddb6762915 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 12:53:14 +0200 Subject: [PATCH 046/126] fix: send clues with player team --- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../com/codenames/frontend/viewmodel/GameViewModel.kt | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 index d659f86..a2c374c 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -39,7 +39,7 @@ fun GameScreenWrapper( availableChatTabs = availableChatTabs, ), onHintChange = { word, count -> - gameViewModel.submitClue(lobbyCode, word, count) + gameViewModel.submitClue(lobbyCode, word, count, team) }, onReveal = { positions -> gameViewModel.submitGuesses(lobbyCode, positions, team) 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 76109bf..99f1ee7 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -87,15 +87,14 @@ class GameViewModel lobbyCode: String, word: String, count: Int, + team: Team?, ) { - if (lobbyCode.isBlank()) { + if (lobbyCode.isBlank() || team == null) { 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 + if (!team.isActiveSpymasterTurn(turn)) return viewModelScope.launch { try { gameRepository.submitClue(lobbyCode, word, count, team) @@ -151,6 +150,10 @@ class GameViewModel (this == Team.BLUE && turn == PlayerRoles.BLUE_OPERATIVE) || (this == Team.RED && turn == PlayerRoles.RED_OPERATIVE) + private fun Team.isActiveSpymasterTurn(turn: PlayerRoles): Boolean = + (this == Team.BLUE && turn == PlayerRoles.BLUE_SPYMASTER) || + (this == Team.RED && turn == PlayerRoles.RED_SPYMASTER) + fun handleMessage(message: GameMessage) { val state = message.toGameState() Log.d("GameViewModel", "New Game State: $state") From 90aed88855c322cc4b847089f827b7656f203537 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 13:08:37 +0200 Subject: [PATCH 047/126] test: cover active spymaster clue restrictions --- .../codenames/frontend/GameboardScreenTest.kt | 19 +++++++++++ .../frontend/viewmodel/GameViewModelTest.kt | 34 ++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index de29c56..cdc7e35 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -75,6 +75,25 @@ class GameboardScreenTest { composeRule.onNodeWithText("Hint: EAGLE").assertIsDisplayed() } + @Test + fun inactiveSpymasterCannotEnterClue() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + gameState = + GameState( + currentHint = "", + currentTurn = PlayerRoles.RED_SPYMASTER, + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onAllNodesWithText("SEND").assertCountEquals(0) + } + @Test fun spymasterCanOpenLobbyChat() { composeRule.setContent { 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 18725ed..9060d9a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -245,7 +245,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitClue(lobbyCode, "EAGLE", 2) + viewModel.submitClue(lobbyCode, "EAGLE", 2, Team.RED) advanceUntilIdle() coVerify { gameRepository.submitClue(lobbyCode, "EAGLE", 2, Team.RED) } @@ -260,7 +260,7 @@ class GameViewModelTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitClue(lobbyCode, "EAGLE", 2) + viewModel.submitClue(lobbyCode, "EAGLE", 2, Team.RED) advanceUntilIdle() val state = viewModel.connectionState.value @@ -270,7 +270,7 @@ class GameViewModelTest { @Test fun testSubmitClue_whenTurnIsNone_doesNotSendClue() = runTest { - viewModel.submitClue(lobbyCode, "EAGLE", 2) + viewModel.submitClue(lobbyCode, "EAGLE", 2, Team.RED) advanceUntilIdle() coVerify(exactly = 0) { gameRepository.submitClue(any(), any(), any(), any()) } } @@ -280,13 +280,39 @@ class GameViewModelTest { runTest { viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) - viewModel.submitClue("", "EAGLE", 2) + viewModel.submitClue("", "EAGLE", 2, Team.RED) advanceUntilIdle() coVerify(exactly = 0) { gameRepository.submitClue(any(), any(), any(), any()) } } + @Test + fun testSubmitClue_nullTeam_doesNotSendClue() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitClue(lobbyCode, "EAGLE", 2, null) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitClue(any(), any(), any(), any()) + } + } + + @Test + fun testSubmitClue_wrongTeamForCurrentTurn_doesNotSendClue() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.submitClue(lobbyCode, "EAGLE", 2, Team.BLUE) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.submitClue(any(), any(), any(), any()) + } + } + @Test fun testSubmitGuess_RedOperative_Success() = runTest { From a55e72ac40874638b37dfa6552ac94ea5d6a3308 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Fri, 12 Jun 2026 13:13:19 +0200 Subject: [PATCH 048/126] refactor: remove unused game connection parameter --- .../frontend/ui/screens/LobbyScreen.kt | 5 ----- .../frontend/viewmodel/GameViewModel.kt | 3 --- .../frontend/viewmodel/GameViewModelTest.kt | 18 ++++-------------- 3 files changed, 4 insertions(+), 22 deletions(-) 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 6d370e3..e3c0e0f 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 @@ -86,13 +86,8 @@ fun LobbyScreen( 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), ) } 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 99f1ee7..55c5f6a 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -37,10 +37,7 @@ class GameViewModel val connectionState: StateFlow = _connectionState fun connect( - username: String, lobbyCode: String, - team: String, - role: String, isHost: Boolean = false, ) { job?.cancel() 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 9060d9a..ef864ce 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -39,10 +39,6 @@ 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 = "", @@ -90,7 +86,7 @@ class GameViewModelTest { coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode, team, role) + viewModel.connect(lobbyCode) advanceUntilIdle() @@ -108,11 +104,11 @@ class GameViewModelTest { coEvery { client.connectStomp() } just Runs coEvery { client.subscribeToLobby(lobbyCode) } returns flow - viewModel.connect(username, lobbyCode, team, role) + viewModel.connect(lobbyCode) advanceUntilIdle() - viewModel.connect(username, lobbyCode, team, role) + viewModel.connect(lobbyCode) coVerify { client.connectStomp() } coVerify { client.subscribeToLobby(lobbyCode) } @@ -156,7 +152,7 @@ class GameViewModelTest { client.connectStomp() } throws RuntimeException("Connection failed") - viewModel.connect(username, lobbyCode, team, role) + viewModel.connect(lobbyCode) advanceUntilIdle() @@ -181,10 +177,7 @@ class GameViewModelTest { } just Runs viewModel.connect( - username, lobbyCode, - team, - role, isHost = true, ) @@ -202,10 +195,7 @@ class GameViewModelTest { coEvery { client.subscribeToLobby(any()) } returns emptyFlow() viewModel.connect( - username, "", - team, - role, isHost = true, ) From 4e8ca7c9e39754e06ac37dc8fa632f61b2cdec43 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 13:46:09 +0200 Subject: [PATCH 049/126] added data store for saving user and lobby data --- .../frontend/data/datastore/DataStore.kt | 10 +++ .../datastore/UserPreferencesDataStore.kt | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt new file mode 100644 index 0000000..08ff8e5 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt @@ -0,0 +1,10 @@ +package com.codenames.frontend.data.datastore + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore + +val Context.dataStore: DataStore by preferencesDataStore( + name = "user_preferences" +) \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt new file mode 100644 index 0000000..84c57e6 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt @@ -0,0 +1,70 @@ +package com.codenames.frontend.data.datastore + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.codenames.frontend.data.model.SessionData +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UserPreferencesDataStore @Inject constructor( + @param:ApplicationContext private val context: Context +){ + private object PreferencesKeys { + val USERNAME = stringPreferencesKey("username") + val USER_ID = stringPreferencesKey("player_id") + val LOBBY_CODE = stringPreferencesKey("lobby_code") + val LOBBY_ROLE = stringPreferencesKey("lobby_role") + val LOBBY_TEAM = stringPreferencesKey("lobby_team") + } + + suspend fun saveLobbyData(lobbyCode: String, lobbyRole: Role, lobbyTeam: Team ) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.LOBBY_CODE] = lobbyCode + prefs[PreferencesKeys.LOBBY_ROLE] = lobbyRole.name + prefs[PreferencesKeys.LOBBY_TEAM] = lobbyTeam.name + } + } + + suspend fun saveUserData( + username: String, + userId: UUID + ) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.USERNAME] = username + prefs[PreferencesKeys.USER_ID] = userId.toString() + } + } + + val sessionData: Flow = + context.dataStore.data.map { prefs -> + SessionData( + username = prefs[PreferencesKeys.USERNAME], + userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString), + lobbyCode = prefs[PreferencesKeys.LOBBY_CODE], + lobbyRole = prefs[PreferencesKeys.LOBBY_ROLE]?.let { + runCatching { Role.valueOf(it) }.getOrNull() + }, + lobbyTeam = prefs[PreferencesKeys.LOBBY_TEAM]?.let { + runCatching { Team.valueOf(it) }.getOrNull() + } + ) + } + + suspend fun clearSessionData() { + context.dataStore.edit { prefs -> + prefs.remove(PreferencesKeys.LOBBY_CODE) + prefs.remove(PreferencesKeys.LOBBY_ROLE) + prefs.remove(PreferencesKeys.LOBBY_TEAM) + } + } + +} + From fde9dd7e052d4a5206efe12511f97f990268a032 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 13:46:29 +0200 Subject: [PATCH 050/126] added gradle dependency for datastore --- app/build.gradle.kts | 3 +++ gradle/libs.versions.toml | 2 ++ 2 files changed, 5 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 862a3c4..4d33890 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -151,6 +151,9 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.activity.compose) + // Android Data Store + implementation(libs.androidx.datastore.preferences) + // Jetpack Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95aabc3..60219bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "9.1.1" coreKtx = "1.18.0" +datastorePreferences = "1.2.1" hiltAndroidCompiler = "2.59.2" hiltNavigationCompose = "1.3.0" junit = "4.13.2" @@ -28,6 +29,7 @@ turbine = "1.0.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtxVersion" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } From fc60387f29ffe29e02252e680bcb53672b5d705a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 13:46:53 +0200 Subject: [PATCH 051/126] added repository and SessionData.kt for accessing datastore --- .../frontend/data/model/SessionData.kt | 13 +++++++++ .../data/repository/SessionRepository.kt | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/SessionData.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt diff --git a/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt b/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt new file mode 100644 index 0000000..ddc3754 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt @@ -0,0 +1,13 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import java.util.UUID + +data class SessionData( + val username: String?, + val userId: UUID?, + val lobbyCode: String? = null, + val lobbyRole: Role? = null, + val lobbyTeam: Team? = null +) diff --git a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt new file mode 100644 index 0000000..1484c2a --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt @@ -0,0 +1,29 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.data.datastore.UserPreferencesDataStore +import com.codenames.frontend.data.model.SessionData +import kotlinx.coroutines.flow.Flow +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SessionRepository @Inject constructor( + private val dataStore: UserPreferencesDataStore +){ + val sessionFlow: Flow = dataStore.sessionData + + suspend fun saveLobby(session: SessionData) { + if(session.lobbyCode != null && session.lobbyRole != null && session.lobbyTeam != null) { + dataStore.saveLobbyData(session.lobbyCode, session.lobbyRole, session.lobbyTeam) + } + } + + suspend fun saveUser(username: String, userId: UUID) { + dataStore.saveUserData(username, userId) + } + + suspend fun clearSessionData() { + dataStore.clearSessionData() + } +} \ No newline at end of file From 6e5ef150358e7f70da5ced924ca7d410b7a38cc4 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 13:59:40 +0200 Subject: [PATCH 052/126] use SessionState instead of SessionData, delete duplicate state --- .../data/datastore/UserPreferencesDataStore.kt | 6 +++--- .../codenames/frontend/data/model/SessionData.kt | 13 ------------- .../codenames/frontend/data/model/SessionState.kt | 10 +++++++++- .../frontend/data/repository/SessionRepository.kt | 6 +++--- 4 files changed, 15 insertions(+), 20 deletions(-) delete mode 100644 app/src/main/java/com/codenames/frontend/data/model/SessionData.kt diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt index 84c57e6..bcdae0d 100644 --- a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt +++ b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt @@ -3,7 +3,7 @@ package com.codenames.frontend.data.datastore import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.codenames.frontend.data.model.SessionData +import com.codenames.frontend.data.model.SessionState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import dagger.hilt.android.qualifiers.ApplicationContext @@ -43,9 +43,9 @@ class UserPreferencesDataStore @Inject constructor( } } - val sessionData: Flow = + val sessionData: Flow = context.dataStore.data.map { prefs -> - SessionData( + SessionState( username = prefs[PreferencesKeys.USERNAME], userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString), lobbyCode = prefs[PreferencesKeys.LOBBY_CODE], diff --git a/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt b/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt deleted file mode 100644 index ddc3754..0000000 --- a/app/src/main/java/com/codenames/frontend/data/model/SessionData.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.codenames.frontend.data.model - -import com.codenames.frontend.data.model.enums.Role -import com.codenames.frontend.data.model.enums.Team -import java.util.UUID - -data class SessionData( - val username: String?, - val userId: UUID?, - val lobbyCode: String? = null, - val lobbyRole: Role? = null, - val lobbyTeam: Team? = null -) 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 index 95dca09..9d2341d 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -1,5 +1,13 @@ package com.codenames.frontend.data.model +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import java.util.UUID + data class SessionState( - val username: String, + val username: String?, + val userId: UUID?, + val lobbyCode: String? = null, + val lobbyRole: Role? = null, + val lobbyTeam: Team? = null ) diff --git a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt index 1484c2a..9a92aa1 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt @@ -1,7 +1,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.datastore.UserPreferencesDataStore -import com.codenames.frontend.data.model.SessionData +import com.codenames.frontend.data.model.SessionState import kotlinx.coroutines.flow.Flow import java.util.UUID import javax.inject.Inject @@ -11,9 +11,9 @@ import javax.inject.Singleton class SessionRepository @Inject constructor( private val dataStore: UserPreferencesDataStore ){ - val sessionFlow: Flow = dataStore.sessionData + val sessionFlow: Flow = dataStore.sessionData - suspend fun saveLobby(session: SessionData) { + suspend fun saveLobby(session: SessionState) { if(session.lobbyCode != null && session.lobbyRole != null && session.lobbyTeam != null) { dataStore.saveLobbyData(session.lobbyCode, session.lobbyRole, session.lobbyTeam) } From f2a90d35188a38ecb698c8bee00e016faf3e5116 Mon Sep 17 00:00:00 2001 From: XtophB Date: Fri, 12 Jun 2026 14:44:03 +0200 Subject: [PATCH 053/126] refactor: change color of neutral cards in spymaster view --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0d60b1a..f016172 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 @@ -991,6 +991,6 @@ fun getColor(type: CardType): Color = when (type) { CardType.BLUE -> AppBlue CardType.RED -> AppRed - CardType.NEUTRAL -> AppInk + CardType.NEUTRAL -> AppSurface CardType.ASSASSIN -> AppBlack } From 7f591e192b46a39301a4314fe7ece50956fdc6e7 Mon Sep 17 00:00:00 2001 From: XtophB Date: Fri, 12 Jun 2026 14:50:40 +0200 Subject: [PATCH 054/126] refactor: update text color to not clash with beige background --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f016172..50f7b81 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 @@ -958,7 +958,7 @@ fun CodenamesCard( } val contentColor = - if (!card.revealed && !isSpymaster) { + if (backgroundColor == AppSurface) { AppInk } else { AppWhite From 787b6a7d97b26a32a67d9c8bc36e4c929bae277f Mon Sep 17 00:00:00 2001 From: XtophB Date: Fri, 12 Jun 2026 15:24:47 +0200 Subject: [PATCH 055/126] feat: change opacity for guessed cards in spymaster view --- .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 8 ++++++++ 1 file changed, 8 insertions(+) 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 50f7b81..d735b97 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 @@ -32,6 +32,7 @@ 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.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.geometry.Offset @@ -976,6 +977,13 @@ fun CodenamesCard( } else { Modifier }, + ) + .then( + if (card.revealed && isSpymaster) { + Modifier.alpha(0.5f) + } else { + Modifier + }, ), style = AppButtonStyle( From ff983fda56c5b331ca0fe2d2d092dfb7f886aa73 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 16:21:57 +0200 Subject: [PATCH 056/126] add rejoin logic to viewmodel: - add rejoin state - add connection to data store - viewmodel collects data and exposes as a flow for ui reaction --- .../datastore/UserPreferencesDataStore.kt | 13 ++- .../frontend/data/model/RejoinState.kt | 6 ++ .../frontend/data/model/SessionState.kt | 3 - .../frontend/data/model/UserState.kt | 8 ++ .../data/repository/SessionRepository.kt | 14 +-- .../frontend/ui/screens/GameScreenWrapper.kt | 4 +- .../frontend/ui/screens/JoinLobbyScreen.kt | 2 +- .../frontend/ui/screens/LobbyScreen.kt | 24 ++--- .../frontend/ui/screens/StartScreen.kt | 6 +- .../frontend/viewmodel/SessionViewModel.kt | 87 +++++++++++++++++-- .../viewmodel/SessionViewModelTest.kt | 4 +- 11 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt create mode 100644 app/src/main/java/com/codenames/frontend/data/model/UserState.kt diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt index bcdae0d..f4c211d 100644 --- a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt +++ b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.codenames.frontend.data.model.SessionState +import com.codenames.frontend.data.model.UserState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import dagger.hilt.android.qualifiers.ApplicationContext @@ -35,7 +36,7 @@ class UserPreferencesDataStore @Inject constructor( suspend fun saveUserData( username: String, - userId: UUID + userId: UUID? ) { context.dataStore.edit { prefs -> prefs[PreferencesKeys.USERNAME] = username @@ -46,8 +47,6 @@ class UserPreferencesDataStore @Inject constructor( val sessionData: Flow = context.dataStore.data.map { prefs -> SessionState( - username = prefs[PreferencesKeys.USERNAME], - userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString), lobbyCode = prefs[PreferencesKeys.LOBBY_CODE], lobbyRole = prefs[PreferencesKeys.LOBBY_ROLE]?.let { runCatching { Role.valueOf(it) }.getOrNull() @@ -58,6 +57,14 @@ class UserPreferencesDataStore @Inject constructor( ) } + val userData: Flow = + context.dataStore.data.map { prefs -> + UserState( + username = prefs[PreferencesKeys.USERNAME] ?: "", + userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString) + ) + } + suspend fun clearSessionData() { context.dataStore.edit { prefs -> prefs.remove(PreferencesKeys.LOBBY_CODE) diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt new file mode 100644 index 0000000..c2d1e3d --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt @@ -0,0 +1,6 @@ +package com.codenames.frontend.data.model + +sealed class RejoinState { + data object None: RejoinState() + data class Available(val sessionState: SessionState): RejoinState() +} 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 index 9d2341d..de86d90 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -2,11 +2,8 @@ package com.codenames.frontend.data.model import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team -import java.util.UUID data class SessionState( - val username: String?, - val userId: UUID?, val lobbyCode: String? = null, val lobbyRole: Role? = null, val lobbyTeam: Team? = null diff --git a/app/src/main/java/com/codenames/frontend/data/model/UserState.kt b/app/src/main/java/com/codenames/frontend/data/model/UserState.kt new file mode 100644 index 0000000..eb2ba4f --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/UserState.kt @@ -0,0 +1,8 @@ +package com.codenames.frontend.data.model + +import java.util.UUID + +data class UserState( + val username: String, + val userId: UUID?, +) diff --git a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt index 9a92aa1..cb1a760 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt @@ -2,6 +2,9 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.datastore.UserPreferencesDataStore import com.codenames.frontend.data.model.SessionState +import com.codenames.frontend.data.model.UserState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team import kotlinx.coroutines.flow.Flow import java.util.UUID import javax.inject.Inject @@ -12,18 +15,17 @@ class SessionRepository @Inject constructor( private val dataStore: UserPreferencesDataStore ){ val sessionFlow: Flow = dataStore.sessionData + val userFlow: Flow = dataStore.userData - suspend fun saveLobby(session: SessionState) { - if(session.lobbyCode != null && session.lobbyRole != null && session.lobbyTeam != null) { - dataStore.saveLobbyData(session.lobbyCode, session.lobbyRole, session.lobbyTeam) - } + suspend fun saveLobby(lobbyCode: String, lobbyRole: Role, lobbyTeam: Team) { + dataStore.saveLobbyData(lobbyCode, lobbyRole, lobbyTeam) } - suspend fun saveUser(username: String, userId: UUID) { + suspend fun saveUser(username: String, userId: UUID?) { dataStore.saveUserData(username, userId) } - suspend fun clearSessionData() { + suspend fun clearLobbyData() { dataStore.clearSessionData() } } \ No newline at end of file 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 index d659f86..1bb9339 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -22,9 +22,9 @@ fun GameScreenWrapper( val lobbyState by lobbyViewModel.state.collectAsState() val gameState by gameViewModel.uiState.collectAsState() val chatState by chatViewModel.chatState.collectAsState() - val usernameState by sessionViewModel.username.collectAsState() + val userState by sessionViewModel.userState.collectAsState() - val username = usernameState.username + val username = userState.username val lobbyCode = lobbyState.lobbyCode.orEmpty() val currentPlayer = lobbyState.players.firstOrNull { it.name == username } val team = currentPlayer?.team 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 index 7f5fcca..66789ba 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -77,7 +77,7 @@ fun JoinlobbyScreen( val focusManager = LocalFocusManager.current val state by viewModel.state.collectAsState() - val username by sessionViewModel.username.collectAsState() + val username by sessionViewModel.userState.collectAsState() val joinEnabled = isLobbyIdValid(lobbyId) 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 6d370e3..6491391 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 @@ -68,14 +68,14 @@ fun LobbyScreen( chatViewModel: ChatViewModel, ) { val dimensions = LocalResponsiveDimensions.current - val usernameState by sessionViewModel.username.collectAsState() + val userState by sessionViewModel.userState.collectAsState() val lobbyUiState by viewModel.state.collectAsState() - val currentPlayer = lobbyUiState.players.firstOrNull { it.name == usernameState.username } + val currentPlayer = lobbyUiState.players.firstOrNull { it.name == userState.username } val currentRole = currentPlayer?.toPlayerRole() ?: PlayerRoles.NONE val connectionState by gameViewModel.connectionState.collectAsState() val onStartGame = { - viewModel.sendStartGame(usernameState.username) + viewModel.sendStartGame(userState.username) } LaunchedEffect(lobbyUiState.isGameStarted) { @@ -89,11 +89,11 @@ fun LobbyScreen( val (team, role) = teamAndRole gameViewModel.connect( - username = usernameState.username, + username = userState.username, lobbyCode = lobbyCode, team = team.name, role = role.name, - isHost = viewModel.getIsHost(usernameState.username), + isHost = viewModel.getIsHost(userState.username), ) } } @@ -110,7 +110,7 @@ fun LobbyScreen( val (team, role) = teamAndRole chatViewModel.subscribeToChats( - username = usernameState.username, + username = userState.username, lobbyCode = lobbyCode, team = team.name, role = role.name, @@ -144,7 +144,7 @@ fun LobbyScreen( gradient = blueGradient, textColor = AppBlueLight, title = "BLUE TEAM", - onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + onRoleSelect = { viewModel.changeRole(it, userState.username) }, lobbyUiState = lobbyUiState, ) @@ -167,7 +167,7 @@ fun LobbyScreen( gradient = redGradient, textColor = AppRedLight, title = "RED TEAM", - onRoleSelect = { viewModel.changeRole(it, usernameState.username) }, + onRoleSelect = { viewModel.changeRole(it, userState.username) }, lobbyUiState = lobbyUiState, ) } @@ -319,12 +319,12 @@ fun GameSettingsColumn( onStartGame: () -> Unit, ) { val dimensions = LocalResponsiveDimensions.current - val usernameState by sessionViewModel.username.collectAsState() + val userState by sessionViewModel.userState.collectAsState() val canStart = - usernameState.username.isNotBlank() && + userState.username.isNotBlank() && lobbyCode.isNotBlank() && currentRole != PlayerRoles.NONE && - viewModel.getIsHost(usernameState.username) + viewModel.getIsHost(userState.username) Column( modifier = modifier, @@ -415,7 +415,7 @@ fun GameSettingsColumn( } } } - viewModel.leaveLobby(username = usernameState.username, onResult = onResult) + viewModel.leaveLobby(username = userState.username, onResult = onResult) }, modifier = Modifier 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 a198775..4035a6d 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 @@ -41,7 +41,7 @@ fun StartScreen( val dimensions = LocalResponsiveDimensions.current val lobbyState by lobbyViewModel.state.collectAsState() - val usernameState by sessionViewModel.username.collectAsState() + val userState by sessionViewModel.userState.collectAsState() LaunchedEffect(lobbyState.lobbyCode, lobbyState.error, lobbyState.isLoading) { if (!lobbyState.isLoading && lobbyState.error == null && lobbyState.lobbyCode != null) { @@ -64,7 +64,7 @@ fun StartScreen( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Welcome to Codenames, ${usernameState.username}!", + text = "Welcome to Codenames, ${userState.username}!", fontSize = dimensions.titleFontSize, modifier = Modifier.padding(bottom = dimensions.sectionSpacing * 2), ) @@ -76,7 +76,7 @@ fun StartScreen( AppButton( text = "Create Lobby", onClick = { - lobbyViewModel.createLobby(usernameState.username) + lobbyViewModel.createLobby(userState.username) }, modifier = Modifier diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index 8a9d666..17d6100 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -1,21 +1,96 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel -import com.codenames.frontend.data.model.SessionState +import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.model.RejoinState +import com.codenames.frontend.data.model.UserState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.data.repository.SessionRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject @HiltViewModel class SessionViewModel @Inject - constructor() : ViewModel() { - private val _username = MutableStateFlow(SessionState("")) - val username: StateFlow = _username + constructor( + private val sessionRepository: SessionRepository + ) : ViewModel() { + private val _userState = MutableStateFlow(UserState("", null)) //initialized with random UUID, is replaced when real uuid is collected + val userState: StateFlow = _userState + + private val _rejoinSessionState = MutableStateFlow(RejoinState.None) + val rejoinSessionState: StateFlow = _rejoinSessionState + + init { + observeUser() + observeSession() + } fun setUsername(username: String) { - _username.update { SessionState(username) } + _userState.update { + it.copy(username = username) + } + } + + fun setUserId(userId: UUID) { + _userState.update { + it.copy( + userId = userId + ) + } + } + + fun persistLobbyState(lobbyCode: String, role: Role, team: Team) { + viewModelScope.launch { + sessionRepository.saveLobby( + lobbyCode, role, team + ) + } + } + + fun persistUserState() { + viewModelScope.launch { + sessionRepository.saveUser( + _userState.value.username, + _userState.value.userId ?: return@launch + ) + } + } + + fun clearLobby() { + viewModelScope.launch { + sessionRepository.clearLobbyData() + } + } + + private fun observeSession() { + viewModelScope.launch { + sessionRepository.sessionFlow.collect { session -> + _rejoinSessionState.value = + if ( + session.lobbyCode != null && + session.lobbyRole != null && + session.lobbyTeam != null + ) { + RejoinState.Available(session) + } else { + RejoinState.None + } + } + } + } + + private fun observeUser() { + viewModelScope.launch { + sessionRepository.userFlow.collect { state -> + _userState.value = state + } + } } - } +} diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt index 7b16c58..1bad049 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -17,7 +17,7 @@ class SessionViewModelTest { @Test fun `initial username is empty`() { - val result = viewModel.username.value + val result = viewModel.sessionState.value assertEquals(SessionState(""), result) } @@ -26,7 +26,7 @@ class SessionViewModelTest { fun `setUsername updates username state`() { viewModel.setUsername("Max") - val result = viewModel.username.value + val result = viewModel.sessionState.value assertEquals(SessionState("Max"), result) } From eb0c0a0ad073f56d087cb2575a5fcbb97fbc6abd Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 17:22:15 +0200 Subject: [PATCH 057/126] lobby state and user state are now saved --- .../com/codenames/frontend/data/model/RejoinState.kt | 1 + .../com/codenames/frontend/ui/screens/LobbyScreen.kt | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt index c2d1e3d..5935a2a 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt @@ -3,4 +3,5 @@ package com.codenames.frontend.data.model sealed class RejoinState { data object None: RejoinState() data class Available(val sessionState: SessionState): RejoinState() + data object Consumed : RejoinState() } 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 065a7ee..072c2db 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 @@ -78,6 +78,12 @@ fun LobbyScreen( viewModel.sendStartGame(userState.username) } + LaunchedEffect(userState.userId) { + if(userState.userId != null) { + sessionViewModel.persistUserState() + } + } + LaunchedEffect(lobbyUiState.isGameStarted) { if (lobbyUiState.isGameStarted) { val lobbyCode = lobbyUiState.lobbyCode.orEmpty() @@ -110,6 +116,12 @@ fun LobbyScreen( team = team.name, role = role.name, ) + + sessionViewModel.persistLobbyState( + lobbyCode, + role, + team + ) } } } From 88c64a20911751547c07276656cdca710cb3ff1f Mon Sep 17 00:00:00 2001 From: XtophB Date: Fri, 12 Jun 2026 17:30:58 +0200 Subject: [PATCH 058/126] style: run ktlint command --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 28b6788..80e57a4 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 @@ -978,8 +978,7 @@ fun CodenamesCard( } else { Modifier }, - ) - .then( + ).then( if (card.revealed && isSpymaster) { Modifier.alpha(0.5f) } else { From 535846322f7ba188fcdd816beec775b83985ad0e Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 18:10:40 +0200 Subject: [PATCH 059/126] added if condition to prevent loading empty username --- .../com/codenames/frontend/ui/screens/GameScreenWrapper.kt | 1 + .../java/com/codenames/frontend/viewmodel/SessionViewModel.kt | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 index 88719f9..0703316 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.ui.screens +import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index 17d6100..c4d745a 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -89,7 +89,9 @@ class SessionViewModel private fun observeUser() { viewModelScope.launch { sessionRepository.userFlow.collect { state -> - _userState.value = state + if(state.username.isNotBlank() ) { + _userState.value = state + } } } } From 57db0288962746383c37df22163b257e8cbed57a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Fri, 12 Jun 2026 19:01:06 +0200 Subject: [PATCH 060/126] saved username is displayed in username input field --- .../java/com/codenames/frontend/ui/screens/UserNameScreen.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 9a1a446..8f4c6d0 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -13,6 +13,7 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -36,8 +37,10 @@ fun UserNameScreen( navController: NavController, viewModel: SessionViewModel, ) { + val userState by viewModel.userState.collectAsState() + val dimensions = LocalResponsiveDimensions.current - var username by remember { mutableStateOf("") } + var username by remember { mutableStateOf(userState.username) } Box( modifier = From 59b426bdc21722093acbd1d9cc9339c1478a761c Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 14:20:01 +0200 Subject: [PATCH 061/126] feat: allow changing background color based on current turn --- .../frontend/ui/screens/GameboardScreen.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 80e57a4..a1e8f5c 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 @@ -61,6 +61,7 @@ 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 +import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppBlack import com.codenames.frontend.ui.theme.AppBlue import com.codenames.frontend.ui.theme.AppGreen @@ -160,7 +161,12 @@ fun GameboardScreen( .take(remainingGuesses.coerceAtLeast(0)) } - Box(modifier = modifier.fillMaxSize()) { + Box( + modifier = + modifier + .fillMaxSize() + .background(getTeamBackgroundColor(currentTurn)), + ) { Column( modifier = Modifier @@ -1002,3 +1008,10 @@ fun getColor(type: CardType): Color = CardType.NEUTRAL -> AppSurface CardType.ASSASSIN -> AppBlack } + +fun getTeamBackgroundColor(currentTurn: PlayerRoles): Color = + when (currentTurn) { + PlayerRoles.BLUE_OPERATIVE, PlayerRoles.BLUE_SPYMASTER -> Color(0xFFD6E8F5) + PlayerRoles.RED_OPERATIVE, PlayerRoles.RED_SPYMASTER -> Color(0xFFF39696) + PlayerRoles.NONE -> AppBackground + } From c9bdde3dc3c3f5b047ba766815fa09d06fc63001 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 14:33:58 +0200 Subject: [PATCH 062/126] refactor: decrease strength of opacity modifier --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a1e8f5c..f2929f9 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 @@ -985,8 +985,8 @@ fun CodenamesCard( Modifier }, ).then( - if (card.revealed && isSpymaster) { - Modifier.alpha(0.5f) + if (card.revealed) { + Modifier.alpha(0.75f) } else { Modifier }, From b2d5d742c2f0cc2f15cfa98ca1ec2287544510ed Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 15:02:26 +0200 Subject: [PATCH 063/126] refactor: extract colors to Color.kt file --- .../com/codenames/frontend/ui/screens/GameboardScreen.kt | 6 ++++-- app/src/main/java/com/codenames/frontend/ui/theme/Color.kt | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) 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 f2929f9..8154f49 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 @@ -64,12 +64,14 @@ import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppBlack import com.codenames.frontend.ui.theme.AppBlue +import com.codenames.frontend.ui.theme.AppBlueTeamBackground import com.codenames.frontend.ui.theme.AppGreen import com.codenames.frontend.ui.theme.AppInk import com.codenames.frontend.ui.theme.AppInkOverlay import com.codenames.frontend.ui.theme.AppLightGray import com.codenames.frontend.ui.theme.AppMutedDark import com.codenames.frontend.ui.theme.AppRed +import com.codenames.frontend.ui.theme.AppRedTeamBackground import com.codenames.frontend.ui.theme.AppSurface import com.codenames.frontend.ui.theme.AppSurfaceOverlay import com.codenames.frontend.ui.theme.AppWhite @@ -1011,7 +1013,7 @@ fun getColor(type: CardType): Color = fun getTeamBackgroundColor(currentTurn: PlayerRoles): Color = when (currentTurn) { - PlayerRoles.BLUE_OPERATIVE, PlayerRoles.BLUE_SPYMASTER -> Color(0xFFD6E8F5) - PlayerRoles.RED_OPERATIVE, PlayerRoles.RED_SPYMASTER -> Color(0xFFF39696) + PlayerRoles.BLUE_OPERATIVE, PlayerRoles.BLUE_SPYMASTER -> AppBlueTeamBackground + PlayerRoles.RED_OPERATIVE, PlayerRoles.RED_SPYMASTER -> AppRedTeamBackground PlayerRoles.NONE -> AppBackground } diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt index 9c32983..96fa717 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt @@ -4,6 +4,10 @@ import androidx.compose.ui.graphics.Color val AppBackground = Color(0xFFF0D8CE) +val AppRedTeamBackground = Color(0xFFF1AAAA) + +val AppBlueTeamBackground = Color(0xFFA9D7F6) + val AppInk = Color(0xFF383330) val AppInkDark = Color(0xFF1A1513) val AppMutedDark = Color(0xFF555555) @@ -26,4 +30,4 @@ val AppBlack = Color.Black val AppLightGray = Color.LightGray val MaterialLightSurface = Color(0xFFFFFBFE) -val MaterialDarkText = Color(0xFF1C1B1F) +val MaterialDarkText = Color(0xFF1C1B1F) \ No newline at end of file From cf38bf58502697ccf3dda02a5017595b497e0422 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 15:02:53 +0200 Subject: [PATCH 064/126] feat: change color of guessed neutral cards to grey --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 1 + 1 file changed, 1 insertion(+) 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 8154f49..e4aeaae 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 @@ -962,6 +962,7 @@ fun CodenamesCard( val backgroundColor = when { + card.revealed && card.type == CardType.NEUTRAL -> AppLightGray card.revealed -> getColor(card.type) isSpymaster -> getColor(card.type) else -> AppSurface From ded6bd9d555f16e06816f2264fda439122b31a8d Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 15:05:25 +0200 Subject: [PATCH 065/126] style: run ktlint format --- app/src/main/java/com/codenames/frontend/ui/theme/Color.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt index 96fa717..998fb72 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/Color.kt @@ -30,4 +30,4 @@ val AppBlack = Color.Black val AppLightGray = Color.LightGray val MaterialLightSurface = Color(0xFFFFFBFE) -val MaterialDarkText = Color(0xFF1C1B1F) \ No newline at end of file +val MaterialDarkText = Color(0xFF1C1B1F) From 07c30b892302e92bf58acbf5bc49337581551238 Mon Sep 17 00:00:00 2001 From: XtophB Date: Sat, 13 Jun 2026 15:34:12 +0200 Subject: [PATCH 066/126] refaactor: increase font size of cards --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 2 +- .../com/codenames/frontend/ui/theme/ResponsiveDimensions.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 e4aeaae..fb937d9 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 @@ -998,7 +998,7 @@ fun CodenamesCard( AppButtonStyle( containerColor = backgroundColor, contentColor = contentColor, - fontSize = dimensions.smallFontSize, + fontSize = dimensions.cardFontSize, shape = cardShape, ), ) diff --git a/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt index 7e7e41b..f0e87b6 100644 --- a/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt +++ b/app/src/main/java/com/codenames/frontend/ui/theme/ResponsiveDimensions.kt @@ -37,6 +37,7 @@ data class ResponsiveDimensions( val titleFontSize: TextUnit, val bodyFontSize: TextUnit, val smallFontSize: TextUnit, + val cardFontSize: TextUnit, val gameBoardTopSpacing: Dp, ) @@ -84,6 +85,7 @@ fun responsiveDimensionsFor( titleFontSize = compactSp(isCompactHeight, compact = 28, regular = 36), bodyFontSize = compactSp(isCompactHeight, compact = 16, regular = 20), smallFontSize = compactSp(isCompactHeight, compact = 10, regular = 12), + cardFontSize = compactSp(isCompactHeight, compact = 16, regular = 18), gameBoardTopSpacing = compactDp(isCompactHeight, compact = 2.dp, regular = 4.dp), ) } From 7b1d70e208e719859ab3a4fb30f43d5148efd6c3 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 19:22:32 +0200 Subject: [PATCH 067/126] add rejoin functionality: - add ui for rejoin decision to username screen - add polling after rejoin - add connection to websocket + send rejoin message after connection - add data handling for saved objects --- .../codenames/frontend/data/model/Mapper.kt | 10 ++ .../frontend/data/model/RejoinState.kt | 1 - .../frontend/data/model/SessionState.kt | 3 +- .../data/repository/GameRepository.kt | 15 +++ .../frontend/ui/navigation/NavGraph.kt | 2 +- .../frontend/ui/screens/GameScreenWrapper.kt | 33 ++++++- .../frontend/ui/screens/UserNameScreen.kt | 95 ++++++++++++++++++- .../frontend/viewmodel/GameViewModel.kt | 21 +++- .../frontend/viewmodel/LobbyViewModel.kt | 4 + .../frontend/viewmodel/SessionViewModel.kt | 16 ++++ 10 files changed, 193 insertions(+), 7 deletions(-) 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 720cd72..f93ea10 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 @@ -55,3 +55,13 @@ fun GameMessage.getCurrentTurn(): PlayerRoles { if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER return PlayerRoles.BLUE_OPERATIVE } + +fun toPlayerRole(team: Team?, role: Role?): PlayerRoles { + if(team == null || role == null) return PlayerRoles.NONE + if (team == Team.RED) { + if (role == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER + return PlayerRoles.RED_OPERATIVE + } + if (role == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER + return PlayerRoles.BLUE_OPERATIVE +} diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt index 5935a2a..c2d1e3d 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt @@ -3,5 +3,4 @@ package com.codenames.frontend.data.model sealed class RejoinState { data object None: RejoinState() data class Available(val sessionState: SessionState): RejoinState() - data object Consumed : RejoinState() } 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 index de86d90..e996dc8 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -6,5 +6,6 @@ import com.codenames.frontend.data.model.enums.Team data class SessionState( val lobbyCode: String? = null, val lobbyRole: Role? = null, - val lobbyTeam: Team? = null + val lobbyTeam: Team? = null, + val consumed: Boolean = false ) 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 index 7ee3c50..6652bcd 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -1,10 +1,14 @@ package com.codenames.frontend.data.repository +import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.StartGameMessage +import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketController +import java.util.UUID import javax.inject.Inject class GameRepository @@ -46,4 +50,15 @@ class GameRepository ) webSocketHandler.sendGuess(msg) } + + suspend fun sendRejoin( + username: String, + userId: UUID, + lobbyCode: String, + lobbyRole: Role, + lobbyTeam: Team + ) { + val msg = WebSocketJoinMessage(username, lobbyCode) + webSocketHandler.sendReconnectMessage(msg) + } } 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 4a08737..0ac54c5 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 @@ -45,7 +45,7 @@ fun NavGraph( startDestination = Screen.Username.route, ) { composable(Screen.Username.route) { - UserNameScreen(navController, sessionViewModel) + UserNameScreen(navController, sessionViewModel, gameViewModel, lobbyViewModel) } composable(Screen.Start.route) { 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 index 0703316..0c6e44b 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -2,10 +2,14 @@ package com.codenames.frontend.ui.screens import android.util.Log import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController +import com.codenames.frontend.data.model.RejoinState +import com.codenames.frontend.data.model.toPlayerRole import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.ChatViewModel import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel @@ -24,13 +28,38 @@ fun GameScreenWrapper( val gameState by gameViewModel.uiState.collectAsState() val chatState by chatViewModel.chatState.collectAsState() val userState by sessionViewModel.userState.collectAsState() + val rejoinState by sessionViewModel.rejoinSessionState.collectAsState() + + val availableRejoinState = rejoinState as? RejoinState.Available val username = userState.username - val lobbyCode = lobbyState.lobbyCode.orEmpty() + var lobbyCode = lobbyState.lobbyCode.orEmpty() val currentPlayer = lobbyState.players.firstOrNull { it.name == username } val team = currentPlayer?.team - val userRole = lobbyViewModel.getRoleForUser(username) + var userRole = lobbyViewModel.getRoleForUser(username) val availableChatTabs = lobbyViewModel.getAvailableChatTabsForUser(username) + val winner = gameState.winner + + Log.d("GameScreenWrapper", "Available Rejoin state: $availableRejoinState") + + if(userRole == PlayerRoles.NONE && availableRejoinState != null) { + val rejoinedTeam = availableRejoinState.sessionState.lobbyTeam + val rejoinedRole = availableRejoinState.sessionState.lobbyRole + + userRole = toPlayerRole(rejoinedTeam, rejoinedRole) + Log.d("GameScreenWrapper", "User role aus Speicher genommen: $userRole") + } + + if(lobbyCode.isEmpty() && availableRejoinState?.sessionState?.lobbyCode != null) { + + lobbyCode = availableRejoinState.sessionState.lobbyCode + } + + LaunchedEffect(winner) { + if(winner != null) { + sessionViewModel.clearLobby() + } + } GameboardScreen( userRole = userRole, 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 index 8f4c6d0..bdd0214 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -10,37 +10,96 @@ 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.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextField 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.Modifier.Companion.any import androidx.compose.ui.platform.testTag import androidx.navigation.NavController +import com.codenames.frontend.data.model.RejoinState +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.NavGraph import com.codenames.frontend.ui.navigation.Screen +import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient +import com.codenames.frontend.viewmodel.GameViewModel +import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel +import java.util.UUID @Suppress("ktlint:standard:function-naming") @Composable fun UserNameScreen( navController: NavController, viewModel: SessionViewModel, + gameViewModel: GameViewModel, + lobbyViewModel: LobbyViewModel ) { val userState by viewModel.userState.collectAsState() + val rejoinState by viewModel.rejoinSessionState.collectAsState() + val connectionState by gameViewModel.connectionState.collectAsState() + val gameState by gameViewModel.uiState.collectAsState() val dimensions = LocalResponsiveDimensions.current - var username by remember { mutableStateOf(userState.username) } + var username by rememberSaveable { mutableStateOf("") } + + val availableRejoinState = rejoinState as? RejoinState.Available + + LaunchedEffect(connectionState) { + val username = userState.username + val userId = userState.userId ?: return@LaunchedEffect + val lobbyCode = availableRejoinState?.sessionState?.lobbyCode ?: return@LaunchedEffect + + if(connectionState !is ConnectionState.CONNECTED) return@LaunchedEffect + + gameViewModel.rejoinGame(username, userId, availableRejoinState) + lobbyViewModel.startUpdateAfterRejoin(lobbyCode) + } + + LaunchedEffect(userState.username) { + if (username.isBlank()) { + username = userState.username + } + } + + LaunchedEffect(availableRejoinState) { + availableRejoinState?: return@LaunchedEffect + if(availableRejoinState.sessionState.consumed) { + navController.navigate(Screen.Gameboard.route) + } + } + + LaunchedEffect(gameState) { + if(gameState.currentTurn != PlayerRoles.NONE) { + viewModel.setRejoinStateConsumed() + } + } + + fun onRejoinGame() { + val lobbyCode = availableRejoinState?.sessionState?.lobbyCode ?: return + gameViewModel.connect(lobbyCode, false) + } + + fun onDiscardGame() { + viewModel.clearLobby() + } + + val showRejoinDialog = rejoinState is RejoinState.Available Box( modifier = @@ -76,6 +135,7 @@ fun UserNameScreen( onClick = { if (username.isBlank()) return@AppButton viewModel.setUsername(username) + viewModel.setUserId(UUID.randomUUID()) //ONLY FOR TESTING!!!! navController.navigate(Screen.Start.route) }, modifier = @@ -97,4 +157,37 @@ fun UserNameScreen( onClick = { navController.navigate(Screen.Settings.route) }, ) } + + if(showRejoinDialog) { + RejoinDialog(onRejoinGame = ::onRejoinGame, onDiscardGame = ::onDiscardGame) + } } + +@Suppress("ktlint:standard:function-naming") +@Composable +fun RejoinDialog( + onRejoinGame : () -> Unit, + onDiscardGame: () -> Unit +) { + AlertDialog( + onDismissRequest = {}, + title = { + Text("Spiel fortsetzen?") + }, + text = { + Text("Es wurde ein gespeichertes Spiel gefunden. Möchtest du wieder beitreten?") + }, + confirmButton = { + AppButton( + text = "Beitreten", + onClick = { onRejoinGame() } + ) + }, + dismissButton = { + AppButton( + text = "Verwerfen", + onClick = { onDiscardGame() } + ) + } + ) +} \ No newline at end of file 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 55c5f6a..328b843 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.RejoinState import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.model.toGameState @@ -18,7 +19,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds private const val CONNECTION_ERROR_MESSAGE = "Connection error" @@ -62,7 +65,7 @@ class GameViewModel Log.d("GameViewModel", "Subscribed to Lobby") if (isHost) { - delay(2000) + delay(3000.milliseconds) sendGameStart(lobbyCode) } } catch (e: Exception) { @@ -87,6 +90,7 @@ class GameViewModel team: Team?, ) { if (lobbyCode.isBlank() || team == null) { + Log.e("GameViewModel","Could not send request, Team: $team, lobbyCode: $lobbyCode") return } @@ -107,6 +111,7 @@ class GameViewModel team: Team?, ) { if (lobbyCode.isBlank() || team == null) { + Log.e("GameViewModel","Could not send request, Team: $team, lobbyCode: $lobbyCode") return } @@ -143,6 +148,20 @@ class GameViewModel } } + fun rejoinGame(username: String, userId: UUID, rejoinState: RejoinState.Available?) { + if(rejoinState == null) return + + val lobbyCode = rejoinState.sessionState.lobbyCode + val team = rejoinState.sessionState.lobbyTeam + val role = rejoinState.sessionState.lobbyRole + + if(lobbyCode != null && team != null && role != null) { + viewModelScope.launch { + gameRepository.sendRejoin(username, userId, lobbyCode, role, team) + } + } + } + private fun Team.isActiveOperativeTurn(turn: PlayerRoles): Boolean = (this == Team.BLUE && turn == PlayerRoles.BLUE_OPERATIVE) || (this == Team.RED && turn == PlayerRoles.RED_OPERATIVE) 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 c99379b..17d0c01 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -232,6 +232,10 @@ class LobbyViewModel } } + fun startUpdateAfterRejoin(lobbyCode: String) { + startPolling(lobbyCode) + } + private fun cleanup() { _state.update { it.copy( diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index c4d745a..cb9f406 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -3,6 +3,7 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.RejoinState +import com.codenames.frontend.data.model.SessionState import com.codenames.frontend.data.model.UserState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team @@ -69,6 +70,21 @@ class SessionViewModel } } + fun setRejoinStateConsumed() { + _rejoinSessionState.update { currentState -> + when (currentState) { + is RejoinState.Available -> { + currentState.copy( + sessionState = currentState.sessionState.copy( + consumed = true + ) + ) + } + RejoinState.None -> currentState + } + } + } + private fun observeSession() { viewModelScope.launch { sessionRepository.sessionFlow.collect { session -> From 4953450b79d11f169d2d463f1b2ffc194073062a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 20:18:56 +0200 Subject: [PATCH 068/126] removed unnecessary checks from game wrapper screen --- .../frontend/ui/screens/GameScreenWrapper.kt | 22 ------------------- .../frontend/viewmodel/SessionViewModel.kt | 1 - 2 files changed, 23 deletions(-) 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 index 0c6e44b..8ab1d54 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -1,15 +1,11 @@ package com.codenames.frontend.ui.screens -import android.util.Log import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.navigation.NavHostController -import com.codenames.frontend.data.model.RejoinState -import com.codenames.frontend.data.model.toPlayerRole import com.codenames.frontend.ui.navigation.Screen -import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.viewmodel.ChatViewModel import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel @@ -28,9 +24,6 @@ fun GameScreenWrapper( val gameState by gameViewModel.uiState.collectAsState() val chatState by chatViewModel.chatState.collectAsState() val userState by sessionViewModel.userState.collectAsState() - val rejoinState by sessionViewModel.rejoinSessionState.collectAsState() - - val availableRejoinState = rejoinState as? RejoinState.Available val username = userState.username var lobbyCode = lobbyState.lobbyCode.orEmpty() @@ -40,21 +33,6 @@ fun GameScreenWrapper( val availableChatTabs = lobbyViewModel.getAvailableChatTabsForUser(username) val winner = gameState.winner - Log.d("GameScreenWrapper", "Available Rejoin state: $availableRejoinState") - - if(userRole == PlayerRoles.NONE && availableRejoinState != null) { - val rejoinedTeam = availableRejoinState.sessionState.lobbyTeam - val rejoinedRole = availableRejoinState.sessionState.lobbyRole - - userRole = toPlayerRole(rejoinedTeam, rejoinedRole) - Log.d("GameScreenWrapper", "User role aus Speicher genommen: $userRole") - } - - if(lobbyCode.isEmpty() && availableRejoinState?.sessionState?.lobbyCode != null) { - - lobbyCode = availableRejoinState.sessionState.lobbyCode - } - LaunchedEffect(winner) { if(winner != null) { sessionViewModel.clearLobby() diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index cb9f406..4cb9e09 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -3,7 +3,6 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.codenames.frontend.data.model.RejoinState -import com.codenames.frontend.data.model.SessionState import com.codenames.frontend.data.model.UserState import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team From 5d1d168443c659458557f6ae19de50514a8b31fa Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 20:39:11 +0200 Subject: [PATCH 069/126] test: add session view model tests --- .../viewmodel/SessionViewModelTest.kt | 217 +++++++++++++++++- 1 file changed, 211 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt index 1bad049..034035e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -1,33 +1,238 @@ package com.codenames.frontend.viewmodel +import com.codenames.frontend.data.model.RejoinState import com.codenames.frontend.data.model.SessionState +import com.codenames.frontend.data.model.UserState +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.data.repository.SessionRepository +import io.mockk.coVerify +import io.mockk.every import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertTrue +import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class SessionViewModelTest { private lateinit var viewModel: SessionViewModel + private lateinit var sessionRepository: SessionRepository + + private val dispatcher = StandardTestDispatcher() + + private lateinit var userFlow: MutableStateFlow + private lateinit var sessionFlow: MutableStateFlow @Before fun setup() { - viewModel = SessionViewModel() + Dispatchers.setMain(dispatcher) + + userFlow = MutableStateFlow(UserState("", null)) + sessionFlow = + MutableStateFlow( + SessionState( + lobbyCode = null, + lobbyRole = null, + lobbyTeam = null, + consumed = false + ) + ) + + sessionRepository = mockk(relaxed = true) + + every { sessionRepository.userFlow } returns userFlow + every { sessionRepository.sessionFlow } returns sessionFlow + + viewModel = SessionViewModel(sessionRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() } @Test fun `initial username is empty`() { - val result = viewModel.sessionState.value - - assertEquals(SessionState(""), result) + assertEquals("", viewModel.userState.value.username) } @Test fun `setUsername updates username state`() { viewModel.setUsername("Max") - val result = viewModel.sessionState.value + assertEquals("Max", viewModel.userState.value.username) + } + + @Test + fun `setUserId updates user id`() { + val uuid = UUID.randomUUID() + + viewModel.setUserId(uuid) + + assertEquals(uuid, viewModel.userState.value.userId) + } + + @Test + fun `persistUserState saves user`() = runTest { + val uuid = UUID.randomUUID() + + viewModel.setUsername("Max") + viewModel.setUserId(uuid) + + viewModel.persistUserState() + + advanceUntilIdle() + + coVerify { + sessionRepository.saveUser("Max", uuid) + } + } + + @Test + fun `persistUserState does nothing when userId is null`() = runTest { + viewModel.setUsername("Max") + + viewModel.persistUserState() + + advanceUntilIdle() + + coVerify(exactly = 0) { + sessionRepository.saveUser(any(), any()) + } + } + + @Test + fun `persistLobbyState saves lobby`() = runTest { + viewModel.persistLobbyState( + lobbyCode = "ABCD", + role = Role.OPERATIVE, + team = Team.RED + ) + + advanceUntilIdle() + + coVerify { + sessionRepository.saveLobby( + "ABCD", + Role.OPERATIVE, + Team.RED + ) + } + } + + @Test + fun `clearLobby clears repository lobby data`() = runTest { + viewModel.clearLobby() + + advanceUntilIdle() + + coVerify { + sessionRepository.clearLobbyData() + } + } + + @Test + fun `observeUser updates state when username is not blank`() = runTest { + val uuid = UUID.randomUUID() + + userFlow.value = + UserState( + username = "Anna", + userId = uuid + ) + + advanceUntilIdle() + + assertEquals("Anna", viewModel.userState.value.username) + assertEquals(uuid, viewModel.userState.value.userId) + } + + @Test + fun `observeUser ignores blank username`() = runTest { + userFlow.value = + UserState( + username = "", + userId = UUID.randomUUID() + ) + + advanceUntilIdle() + + assertEquals("", viewModel.userState.value.username) + } + + @Test + fun `observeSession creates Available state when lobby data exists`() = runTest { + val session = + SessionState( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false + ) + + sessionFlow.value = session + + advanceUntilIdle() + + assertTrue(viewModel.rejoinSessionState.value is RejoinState.Available) + } + + @Test + fun `observeSession creates None state when lobby data incomplete`() = runTest { + sessionFlow.value = + SessionState( + lobbyCode = null, + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false + ) + + advanceUntilIdle() + + assertEquals( + RejoinState.None, + viewModel.rejoinSessionState.value + ) + } + + @Test + fun `setRejoinStateConsumed sets consumed to true`() = runTest { + sessionFlow.value = + SessionState( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false + ) + + advanceUntilIdle() + + viewModel.setRejoinStateConsumed() + + val result = + viewModel.rejoinSessionState.value as RejoinState.Available + + assertTrue(result.sessionState.consumed) + } + + @Test + fun `setRejoinStateConsumed does nothing for None state`() = runTest { + viewModel.setRejoinStateConsumed() - assertEquals(SessionState("Max"), result) + assertEquals( + RejoinState.None, + viewModel.rejoinSessionState.value + ) } } From ed42ce070595af4a6be106be532b1f08d933cb12 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 20:59:33 +0200 Subject: [PATCH 070/126] test: add GameViewModel Tests --- .../frontend/viewmodel/GameViewModel.kt | 2 - .../frontend/viewmodel/GameViewModelTest.kt | 57 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) 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 328b843..63eaaff 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -90,7 +90,6 @@ class GameViewModel team: Team?, ) { if (lobbyCode.isBlank() || team == null) { - Log.e("GameViewModel","Could not send request, Team: $team, lobbyCode: $lobbyCode") return } @@ -111,7 +110,6 @@ class GameViewModel team: Team?, ) { if (lobbyCode.isBlank() || team == null) { - Log.e("GameViewModel","Could not send request, Team: $team, lobbyCode: $lobbyCode") return } 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 ef864ce..da61007 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -2,6 +2,8 @@ package com.codenames.frontend.viewmodel import android.util.Log import com.codenames.frontend.data.model.GameState +import com.codenames.frontend.data.model.RejoinState +import com.codenames.frontend.data.model.SessionState import com.codenames.frontend.data.model.enums.CardType import com.codenames.frontend.data.model.enums.ConnectionState import com.codenames.frontend.data.model.enums.Role @@ -33,6 +35,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class GameViewModelTest { @@ -475,4 +478,58 @@ class GameViewModelTest { val state = viewModel.connectionState.value assertTrue(state is ConnectionState.Error) } + + @Test + fun testSendRejoinMessage_sendsMessage() = + runTest { + coEvery { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } just Runs + + val rejoinState = RejoinState.Available(SessionState(lobbyCode, Role.OPERATIVE, Team.RED)) + + viewModel.rejoinGame("User", UUID.randomUUID(), rejoinState) + + advanceUntilIdle() + + coVerify { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } + } + + @Test + fun testSendRejoinMessage_ignoresWithoutRejoinState() = + runTest { + coEvery { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } just Runs + + val rejoinState = null + + viewModel.rejoinGame("User", UUID.randomUUID(), rejoinState ) + + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } + } + + @Test + fun testSendRejoinMessage_ignoresNullValuesInRejoinState() = + runTest { + coEvery { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } just Runs + + val rejoinState = RejoinState.Available(SessionState(null, null, null)) + + viewModel.rejoinGame("User", UUID.randomUUID(), rejoinState) + + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.sendRejoin(any(), any(), any(), any(), any()) + } + } } From 21b5cc29da0af5b75284e18068c919cd9df5ad3a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 20:59:43 +0200 Subject: [PATCH 071/126] test: add SessionRepositoryTest.kt --- .../data/repository/SessionRepositoryTest.kt | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt diff --git a/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt new file mode 100644 index 0000000..35ada62 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt @@ -0,0 +1,65 @@ +package com.codenames.frontend.data.repository + +import com.codenames.frontend.data.datastore.UserPreferencesDataStore +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionRepositoryTest { + + private lateinit var repository: SessionRepository + private lateinit var dataStore: UserPreferencesDataStore + + @Before + fun setup() { + dataStore = mockk(relaxed = true) + repository = SessionRepository(dataStore) + } + + @Test + fun `saveLobby delegates to datastore`() = runTest { + repository.saveLobby( + "ABCD", + Role.OPERATIVE, + Team.RED + ) + + coVerify { + dataStore.saveLobbyData( + "ABCD", + Role.OPERATIVE, + Team.RED + ) + } + } + + @Test + fun `saveUser delegates to datastore`() = runTest { + val uuid = UUID.randomUUID() + + repository.saveUser("Anna", uuid) + + coVerify { + dataStore.saveUserData( + "Anna", + uuid + ) + } + } + + @Test + fun `clearLobbyData delegates to datastore`() = runTest { + repository.clearLobbyData() + + coVerify { + dataStore.clearSessionData() + } + } +} \ No newline at end of file From a736ee28409a47759854af4a032f8f03ddcc59ed Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 21:03:48 +0200 Subject: [PATCH 072/126] test: add datastore tests --- ...kotlin-compiler-5243716582658115778.salive | 0 .../frontend/UserPreferencesDataStoreTest.kt | 87 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 .kotlin/sessions/kotlin-compiler-5243716582658115778.salive create mode 100644 app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt diff --git a/.kotlin/sessions/kotlin-compiler-5243716582658115778.salive b/.kotlin/sessions/kotlin-compiler-5243716582658115778.salive new file mode 100644 index 0000000..e69de29 diff --git a/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt new file mode 100644 index 0000000..a67eae0 --- /dev/null +++ b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt @@ -0,0 +1,87 @@ +package com.codenames.frontend + +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.codenames.frontend.data.datastore.UserPreferencesDataStore +import com.codenames.frontend.data.model.enums.Role +import com.codenames.frontend.data.model.enums.Team +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class UserPreferencesDataStoreTest { + + private lateinit var dataStore: UserPreferencesDataStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + + dataStore = UserPreferencesDataStore(context) + + runBlocking { + dataStore.clearSessionData() + dataStore.saveUserData("", null) + } + } + + @After + fun cleanup() { + runBlocking { + dataStore.clearSessionData() + } + } + + @Test + fun saveUserData_persistsUserData() = runBlocking { + val uuid = UUID.randomUUID() + + dataStore.saveUserData( + username = "Anna", + userId = uuid + ) + + val result = dataStore.userData.first() + + assertEquals("Anna", result.username) + assertEquals(uuid, result.userId) + } + + @Test + fun saveLobbyData_persistsLobbyData() = runBlocking { + dataStore.saveLobbyData( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED + ) + + val result = dataStore.sessionData.first() + + assertEquals("ABCD", result.lobbyCode) + assertEquals(Role.OPERATIVE, result.lobbyRole) + assertEquals(Team.RED, result.lobbyTeam) + } + + @Test + fun clearSessionData_removesLobbyInformation() = runBlocking { + dataStore.saveLobbyData( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED + ) + + dataStore.clearSessionData() + + val result = dataStore.sessionData.first() + + assertNull(result.lobbyCode) + assertNull(result.lobbyRole) + assertNull(result.lobbyTeam) + } +} \ No newline at end of file From 3bcb2a55345281240383b574b94006cb6e92123e Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 21:05:11 +0200 Subject: [PATCH 073/126] refactor: remove unused method --- .../kotlin-compiler-5243716582658115778.salive | 0 .../java/com/codenames/frontend/data/model/Mapper.kt | 10 ---------- 2 files changed, 10 deletions(-) delete mode 100644 .kotlin/sessions/kotlin-compiler-5243716582658115778.salive diff --git a/.kotlin/sessions/kotlin-compiler-5243716582658115778.salive b/.kotlin/sessions/kotlin-compiler-5243716582658115778.salive deleted file mode 100644 index e69de29..0000000 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 f93ea10..720cd72 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 @@ -55,13 +55,3 @@ fun GameMessage.getCurrentTurn(): PlayerRoles { if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER return PlayerRoles.BLUE_OPERATIVE } - -fun toPlayerRole(team: Team?, role: Role?): PlayerRoles { - if(team == null || role == null) return PlayerRoles.NONE - if (team == Team.RED) { - if (role == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER - return PlayerRoles.RED_OPERATIVE - } - if (role == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER - return PlayerRoles.BLUE_OPERATIVE -} From 2ad24b74e6c74d4d685df5d912c5dfa18edc84dd Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 21:33:55 +0200 Subject: [PATCH 074/126] refactor: refactored method to fit sonar qube quality gates --- .idea/androidTestResultsUserPreferences.xml | 26 +++++ .../frontend/data/model/RejoinUiState.kt | 13 +++ .../data/repository/GameRepository.kt | 1 - .../frontend/ui/screens/GameScreenWrapper.kt | 4 +- .../frontend/ui/screens/UserNameScreen.kt | 107 +++++++++++------- 5 files changed, 109 insertions(+), 42 deletions(-) create mode 100644 .idea/androidTestResultsUserPreferences.xml create mode 100644 app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml new file mode 100644 index 0000000..ea646df --- /dev/null +++ b/.idea/androidTestResultsUserPreferences.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt new file mode 100644 index 0000000..1d1988a --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt @@ -0,0 +1,13 @@ +package com.codenames.frontend.data.model + +import com.codenames.frontend.data.model.enums.ConnectionState +import java.util.UUID + +data class RejoinUiState( + val availableRejoinState: RejoinState.Available, + val connectionState: ConnectionState, + val gameState: GameState, + val username: String, + val userId: UUID?, + val lobbyCode: String +) 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 index 6652bcd..fb9b218 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -4,7 +4,6 @@ import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GuessMessage -import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketController 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 index 8ab1d54..cec0cbd 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -26,10 +26,10 @@ fun GameScreenWrapper( val userState by sessionViewModel.userState.collectAsState() val username = userState.username - var lobbyCode = lobbyState.lobbyCode.orEmpty() + val lobbyCode = lobbyState.lobbyCode.orEmpty() val currentPlayer = lobbyState.players.firstOrNull { it.name == username } val team = currentPlayer?.team - var userRole = lobbyViewModel.getRoleForUser(username) + val userRole = lobbyViewModel.getRoleForUser(username) val availableChatTabs = lobbyViewModel.getAvailableChatTabsForUser(username) val winner = gameState.winner 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 index bdd0214..5e7990d 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -18,20 +18,18 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.Modifier.Companion.any import androidx.compose.ui.platform.testTag import androidx.navigation.NavController import com.codenames.frontend.data.model.RejoinState +import com.codenames.frontend.data.model.RejoinUiState 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.NavGraph import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.theme.AppBackground @@ -59,16 +57,24 @@ fun UserNameScreen( var username by rememberSaveable { mutableStateOf("") } val availableRejoinState = rejoinState as? RejoinState.Available - - LaunchedEffect(connectionState) { - val username = userState.username - val userId = userState.userId ?: return@LaunchedEffect - val lobbyCode = availableRejoinState?.sessionState?.lobbyCode ?: return@LaunchedEffect - - if(connectionState !is ConnectionState.CONNECTED) return@LaunchedEffect - - gameViewModel.rejoinGame(username, userId, availableRejoinState) - lobbyViewModel.startUpdateAfterRejoin(lobbyCode) + val lobbyCode = availableRejoinState?.sessionState?.lobbyCode + val userId = userState.userId + + if(availableRejoinState != null && lobbyCode != null) { + HandleRejoinEffects( + navController, + viewModel, + gameViewModel, + lobbyViewModel, + RejoinUiState( + availableRejoinState = availableRejoinState, + connectionState = connectionState, + gameState = gameState, + userId = userId, + lobbyCode = lobbyCode, + username = username + ) + ) } LaunchedEffect(userState.username) { @@ -77,30 +83,6 @@ fun UserNameScreen( } } - LaunchedEffect(availableRejoinState) { - availableRejoinState?: return@LaunchedEffect - if(availableRejoinState.sessionState.consumed) { - navController.navigate(Screen.Gameboard.route) - } - } - - LaunchedEffect(gameState) { - if(gameState.currentTurn != PlayerRoles.NONE) { - viewModel.setRejoinStateConsumed() - } - } - - fun onRejoinGame() { - val lobbyCode = availableRejoinState?.sessionState?.lobbyCode ?: return - gameViewModel.connect(lobbyCode, false) - } - - fun onDiscardGame() { - viewModel.clearLobby() - } - - val showRejoinDialog = rejoinState is RejoinState.Available - Box( modifier = Modifier @@ -158,8 +140,15 @@ fun UserNameScreen( ) } - if(showRejoinDialog) { - RejoinDialog(onRejoinGame = ::onRejoinGame, onDiscardGame = ::onDiscardGame) + if(availableRejoinState != null) { + RejoinDialog(onRejoinGame = { + lobbyCode?.let { + gameViewModel.connect(lobbyCode, false) + } + }, + onDiscardGame = { + viewModel.clearLobby() + }) } } @@ -190,4 +179,44 @@ fun RejoinDialog( ) } ) +} + +@Composable +fun HandleRejoinEffects( + navController: NavController, + viewModel: SessionViewModel, + gameViewModel: GameViewModel, + lobbyViewModel: LobbyViewModel, + rejoinUiState: RejoinUiState +) { + val connectionState = rejoinUiState.connectionState + val userId = rejoinUiState.userId + val lobbyCode = rejoinUiState.lobbyCode + val username = rejoinUiState.username + val availableRejoinState = rejoinUiState.availableRejoinState + val gameState = rejoinUiState.gameState + + + val canRejoin = + connectionState is ConnectionState.CONNECTED && + userId != null + + LaunchedEffect(canRejoin) { + if(!canRejoin) return@LaunchedEffect + + gameViewModel.rejoinGame(username, userId, availableRejoinState) + lobbyViewModel.startUpdateAfterRejoin(lobbyCode) + } + + LaunchedEffect(availableRejoinState) { + if(availableRejoinState.sessionState.consumed) { + navController.navigate(Screen.Gameboard.route) + } + } + + LaunchedEffect(gameState) { + if(gameState.currentTurn != PlayerRoles.NONE) { + viewModel.setRejoinStateConsumed() + } + } } \ No newline at end of file From 1c28ae28e7a4fd6d372de227a6779e4279cc77fb Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 21:37:43 +0200 Subject: [PATCH 075/126] style: reformat code --- .../frontend/UserPreferencesDataStoreTest.kt | 79 +++--- .../frontend/data/datastore/DataStore.kt | 4 +- .../datastore/UserPreferencesDataStore.kt | 108 ++++---- .../frontend/data/model/RejoinState.kt | 7 +- .../frontend/data/model/RejoinUiState.kt | 2 +- .../frontend/data/model/SessionState.kt | 2 +- .../data/repository/GameRepository.kt | 2 +- .../data/repository/SessionRepository.kt | 37 +-- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../frontend/ui/screens/LobbyScreen.kt | 4 +- .../frontend/ui/screens/UserNameScreen.kt | 44 ++-- .../frontend/viewmodel/GameViewModel.kt | 10 +- .../frontend/viewmodel/SessionViewModel.kt | 31 ++- .../data/repository/SessionRepositoryTest.kt | 56 +++-- .../frontend/viewmodel/GameViewModelTest.kt | 2 +- .../viewmodel/SessionViewModelTest.kt | 232 +++++++++--------- 16 files changed, 335 insertions(+), 287 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt index a67eae0..a6308b5 100644 --- a/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt @@ -8,7 +8,8 @@ import com.codenames.frontend.data.model.enums.Team import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -16,7 +17,6 @@ import java.util.UUID @RunWith(AndroidJUnit4::class) class UserPreferencesDataStoreTest { - private lateinit var dataStore: UserPreferencesDataStore @Before @@ -39,49 +39,52 @@ class UserPreferencesDataStoreTest { } @Test - fun saveUserData_persistsUserData() = runBlocking { - val uuid = UUID.randomUUID() + fun saveUserData_persistsUserData() = + runBlocking { + val uuid = UUID.randomUUID() - dataStore.saveUserData( - username = "Anna", - userId = uuid - ) + dataStore.saveUserData( + username = "Anna", + userId = uuid, + ) - val result = dataStore.userData.first() + val result = dataStore.userData.first() - assertEquals("Anna", result.username) - assertEquals(uuid, result.userId) - } + assertEquals("Anna", result.username) + assertEquals(uuid, result.userId) + } @Test - fun saveLobbyData_persistsLobbyData() = runBlocking { - dataStore.saveLobbyData( - lobbyCode = "ABCD", - lobbyRole = Role.OPERATIVE, - lobbyTeam = Team.RED - ) - - val result = dataStore.sessionData.first() - - assertEquals("ABCD", result.lobbyCode) - assertEquals(Role.OPERATIVE, result.lobbyRole) - assertEquals(Team.RED, result.lobbyTeam) - } + fun saveLobbyData_persistsLobbyData() = + runBlocking { + dataStore.saveLobbyData( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + ) + + val result = dataStore.sessionData.first() + + assertEquals("ABCD", result.lobbyCode) + assertEquals(Role.OPERATIVE, result.lobbyRole) + assertEquals(Team.RED, result.lobbyTeam) + } @Test - fun clearSessionData_removesLobbyInformation() = runBlocking { - dataStore.saveLobbyData( - lobbyCode = "ABCD", - lobbyRole = Role.OPERATIVE, - lobbyTeam = Team.RED - ) + fun clearSessionData_removesLobbyInformation() = + runBlocking { + dataStore.saveLobbyData( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + ) - dataStore.clearSessionData() + dataStore.clearSessionData() - val result = dataStore.sessionData.first() + val result = dataStore.sessionData.first() - assertNull(result.lobbyCode) - assertNull(result.lobbyRole) - assertNull(result.lobbyTeam) - } -} \ No newline at end of file + assertNull(result.lobbyCode) + assertNull(result.lobbyRole) + assertNull(result.lobbyTeam) + } +} diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt index 08ff8e5..7e271a1 100644 --- a/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt +++ b/app/src/main/java/com/codenames/frontend/data/datastore/DataStore.kt @@ -6,5 +6,5 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStore val Context.dataStore: DataStore by preferencesDataStore( - name = "user_preferences" -) \ No newline at end of file + name = "user_preferences", +) diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt index f4c211d..c2c8586 100644 --- a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt +++ b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt @@ -15,63 +15,69 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class UserPreferencesDataStore @Inject constructor( - @param:ApplicationContext private val context: Context -){ - private object PreferencesKeys { - val USERNAME = stringPreferencesKey("username") - val USER_ID = stringPreferencesKey("player_id") - val LOBBY_CODE = stringPreferencesKey("lobby_code") - val LOBBY_ROLE = stringPreferencesKey("lobby_role") - val LOBBY_TEAM = stringPreferencesKey("lobby_team") - } - - suspend fun saveLobbyData(lobbyCode: String, lobbyRole: Role, lobbyTeam: Team ) { - context.dataStore.edit { prefs -> - prefs[PreferencesKeys.LOBBY_CODE] = lobbyCode - prefs[PreferencesKeys.LOBBY_ROLE] = lobbyRole.name - prefs[PreferencesKeys.LOBBY_TEAM] = lobbyTeam.name - } - } - - suspend fun saveUserData( - username: String, - userId: UUID? +class UserPreferencesDataStore + @Inject + constructor( + @param:ApplicationContext private val context: Context, ) { - context.dataStore.edit { prefs -> - prefs[PreferencesKeys.USERNAME] = username - prefs[PreferencesKeys.USER_ID] = userId.toString() + private object PreferencesKeys { + val USERNAME = stringPreferencesKey("username") + val USER_ID = stringPreferencesKey("player_id") + val LOBBY_CODE = stringPreferencesKey("lobby_code") + val LOBBY_ROLE = stringPreferencesKey("lobby_role") + val LOBBY_TEAM = stringPreferencesKey("lobby_team") } - } - val sessionData: Flow = - context.dataStore.data.map { prefs -> - SessionState( - lobbyCode = prefs[PreferencesKeys.LOBBY_CODE], - lobbyRole = prefs[PreferencesKeys.LOBBY_ROLE]?.let { - runCatching { Role.valueOf(it) }.getOrNull() - }, - lobbyTeam = prefs[PreferencesKeys.LOBBY_TEAM]?.let { - runCatching { Team.valueOf(it) }.getOrNull() - } - ) + suspend fun saveLobbyData( + lobbyCode: String, + lobbyRole: Role, + lobbyTeam: Team, + ) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.LOBBY_CODE] = lobbyCode + prefs[PreferencesKeys.LOBBY_ROLE] = lobbyRole.name + prefs[PreferencesKeys.LOBBY_TEAM] = lobbyTeam.name + } } - val userData: Flow = - context.dataStore.data.map { prefs -> - UserState( - username = prefs[PreferencesKeys.USERNAME] ?: "", - userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString) - ) + suspend fun saveUserData( + username: String, + userId: UUID?, + ) { + context.dataStore.edit { prefs -> + prefs[PreferencesKeys.USERNAME] = username + prefs[PreferencesKeys.USER_ID] = userId.toString() + } } - suspend fun clearSessionData() { - context.dataStore.edit { prefs -> - prefs.remove(PreferencesKeys.LOBBY_CODE) - prefs.remove(PreferencesKeys.LOBBY_ROLE) - prefs.remove(PreferencesKeys.LOBBY_TEAM) - } - } + val sessionData: Flow = + context.dataStore.data.map { prefs -> + SessionState( + lobbyCode = prefs[PreferencesKeys.LOBBY_CODE], + lobbyRole = + prefs[PreferencesKeys.LOBBY_ROLE]?.let { + runCatching { Role.valueOf(it) }.getOrNull() + }, + lobbyTeam = + prefs[PreferencesKeys.LOBBY_TEAM]?.let { + runCatching { Team.valueOf(it) }.getOrNull() + }, + ) + } -} + val userData: Flow = + context.dataStore.data.map { prefs -> + UserState( + username = prefs[PreferencesKeys.USERNAME] ?: "", + userId = prefs[PreferencesKeys.USER_ID]?.let(UUID::fromString), + ) + } + suspend fun clearSessionData() { + context.dataStore.edit { prefs -> + prefs.remove(PreferencesKeys.LOBBY_CODE) + prefs.remove(PreferencesKeys.LOBBY_ROLE) + prefs.remove(PreferencesKeys.LOBBY_TEAM) + } + } + } diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt index c2d1e3d..d09236c 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinState.kt @@ -1,6 +1,9 @@ package com.codenames.frontend.data.model sealed class RejoinState { - data object None: RejoinState() - data class Available(val sessionState: SessionState): RejoinState() + data object None : RejoinState() + + data class Available( + val sessionState: SessionState, + ) : RejoinState() } diff --git a/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt b/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt index 1d1988a..4f89bac 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/RejoinUiState.kt @@ -9,5 +9,5 @@ data class RejoinUiState( val gameState: GameState, val username: String, val userId: UUID?, - val lobbyCode: String + val lobbyCode: String, ) 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 index e996dc8..6aad6a4 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/SessionState.kt @@ -7,5 +7,5 @@ data class SessionState( val lobbyCode: String? = null, val lobbyRole: Role? = null, val lobbyTeam: Team? = null, - val consumed: Boolean = false + val consumed: Boolean = false, ) 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 index fb9b218..6d3c5c3 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -55,7 +55,7 @@ class GameRepository userId: UUID, lobbyCode: String, lobbyRole: Role, - lobbyTeam: Team + lobbyTeam: Team, ) { val msg = WebSocketJoinMessage(username, lobbyCode) webSocketHandler.sendReconnectMessage(msg) diff --git a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt index cb1a760..a4d00ed 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt @@ -11,21 +11,30 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class SessionRepository @Inject constructor( - private val dataStore: UserPreferencesDataStore -){ - val sessionFlow: Flow = dataStore.sessionData - val userFlow: Flow = dataStore.userData +class SessionRepository + @Inject + constructor( + private val dataStore: UserPreferencesDataStore, + ) { + val sessionFlow: Flow = dataStore.sessionData + val userFlow: Flow = dataStore.userData - suspend fun saveLobby(lobbyCode: String, lobbyRole: Role, lobbyTeam: Team) { - dataStore.saveLobbyData(lobbyCode, lobbyRole, lobbyTeam) - } + suspend fun saveLobby( + lobbyCode: String, + lobbyRole: Role, + lobbyTeam: Team, + ) { + dataStore.saveLobbyData(lobbyCode, lobbyRole, lobbyTeam) + } - suspend fun saveUser(username: String, userId: UUID?) { - dataStore.saveUserData(username, userId) - } + suspend fun saveUser( + username: String, + userId: UUID?, + ) { + dataStore.saveUserData(username, userId) + } - suspend fun clearLobbyData() { - dataStore.clearSessionData() + suspend fun clearLobbyData() { + dataStore.clearSessionData() + } } -} \ No newline at end of file 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 index cec0cbd..0053716 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -34,7 +34,7 @@ fun GameScreenWrapper( val winner = gameState.winner LaunchedEffect(winner) { - if(winner != null) { + if (winner != null) { sessionViewModel.clearLobby() } } 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 072c2db..10bf4e5 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 @@ -79,7 +79,7 @@ fun LobbyScreen( } LaunchedEffect(userState.userId) { - if(userState.userId != null) { + if (userState.userId != null) { sessionViewModel.persistUserState() } } @@ -120,7 +120,7 @@ fun LobbyScreen( sessionViewModel.persistLobbyState( lobbyCode, role, - team + team, ) } } 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 index 5e7990d..fa6e5cd 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -46,7 +46,7 @@ fun UserNameScreen( navController: NavController, viewModel: SessionViewModel, gameViewModel: GameViewModel, - lobbyViewModel: LobbyViewModel + lobbyViewModel: LobbyViewModel, ) { val userState by viewModel.userState.collectAsState() val rejoinState by viewModel.rejoinSessionState.collectAsState() @@ -60,7 +60,7 @@ fun UserNameScreen( val lobbyCode = availableRejoinState?.sessionState?.lobbyCode val userId = userState.userId - if(availableRejoinState != null && lobbyCode != null) { + if (availableRejoinState != null && lobbyCode != null) { HandleRejoinEffects( navController, viewModel, @@ -72,8 +72,8 @@ fun UserNameScreen( gameState = gameState, userId = userId, lobbyCode = lobbyCode, - username = username - ) + username = username, + ), ) } @@ -117,7 +117,7 @@ fun UserNameScreen( onClick = { if (username.isBlank()) return@AppButton viewModel.setUsername(username) - viewModel.setUserId(UUID.randomUUID()) //ONLY FOR TESTING!!!! + viewModel.setUserId(UUID.randomUUID()) // ONLY FOR TESTING!!!! navController.navigate(Screen.Start.route) }, modifier = @@ -140,23 +140,25 @@ fun UserNameScreen( ) } - if(availableRejoinState != null) { - RejoinDialog(onRejoinGame = { - lobbyCode?.let { + if (availableRejoinState != null) { + RejoinDialog( + onRejoinGame = { + lobbyCode?.let { gameViewModel.connect(lobbyCode, false) } }, onDiscardGame = { viewModel.clearLobby() - }) + }, + ) } } @Suppress("ktlint:standard:function-naming") @Composable fun RejoinDialog( - onRejoinGame : () -> Unit, - onDiscardGame: () -> Unit + onRejoinGame: () -> Unit, + onDiscardGame: () -> Unit, ) { AlertDialog( onDismissRequest = {}, @@ -169,25 +171,26 @@ fun RejoinDialog( confirmButton = { AppButton( text = "Beitreten", - onClick = { onRejoinGame() } + onClick = { onRejoinGame() }, ) }, dismissButton = { AppButton( text = "Verwerfen", - onClick = { onDiscardGame() } + onClick = { onDiscardGame() }, ) - } + }, ) } +@Suppress("ktlint:standard:function-naming") @Composable fun HandleRejoinEffects( navController: NavController, viewModel: SessionViewModel, gameViewModel: GameViewModel, lobbyViewModel: LobbyViewModel, - rejoinUiState: RejoinUiState + rejoinUiState: RejoinUiState, ) { val connectionState = rejoinUiState.connectionState val userId = rejoinUiState.userId @@ -196,27 +199,26 @@ fun HandleRejoinEffects( val availableRejoinState = rejoinUiState.availableRejoinState val gameState = rejoinUiState.gameState - val canRejoin = connectionState is ConnectionState.CONNECTED && - userId != null + userId != null LaunchedEffect(canRejoin) { - if(!canRejoin) return@LaunchedEffect + if (!canRejoin) return@LaunchedEffect gameViewModel.rejoinGame(username, userId, availableRejoinState) lobbyViewModel.startUpdateAfterRejoin(lobbyCode) } LaunchedEffect(availableRejoinState) { - if(availableRejoinState.sessionState.consumed) { + if (availableRejoinState.sessionState.consumed) { navController.navigate(Screen.Gameboard.route) } } LaunchedEffect(gameState) { - if(gameState.currentTurn != PlayerRoles.NONE) { + if (gameState.currentTurn != PlayerRoles.NONE) { viewModel.setRejoinStateConsumed() } } -} \ No newline at end of file +} 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 63eaaff..ff74005 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -146,14 +146,18 @@ class GameViewModel } } - fun rejoinGame(username: String, userId: UUID, rejoinState: RejoinState.Available?) { - if(rejoinState == null) return + fun rejoinGame( + username: String, + userId: UUID, + rejoinState: RejoinState.Available?, + ) { + if (rejoinState == null) return val lobbyCode = rejoinState.sessionState.lobbyCode val team = rejoinState.sessionState.lobbyTeam val role = rejoinState.sessionState.lobbyRole - if(lobbyCode != null && team != null && role != null) { + if (lobbyCode != null && team != null && role != null) { viewModelScope.launch { gameRepository.sendRejoin(username, userId, lobbyCode, role, team) } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index 4cb9e09..9b72566 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -19,9 +19,11 @@ import javax.inject.Inject class SessionViewModel @Inject constructor( - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, ) : ViewModel() { - private val _userState = MutableStateFlow(UserState("", null)) //initialized with random UUID, is replaced when real uuid is collected + private val _userState = MutableStateFlow(UserState("", null)) + + // initialized with random UUID, is replaced when real uuid is collected val userState: StateFlow = _userState private val _rejoinSessionState = MutableStateFlow(RejoinState.None) @@ -41,15 +43,21 @@ class SessionViewModel fun setUserId(userId: UUID) { _userState.update { it.copy( - userId = userId + userId = userId, ) } } - fun persistLobbyState(lobbyCode: String, role: Role, team: Team) { + fun persistLobbyState( + lobbyCode: String, + role: Role, + team: Team, + ) { viewModelScope.launch { sessionRepository.saveLobby( - lobbyCode, role, team + lobbyCode, + role, + team, ) } } @@ -58,7 +66,7 @@ class SessionViewModel viewModelScope.launch { sessionRepository.saveUser( _userState.value.username, - _userState.value.userId ?: return@launch + _userState.value.userId ?: return@launch, ) } } @@ -74,9 +82,10 @@ class SessionViewModel when (currentState) { is RejoinState.Available -> { currentState.copy( - sessionState = currentState.sessionState.copy( - consumed = true - ) + sessionState = + currentState.sessionState.copy( + consumed = true, + ), ) } RejoinState.None -> currentState @@ -104,10 +113,10 @@ class SessionViewModel private fun observeUser() { viewModelScope.launch { sessionRepository.userFlow.collect { state -> - if(state.username.isNotBlank() ) { + if (state.username.isNotBlank()) { _userState.value = state } } } } -} + } diff --git a/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt index 35ada62..60d11d4 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt @@ -13,7 +13,6 @@ import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class SessionRepositoryTest { - private lateinit var repository: SessionRepository private lateinit var dataStore: UserPreferencesDataStore @@ -24,42 +23,45 @@ class SessionRepositoryTest { } @Test - fun `saveLobby delegates to datastore`() = runTest { - repository.saveLobby( - "ABCD", - Role.OPERATIVE, - Team.RED - ) - - coVerify { - dataStore.saveLobbyData( + fun `saveLobby delegates to datastore`() = + runTest { + repository.saveLobby( "ABCD", Role.OPERATIVE, - Team.RED + Team.RED, ) + + coVerify { + dataStore.saveLobbyData( + "ABCD", + Role.OPERATIVE, + Team.RED, + ) + } } - } @Test - fun `saveUser delegates to datastore`() = runTest { - val uuid = UUID.randomUUID() + fun `saveUser delegates to datastore`() = + runTest { + val uuid = UUID.randomUUID() - repository.saveUser("Anna", uuid) + repository.saveUser("Anna", uuid) - coVerify { - dataStore.saveUserData( - "Anna", - uuid - ) + coVerify { + dataStore.saveUserData( + "Anna", + uuid, + ) + } } - } @Test - fun `clearLobbyData delegates to datastore`() = runTest { - repository.clearLobbyData() + fun `clearLobbyData delegates to datastore`() = + runTest { + repository.clearLobbyData() - coVerify { - dataStore.clearSessionData() + coVerify { + dataStore.clearSessionData() + } } - } -} \ No newline at end of file +} 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 da61007..4e5c4c8 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -506,7 +506,7 @@ class GameViewModelTest { val rejoinState = null - viewModel.rejoinGame("User", UUID.randomUUID(), rejoinState ) + viewModel.rejoinGame("User", UUID.randomUUID(), rejoinState) advanceUntilIdle() diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt index 034035e..43b90c7 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -8,12 +8,9 @@ import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.data.repository.SessionRepository import io.mockk.coVerify import io.mockk.every -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.Assert.assertEquals -import org.junit.Before -import org.junit.Test import io.mockk.mockk import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle @@ -21,7 +18,10 @@ import kotlinx.coroutines.test.resetMain 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 import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) @@ -45,8 +45,8 @@ class SessionViewModelTest { lobbyCode = null, lobbyRole = null, lobbyTeam = null, - consumed = false - ) + consumed = false, + ), ) sessionRepository = mockk(relaxed = true) @@ -84,155 +84,165 @@ class SessionViewModelTest { } @Test - fun `persistUserState saves user`() = runTest { - val uuid = UUID.randomUUID() + fun `persistUserState saves user`() = + runTest { + val uuid = UUID.randomUUID() - viewModel.setUsername("Max") - viewModel.setUserId(uuid) + viewModel.setUsername("Max") + viewModel.setUserId(uuid) - viewModel.persistUserState() + viewModel.persistUserState() - advanceUntilIdle() + advanceUntilIdle() - coVerify { - sessionRepository.saveUser("Max", uuid) + coVerify { + sessionRepository.saveUser("Max", uuid) + } } - } @Test - fun `persistUserState does nothing when userId is null`() = runTest { - viewModel.setUsername("Max") + fun `persistUserState does nothing when userId is null`() = + runTest { + viewModel.setUsername("Max") - viewModel.persistUserState() + viewModel.persistUserState() - advanceUntilIdle() + advanceUntilIdle() - coVerify(exactly = 0) { - sessionRepository.saveUser(any(), any()) + coVerify(exactly = 0) { + sessionRepository.saveUser(any(), any()) + } } - } @Test - fun `persistLobbyState saves lobby`() = runTest { - viewModel.persistLobbyState( - lobbyCode = "ABCD", - role = Role.OPERATIVE, - team = Team.RED - ) - - advanceUntilIdle() - - coVerify { - sessionRepository.saveLobby( - "ABCD", - Role.OPERATIVE, - Team.RED + fun `persistLobbyState saves lobby`() = + runTest { + viewModel.persistLobbyState( + lobbyCode = "ABCD", + role = Role.OPERATIVE, + team = Team.RED, ) + + advanceUntilIdle() + + coVerify { + sessionRepository.saveLobby( + "ABCD", + Role.OPERATIVE, + Team.RED, + ) + } } - } @Test - fun `clearLobby clears repository lobby data`() = runTest { - viewModel.clearLobby() + fun `clearLobby clears repository lobby data`() = + runTest { + viewModel.clearLobby() - advanceUntilIdle() + advanceUntilIdle() - coVerify { - sessionRepository.clearLobbyData() + coVerify { + sessionRepository.clearLobbyData() + } } - } @Test - fun `observeUser updates state when username is not blank`() = runTest { - val uuid = UUID.randomUUID() - - userFlow.value = - UserState( - username = "Anna", - userId = uuid - ) + fun `observeUser updates state when username is not blank`() = + runTest { + val uuid = UUID.randomUUID() + + userFlow.value = + UserState( + username = "Anna", + userId = uuid, + ) - advanceUntilIdle() + advanceUntilIdle() - assertEquals("Anna", viewModel.userState.value.username) - assertEquals(uuid, viewModel.userState.value.userId) - } + assertEquals("Anna", viewModel.userState.value.username) + assertEquals(uuid, viewModel.userState.value.userId) + } @Test - fun `observeUser ignores blank username`() = runTest { - userFlow.value = - UserState( - username = "", - userId = UUID.randomUUID() - ) + fun `observeUser ignores blank username`() = + runTest { + userFlow.value = + UserState( + username = "", + userId = UUID.randomUUID(), + ) - advanceUntilIdle() + advanceUntilIdle() - assertEquals("", viewModel.userState.value.username) - } + assertEquals("", viewModel.userState.value.username) + } @Test - fun `observeSession creates Available state when lobby data exists`() = runTest { - val session = - SessionState( - lobbyCode = "ABCD", - lobbyRole = Role.OPERATIVE, - lobbyTeam = Team.RED, - consumed = false - ) + fun `observeSession creates Available state when lobby data exists`() = + runTest { + val session = + SessionState( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false, + ) - sessionFlow.value = session + sessionFlow.value = session - advanceUntilIdle() + advanceUntilIdle() - assertTrue(viewModel.rejoinSessionState.value is RejoinState.Available) - } + assertTrue(viewModel.rejoinSessionState.value is RejoinState.Available) + } @Test - fun `observeSession creates None state when lobby data incomplete`() = runTest { - sessionFlow.value = - SessionState( - lobbyCode = null, - lobbyRole = Role.OPERATIVE, - lobbyTeam = Team.RED, - consumed = false - ) + fun `observeSession creates None state when lobby data incomplete`() = + runTest { + sessionFlow.value = + SessionState( + lobbyCode = null, + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false, + ) - advanceUntilIdle() + advanceUntilIdle() - assertEquals( - RejoinState.None, - viewModel.rejoinSessionState.value - ) - } + assertEquals( + RejoinState.None, + viewModel.rejoinSessionState.value, + ) + } @Test - fun `setRejoinStateConsumed sets consumed to true`() = runTest { - sessionFlow.value = - SessionState( - lobbyCode = "ABCD", - lobbyRole = Role.OPERATIVE, - lobbyTeam = Team.RED, - consumed = false - ) + fun `setRejoinStateConsumed sets consumed to true`() = + runTest { + sessionFlow.value = + SessionState( + lobbyCode = "ABCD", + lobbyRole = Role.OPERATIVE, + lobbyTeam = Team.RED, + consumed = false, + ) - advanceUntilIdle() + advanceUntilIdle() - viewModel.setRejoinStateConsumed() + viewModel.setRejoinStateConsumed() - val result = - viewModel.rejoinSessionState.value as RejoinState.Available + val result = + viewModel.rejoinSessionState.value as RejoinState.Available - assertTrue(result.sessionState.consumed) - } + assertTrue(result.sessionState.consumed) + } @Test - fun `setRejoinStateConsumed does nothing for None state`() = runTest { - viewModel.setRejoinStateConsumed() + fun `setRejoinStateConsumed does nothing for None state`() = + runTest { + viewModel.setRejoinStateConsumed() - assertEquals( - RejoinState.None, - viewModel.rejoinSessionState.value - ) - } + assertEquals( + RejoinState.None, + viewModel.rejoinSessionState.value, + ) + } } From 3b53022445e2f00ccb1050be189e8eab11d33c30 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sat, 13 Jun 2026 23:57:15 +0200 Subject: [PATCH 076/126] add android tests to github workflow + dependency update --- .github/workflows/build.yml | 4 ++ .idea/androidTestResultsUserPreferences.xml | 61 +++++++++++++++++++ app/build.gradle.kts | 18 ++++++ .../codenames/frontend/GameboardScreenTest.kt | 4 +- .../frontend/viewmodel/ChatViewModelTest.kt | 1 + gradle/libs.versions.toml | 6 +- gradle/wrapper/gradle-wrapper.properties | 3 +- sonar-project.properties | 2 +- 8 files changed, 91 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 22f37d6..3a172f3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,6 +32,10 @@ jobs: restore-keys: ${{ runner.os }}-gradle - name: Make gradlew executable run: chmod +x ./gradlew + - name: Run Unit Tests + run: ./gradlew testDebugUnitTest + - name: Run Android Test (Managed Device) + run: ./gradlew pixel6Api34DebugAndroidTest - name: Build project run: ./gradlew build diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index ea646df..36c3f52 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -3,6 +3,24 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d33890..b0c61cd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,11 @@ android { "proguard-rules.pro", ) } + + debug { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 @@ -141,6 +146,19 @@ android { compose = true buildConfig = true } + + + testOptions { + managedDevices { + localDevices { + create("pixel6Api34") { + device = "Pixel 6" + apiLevel = 34 + systemImageSource = "aosp-atd" + } + } + } + } } dependencies { diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index cdc7e35..1f97eab 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -40,6 +40,7 @@ class GameboardScreenTest { currentHint = "EAGLE", currentTurn = PlayerRoles.BLUE_OPERATIVE, remainingGuesses = 3, + numGuesses = 3, currentBlueFound = 0, currentRedFound = 1, cards = cards, @@ -51,10 +52,9 @@ class GameboardScreenTest { composeRule.onNodeWithText("BERLIN").assertIsDisplayed() composeRule.onNodeWithText("ROME").assertIsDisplayed() - composeRule.onNodeWithText("Turn: BLUE_OPERATIVE | Guesses: 3").assertIsDisplayed() + composeRule.onNodeWithText("Turn: BLUE_OPERATIVE | Remaining Guesses: 3/3").assertIsDisplayed() composeRule.onNodeWithText("0 FOUND").assertIsDisplayed() composeRule.onNodeWithText("1 FOUND").assertIsDisplayed() - composeRule.onAllNodesWithText("Hint: EAGLE").assertCountEquals(0) } @Test diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt index 020111e..5227a0a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -16,6 +16,7 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ChatViewModelTest { private lateinit var repository: ChatRepository private lateinit var viewModel: ChatViewModel diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60219bf..4dc15eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,13 +1,13 @@ [versions] -agp = "9.1.1" +agp = "9.2.1" coreKtx = "1.18.0" datastorePreferences = "1.2.1" hiltAndroidCompiler = "2.59.2" hiltNavigationCompose = "1.3.0" junit = "4.13.2" junitVersion = "1.3.0" -espressoCore = "3.5.1" -kotlinxCoroutinesAndroid = "1.10.2" +espressoCore = "3.7.0" +kotlinxCoroutinesAndroid = "1.11.0" kotlinxCoroutinesTest = "1.7.3" kotlinxSerializationJson = "1.11.0" krossbowStompCore = "9.3.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a02cf10..cb610a4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,8 +1,7 @@ #Mon Mar 23 15:59:43 CET 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=b266d5ff6b90eada6dc3b20cb090e3731302e553a27c5d3e4df1f0d76beaff06 -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/sonar-project.properties b/sonar-project.properties index 1d97e23..09e8e92 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,6 +3,6 @@ sonar.organization=ss26-se2-codenames sonar.host.url=https://sonarcloud.io sonar.sources=app/src/main -sonar.tests=app/src/test/java +sonar.tests=app/src/test/java,app/src/androidTest/java sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml sonar.coverage.exclusions=**/ui/**,**/Hilt_*.kt,**/*_Hilt*.kt,**/*_Factory*.kt,**/*_MembersInjector*.kt,**/*_GeneratedInjector*.kt,**/*_ComponentTreeDeps*.kt,**/Dagger*.kt,**/CodenamesApp.kt,**/MainActivity.kt \ No newline at end of file From 8dccb130eb29c76b13c6bd589335be0571da5091 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 14 Jun 2026 00:00:14 +0200 Subject: [PATCH 077/126] include android test outputs in jacoco report --- app/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b0c61cd..241240c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -95,6 +95,8 @@ tasks.register("jacocoTestReport") { include("jacoco/testDebugUnitTest.exec") include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec") include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec.ec") + include("outputs/androidTest-results/connected/**/*.ec") + include("outputs/code_coverage/**/connected/**/*.ec") }, ) } From 783b14243c6d00da934c2e36d54258d7351228ce Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 14 Jun 2026 00:11:00 +0200 Subject: [PATCH 078/126] fix: change workflow to use github emulator instead of local managed device --- .github/workflows/build.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3a172f3..33dfc44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,13 @@ jobs: - name: Run Unit Tests run: ./gradlew testDebugUnitTest - name: Run Android Test (Managed Device) - run: ./gradlew pixel6Api34DebugAndroidTest + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + script: ./gradlew connectedDebugAndroidTest - name: Build project run: ./gradlew build From 70560e8f29f9eabd2e68cbb437b4aee5409d652c Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 14 Jun 2026 09:18:45 +0200 Subject: [PATCH 079/126] style: format --- app/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 241240c..724550d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -149,7 +149,6 @@ android { buildConfig = true } - testOptions { managedDevices { localDevices { From 9d6796ba0513a44de660ec38639ef466be1f3eb8 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Sun, 14 Jun 2026 09:48:03 +0200 Subject: [PATCH 080/126] fix: add kvm to worflow to speed up emulator booting --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 33dfc44..ab41af4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,6 +34,12 @@ jobs: run: chmod +x ./gradlew - name: Run Unit Tests run: ./gradlew testDebugUnitTest + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm - name: Run Android Test (Managed Device) uses: reactivecircus/android-emulator-runner@v2 with: @@ -41,6 +47,7 @@ jobs: target: google_apis arch: x86_64 profile: pixel_6 + emulator-boot-timeout: 900 script: ./gradlew connectedDebugAndroidTest - name: Build project run: ./gradlew build From b9ba0fa7618d230983160c14f3499681577fa15c Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 15:33:10 +0200 Subject: [PATCH 081/126] feat: add pass turn message dto --- .../codenames/frontend/network/dto/PassTurnMessage.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/PassTurnMessage.kt diff --git a/app/src/main/java/com/codenames/frontend/network/dto/PassTurnMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/PassTurnMessage.kt new file mode 100644 index 0000000..3588202 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/PassTurnMessage.kt @@ -0,0 +1,10 @@ +package com.codenames.frontend.network.dto + +import com.codenames.frontend.data.model.enums.Team +import kotlinx.serialization.Serializable + +@Serializable +data class PassTurnMessage( + val lobbyCode: String, + val currentTurn: Team, +) From 63ec7ee9293a7a2da7d1d881604086d4566af4b0 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 15:34:09 +0200 Subject: [PATCH 082/126] feat: add pass turn websocket call --- .../frontend/network/websocket/GameWebSocketController.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index e91e120..7f37813 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -3,6 +3,7 @@ package com.codenames.frontend.network.websocket 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.PassTurnMessage import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import kotlinx.coroutines.flow.Flow @@ -40,4 +41,8 @@ class GameWebSocketController suspend fun sendGuess(msg: GuessMessage) { webSocketSessionManager.getSession().convertAndSend("/app/reveal-card", msg, GuessMessage.serializer()) } + + suspend fun passTurn(msg: PassTurnMessage) { + webSocketSessionManager.getSession().convertAndSend("/app/pass-turn", msg, PassTurnMessage.serializer()) + } } From 27dbc0f6806a93ca3ea356c9cd5bc87d197bd280 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 15:35:01 +0200 Subject: [PATCH 083/126] feat: add pass turn repository method --- .../frontend/data/repository/GameRepository.kt | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 index 6d3c5c3..3cdc7a0 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.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.dto.ClueMessageDto import com.codenames.frontend.network.dto.GuessMessage +import com.codenames.frontend.network.dto.PassTurnMessage import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import com.codenames.frontend.network.websocket.GameWebSocketController @@ -46,10 +47,22 @@ class GameRepository lobbyCode = lobbyCode, position = position, currentTurn = currentTurn, - ) + ) webSocketHandler.sendGuess(msg) } + suspend fun passTurn( + lobbyCode: String, + currentTurn: Team, + ) { + val msg = + PassTurnMessage( + lobbyCode = lobbyCode, + currentTurn = currentTurn, + ) + webSocketHandler.passTurn(msg) + } + suspend fun sendRejoin( username: String, userId: UUID, From fd96869b8ab1eaba6b722b90e2b60a6cba11d192 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 15:42:28 +0200 Subject: [PATCH 084/126] feat: add pass turn action to game viewmodel --- .../frontend/viewmodel/GameViewModel.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 ff74005..9754ece 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -146,6 +146,25 @@ class GameViewModel } } + fun passTurn( + lobbyCode: String, + team: Team?, + ) { + if (lobbyCode.isBlank() || team == null) { + return + } + + val turn = uiState.value.currentTurn + if (!team.isActiveOperativeTurn(turn)) return + viewModelScope.launch { + try { + gameRepository.passTurn(lobbyCode, team) + } catch (e: Exception) { + _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + } + } + } + fun rejoinGame( username: String, userId: UUID, From e516f8a55fdea4331bb4a72471d699f096cfca1c Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:10:12 +0200 Subject: [PATCH 085/126] feat: add end turn button --- .../frontend/ui/screens/GameScreenWrapper.kt | 3 ++ .../frontend/ui/screens/GameboardScreen.kt | 52 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) 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 index 0053716..1e96b19 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -52,6 +52,9 @@ fun GameScreenWrapper( onReveal = { positions -> gameViewModel.submitGuesses(lobbyCode, positions, team) }, + onPassTurn = { + gameViewModel.passTurn(lobbyCode, team) + }, onSendChatMessage = { tab, message -> chatViewModel.sendChatMessage( tab = tab, 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 fb937d9..d7288e4 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 @@ -121,6 +121,7 @@ fun GameboardScreen( onHintChange: (String, Int) -> Unit, onReveal: (List) -> Unit, modifier: Modifier = Modifier, + onPassTurn: () -> Unit = {}, onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, ) { @@ -147,7 +148,8 @@ fun GameboardScreen( val isSpymaster = userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val isActiveSpymaster = userRole == currentTurn && isSpymaster - val canSelectCards = userRole == currentTurn && !isSpymaster && remainingGuesses > 0 + val canEndTurn = userRole == currentTurn && !isSpymaster && remainingGuesses > 0 + val canSelectCards = canEndTurn val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -187,11 +189,23 @@ fun GameboardScreen( numGuesses = numGuesses, ) - ChatToggle( - isVisible = availableChatTabs.isNotEmpty(), - isChatOpen = isChatOpen, - onClick = { isChatOpen = !isChatOpen }, - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + ChatToggle( + isVisible = availableChatTabs.isNotEmpty(), + isChatOpen = isChatOpen, + onClick = { isChatOpen = !isChatOpen }, + ) + + EndTurnButton( + isVisible = canEndTurn, + onClick = onPassTurn, + modifier = Modifier.width(140.dp), + ) + } Spacer(modifier = Modifier.height(dimensions.gameBoardTopSpacing)) @@ -454,6 +468,32 @@ private fun DeselectAllButton( } } +@Suppress("ktlint:standard:function-naming") +@Composable +private fun EndTurnButton( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val dimensions = LocalResponsiveDimensions.current + + if (isVisible) { + AppButton( + text = "End Turn", + onClick = onClick, + modifier = + modifier + .height(dimensions.secondaryButtonHeight), + style = + AppButtonStyle( + containerColor = AppGreen, + contentColor = AppWhite, + fontSize = dimensions.bodyFontSize, + ), + ) + } +} + @Suppress("ktlint:standard:function-naming") @Composable private fun GameChatOverlay( From 3d5e24f568e7e564c981b766096c1e81b56b12c7 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:10:39 +0200 Subject: [PATCH 086/126] style: adjust settings button position --- .../com/codenames/frontend/ui/buttons/SettingsCornerButton.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index f62fec8..3299f98 100644 --- a/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt +++ b/app/src/main/java/com/codenames/frontend/ui/buttons/SettingsCornerButton.kt @@ -12,6 +12,7 @@ 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 import com.codenames.frontend.ui.theme.AppInk @@ -27,7 +28,7 @@ fun BoxScope.SettingsCornerButton(onClick: () -> Unit) { modifier = Modifier .align(Alignment.TopEnd) - .padding(top = dimensions.smallSpacing, end = dimensions.smallSpacing) + .padding(top = 0.dp, end = dimensions.smallSpacing) .width(dimensions.cornerButtonSize) .height(dimensions.cornerButtonSize) .zIndex(1f), From c172fb8636da9b7a6abb28adcb6c60374e2ec55b Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:41:55 +0200 Subject: [PATCH 087/126] test: cover pass turn websocket call --- .../websocket/GameWebSocketControllerTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index b058f0a..a2563ea 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -4,6 +4,7 @@ import com.codenames.frontend.data.model.enums.Team 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.PassTurnMessage import com.codenames.frontend.network.dto.StartGameMessage import com.codenames.frontend.network.dto.WebSocketJoinMessage import io.mockk.coEvery @@ -125,4 +126,20 @@ class GameWebSocketControllerTest { ) } } + + @Test + fun testPassTurn() = + runTest { + val passTurnMessage = PassTurnMessage("LOBBY123", Team.RED) + + wsClient.passTurn(passTurnMessage) + + coVerify { + session.convertAndSend( + "/app/pass-turn", + passTurnMessage, + PassTurnMessage.serializer(), + ) + } + } } From 41485c4a0d0b9b3961ac3091ea9f073473a73ab1 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:44:23 +0200 Subject: [PATCH 088/126] test: cover pass turn repository method --- .../frontend/data/repository/GameRepositoryTest.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index c084062..87904ae 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -61,4 +61,17 @@ class GameRepositoryTest { coVerify { webSocketHandler.sendGuess(any()) } } + + @Test + fun testPassTurn() = + runTest { + val lobbyCode = "ABCDE" + val currentTurn = Team.RED + + coEvery { webSocketHandler.passTurn(any()) } just Runs + + gameRepository.passTurn(lobbyCode, currentTurn) + + coVerify { webSocketHandler.passTurn(any()) } + } } From 3324c46e22a5194dba1383032fe95010490cb76c Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:45:57 +0200 Subject: [PATCH 089/126] test: cover pass turn viewmodel action --- .../frontend/viewmodel/GameViewModelTest.kt | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) 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 4e5c4c8..b4f722e 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -479,6 +479,91 @@ class GameViewModelTest { assertTrue(state is ConnectionState.Error) } + @Test + fun passTurn_redOperative_sendsPassTurn() = + runTest { + coEvery { + gameRepository.passTurn(any(), any()) + } just Runs + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.passTurn(lobbyCode, Team.RED) + advanceUntilIdle() + + coVerify { + gameRepository.passTurn(lobbyCode, Team.RED) + } + } + + @Test + fun passTurn_blankLobbyCode_doesNotSendPassTurn() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.passTurn("", Team.RED) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.passTurn(any(), any()) + } + } + + @Test + fun passTurn_nullTeam_doesNotSendPassTurn() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.passTurn(lobbyCode, null) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.passTurn(any(), any()) + } + } + + @Test + fun passTurn_whenTurnIsSpymaster_doesNotSendPassTurn() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.SPYMASTER)) + + viewModel.passTurn(lobbyCode, Team.RED) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.passTurn(any(), any()) + } + } + + @Test + fun passTurn_wrongTeamForCurrentTurn_doesNotSendPassTurn() = + runTest { + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.passTurn(lobbyCode, Team.BLUE) + advanceUntilIdle() + + coVerify(exactly = 0) { + gameRepository.passTurn(any(), any()) + } + } + + @Test + fun passTurn_networkError_updatesConnectionState() = + runTest { + coEvery { + gameRepository.passTurn(any(), any()) + } throws Exception("Network connection failed") + + viewModel.handleMessage(testMessage.copy(currentTurn = Team.RED, currentPhase = Role.OPERATIVE)) + + viewModel.passTurn(lobbyCode, Team.RED) + advanceUntilIdle() + + val state = viewModel.connectionState.value + assertTrue(state is ConnectionState.Error) + } + @Test fun testSendRejoinMessage_sendsMessage() = runTest { From dd7d88d8db1bc8b823e5b0309cf57d6d587a4ba2 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:53:21 +0200 Subject: [PATCH 090/126] test: cover end turn button behavior --- .../codenames/frontend/GameboardScreenTest.kt | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index 1f97eab..7f3f2ed 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -235,6 +235,93 @@ class GameboardScreenTest { composeRule.onAllNodesWithText("Deselect all").assertCountEquals(0) } + @Test + fun activeOperativeSeesEndTurnButton() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onNodeWithText("End Turn").assertIsDisplayed() + } + + @Test + fun spymasterDoesNotSeeEndTurnButton() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_SPYMASTER, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onAllNodesWithText("End Turn").assertCountEquals(0) + } + + @Test + fun inactiveOperativeDoesNotSeeEndTurnButton() { + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.RED_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + ) + } + + composeRule.onAllNodesWithText("End Turn").assertCountEquals(0) + } + + @Test + fun endTurnButtonCallsPassTurn() { + var passTurnClicks = 0 + + composeRule.setContent { + GameboardScreen( + userRole = PlayerRoles.BLUE_OPERATIVE, + gameState = + GameState( + currentHint = "EAGLE", + currentTurn = PlayerRoles.BLUE_OPERATIVE, + remainingGuesses = 2, + cards = listOf(GameCard("BERLIN", CardType.BLUE)), + ), + onHintChange = { _, _ -> }, + onReveal = {}, + onPassTurn = { passTurnClicks++ }, + ) + } + + composeRule.onNodeWithText("End Turn").performClick() + + composeRule.runOnIdle { + assertEquals(1, passTurnClicks) + } + } + @Test fun clickingSelectedCardRevealsOnlyThatCard() { var revealedPositions = emptyList() From 72f8367ae90deb84f51b0112c1bbd7c9624e14b6 Mon Sep 17 00:00:00 2001 From: tasaje1 Date: Sun, 14 Jun 2026 17:59:47 +0200 Subject: [PATCH 091/126] style: format --- .../com/codenames/frontend/data/repository/GameRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 3cdc7a0..d2adc9b 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -47,7 +47,7 @@ class GameRepository lobbyCode = lobbyCode, position = position, currentTurn = currentTurn, - ) + ) webSocketHandler.sendGuess(msg) } From c06f0dd58e95faad793142d8ea1f87579dadf0c2 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:06:52 +0200 Subject: [PATCH 092/126] Implement shake cheat frontend Add shake detection and send selected card positions to backend cheat endpoint. Subscribe to private system messages and display them in operative chat. --- .../data/repository/ChatRepository.kt | 15 +++++++ .../data/repository/GameRepository.kt | 16 ++++++++ .../frontend/network/dto/CheatCardMessage.kt | 10 +++++ .../websocket/ChatWebSocketController.kt | 7 ++++ .../websocket/GameWebSocketController.kt | 9 +++++ .../frontend/ui/screens/GameScreenWrapper.kt | 7 ++++ .../frontend/ui/screens/GameboardScreen.kt | 39 +++++++++++++++++++ .../codenames/frontend/util/ShakeDetector.kt | 39 +++++++++++++++++++ .../frontend/viewmodel/ChatViewModel.kt | 13 +++++++ .../frontend/viewmodel/GameViewModel.kt | 25 ++++++++++++ 10 files changed, 180 insertions(+) create mode 100644 app/src/main/java/com/codenames/frontend/network/dto/CheatCardMessage.kt create mode 100644 app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt 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 index 14a994a..f16fad3 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/ChatRepository.kt @@ -108,4 +108,19 @@ class ChatRepository chatWebSocketController.sendChatMessage(destination, dto) } + + fun observeSystemMessages(currentUsername: String): Flow = + flow { + chatWebSocketController + .subscribeToSystemMessages() + .collect { dto -> + emit( + ChatDomainModel( + sender = dto.senderUsername, + text = dto.content, + isFromMe = dto.senderUsername == currentUsername, + ), + ) + } + } } 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 index 6d3c5c3..0c54a3e 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -2,6 +2,7 @@ package com.codenames.frontend.data.repository import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CheatCardMessage import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GuessMessage import com.codenames.frontend.network.dto.StartGameMessage @@ -60,4 +61,19 @@ class GameRepository val msg = WebSocketJoinMessage(username, lobbyCode) webSocketHandler.sendReconnectMessage(msg) } + + suspend fun requestCheat( + lobbyCode: String, + username: String, + positions: List, + ) { + val msg = + CheatCardMessage( + lobbyCode = lobbyCode, + username = username, + positions = positions, + ) + + webSocketHandler.sendCheat(msg) + } } diff --git a/app/src/main/java/com/codenames/frontend/network/dto/CheatCardMessage.kt b/app/src/main/java/com/codenames/frontend/network/dto/CheatCardMessage.kt new file mode 100644 index 0000000..d236d4a --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/network/dto/CheatCardMessage.kt @@ -0,0 +1,10 @@ +package com.codenames.frontend.network.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CheatCardMessage( + val lobbyCode: String, + val username: String, + val positions: List, +) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt index 315662f..334b930 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/ChatWebSocketController.kt @@ -20,6 +20,13 @@ class ChatWebSocketController ChatMessageDto.serializer(), ) + @Suppress("kotlin:S6309") + suspend fun subscribeToSystemMessages(): Flow = + webSocketSessionManager.getSession().subscribe( + "/user/queue/system", + ChatMessageDto.serializer(), + ) + suspend fun sendChatMessage( destination: String, msg: ChatMessageDto, diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index e91e120..ae3788a 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -1,5 +1,6 @@ package com.codenames.frontend.network.websocket +import com.codenames.frontend.network.dto.CheatCardMessage import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage @@ -40,4 +41,12 @@ class GameWebSocketController suspend fun sendGuess(msg: GuessMessage) { webSocketSessionManager.getSession().convertAndSend("/app/reveal-card", msg, GuessMessage.serializer()) } + + suspend fun sendCheat(msg: CheatCardMessage) { + webSocketSessionManager.getSession().convertAndSend( + "/app/cheat", + msg, + CheatCardMessage.serializer(), + ) + } } 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 index 0053716..f48a1c3 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -52,6 +52,13 @@ fun GameScreenWrapper( onReveal = { positions -> gameViewModel.submitGuesses(lobbyCode, positions, team) }, + onCheatRequest = { positions -> + gameViewModel.requestCheat( + lobbyCode = lobbyCode, + username = username, + positions = positions, + ) + }, onSendChatMessage = { tab, message -> chatViewModel.sendChatMessage( tab = tab, 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 fb937d9..2fe3f09 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 @@ -1,5 +1,8 @@ package com.codenames.frontend.ui.screens +import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorManager import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -23,11 +26,13 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -39,6 +44,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController @@ -79,6 +85,7 @@ import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient import com.codenames.frontend.ui.theme.redGradient +import com.codenames.frontend.util.ShakeDetector private data class BoardSectionState( val userRole: PlayerRoles, @@ -120,6 +127,7 @@ fun GameboardScreen( gameState: GameState, onHintChange: (String, Int) -> Unit, onReveal: (List) -> Unit, + onCheatRequest: (List) -> Unit, modifier: Modifier = Modifier, onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, @@ -151,11 +159,42 @@ fun GameboardScreen( val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current + val context = LocalContext.current + val sensorManager = + context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + + val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + var scale by remember { mutableFloatStateOf(1f) } var offset by remember { mutableStateOf(Offset.Zero) } val onInputChange: (String) -> Unit = { hintInput = it } + val currentCanSelectCards by rememberUpdatedState(canSelectCards) + val currentSelectedCardPositions by rememberUpdatedState(selectedCardPositions) + val currentOnCheatRequest by rememberUpdatedState(onCheatRequest) + + val shakeDetector = + remember { + ShakeDetector { + if (currentCanSelectCards && currentSelectedCardPositions.isNotEmpty()) { + currentOnCheatRequest(currentSelectedCardPositions) + } + } + } + + DisposableEffect(Unit) { + sensorManager.registerListener( + shakeDetector, + accelerometer, + SensorManager.SENSOR_DELAY_UI, + ) + + onDispose { + sensorManager.unregisterListener(shakeDetector) + } + } + LaunchedEffect(cards, currentTurn, remainingGuesses) { selectedCardPositions = selectedCardPositions diff --git a/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt b/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt new file mode 100644 index 0000000..203c040 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt @@ -0,0 +1,39 @@ +package com.codenames.frontend.util + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import kotlin.math.sqrt + +class ShakeDetector( + private val onShake: () -> Unit, +) : SensorEventListener { + private var lastShakeTime = 0L + + override fun onSensorChanged(event: SensorEvent?) { + if (event == null) return + + val x = event.values[0] + val y = event.values[1] + val z = event.values[2] + + val acceleration = + sqrt( + (x * x + y * y + z * z).toDouble(), + ) + + if (acceleration > 15) { + val currentTime = System.currentTimeMillis() + + if (currentTime - lastShakeTime > 1000) { + lastShakeTime = currentTime + onShake() + } + } + } + + override fun onAccuracyChanged( + sensor: Sensor?, + accuracy: Int, + ) {} +} diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 617b58f..4f58e61 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -73,6 +73,19 @@ class ChatViewModel } } } + + viewModelScope.launch { + chatRepository + .observeSystemMessages( + currentUsername = username, + ).collect { msg -> + _chatState.update { + it.copy( + operativeMessages = it.operativeMessages + msg, + ) + } + } + } } } 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 ff74005..61d01d4 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -146,6 +146,31 @@ class GameViewModel } } + fun requestCheat( + lobbyCode: String, + username: String, + positions: List, + ) { + if (lobbyCode.isBlank() || positions.isEmpty()) { + return + } + + viewModelScope.launch { + try { + gameRepository.requestCheat( + lobbyCode = lobbyCode, + username = username, + positions = positions, + ) + } catch (e: Exception) { + _connectionState.value = + ConnectionState.Error( + e.message ?: CONNECTION_ERROR_MESSAGE, + ) + } + } + } + fun rejoinGame( username: String, userId: UUID, From 59c8f8e7ab607d1e390ef84c75833240c797ecac Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:55:48 +0200 Subject: [PATCH 093/126] Add tests for chat and cheat functionality --- .../codenames/frontend/GameboardScreenTest.kt | 9 +++ .../data/repository/ChatRepositoryTest.kt | 22 ++++++ .../data/repository/GameRepositoryTest.kt | 22 ++++++ .../websocket/GameWebSocketControllerTest.kt | 22 ++++++ .../frontend/viewmodel/ChatViewModelTest.kt | 64 ++++++++++++++++ .../frontend/viewmodel/GameViewModelTest.kt | 74 +++++++++++++++++++ 6 files changed, 213 insertions(+) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index 1f97eab..81240cb 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -47,6 +47,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -69,6 +70,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -88,6 +90,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -118,6 +121,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -151,6 +155,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -173,6 +178,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -198,6 +204,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -228,6 +235,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {}, ) } @@ -255,6 +263,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = { positions -> revealedPositions = positions }, + onCheatRequest = {}, ) } 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 index d0deaec..cbf57a1 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/ChatRepositoryTest.kt @@ -315,4 +315,26 @@ class ChatRepositoryTest { assertTrue(result.isEmpty()) } + + @Test + fun testObserveSystemMessages_correctMapping() = + runTest { + val dto = + ChatMessageDto( + "System", + "London is correct", + ) + + coEvery { + chatSocketHandler.subscribeToSystemMessages() + } returns flowOf(dto) + + val result = + repository + .observeSystemMessages(testUser) + .toList() + + assertEquals("System", result[0].sender) + assertEquals("London is correct", result[0].text) + } } 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 index c084062..894a0f5 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -61,4 +61,26 @@ class GameRepositoryTest { coVerify { webSocketHandler.sendGuess(any()) } } + + @Test + fun testRequestCheat() = + runTest { + val lobbyCode = "ABCDE" + val username = "Max" + val positions = listOf(1, 3, 5) + + coEvery { + webSocketHandler.sendCheat(any()) + } just Runs + + gameRepository.requestCheat( + lobbyCode, + username, + positions, + ) + + coVerify { + webSocketHandler.sendCheat(any()) + } + } } diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index b058f0a..00bfdd6 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -1,6 +1,7 @@ package com.codenames.frontend.network.websocket import com.codenames.frontend.data.model.enums.Team +import com.codenames.frontend.network.dto.CheatCardMessage import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GameMessage import com.codenames.frontend.network.dto.GuessMessage @@ -125,4 +126,25 @@ class GameWebSocketControllerTest { ) } } + + @Test + fun testSendCheat() = + runTest { + val cheatMessage = + CheatCardMessage( + "LOBBY123", + "Max", + listOf(1, 4, 7), + ) + + wsClient.sendCheat(cheatMessage) + + coVerify { + session.convertAndSend( + "/app/cheat", + cheatMessage, + CheatCardMessage.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 index 5227a0a..90b3b79 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -8,21 +8,29 @@ import com.codenames.frontend.data.repository.ChatRepository import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class ChatViewModelTest { + private val testDispatcher = UnconfinedTestDispatcher() private lateinit var repository: ChatRepository private lateinit var viewModel: ChatViewModel @Before fun setup() { + Dispatchers.setMain(testDispatcher) + repository = mockk(relaxed = true) every { @@ -37,9 +45,18 @@ class ChatViewModelTest { repository.observeOperativeChat(any(), any(), any()) } returns emptyFlow() + every { + repository.observeSystemMessages(any()) + } returns emptyFlow() + viewModel = ChatViewModel(repository) } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun sendChatMessage_global_sendsLobbyMessage() = runTest { @@ -52,6 +69,8 @@ class ChatViewModelTest { availableChatTabs = listOf(ChatTab.GLOBAL), ) + advanceUntilIdle() + coVerify { repository.sendLobbyMessage( "ABCD", @@ -77,6 +96,8 @@ class ChatViewModelTest { ), ) + advanceUntilIdle() + coVerify { repository.sendTeamMessage( "ABCD", @@ -104,6 +125,8 @@ class ChatViewModelTest { ), ) + advanceUntilIdle() + coVerify { repository.sendOperativeMessage( "ABCD", @@ -220,4 +243,45 @@ class ChatViewModelTest { assertEquals("Anna", first.sender) assertEquals("Hallo", first.text) } + + @Test + fun subscribeToChats_systemMessage_updatesOperativeMessages() = + runTest { + val msg = + ChatDomainModel( + sender = "System", + text = "London is correct", + isFromMe = false, + ) + + every { + repository.observeSystemMessages("Max") + } returns flowOf(msg) + + every { + repository.observeLobbyChat(any(), any()) + } returns emptyFlow() + + every { + repository.observeTeamChat(any(), any(), any()) + } returns emptyFlow() + + every { + repository.observeOperativeChat(any(), any(), any()) + } returns emptyFlow() + + viewModel.subscribeToChats( + username = "Max", + lobbyCode = "ABCD", + team = "RED", + role = Role.OPERATIVE.name, + ) + + advanceUntilIdle() + + assertEquals( + 1, + viewModel.chatState.value.operativeMessages.size, + ) + } } 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 4e5c4c8..2fd05e5 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -532,4 +532,78 @@ class GameViewModelTest { gameRepository.sendRejoin(any(), any(), any(), any(), any()) } } + + @Test + fun requestCheat_callsRepository() = + runTest { + viewModel.requestCheat( + lobbyCode = "ABCD", + username = "Max", + positions = listOf(1, 3, 5), + ) + + advanceUntilIdle() + + coVerify { + gameRepository.requestCheat( + "ABCD", + "Max", + listOf(1, 3, 5), + ) + } + } + + @Test + fun requestCheat_emptyPositions_doesNothing() = + runTest { + viewModel.requestCheat( + lobbyCode = "ABCD", + username = "Max", + positions = emptyList(), + ) + + coVerify(exactly = 0) { + gameRepository.requestCheat( + any(), + any(), + any(), + ) + } + } + + @Test + fun requestCheat_blankLobbyCode_doesNothing() = + runTest { + viewModel.requestCheat( + lobbyCode = "", + username = "Max", + positions = listOf(1, 2, 3), + ) + + coVerify(exactly = 0) { + gameRepository.requestCheat( + any(), + any(), + any(), + ) + } + } + + @Test + fun requestCheat_repositoryThrows() = + runTest { + coEvery { + gameRepository.requestCheat(any(), any(), any()) + } throws RuntimeException() + + viewModel.requestCheat( + "ABCD", + "Max", + listOf(1, 2, 3), + ) + + advanceUntilIdle() + + assertTrue(viewModel.connectionState.value is ConnectionState.Error) + } } From 938c0d4b4da61406ebbea2328b11e2c3ffcfc0f9 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:36:16 +0200 Subject: [PATCH 094/126] fix --- .../java/com/codenames/frontend/GameboardScreenTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index faf48c1..78c83c6 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -257,6 +257,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {} ) } @@ -277,6 +278,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {} ) } @@ -297,6 +299,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, + onCheatRequest = {} ) } @@ -320,6 +323,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onPassTurn = { passTurnClicks++ }, + onCheatRequest = {} ) } From d01def46fcb905a2ddd8429ea7db281ff68b89ad Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:53:38 +0200 Subject: [PATCH 095/126] Update GameboardScreenTest.kt --- .../java/com/codenames/frontend/GameboardScreenTest.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index 78c83c6..f4b5bf9 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -257,7 +257,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, - onCheatRequest = {} + onCheatRequest = {}, ) } @@ -278,7 +278,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, - onCheatRequest = {} + onCheatRequest = {}, ) } @@ -299,7 +299,7 @@ class GameboardScreenTest { ), onHintChange = { _, _ -> }, onReveal = {}, - onCheatRequest = {} + onCheatRequest = {}, ) } @@ -323,7 +323,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onPassTurn = { passTurnClicks++ }, - onCheatRequest = {} + onCheatRequest = {}, ) } From f25182a514de647a7bfc5e1a5235bcb3e1a43780 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:20:52 +0200 Subject: [PATCH 096/126] Fix Sonar issues --- .../java/com/codenames/frontend/ui/screens/GameboardScreen.kt | 4 +--- .../main/java/com/codenames/frontend/util/ShakeDetector.kt | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) 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 1568644..1ddabdb 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 @@ -174,17 +174,15 @@ fun GameboardScreen( val currentCanSelectCards by rememberUpdatedState(canSelectCards) val currentSelectedCardPositions by rememberUpdatedState(selectedCardPositions) - val currentOnCheatRequest by rememberUpdatedState(onCheatRequest) val shakeDetector = remember { ShakeDetector { if (currentCanSelectCards && currentSelectedCardPositions.isNotEmpty()) { - currentOnCheatRequest(currentSelectedCardPositions) + onCheatRequest(currentSelectedCardPositions) } } } - DisposableEffect(Unit) { sensorManager.registerListener( shakeDetector, diff --git a/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt b/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt index 203c040..d8f3d44 100644 --- a/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt +++ b/app/src/main/java/com/codenames/frontend/util/ShakeDetector.kt @@ -35,5 +35,7 @@ class ShakeDetector( override fun onAccuracyChanged( sensor: Sensor?, accuracy: Int, - ) {} + ) { + // Accuracy changes are irrelevant for shake detection. + } } From 87fec0dc37d94eb02ff43e31eb21df0f01e5dbbd Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 16 Jun 2026 13:45:08 +0200 Subject: [PATCH 097/126] feature: improved error messages (pop-up, text descriptions) --- .../frontend/data/error/ErrorMessageMapper.kt | 47 +++++++ .../codenames/frontend/data/model/Mapper.kt | 9 +- .../frontend/network/dto/LobbyResponse.kt | 7 +- .../frontend/ui/composables/ErrorDialog.kt | 33 +++++ .../frontend/ui/navigation/NavGraph.kt | 24 ++++ .../frontend/viewmodel/ChatViewModel.kt | 126 +++++++++++------- .../frontend/viewmodel/GameViewModel.kt | 57 +++++--- .../frontend/viewmodel/LobbyViewModel.kt | 24 +++- .../data/error/ErrorMessageMapperTest.kt | 112 ++++++++++++++++ .../frontend/viewmodel/ChatViewModelTest.kt | 79 ++++++++++- .../frontend/viewmodel/GameViewModelTest.kt | 37 +++++ .../frontend/viewmodel/LobbyViewModelTest.kt | 24 +++- 12 files changed, 497 insertions(+), 82 deletions(-) create mode 100644 app/src/main/java/com/codenames/frontend/data/error/ErrorMessageMapper.kt create mode 100644 app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt create mode 100644 app/src/test/java/com/codenames/frontend/data/error/ErrorMessageMapperTest.kt diff --git a/app/src/main/java/com/codenames/frontend/data/error/ErrorMessageMapper.kt b/app/src/main/java/com/codenames/frontend/data/error/ErrorMessageMapper.kt new file mode 100644 index 0000000..32cc1b7 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/data/error/ErrorMessageMapper.kt @@ -0,0 +1,47 @@ +package com.codenames.frontend.data.error + +import com.codenames.frontend.network.dto.LobbyResponse +import kotlinx.serialization.json.Json +import retrofit2.HttpException +import java.io.IOException + +object ErrorMessageMapper { + private val json = + Json { + ignoreUnknownKeys = true + } + + fun toUserMessage(error: Throwable): String { + if (error is HttpException) { + val backendMessage = + error + .response() + ?.errorBody() + ?.string() + ?.let { body -> + runCatching { + json.decodeFromString(body).message + }.getOrNull() + } + + if (!backendMessage.isNullOrBlank()) { + return backendMessage + } + + return when (error.code()) { + 400 -> "The request could not be processed. Please check your input." + 404 -> "The requested lobby could not be found." + 500 -> "The server has a problem right now. Please try again later." + else -> "Something went wrong. Please try again." + } + } + + if (error is IOException) { + return "Could not connect to the server. Please check your connection." + } + + return error.message + ?.takeIf { it.isNotBlank() } + ?: "Something went wrong. Please try again." + } +} 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 720cd72..8b21acf 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 @@ -12,7 +12,7 @@ import com.codenames.frontend.ui.roles.PlayerRoles fun LobbyResponse.toLobbyState(): LobbyUiState = LobbyUiState( lobbyCode = lobbyCode, - players = playerList.map { it.toUi() }, + players = playerList.orEmpty().map { it.toUi() }, isGameStarted = isStarted, ) @@ -52,6 +52,9 @@ fun GameMessage.getCurrentTurn(): PlayerRoles { if (currentPhase == Role.SPYMASTER) return PlayerRoles.RED_SPYMASTER return PlayerRoles.RED_OPERATIVE } - if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER - return PlayerRoles.BLUE_OPERATIVE + if (currentTurn == Team.BLUE) { + if (currentPhase == Role.SPYMASTER) return PlayerRoles.BLUE_SPYMASTER + return PlayerRoles.BLUE_OPERATIVE + } + return PlayerRoles.NONE } 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 abd147d..09238e5 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 @@ -4,7 +4,8 @@ import kotlinx.serialization.Serializable @Serializable data class LobbyResponse( - val lobbyCode: String, - val playerList: List, - val isStarted: Boolean, + val lobbyCode: String = "", + val playerList: List? = emptyList(), + val isStarted: Boolean = false, + val message: String = "", ) diff --git a/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt b/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt new file mode 100644 index 0000000..5863cc6 --- /dev/null +++ b/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt @@ -0,0 +1,33 @@ +package com.codenames.frontend.ui.composables + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.codenames.frontend.ui.buttons.AppButton +import com.codenames.frontend.ui.theme.AppInk + +@Suppress("ktlint:standard:function-naming") +@Composable +fun ErrorDialog( + message: String, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("Something went wrong") + }, + text = { + Text( + text = message, + color = AppInk, + ) + }, + confirmButton = { + AppButton( + text = "OK", + onClick = onDismiss, + ) + }, + ) +} 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 0ac54c5..cb68eec 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 @@ -3,10 +3,14 @@ package com.codenames.frontend.ui.navigation import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import com.codenames.frontend.data.model.enums.ConnectionState +import com.codenames.frontend.ui.composables.ErrorDialog import com.codenames.frontend.ui.screens.GameScreenWrapper import com.codenames.frontend.ui.screens.GameSettingsScreen import com.codenames.frontend.ui.screens.JoinlobbyScreen @@ -31,6 +35,15 @@ fun NavGraph( ) { val navController = rememberNavController() + val lobbyState by lobbyViewModel.state.collectAsState() + val connectionState by gameViewModel.connectionState.collectAsState() + val chatErrorMessage by chatViewModel.errorMessage.collectAsState() + + val errorMessage = + lobbyState.error + ?: (connectionState as? ConnectionState.Error)?.message + ?: chatErrorMessage + @Suppress("UnusedBoxWithConstraintsScope") BoxWithConstraints { val responsiveDimensions = @@ -92,6 +105,17 @@ fun NavGraph( SettingsScreen(navController) } } + + errorMessage?.let { message -> + ErrorDialog( + message = message, + onDismiss = { + lobbyViewModel.clearError() + gameViewModel.clearError() + chatViewModel.clearError() + }, + ) + } } } } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt index 617b58f..12a84d7 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/ChatViewModel.kt @@ -2,6 +2,7 @@ package com.codenames.frontend.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.error.ErrorMessageMapper import com.codenames.frontend.data.model.ChatLists import com.codenames.frontend.data.model.enums.ChatTab import com.codenames.frontend.data.model.enums.Role @@ -23,6 +24,9 @@ class ChatViewModel private val _chatState = MutableStateFlow(ChatLists()) val chatState: StateFlow = _chatState + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage + fun subscribeToChats( username: String, lobbyCode: String, @@ -30,48 +34,60 @@ class ChatViewModel role: String, ) { viewModelScope.launch { - chatRepository - .observeLobbyChat( - lobbyCode = lobbyCode, - currentUsername = username, - ).collect { msg -> - _chatState.update { - it.copy( - lobbyMessages = it.lobbyMessages + msg, - ) + try { + chatRepository + .observeLobbyChat( + lobbyCode = lobbyCode, + currentUsername = username, + ).collect { msg -> + _chatState.update { + it.copy( + lobbyMessages = it.lobbyMessages + msg, + ) + } } - } + } catch (e: Exception) { + setError(e) + } } viewModelScope.launch { - chatRepository - .observeTeamChat( - lobbyCode = lobbyCode, - team = team, - currentUsername = username, - ).collect { msg -> - _chatState.update { - it.copy( - teamMessages = it.teamMessages + msg, - ) - } - } - } - - if (role == Role.OPERATIVE.name) { - viewModelScope.launch { + try { chatRepository - .observeOperativeChat( + .observeTeamChat( lobbyCode = lobbyCode, team = team, currentUsername = username, ).collect { msg -> _chatState.update { it.copy( - operativeMessages = it.operativeMessages + msg, + teamMessages = it.teamMessages + msg, ) } } + } catch (e: Exception) { + setError(e) + } + } + + if (role == Role.OPERATIVE.name) { + viewModelScope.launch { + try { + chatRepository + .observeOperativeChat( + lobbyCode = lobbyCode, + team = team, + currentUsername = username, + ).collect { msg -> + _chatState.update { + it.copy( + operativeMessages = it.operativeMessages + msg, + ) + } + } + } catch (e: Exception) { + setError(e) + } } } } @@ -82,11 +98,15 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendLobbyMessage( - lobbyCode = lobbyCode, - username = username, - text = content, - ) + try { + chatRepository.sendLobbyMessage( + lobbyCode = lobbyCode, + username = username, + text = content, + ) + } catch (e: Exception) { + setError(e) + } } } @@ -97,12 +117,16 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendTeamMessage( - lobbyCode = lobbyCode, - team = team, - username = username, - text = content, - ) + try { + chatRepository.sendTeamMessage( + lobbyCode = lobbyCode, + team = team, + username = username, + text = content, + ) + } catch (e: Exception) { + setError(e) + } } } @@ -113,12 +137,16 @@ class ChatViewModel content: String, ) { viewModelScope.launch { - chatRepository.sendOperativeMessage( - lobbyCode = lobbyCode, - team = team, - username = username, - text = content, - ) + try { + chatRepository.sendOperativeMessage( + lobbyCode = lobbyCode, + team = team, + username = username, + text = content, + ) + } catch (e: Exception) { + setError(e) + } } } @@ -163,4 +191,12 @@ class ChatViewModel } } } + + fun clearError() { + _errorMessage.value = null + } + + private fun setError(error: Throwable) { + _errorMessage.value = ErrorMessageMapper.toUserMessage(error) + } } 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 9754ece..628375b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -3,6 +3,7 @@ package com.codenames.frontend.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.error.ErrorMessageMapper import com.codenames.frontend.data.model.GameState import com.codenames.frontend.data.model.RejoinState import com.codenames.frontend.data.model.enums.ConnectionState @@ -23,8 +24,6 @@ import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds -private const val CONNECTION_ERROR_MESSAGE = "Connection error" - @HiltViewModel class GameViewModel @Inject @@ -36,6 +35,7 @@ class GameViewModel private val _uiState = MutableStateFlow(GameState()) val uiState: StateFlow = _uiState + private val _connectionState = MutableStateFlow(ConnectionState.IDLE) val connectionState: StateFlow = _connectionState @@ -69,7 +69,7 @@ class GameViewModel sendGameStart(lobbyCode) } } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + setConnectionError(e) } } } @@ -79,7 +79,11 @@ class GameViewModel return } viewModelScope.launch { - gameRepository.startGame(lobbyCode) + try { + gameRepository.startGame(lobbyCode) + } catch (e: Exception) { + setConnectionError(e) + } } } @@ -99,7 +103,7 @@ class GameViewModel try { gameRepository.submitClue(lobbyCode, word, count, team) } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + setConnectionError(e) } } } @@ -119,7 +123,7 @@ class GameViewModel try { gameRepository.submitGuess(lobbyCode, position, team) } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + setConnectionError(e) } } } @@ -141,7 +145,7 @@ class GameViewModel gameRepository.submitGuess(lobbyCode, position, team) } } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + setConnectionError(e) } } } @@ -160,7 +164,7 @@ class GameViewModel try { gameRepository.passTurn(lobbyCode, team) } catch (e: Exception) { - _connectionState.value = ConnectionState.Error(e.message ?: CONNECTION_ERROR_MESSAGE) + setConnectionError(e) } } } @@ -178,24 +182,43 @@ class GameViewModel if (lobbyCode != null && team != null && role != null) { viewModelScope.launch { - gameRepository.sendRejoin(username, userId, lobbyCode, role, team) + try { + gameRepository.sendRejoin(username, userId, lobbyCode, role, team) + } catch (e: Exception) { + setConnectionError(e) + } } } } - private fun Team.isActiveOperativeTurn(turn: PlayerRoles): Boolean = - (this == Team.BLUE && turn == PlayerRoles.BLUE_OPERATIVE) || - (this == Team.RED && turn == PlayerRoles.RED_OPERATIVE) - - private fun Team.isActiveSpymasterTurn(turn: PlayerRoles): Boolean = - (this == Team.BLUE && turn == PlayerRoles.BLUE_SPYMASTER) || - (this == Team.RED && turn == PlayerRoles.RED_SPYMASTER) - fun handleMessage(message: GameMessage) { + if (!message.error.isNullOrBlank()) { + _connectionState.value = ConnectionState.Error(message.error) + return + } + val state = message.toGameState() Log.d("GameViewModel", "New Game State: $state") _uiState.update { state } } + + fun clearError() { + if (_connectionState.value is ConnectionState.Error) { + _connectionState.value = ConnectionState.IDLE + } + } + + private fun setConnectionError(error: Throwable) { + _connectionState.value = ConnectionState.Error(ErrorMessageMapper.toUserMessage(error)) + } + + private fun Team.isActiveOperativeTurn(turn: PlayerRoles): Boolean = + (this == Team.BLUE && turn == PlayerRoles.BLUE_OPERATIVE) || + (this == Team.RED && turn == PlayerRoles.RED_OPERATIVE) + + private fun Team.isActiveSpymasterTurn(turn: PlayerRoles): Boolean = + (this == Team.BLUE && turn == PlayerRoles.BLUE_SPYMASTER) || + (this == Team.RED && turn == PlayerRoles.RED_SPYMASTER) } 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 17d0c01..54b68ea 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -3,6 +3,7 @@ package com.codenames.frontend.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.codenames.frontend.data.error.ErrorMessageMapper import com.codenames.frontend.data.model.LobbyUiState import com.codenames.frontend.data.model.Player import com.codenames.frontend.data.model.enums.ChatTab @@ -50,7 +51,7 @@ class LobbyViewModel } startPolling(response.lobbyCode) } catch (e: Exception) { - setError(e.message) + setError(e) } finally { setLoading(false) } @@ -79,7 +80,7 @@ class LobbyViewModel updateUiState(_state.value.players) startPolling(response.lobbyCode) } catch (e: Exception) { - setError(e.message) + setError(e) } finally { setLoading(false) } @@ -111,7 +112,7 @@ class LobbyViewModel stopPolling() successful = true } catch (e: Exception) { - setError(e.message) + setError(e) successful = false } finally { setLoading(false) @@ -143,7 +144,7 @@ class LobbyViewModel } updateUiState(_state.value.players) } catch (e: Exception) { - setError(e.message) + setError(e) } finally { setLoading(false) } @@ -223,7 +224,7 @@ class LobbyViewModel } updateUiState(_state.value.players) } catch (e: Exception) { - setError(e.message) + setError(e) } finally { Log.d("LobbyViewModel", "Game start sent. Current state: ${_state.value}") setLoading(false) @@ -236,6 +237,12 @@ class LobbyViewModel startPolling(lobbyCode) } + fun clearError() { + _state.update { + it.copy(error = null) + } + } + private fun cleanup() { _state.update { it.copy( @@ -286,7 +293,7 @@ class LobbyViewModel } updateUiState(_state.value.players) } catch (e: Exception) { - setError(e.message) + setError(e) return@launch } @@ -300,6 +307,10 @@ class LobbyViewModel pollingJob = null } + private fun setError(error: Throwable) { + setError(ErrorMessageMapper.toUserMessage(error)) + } + private fun setError(msg: String?) { val errorMessage = if (msg.isNullOrBlank()) { @@ -319,7 +330,6 @@ class LobbyViewModel } } - // for testing polling internal fun startPollingForTest(lobbyCode: String) { startPolling(lobbyCode) } diff --git a/app/src/test/java/com/codenames/frontend/data/error/ErrorMessageMapperTest.kt b/app/src/test/java/com/codenames/frontend/data/error/ErrorMessageMapperTest.kt new file mode 100644 index 0000000..f6199a2 --- /dev/null +++ b/app/src/test/java/com/codenames/frontend/data/error/ErrorMessageMapperTest.kt @@ -0,0 +1,112 @@ +package com.codenames.frontend.data.error + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException + +class ErrorMessageMapperTest { + @Test + fun toUserMessage_httpExceptionWithBackendMessage_returnsBackendMessage() { + val errorBody = + """ + { + "message": "Could not find lobby.", + "lobbyCode": "ABCDE", + "playerList": null, + "isStarted": false + } + """.trimIndent() + .toResponseBody("application/json".toMediaType()) + + val exception = HttpException(Response.error(400, errorBody)) + + val result = ErrorMessageMapper.toUserMessage(exception) + + assertEquals("Could not find lobby.", result) + } + + @Test + fun toUserMessage_badRequestWithoutBackendMessage_returnsUserFriendlyMessage() { + val exception = + HttpException( + Response.error( + 400, + "{}".toResponseBody("application/json".toMediaType()), + ), + ) + + val result = ErrorMessageMapper.toUserMessage(exception) + + assertEquals( + "The request could not be processed. Please check your input.", + result, + ) + } + + @Test + fun toUserMessage_notFoundWithoutBackendMessage_returnsUserFriendlyMessage() { + val exception = + HttpException( + Response.error( + 404, + "{}".toResponseBody("application/json".toMediaType()), + ), + ) + + val result = ErrorMessageMapper.toUserMessage(exception) + + assertEquals( + "The requested lobby could not be found.", + result, + ) + } + + @Test + fun toUserMessage_serverErrorWithoutBackendMessage_returnsUserFriendlyMessage() { + val exception = + HttpException( + Response.error( + 500, + "{}".toResponseBody("application/json".toMediaType()), + ), + ) + + val result = ErrorMessageMapper.toUserMessage(exception) + + assertEquals( + "The server has a problem right now. Please try again later.", + result, + ) + } + + @Test + fun toUserMessage_ioException_returnsConnectionMessage() { + val result = ErrorMessageMapper.toUserMessage(IOException()) + + assertEquals( + "Could not connect to the server. Please check your connection.", + result, + ) + } + + @Test + fun toUserMessage_exceptionWithMessage_returnsMessage() { + val result = ErrorMessageMapper.toUserMessage(RuntimeException("Network error")) + + assertEquals("Network error", result) + } + + @Test + fun toUserMessage_exceptionWithoutMessage_returnsGenericMessage() { + val result = ErrorMessageMapper.toUserMessage(RuntimeException()) + + assertEquals( + "Something went wrong. Please try again.", + result, + ) + } +} diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt index 5227a0a..eef378a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -5,24 +5,36 @@ 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.ChatRepository +import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class ChatViewModelTest { + private val dispatcher = StandardTestDispatcher() + private lateinit var repository: ChatRepository private lateinit var viewModel: ChatViewModel @Before fun setup() { + Dispatchers.setMain(dispatcher) + repository = mockk(relaxed = true) every { @@ -40,6 +52,11 @@ class ChatViewModelTest { viewModel = ChatViewModel(repository) } + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun sendChatMessage_global_sendsLobbyMessage() = runTest { @@ -52,6 +69,8 @@ class ChatViewModelTest { availableChatTabs = listOf(ChatTab.GLOBAL), ) + advanceUntilIdle() + coVerify { repository.sendLobbyMessage( "ABCD", @@ -77,6 +96,8 @@ class ChatViewModelTest { ), ) + advanceUntilIdle() + coVerify { repository.sendTeamMessage( "ABCD", @@ -104,6 +125,8 @@ class ChatViewModelTest { ), ) + advanceUntilIdle() + coVerify { repository.sendOperativeMessage( "ABCD", @@ -130,6 +153,8 @@ class ChatViewModelTest { ), ) + advanceUntilIdle() + coVerify(exactly = 0) { repository.sendOperativeMessage( any(), @@ -152,6 +177,8 @@ class ChatViewModelTest { availableChatTabs = listOf(ChatTab.GLOBAL), ) + advanceUntilIdle() + coVerify(exactly = 0) { repository.sendLobbyMessage(any(), any(), any()) } @@ -208,10 +235,6 @@ class ChatViewModelTest { advanceUntilIdle() val messages = viewModel.chatState.value.lobbyMessages - if (messages.isNotEmpty()) { - println("First sender: ${messages.first().sender}") - println("First text: ${messages.first().text}") - } assertEquals(1, messages.size) @@ -220,4 +243,50 @@ class ChatViewModelTest { assertEquals("Anna", first.sender) assertEquals("Hallo", first.text) } + + @Test + fun sendLobbyMessage_whenRepositoryThrows_setsErrorMessage() = + runTest { + coEvery { + repository.sendLobbyMessage(any(), any(), any()) + } throws RuntimeException("Chat message could not be sent") + + viewModel.sendLobbyMessage( + lobbyCode = "ABCD", + username = "Max", + content = "Hallo", + ) + + advanceUntilIdle() + + assertEquals( + "Chat message could not be sent", + viewModel.errorMessage.value, + ) + } + + @Test + fun clearError_resetsErrorMessage() = + runTest { + coEvery { + repository.sendLobbyMessage(any(), any(), any()) + } throws RuntimeException("Chat message could not be sent") + + viewModel.sendLobbyMessage( + lobbyCode = "ABCD", + username = "Max", + content = "Hallo", + ) + + advanceUntilIdle() + + assertEquals( + "Chat message could not be sent", + viewModel.errorMessage.value, + ) + + viewModel.clearError() + + assertNull(viewModel.errorMessage.value) + } } 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 b4f722e..e04b29f 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -617,4 +617,41 @@ class GameViewModelTest { gameRepository.sendRejoin(any(), any(), any(), any(), any()) } } + + @Test + fun handleMessage_withError_setsConnectionError() = + runTest { + val message = + GameMessage( + error = "Not your turn.", + ) + + viewModel.handleMessage(message) + + val state = viewModel.connectionState.value + + assertTrue(state is ConnectionState.Error) + assertEquals( + "Not your turn.", + (state as ConnectionState.Error).message, + ) + } + + @Test + fun clearError_whenConnectionStateIsError_resetsToIdle() = + runTest { + coEvery { + client.connectStomp() + } throws RuntimeException("Connection failed") + + viewModel.connect(lobbyCode) + + advanceUntilIdle() + + assertTrue(viewModel.connectionState.value is ConnectionState.Error) + + viewModel.clearError() + + assertEquals(ConnectionState.IDLE, viewModel.connectionState.value) + } } 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 55707f6..ba43d3a 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -328,7 +328,7 @@ class LobbyViewModelTest { val state = viewModel.state.value - assertEquals("Unknown error", state.error) + assertEquals("Something went wrong. Please try again.", state.error) assertFalse(state.isLoading) } @@ -349,7 +349,7 @@ class LobbyViewModelTest { val state = viewModel.state.value - assertEquals("Unknown error", state.error) + assertEquals("Something went wrong. Please try again.", state.error) assertFalse(state.isLoading) } @@ -1144,4 +1144,24 @@ class LobbyViewModelTest { viewModel.getAvailableChatTabsForUser("Alice"), ) } + + @Test + fun clearError_resetsError() = + runTest { + val repository = mockk() + + coEvery { repository.createLobby(any()) } throws RuntimeException("Network error") + + val viewModel = LobbyViewModel(repository) + + viewModel.createLobby("Max") + + advanceUntilIdle() + + assertEquals("Network error", viewModel.state.value.error) + + viewModel.clearError() + + assertNull(viewModel.state.value.error) + } } From 62761beb38b450e53135d5a838c52040cf87b6e7 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 16 Jun 2026 13:56:38 +0200 Subject: [PATCH 098/126] refactor: removed old error lines in favour of error message pop-up --- .../java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt | 4 ++-- .../java/com/codenames/frontend/ui/screens/LobbyScreen.kt | 4 ++-- .../java/com/codenames/frontend/ui/screens/StartScreen.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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 index 66789ba..99fb2c3 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -39,7 +39,6 @@ import com.codenames.frontend.ui.inputs.AppTextFieldType import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppInk -import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient @@ -173,6 +172,7 @@ fun JoinlobbyScreen( ) } + /* old error line, replaced by error pop-up state.error?.let { error -> Text( text = error, @@ -180,7 +180,7 @@ fun JoinlobbyScreen( fontSize = dimensions.bodyFontSize, modifier = Modifier.padding(top = dimensions.itemSpacing), ) - } + }*/ } SettingsCornerButton( 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 10bf4e5..35b60d4 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 @@ -41,7 +41,6 @@ import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppBlack import com.codenames.frontend.ui.theme.AppBlueLight import com.codenames.frontend.ui.theme.AppMutedDark -import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.AppRedLight import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.LocalResponsiveDimensions @@ -179,6 +178,7 @@ fun LobbyScreen( ) } + /* old error line, replaced by error pop-up lobbyUiState.error?.let { error -> Text( text = error, @@ -189,7 +189,7 @@ fun LobbyScreen( .align(Alignment.BottomCenter) .padding(bottom = dimensions.screenPadding), ) - } + } */ SettingsCornerButton( onClick = { 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 4035a6d..3fb7935 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 @@ -23,7 +23,6 @@ import com.codenames.frontend.ui.buttons.SettingsCornerButton import com.codenames.frontend.ui.navigation.Screen import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppInk -import com.codenames.frontend.ui.theme.AppRed import com.codenames.frontend.ui.theme.LocalResponsiveDimensions import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.ui.theme.greenGradient @@ -119,6 +118,7 @@ fun StartScreen( ) } + /* old error line, replaced by error pop-up lobbyState.error?.let { error -> Text( text = error, @@ -126,7 +126,7 @@ fun StartScreen( fontSize = dimensions.bodyFontSize, modifier = Modifier.padding(top = dimensions.itemSpacing), ) - } + }*/ } SettingsCornerButton( From cb0d675e96d86796733d0d46253bb400a5b21894 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:02:46 +0200 Subject: [PATCH 099/126] Add cheat status fields to game state --- .../main/java/com/codenames/frontend/data/model/GameState.kt | 2 ++ app/src/main/java/com/codenames/frontend/data/model/Mapper.kt | 2 ++ .../main/java/com/codenames/frontend/network/dto/GameMessage.kt | 2 ++ 3 files changed, 6 insertions(+) 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 index 25499da..7d325f5 100644 --- a/app/src/main/java/com/codenames/frontend/data/model/GameState.kt +++ b/app/src/main/java/com/codenames/frontend/data/model/GameState.kt @@ -15,4 +15,6 @@ data class GameState( val currentBlueFound: Int = 0, val chatLists: ChatLists = ChatLists(), val availableChatTabs: List = listOf(ChatTab.GLOBAL), + val redTeamCheatUsed: Boolean = false, + val blueTeamCheatUsed: Boolean = false, ) 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 720cd72..b622ef2 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 @@ -37,6 +37,8 @@ fun GameMessage.toGameState(): GameState { remainingGuesses = remainingGuesses, currentRedFound = cards.count { it.type == CardType.RED && it.revealed }, currentBlueFound = cards.count { it.type == CardType.BLUE && it.revealed }, + redTeamCheatUsed = redTeamCheatUsed, + blueTeamCheatUsed = blueTeamCheatUsed, ) } 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 f9bf24b..ebc8546 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 @@ -13,4 +13,6 @@ data class GameMessage( val cardList: List = emptyList(), val error: String? = null, val remainingGuesses: Int = 0, + val redTeamCheatUsed: Boolean = false, + val blueTeamCheatUsed: Boolean = false, ) From 893f270eb1d2ac2226cfff21ac3af8306f833900 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 16 Jun 2026 19:52:44 +0200 Subject: [PATCH 100/126] fix: sonar issue useless null-safe access --- .../java/com/codenames/frontend/ui/navigation/NavGraph.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 cb68eec..9b640b7 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 @@ -39,7 +39,7 @@ fun NavGraph( val connectionState by gameViewModel.connectionState.collectAsState() val chatErrorMessage by chatViewModel.errorMessage.collectAsState() - val errorMessage = + val errorMessage: String? = lobbyState.error ?: (connectionState as? ConnectionState.Error)?.message ?: chatErrorMessage @@ -106,9 +106,9 @@ fun NavGraph( } } - errorMessage?.let { message -> + if (errorMessage != null) { ErrorDialog( - message = message, + message = errorMessage, onDismiss = { lobbyViewModel.clearError() gameViewModel.clearError() From 48241ad1c03eecbcdf0552fc0ff1d93ca8580639 Mon Sep 17 00:00:00 2001 From: ad-devel Date: Tue, 16 Jun 2026 20:04:53 +0200 Subject: [PATCH 101/126] refactor: reworked color themes of error message pop-up --- .../frontend/ui/composables/ErrorDialog.kt | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt b/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt index 5863cc6..29e6535 100644 --- a/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt +++ b/app/src/main/java/com/codenames/frontend/ui/composables/ErrorDialog.kt @@ -4,7 +4,12 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.runtime.Composable import com.codenames.frontend.ui.buttons.AppButton -import com.codenames.frontend.ui.theme.AppInk +import com.codenames.frontend.ui.buttons.AppButtonStyle +import com.codenames.frontend.ui.theme.AppBlue +import com.codenames.frontend.ui.theme.AppInkDark +import com.codenames.frontend.ui.theme.AppInkOverlay +import com.codenames.frontend.ui.theme.AppSurface +import com.codenames.frontend.ui.theme.AppWhite @Suppress("ktlint:standard:function-naming") @Composable @@ -14,19 +19,24 @@ fun ErrorDialog( ) { AlertDialog( onDismissRequest = onDismiss, + containerColor = AppSurface, + titleContentColor = AppInkDark, + textContentColor = AppInkOverlay, title = { Text("Something went wrong") }, text = { - Text( - text = message, - color = AppInk, - ) + Text(text = message) }, confirmButton = { AppButton( text = "OK", onClick = onDismiss, + style = + AppButtonStyle( + containerColor = AppBlue, + contentColor = AppWhite, + ), ) }, ) From b37267647a1956bff04d9ed3d278e7c3047ee3a1 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:28:24 +0200 Subject: [PATCH 102/126] Fix cheat system message handling --- .../frontend/data/model/enums/ChatMessageType.kt | 1 + .../codenames/frontend/viewmodel/LobbyViewModel.kt | 11 +---------- .../frontend/viewmodel/LobbyViewModelTest.kt | 5 ++++- 3 files changed, 6 insertions(+), 11 deletions(-) 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 index 1ca50f5..c31ed50 100644 --- 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 @@ -5,4 +5,5 @@ import kotlinx.serialization.Serializable @Serializable enum class ChatMessageType { CHAT, + SYSTEM, } 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 17d0c01..f39e508 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -194,16 +194,7 @@ class LobbyViewModel 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 + return team != null && role == Role.OPERATIVE } fun getIsHost(username: String): Boolean { 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 55707f6..a31f9c5 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -1112,7 +1112,10 @@ class LobbyViewModelTest { advanceTimeBy(1) viewModel.stopPollingForTest() - assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) + assertEquals( + listOf(ChatTab.GLOBAL, ChatTab.TEAM, ChatTab.OPERATIVES), + viewModel.getAvailableChatTabsForUser("Alice"), + ) } @Test From cc0c9abe184f57e90ef04f98719f214f3a46a514 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Wed, 17 Jun 2026 02:44:40 +0200 Subject: [PATCH 103/126] Fix Sonar issues --- .../java/com/codenames/frontend/viewmodel/LobbyViewModel.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 f39e508..bbb9353 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -193,9 +193,7 @@ class LobbyViewModel private fun canUseOperativesChat( team: Team?, role: Role?, - ): Boolean { - return team != null && role == Role.OPERATIVE - } + ): Boolean = team != null && role == Role.OPERATIVE fun getIsHost(username: String): Boolean { val player: Player = _state.value.players.firstOrNull { it.name == username } ?: return false From db54003113bbf2ded71594a4d410f023aff6d1ef Mon Sep 17 00:00:00 2001 From: ad-devel Date: Wed, 17 Jun 2026 06:18:28 +0200 Subject: [PATCH 104/126] refactor: removed redundant code --- .../frontend/ui/screens/JoinLobbyScreen.kt | 10 ---------- .../codenames/frontend/ui/screens/LobbyScreen.kt | 13 ------------- .../codenames/frontend/ui/screens/StartScreen.kt | 10 ---------- 3 files changed, 33 deletions(-) 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 index 99fb2c3..290112a 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -171,16 +171,6 @@ fun JoinlobbyScreen( modifier = Modifier.padding(top = dimensions.itemSpacing), ) } - - /* old error line, replaced by error pop-up - state.error?.let { error -> - Text( - text = error, - color = AppRed, - fontSize = dimensions.bodyFontSize, - modifier = Modifier.padding(top = dimensions.itemSpacing), - ) - }*/ } SettingsCornerButton( 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 35b60d4..a4db508 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 @@ -178,19 +178,6 @@ fun LobbyScreen( ) } - /* old error line, replaced by error pop-up - lobbyUiState.error?.let { error -> - Text( - text = error, - color = AppRed, - fontSize = dimensions.bodyFontSize, - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = dimensions.screenPadding), - ) - } */ - SettingsCornerButton( onClick = { navController.navigate(Screen.Settings.route) 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 3fb7935..a5e1510 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 @@ -117,16 +117,6 @@ fun StartScreen( modifier = Modifier.padding(top = dimensions.itemSpacing), ) } - - /* old error line, replaced by error pop-up - lobbyState.error?.let { error -> - Text( - text = error, - color = AppRed, - fontSize = dimensions.bodyFontSize, - modifier = Modifier.padding(top = dimensions.itemSpacing), - ) - }*/ } SettingsCornerButton( From 160e2fbad8f84e121ed345ed76429057783e7dba Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:47:28 +0200 Subject: [PATCH 105/126] Error Fix --- .../java/com/codenames/frontend/viewmodel/GameViewModel.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 4115f36..4fe495e 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -167,10 +167,7 @@ class GameViewModel positions = positions, ) } catch (e: Exception) { - _connectionState.value = - ConnectionState.Error( - e.message ?: CONNECTION_ERROR_MESSAGE, - ) + setConnectionError(e) } } } From c9de9304cac85175ccbd99e81e751df1cf540253 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 12:53:57 +0200 Subject: [PATCH 106/126] dto: update player dto and lobby response to include UUID --- .../java/com/codenames/frontend/network/dto/LobbyResponse.kt | 1 + .../main/java/com/codenames/frontend/network/dto/PlayerDto.kt | 1 + 2 files changed, 2 insertions(+) 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 09238e5..d92eb54 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 @@ -8,4 +8,5 @@ data class LobbyResponse( val playerList: List? = emptyList(), val isStarted: Boolean = false, val message: String = "", + val uuid: String? = null ) 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 c663fa7..970faf5 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 @@ -10,4 +10,5 @@ data class PlayerDto( val role: Role? = null, val team: Team? = null, val isHost: Boolean, + val uuid: String? = null ) From 4bebe28024669a0771f5a91dd3623262d8c96fda Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 12:54:50 +0200 Subject: [PATCH 107/126] update lobby flow to include uuid in requests, where the backend expects uuid --- .../data/repository/LobbyRepository.kt | 19 ++++++---- .../frontend/network/api/LobbyApi.kt | 3 +- .../frontend/viewmodel/LobbyViewModel.kt | 36 ++++++++++++------- 3 files changed, 39 insertions(+), 19 deletions(-) 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 fd0f9f6..30f9163 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 @@ -5,6 +5,7 @@ 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 java.util.UUID import javax.inject.Inject class LobbyRepository @@ -16,28 +17,34 @@ class LobbyRepository suspend fun leaveLobby( lobbyCode: String, - username: String, - ): LobbyResponse = api.leaveLobby(lobbyCode, username) + userId: UUID, + ): LobbyResponse = api.leaveLobby(lobbyCode, userId.toString()) suspend fun joinLobby( username: String, lobbyCode: String, - ): LobbyResponse = api.joinLobby(lobbyCode, username) + userId: UUID? + ): LobbyResponse { + var id : String? = userId.toString() + if(id.equals("null")) id = null + return api.joinLobby(lobbyCode, username, id) + } suspend fun getLobbyInfo(lobbyCode: String): LobbyResponse = api.getLobbyInfo(lobbyCode) suspend fun changeRole( username: String, + userId: UUID, lobbyCode: String, role: Role, team: Team, ): LobbyResponse { - val player = PlayerDto(username, role, team, false) + val player = PlayerDto(username, role, team, false, userId.toString()) return api.changeRole(lobbyCode, player) } suspend fun sendStartGame( lobbyCode: String, - username: String, - ): LobbyResponse = api.startGame(lobbyCode, username) + userId: UUID, + ): LobbyResponse = api.startGame(lobbyCode, userId.toString()) } 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 ccfb313..bae0acb 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 @@ -19,6 +19,7 @@ interface LobbyApi { suspend fun joinLobby( @Path("lobbyCode") lobbyCode: String, @Query("username") username: String, + @Query("uuid") userId: String? ): LobbyResponse @GET("lobby/{lobbyCode}") @@ -29,7 +30,7 @@ interface LobbyApi { @GET("lobby/{lobbyCode}/leave") suspend fun leaveLobby( @Path("lobbyCode") lobbyCode: String, - @Query("username") username: String, + @Query("uuid") uuid: String, ): LobbyResponse @POST("lobby/{lobbyCode}/select-position") 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 54b68ea..b566427 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -20,13 +20,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import java.util.UUID import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class LobbyViewModel @Inject constructor( private val repository: LobbyRepository, + private val sessionViewModel: SessionViewModel ) : ViewModel() { private val _state = MutableStateFlow(LobbyUiState()) val state: StateFlow = _state @@ -49,6 +52,9 @@ class LobbyViewModel _state.update { response.toLobbyState() } + if(response.uuid != null) { + sessionViewModel.setUserId(UUID.fromString(response.uuid)) + } startPolling(response.lobbyCode) } catch (e: Exception) { setError(e) @@ -60,6 +66,7 @@ class LobbyViewModel fun joinLobby( username: String, + userId: UUID? = null, lobbyCode: String, ) { val inLobby = !_state.value.lobbyCode.isNullOrBlank() @@ -72,13 +79,16 @@ class LobbyViewModel setLoading(true) try { - val response = repository.joinLobby(username, lobbyCode) + val response = repository.joinLobby(username, lobbyCode, userId) _state.update { response.toLobbyState() } updateUiState(_state.value.players) startPolling(response.lobbyCode) + if(response.uuid != null) { + sessionViewModel.setUserId(UUID.fromString(response.uuid)) + } } catch (e: Exception) { setError(e) } finally { @@ -88,7 +98,7 @@ class LobbyViewModel } fun leaveLobby( - username: String, + userId: UUID, onResult: (Boolean) -> Unit, ) { val lobbyCode = _state.value.lobbyCode @@ -104,7 +114,7 @@ class LobbyViewModel setLoading(true) try { - val response = repository.leaveLobby(lobbyCode, username) + val response = repository.leaveLobby(lobbyCode, userId) _state.update { response.toLobbyState() } @@ -126,6 +136,7 @@ class LobbyViewModel role: Role, team: Team, username: String, + userId: UUID ) { val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { @@ -137,7 +148,7 @@ class LobbyViewModel setLoading(true) try { - val response = repository.changeRole(username, lobbyCode, role, team) + val response = repository.changeRole(username, userId, lobbyCode, role, team) _state.update { response.toLobbyState() @@ -154,12 +165,13 @@ class LobbyViewModel fun changeRole( role: PlayerRoles, username: String, + userId: UUID ) { 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) + PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username, userId) + PlayerRoles.RED_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.RED, username = username, userId) + PlayerRoles.BLUE_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.BLUE, username = username, userId) + PlayerRoles.RED_OPERATIVE -> changeRole(role = Role.OPERATIVE, team = Team.RED, username = username, userId) else -> setError("Invalid role") } } @@ -212,13 +224,13 @@ class LobbyViewModel return player.isHost } - fun sendStartGame(username: String) { + fun sendStartGame(username: String, userId: UUID) { val lobbyCode = _state.value.lobbyCode.orEmpty() - if (!username.isBlank() && !lobbyCode.isEmpty() && getIsHost(username)) { + if (!username.isBlank() && lobbyCode.isNotEmpty() && getIsHost(username)) { viewModelScope.launch { try { setLoading(true) - val response = repository.sendStartGame(lobbyCode, username) + val response = repository.sendStartGame(lobbyCode, userId) _state.update { response.toLobbyState() } @@ -297,7 +309,7 @@ class LobbyViewModel return@launch } - delay(pollingTime) + delay(pollingTime.milliseconds) } } } From d6ef9e7dc413b39e3c3519c927acd5f653073deb Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 13:01:57 +0200 Subject: [PATCH 108/126] add uuid calls to lobby screen --- .../frontend/ui/screens/LobbyScreen.kt | 8 +++---- .../frontend/viewmodel/LobbyViewModel.kt | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) 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 a4db508..e5118cf 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 @@ -74,7 +74,7 @@ fun LobbyScreen( val connectionState by gameViewModel.connectionState.collectAsState() val onStartGame = { - viewModel.sendStartGame(userState.username) + viewModel.sendStartGame(userState.username, userState.userId) } LaunchedEffect(userState.userId) { @@ -150,7 +150,7 @@ fun LobbyScreen( gradient = blueGradient, textColor = AppBlueLight, title = "BLUE TEAM", - onRoleSelect = { viewModel.changeRole(it, userState.username) }, + onRoleSelect = { viewModel.changeRole(it, userState.username, userState.userId) }, lobbyUiState = lobbyUiState, ) @@ -173,7 +173,7 @@ fun LobbyScreen( gradient = redGradient, textColor = AppRedLight, title = "RED TEAM", - onRoleSelect = { viewModel.changeRole(it, userState.username) }, + onRoleSelect = { viewModel.changeRole(it, userState.username, userState.userId) }, lobbyUiState = lobbyUiState, ) } @@ -409,7 +409,7 @@ fun GameSettingsColumn( } } } - viewModel.leaveLobby(username = userState.username, onResult = onResult) + viewModel.leaveLobby(userId = userState.userId, onResult = onResult) }, modifier = Modifier 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 b566427..cf37a77 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -24,6 +24,8 @@ import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds +private const val ID_NOT_FOUND = "No valid ID found." + @HiltViewModel class LobbyViewModel @Inject @@ -98,7 +100,7 @@ class LobbyViewModel } fun leaveLobby( - userId: UUID, + userId: UUID?, onResult: (Boolean) -> Unit, ) { val lobbyCode = _state.value.lobbyCode @@ -109,6 +111,10 @@ class LobbyViewModel onResult(successful) return } + if(userId == null) { + setError(ID_NOT_FOUND) + return + } viewModelScope.launch { setLoading(true) @@ -136,13 +142,17 @@ class LobbyViewModel role: Role, team: Team, username: String, - userId: UUID + userId: UUID? ) { val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { setError("Not in a Lobby") return } + if(userId == null) { + setError(ID_NOT_FOUND) + return + } viewModelScope.launch { setLoading(true) @@ -165,7 +175,7 @@ class LobbyViewModel fun changeRole( role: PlayerRoles, username: String, - userId: UUID + userId: UUID? ) { when (role) { PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username, userId) @@ -224,8 +234,12 @@ class LobbyViewModel return player.isHost } - fun sendStartGame(username: String, userId: UUID) { + fun sendStartGame(username: String, userId: UUID?) { val lobbyCode = _state.value.lobbyCode.orEmpty() + if(userId == null) { + setError(ID_NOT_FOUND) + return + } if (!username.isBlank() && lobbyCode.isNotEmpty() && getIsHost(username)) { viewModelScope.launch { try { From 6932f981407c4eda1e49d07801d859f2abb1cc21 Mon Sep 17 00:00:00 2001 From: Emre Orhan <116675659+Zheaver@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:06:55 +0200 Subject: [PATCH 109/126] Fix Sonar issues --- .../java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt index dcff94d..8a31688 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/ChatViewModelTest.kt @@ -13,8 +13,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest From 5e028b3b97249fe597819abb5c7fda46287b906a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 13:09:01 +0200 Subject: [PATCH 110/126] fix: fix user id save flow to avoid loops --- .../frontend/ui/screens/UserNameScreen.kt | 1 - .../frontend/viewmodel/LobbyViewModel.kt | 4 +-- .../frontend/viewmodel/SessionViewModel.kt | 27 ++++++++++--------- .../viewmodel/SessionViewModelTest.kt | 14 ++-------- 4 files changed, 18 insertions(+), 28 deletions(-) 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 index fa6e5cd..60f3e44 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -117,7 +117,6 @@ fun UserNameScreen( onClick = { if (username.isBlank()) return@AppButton viewModel.setUsername(username) - viewModel.setUserId(UUID.randomUUID()) // ONLY FOR TESTING!!!! navController.navigate(Screen.Start.route) }, modifier = 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 cf37a77..1dd7fe2 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -55,7 +55,7 @@ class LobbyViewModel response.toLobbyState() } if(response.uuid != null) { - sessionViewModel.setUserId(UUID.fromString(response.uuid)) + sessionViewModel.persistUserId(UUID.fromString(response.uuid)) } startPolling(response.lobbyCode) } catch (e: Exception) { @@ -89,7 +89,7 @@ class LobbyViewModel updateUiState(_state.value.players) startPolling(response.lobbyCode) if(response.uuid != null) { - sessionViewModel.setUserId(UUID.fromString(response.uuid)) + sessionViewModel.persistUserId(UUID.fromString(response.uuid)) } } catch (e: Exception) { setError(e) diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index 9b72566..de73761 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -40,25 +40,17 @@ class SessionViewModel } } - fun setUserId(userId: UUID) { - _userState.update { - it.copy( - userId = userId, - ) - } - } - fun persistLobbyState( - lobbyCode: String, - role: Role, - team: Team, - ) { + lobbyCode: String, + role: Role, + team: Team, + ) { viewModelScope.launch { sessionRepository.saveLobby( lobbyCode, role, team, - ) + ) } } @@ -71,6 +63,15 @@ class SessionViewModel } } + fun persistUserId(userId: UUID) { + viewModelScope.launch { + sessionRepository.saveUser( + _userState.value.username, + userId + ) + } + } + fun clearLobby() { viewModelScope.launch { sessionRepository.clearLobbyData() diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt index 43b90c7..28f7837 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -75,28 +75,18 @@ class SessionViewModelTest { } @Test - fun `setUserId updates user id`() { - val uuid = UUID.randomUUID() - - viewModel.setUserId(uuid) - - assertEquals(uuid, viewModel.userState.value.userId) - } - - @Test - fun `persistUserState saves user`() = + fun `persistUserState saves username`() = runTest { val uuid = UUID.randomUUID() viewModel.setUsername("Max") - viewModel.setUserId(uuid) viewModel.persistUserState() advanceUntilIdle() coVerify { - sessionRepository.saveUser("Max", uuid) + sessionRepository.saveUser("Max", any()) } } From cabdb3ac5cd884385c382897e4a33a1559667f3a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 13:37:42 +0200 Subject: [PATCH 111/126] add userId to websocket join message --- .../com/codenames/frontend/data/repository/GameRepository.kt | 5 +---- .../codenames/frontend/network/dto/WebSocketJoinMessage.kt | 1 + .../java/com/codenames/frontend/viewmodel/GameViewModel.kt | 2 +- 3 files changed, 3 insertions(+), 5 deletions(-) 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 index d2adc9b..0251a69 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/GameRepository.kt @@ -1,6 +1,5 @@ package com.codenames.frontend.data.repository -import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.dto.ClueMessageDto import com.codenames.frontend.network.dto.GuessMessage @@ -67,10 +66,8 @@ class GameRepository username: String, userId: UUID, lobbyCode: String, - lobbyRole: Role, - lobbyTeam: Team, ) { - val msg = WebSocketJoinMessage(username, lobbyCode) + val msg = WebSocketJoinMessage(username, lobbyCode, userId.toString()) webSocketHandler.sendReconnectMessage(msg) } } 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 2423f49..dbf447c 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 @@ -9,4 +9,5 @@ data class WebSocketJoinMessage( val username: String, @SerialName("code") val lobbyCode: String, + val uuid: String ) 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 628375b..92107c6 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -183,7 +183,7 @@ class GameViewModel if (lobbyCode != null && team != null && role != null) { viewModelScope.launch { try { - gameRepository.sendRejoin(username, userId, lobbyCode, role, team) + gameRepository.sendRejoin(username, userId, lobbyCode) } catch (e: Exception) { setConnectionError(e) } From 7dcaec9a33c2f07ecb2ff6a009bd91eae6e274ff Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 15:37:00 +0200 Subject: [PATCH 112/126] reorganised user id saving to adhere to hilt best practice --- .../codenames/frontend/ui/screens/JoinLobbyScreen.kt | 4 ++-- .../codenames/frontend/viewmodel/LobbyViewModel.kt | 12 ++++-------- 2 files changed, 6 insertions(+), 10 deletions(-) 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 index 290112a..5e80d23 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -76,7 +76,7 @@ fun JoinlobbyScreen( val focusManager = LocalFocusManager.current val state by viewModel.state.collectAsState() - val username by sessionViewModel.userState.collectAsState() + val userState by sessionViewModel.userState.collectAsState() val joinEnabled = isLobbyIdValid(lobbyId) @@ -92,7 +92,7 @@ fun JoinlobbyScreen( keyboardController?.hide() focusManager.clearFocus() - viewModel.joinLobby(username.username, lobbyId) + viewModel.joinLobby(userState.username, userState.userId, lobbyId) } Box( 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 dc2df15..b351158 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -11,6 +11,7 @@ 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.data.repository.SessionRepository import com.codenames.frontend.ui.roles.PlayerRoles import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -31,7 +32,7 @@ class LobbyViewModel @Inject constructor( private val repository: LobbyRepository, - private val sessionViewModel: SessionViewModel + private val sessionRepository: SessionRepository ) : ViewModel() { private val _state = MutableStateFlow(LobbyUiState()) val state: StateFlow = _state @@ -50,13 +51,10 @@ class LobbyViewModel try { val response = repository.createLobby(username) - _state.update { response.toLobbyState() } - if(response.uuid != null) { - sessionViewModel.persistUserId(UUID.fromString(response.uuid)) - } + if(response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) startPolling(response.lobbyCode) } catch (e: Exception) { setError(e) @@ -86,11 +84,9 @@ class LobbyViewModel _state.update { response.toLobbyState() } + if(response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) updateUiState(_state.value.players) startPolling(response.lobbyCode) - if(response.uuid != null) { - sessionViewModel.persistUserId(UUID.fromString(response.uuid)) - } } catch (e: Exception) { setError(e) } finally { From 4e213bf1c3fcf567e89673bae3d0174daaeaa858 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 16:44:23 +0200 Subject: [PATCH 113/126] refactor: remove userId from join request to avoid data base duplications --- .../frontend/data/repository/LobbyRepository.kt | 9 +++++---- .../java/com/codenames/frontend/network/api/LobbyApi.kt | 3 +-- .../com/codenames/frontend/ui/screens/JoinLobbyScreen.kt | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) 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 30f9163..b5b458c 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 @@ -1,5 +1,6 @@ package com.codenames.frontend.data.repository +import android.util.Log import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.api.LobbyApi @@ -23,11 +24,11 @@ class LobbyRepository suspend fun joinLobby( username: String, lobbyCode: String, - userId: UUID? ): LobbyResponse { - var id : String? = userId.toString() - if(id.equals("null")) id = null - return api.joinLobby(lobbyCode, username, id) + Log.d("LobbyRepository", "Joining lobby $lobbyCode ") + val msg = api.joinLobby(lobbyCode, username) + Log.d("LobbyRepository", "Got Lobby Join message: $msg") + return msg } suspend fun getLobbyInfo(lobbyCode: String): LobbyResponse = api.getLobbyInfo(lobbyCode) 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 bae0acb..c2f6c4d 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 @@ -19,7 +19,6 @@ interface LobbyApi { suspend fun joinLobby( @Path("lobbyCode") lobbyCode: String, @Query("username") username: String, - @Query("uuid") userId: String? ): LobbyResponse @GET("lobby/{lobbyCode}") @@ -42,6 +41,6 @@ interface LobbyApi { @GET("lobby/{lobbyCode}/start-game") suspend fun startGame( @Path("lobbyCode") lobbyCode: String, - @Query("username") username: String, + @Query("uuid") uuid: String, ): LobbyResponse } 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 index 5e80d23..863b2c6 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/JoinLobbyScreen.kt @@ -92,7 +92,7 @@ fun JoinlobbyScreen( keyboardController?.hide() focusManager.clearFocus() - viewModel.joinLobby(userState.username, userState.userId, lobbyId) + viewModel.joinLobby(userState.username, lobbyId) } Box( From b7de5673232eb73a42dd44bb2d295f9029db66c4 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 16:45:10 +0200 Subject: [PATCH 114/126] fix: add remove id function to datastore to correctly save new ids (so that there are no problems in a second game) --- .../frontend/data/datastore/UserPreferencesDataStore.kt | 6 ++++++ .../frontend/data/repository/SessionRepository.kt | 4 ++++ .../com/codenames/frontend/viewmodel/LobbyViewModel.kt | 7 ++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt index c2c8586..d079fab 100644 --- a/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt +++ b/app/src/main/java/com/codenames/frontend/data/datastore/UserPreferencesDataStore.kt @@ -80,4 +80,10 @@ class UserPreferencesDataStore prefs.remove(PreferencesKeys.LOBBY_TEAM) } } + + suspend fun removeUserId() { + context.dataStore.edit { prefs -> + prefs.remove(PreferencesKeys.USER_ID) + } + } } diff --git a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt index a4d00ed..1d3d6e3 100644 --- a/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt +++ b/app/src/main/java/com/codenames/frontend/data/repository/SessionRepository.kt @@ -37,4 +37,8 @@ class SessionRepository suspend fun clearLobbyData() { dataStore.clearSessionData() } + + suspend fun clearUserId() { + dataStore.removeUserId() + } } 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 b351158..139e1fa 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -48,7 +48,7 @@ class LobbyViewModel } viewModelScope.launch { setLoading(true) - + sessionRepository.clearUserId() try { val response = repository.createLobby(username) _state.update { @@ -66,7 +66,6 @@ class LobbyViewModel fun joinLobby( username: String, - userId: UUID? = null, lobbyCode: String, ) { val inLobby = !_state.value.lobbyCode.isNullOrBlank() @@ -78,8 +77,10 @@ class LobbyViewModel viewModelScope.launch { setLoading(true) + sessionRepository.clearUserId() + try { - val response = repository.joinLobby(username, lobbyCode, userId) + val response = repository.joinLobby(username, lobbyCode) _state.update { response.toLobbyState() From 1d690bb960d2880e90d65c7616c47e189009e4e9 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 17:29:10 +0200 Subject: [PATCH 115/126] test: fix unit tests --- .../data/repository/LobbyRepository.kt | 2 - .../data/repository/LobbyRepositoryTest.kt | 14 +- .../websocket/GameWebSocketControllerTest.kt | 3 +- .../frontend/viewmodel/GameViewModelTest.kt | 12 +- .../frontend/viewmodel/LobbyViewModelTest.kt | 287 +++++++++--------- .../viewmodel/SessionViewModelTest.kt | 2 + 6 files changed, 164 insertions(+), 156 deletions(-) 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 b5b458c..bb2e182 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 @@ -25,9 +25,7 @@ class LobbyRepository username: String, lobbyCode: String, ): LobbyResponse { - Log.d("LobbyRepository", "Joining lobby $lobbyCode ") val msg = api.joinLobby(lobbyCode, username) - Log.d("LobbyRepository", "Got Lobby Join message: $msg") return msg } 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 0b215ae..a259e43 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 @@ -13,6 +13,7 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test +import java.util.UUID import kotlin.test.assertFailsWith @OptIn(ExperimentalCoroutinesApi::class) @@ -66,14 +67,15 @@ class LobbyRepositoryTest { runTest { val username = "Max" val lobbyCode = "1234" + val userId = UUID.randomUUID() val response = LobbyResponse(lobbyCode, emptyList(), false) - coEvery { api.leaveLobby(username, lobbyCode) } returns response + coEvery { api.leaveLobby(lobbyCode = lobbyCode, uuid = userId.toString()) } returns response - val result = repository.leaveLobby(username, lobbyCode) + val result = repository.leaveLobby(lobbyCode, userId) - coVerify { api.leaveLobby(username, lobbyCode) } + coVerify { api.leaveLobby(lobbyCode, userId.toString()) } assertEquals(response, result) } @@ -97,12 +99,13 @@ class LobbyRepositoryTest { val lobbyCode = "1234" val role = Role.OPERATIVE val team = Team.RED + val userId = UUID.randomUUID() val response = LobbyResponse(lobbyCode, emptyList(), false) coEvery { api.changeRole(eq(lobbyCode), any()) } returns response - repository.changeRole(username, lobbyCode, role, team) + repository.changeRole(username, userId, lobbyCode, role, team) coVerify { api.changeRole( @@ -133,6 +136,7 @@ class LobbyRepositoryTest { runTest { val lobbyCode = "ABCDE" val username = "Test" + val userId = UUID.randomUUID() val list = listOf( PlayerDto( @@ -145,7 +149,7 @@ class LobbyRepositoryTest { coEvery { api.startGame(any(), any()) } returns LobbyResponse(lobbyCode, list, false) - val response = repository.sendStartGame(lobbyCode, username) + val response = repository.sendStartGame(lobbyCode, userId) coVerify { api.startGame(any(), any()) } assertEquals(response.lobbyCode, lobbyCode) diff --git a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt index 2038626..56173a3 100644 --- a/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt +++ b/app/src/test/java/com/codenames/frontend/network/websocket/GameWebSocketControllerTest.kt @@ -18,6 +18,7 @@ import org.hildan.krossbow.stomp.conversions.kxserialization.convertAndSend import org.hildan.krossbow.stomp.conversions.kxserialization.subscribe import org.junit.Before import org.junit.Test +import java.util.UUID class GameWebSocketControllerTest { private lateinit var wsClient: GameWebSocketController @@ -63,7 +64,7 @@ class GameWebSocketControllerTest { @Test fun testSendReconnectMessageSendsMessage() = runTest { - val msg = WebSocketJoinMessage("name", "1234") + val msg = WebSocketJoinMessage("name", "1234", UUID.randomUUID().toString()) wsClient.sendReconnectMessage(msg) 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 927887a..48b1e0c 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -568,7 +568,7 @@ class GameViewModelTest { fun testSendRejoinMessage_sendsMessage() = runTest { coEvery { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(any(), any(), any()) } just Runs val rejoinState = RejoinState.Available(SessionState(lobbyCode, Role.OPERATIVE, Team.RED)) @@ -578,7 +578,7 @@ class GameViewModelTest { advanceUntilIdle() coVerify { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(any(), any(), any()) } } @@ -586,7 +586,7 @@ class GameViewModelTest { fun testSendRejoinMessage_ignoresWithoutRejoinState() = runTest { coEvery { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(any(), any(), any()) } just Runs val rejoinState = null @@ -596,7 +596,7 @@ class GameViewModelTest { advanceUntilIdle() coVerify(exactly = 0) { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(any(), any(), any()) } } @@ -604,7 +604,7 @@ class GameViewModelTest { fun testSendRejoinMessage_ignoresNullValuesInRejoinState() = runTest { coEvery { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(any(), any(), any()) } just Runs val rejoinState = RejoinState.Available(SessionState(null, null, null)) @@ -614,7 +614,7 @@ class GameViewModelTest { advanceUntilIdle() coVerify(exactly = 0) { - gameRepository.sendRejoin(any(), any(), any(), any(), any()) + gameRepository.sendRejoin(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 8e428f7..2f4da2b 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -5,12 +5,15 @@ 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.data.repository.SessionRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto 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 io.mockk.spyk @@ -30,14 +33,19 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.util.UUID +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) class LobbyViewModelTest { private val dispatcher = StandardTestDispatcher() + val repository = mockk() + val sessionRepository = mockk() @Before fun setup() { Dispatchers.setMain(dispatcher) + } @After @@ -48,8 +56,6 @@ class LobbyViewModelTest { @Test fun testCreateLobby_success() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "1234", @@ -59,12 +65,13 @@ class LobbyViewModelTest { coEvery { repository.createLobby("User") } returns response coEvery { repository.getLobbyInfo(any()) } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("User") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -78,15 +85,14 @@ class LobbyViewModelTest { @Test fun testCreateLobby_error() = runTest { - val repository = mockk() - coEvery { repository.createLobby(any()) } throws RuntimeException("Network error") + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("Max") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -99,7 +105,6 @@ class LobbyViewModelTest { @Test fun testJoinLobby_success() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -116,11 +121,13 @@ class LobbyViewModelTest { false, ) - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "1234") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -134,15 +141,15 @@ class LobbyViewModelTest { @Test fun testJoinLobby_error() = runTest { - val repository = mockk() coEvery { repository.joinLobby(any(), any()) } throws RuntimeException("Network error") + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Max", "1234") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -155,7 +162,6 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_success() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -171,17 +177,20 @@ class LobbyViewModelTest { false, ) + val userId = UUID.randomUUID() + coEvery { repository.joinLobby("User", "1234") } returns response coEvery { repository.getLobbyInfo(any()) } returns response - coEvery { repository.leaveLobby("1234", "User") } returns response2 + coEvery { repository.leaveLobby("1234", userId) } returns response2 + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "1234") - advanceTimeBy(2000) - viewModel.leaveLobby("User", onResult = {}) + advanceTimeBy(2000.milliseconds) + viewModel.leaveLobby(userId = userId , onResult = {}) - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -195,7 +204,6 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_error() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -203,19 +211,21 @@ class LobbyViewModelTest { playerList = listOf(PlayerDto("User", null, Team.RED, true)), false, ) + val userID = UUID.randomUUID() coEvery { repository.joinLobby("User", "1234") } returns response coEvery { repository.leaveLobby(any(), any()) } throws RuntimeException("Network error") + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "1234") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) - viewModel.leaveLobby("User", onResult = {}) + viewModel.leaveLobby(userId = userID, onResult = {}) - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -228,10 +238,10 @@ class LobbyViewModelTest { @Test fun testChangeRole_success() = runTest { - val repository = mockk() val newRole = Role.OPERATIVE val newTeam = Team.RED + val username = "User" val response = LobbyResponse( @@ -246,21 +256,23 @@ class LobbyViewModelTest { playerList = listOf(PlayerDto("User", newRole, newTeam, true)), false, ) + val userId = UUID.randomUUID() coEvery { repository.joinLobby("User", "1234") } returns response coEvery { repository.getLobbyInfo(any()) } returns response - coEvery { repository.changeRole("User", "1234", newRole, newTeam) } returns response2 + coEvery { repository.changeRole("User", userId, "1234", newRole, newTeam) } returns response2 + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "1234") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) - viewModel.changeRole(newRole, newTeam, "User") + viewModel.changeRole(newRole, newTeam, username, userId) viewModel.stopPollingForTest() - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) val state = viewModel.state.value @@ -274,7 +286,6 @@ class LobbyViewModelTest { @Test fun testChangeRole_error() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -290,18 +301,21 @@ class LobbyViewModelTest { any(), any(), any(), + any() ) } throws RuntimeException("Network error") - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "1234") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) - viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User", UUID.randomUUID()) - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -314,15 +328,15 @@ class LobbyViewModelTest { @Test fun testSetError_WithEmptyValue() = runTest { - val repository = mockk() coEvery { repository.createLobby(any()) } throws RuntimeException("") + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("Max") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -335,15 +349,15 @@ class LobbyViewModelTest { @Test fun testSetError_WithNullValue() = runTest { - val repository = mockk() coEvery { repository.createLobby(any()) } throws RuntimeException() + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("Max") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -357,7 +371,6 @@ class LobbyViewModelTest { @Test fun testPolling_callsRepositoryRepeatedly() = runTest { - val repository = mockk() coEvery { repository.getLobbyInfo(any()) } returns LobbyResponse( @@ -366,12 +379,11 @@ class LobbyViewModelTest { false, ) - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.startPollingForTest("1234") - advanceTimeBy(2000) - advanceTimeBy(2000) + advanceTimeBy(4000.milliseconds) viewModel.stopPollingForTest() @@ -383,20 +395,17 @@ class LobbyViewModelTest { @Test fun testPolling_stateIsUpdated() = runTest { - val repository = mockk() - coEvery { repository.getLobbyInfo(any()) } returnsMany listOf( LobbyResponse("1", emptyList(), false), LobbyResponse("2", emptyList(), false), ) - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.startPollingForTest("1234") - advanceTimeBy(2000) - advanceTimeBy(2000) + advanceTimeBy(4000.milliseconds) viewModel.stopPollingForTest() @@ -406,7 +415,6 @@ class LobbyViewModelTest { @Test fun testPolling_doesNotStartTwice() = runTest { - val repository = mockk() coEvery { repository.getLobbyInfo("1234") } returns LobbyResponse( @@ -415,16 +423,15 @@ class LobbyViewModelTest { false, ) - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.startPollingForTest("1234") - advanceTimeBy(2000) - advanceTimeBy(2000) + advanceTimeBy(4000.milliseconds) viewModel.startPollingForTest("2345") - advanceTimeBy(2000) + advanceTimeBy(2000.milliseconds) viewModel.stopPollingForTest() @@ -437,7 +444,6 @@ class LobbyViewModelTest { @Test fun testCreateLobby_alreadyInLobby() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -447,8 +453,9 @@ class LobbyViewModelTest { ) coEvery { repository.createLobby("User") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("User") @@ -468,8 +475,6 @@ class LobbyViewModelTest { @Test fun testJoinLobby_alreadyInLobby() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "1234", @@ -478,8 +483,9 @@ class LobbyViewModelTest { ) coEvery { repository.createLobby("User") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("User") @@ -499,11 +505,10 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_notInLobby() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) - viewModel.leaveLobby("User", onResult = {}) + viewModel.leaveLobby(UUID.randomUUID(), onResult = {}) advanceUntilIdle() @@ -517,11 +522,9 @@ class LobbyViewModelTest { @Test fun testChangeRoles_notInLobby() = runTest { - val repository = mockk() + val viewModel = LobbyViewModel(repository, sessionRepository) - val viewModel = LobbyViewModel(repository) - - viewModel.changeRole(Role.OPERATIVE, Team.RED, "User") + viewModel.changeRole(Role.OPERATIVE, Team.RED, "User", UUID.randomUUID()) advanceUntilIdle() @@ -534,32 +537,29 @@ class LobbyViewModelTest { @Test fun changeRole_DelegatesCorrectly1() { - val repository = mockk() - val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) - viewModel.changeRole(PlayerRoles.BLUE_SPYMASTER, "Alice") + val viewModel = spyk(LobbyViewModel(repository, sessionRepository), recordPrivateCalls = true) + viewModel.changeRole(PlayerRoles.BLUE_SPYMASTER, "Alice", UUID.randomUUID()) verify { - viewModel.changeRole(Role.SPYMASTER, Team.BLUE, "Alice") + viewModel.changeRole(Role.SPYMASTER, Team.BLUE, "Alice", any()) } } @Test fun changeRole_DelegatesCorrectly2() { - val repository = mockk() - val viewModel = spyk(LobbyViewModel(repository), recordPrivateCalls = true) + val viewModel = spyk(LobbyViewModel(repository, sessionRepository), recordPrivateCalls = true) - viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob") + viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob", UUID.randomUUID()) verify { - viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob") + viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob", any()) } } @Test fun `getRoleForUser returns BLUE_OPERATIVE`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) val players = listOf( @@ -582,6 +582,8 @@ class LobbyViewModelTest { repository.joinLobby("Max", "ABCD") } returns response + coEvery { sessionRepository.clearUserId() } just Runs + viewModel.joinLobby("Max", "ABCD") advanceUntilIdle() @@ -594,8 +596,7 @@ class LobbyViewModelTest { @Test fun `getRoleForUser returns NONE when player does not exist`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) val result = viewModel.getRoleForUser("Unknown") @@ -605,8 +606,7 @@ class LobbyViewModelTest { @Test fun `changeRole updates player role correctly`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) val initialPlayers = listOf( @@ -642,13 +642,17 @@ class LobbyViewModelTest { false, ) + val userId = UUID.randomUUID() + coEvery { repository.joinLobby("Max", "ABCD") } returns joinResponse + coEvery { sessionRepository.clearUserId() } just Runs coEvery { repository.changeRole( "Max", + userId, "ABCD", Role.SPYMASTER, Team.RED, @@ -662,6 +666,7 @@ class LobbyViewModelTest { role = Role.SPYMASTER, team = Team.RED, username = "Max", + userId = userId ) advanceUntilIdle() @@ -673,6 +678,7 @@ class LobbyViewModelTest { coVerify(exactly = 1) { repository.changeRole( "Max", + any(), "ABCD", Role.SPYMASTER, Team.RED, @@ -683,13 +689,13 @@ class LobbyViewModelTest { @Test fun `changeRole sets error when not in lobby`() = runTest { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.changeRole( role = Role.SPYMASTER, team = Team.RED, username = "Max", + userId = UUID.randomUUID() ) advanceUntilIdle() @@ -702,36 +708,32 @@ class LobbyViewModelTest { @Test fun changeRole_DelegatesCorrectly_redSpymaster() { - val repository = mockk() - val viewModel = spyk(LobbyViewModel(repository)) + val viewModel = spyk(LobbyViewModel(repository, sessionRepository)) - viewModel.changeRole(PlayerRoles.RED_SPYMASTER, "Alice") + viewModel.changeRole(PlayerRoles.RED_SPYMASTER, "Alice", UUID.randomUUID()) verify { - viewModel.changeRole(Role.SPYMASTER, Team.RED, "Alice") + viewModel.changeRole(Role.SPYMASTER, Team.RED, "Alice", any()) } } @Test fun changeRole_DelegatesCorrectly_blueOperative() { - val repository = mockk() - val viewModel = spyk(LobbyViewModel(repository)) + val viewModel = spyk(LobbyViewModel(repository, sessionRepository)) - viewModel.changeRole(PlayerRoles.BLUE_OPERATIVE, "Bob") + viewModel.changeRole(PlayerRoles.BLUE_OPERATIVE, "Bob", UUID.randomUUID()) verify { - viewModel.changeRole(Role.OPERATIVE, Team.BLUE, "Bob") + viewModel.changeRole(Role.OPERATIVE, Team.BLUE, "Bob", any()) } } @Test fun changeRole_invalidRole_setsError() = runTest { - val repository = mockk() + val viewModel = LobbyViewModel(repository, sessionRepository) - val viewModel = LobbyViewModel(repository) - - viewModel.changeRole(PlayerRoles.NONE, "User") + viewModel.changeRole(PlayerRoles.NONE, "User", UUID.randomUUID()) advanceUntilIdle() @@ -743,8 +745,7 @@ class LobbyViewModelTest { @Test fun getRoleForUser_userNotFound_returnsNone() { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) val result = viewModel.getRoleForUser("Unknown") @@ -754,11 +755,9 @@ class LobbyViewModelTest { @Test fun sendStartGame_blankUsername_doesNothing() = runTest { - val repository = mockk(relaxed = true) - - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) - viewModel.sendStartGame("") + viewModel.sendStartGame("", UUID.randomUUID()) advanceUntilIdle() @@ -770,8 +769,6 @@ class LobbyViewModelTest { @Test fun getRoleForUser_blueOperative_returnsCorrectRole() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "12345", @@ -791,7 +788,9 @@ class LobbyViewModelTest { repository.joinLobby("Alice", "12345") } returns response - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Alice", "12345") @@ -805,8 +804,6 @@ class LobbyViewModelTest { @Test fun getRoleForUser_redSpymaster_returnsCorrectRole() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "12345", @@ -826,7 +823,9 @@ class LobbyViewModelTest { repository.joinLobby("Bob", "12345") } returns response - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Bob", "12345") @@ -840,8 +839,6 @@ class LobbyViewModelTest { @Test fun getRoleForUser_nullRole_returnsNone() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "12345", @@ -861,7 +858,11 @@ class LobbyViewModelTest { repository.joinLobby("Bob", "12345") } returns response - val viewModel = LobbyViewModel(repository) + coEvery { + sessionRepository.clearUserId() + } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Bob", "12345") @@ -875,8 +876,6 @@ class LobbyViewModelTest { @Test fun getIsHost_returnsTrueForHost() = runTest { - val repository = mockk() - val response = LobbyResponse( lobbyCode = "12345", @@ -896,7 +895,9 @@ class LobbyViewModelTest { repository.joinLobby("Host", "12345") } returns response - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Host", "12345") @@ -908,7 +909,6 @@ class LobbyViewModelTest { @Test fun getIsHost_returnsFalseForNonHost() = runTest { - val repository = mockk() val response = LobbyResponse( @@ -927,7 +927,11 @@ class LobbyViewModelTest { repository.joinLobby("User", "12345") } returns response - val viewModel = LobbyViewModel(repository) + coEvery { + sessionRepository.clearUserId() + } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("User", "12345") @@ -942,8 +946,6 @@ class LobbyViewModelTest { mockkStatic(Log::class) every { Log.d(any(), any()) } returns 0 - val repository = mockk() - val joinResponse = LobbyResponse( lobbyCode = "12345", @@ -969,21 +971,23 @@ class LobbyViewModelTest { } returns joinResponse coEvery { - repository.sendStartGame("12345", "Host") + repository.sendStartGame("12345", any()) } returns startResponse - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Host", "12345") advanceUntilIdle() - viewModel.sendStartGame("Host") + viewModel.sendStartGame("Host", UUID.randomUUID()) advanceUntilIdle() coVerify { - repository.sendStartGame("12345", "Host") + repository.sendStartGame("12345", any()) } } @@ -993,8 +997,6 @@ class LobbyViewModelTest { mockkStatic(Log::class) every { Log.d(any(), any()) } returns 0 - val repository = mockk() - val joinResponse = LobbyResponse( lobbyCode = "12345", @@ -1016,13 +1018,15 @@ class LobbyViewModelTest { repository.sendStartGame(any(), any()) } throws RuntimeException("Start failed") - val viewModel = LobbyViewModel(repository) + coEvery { sessionRepository.clearUserId() } just Runs + + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Host", "12345") advanceUntilIdle() - viewModel.sendStartGame("Host") + viewModel.sendStartGame("Host", UUID.randomUUID()) advanceUntilIdle() @@ -1034,8 +1038,7 @@ class LobbyViewModelTest { @Test fun getAvailableChatTabsForUser_unknownUser_returnsGlobalOnly() { - val repository = mockk() - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) val result = viewModel.getAvailableChatTabsForUser("Unknown") @@ -1045,7 +1048,6 @@ class LobbyViewModelTest { @Test fun getAvailableChatTabsForUser_playerWithoutTeam_returnsGlobalOnly() = runTest { - val repository = mockk() val response = LobbyResponse( lobbyCode = "12345", @@ -1055,11 +1057,12 @@ class LobbyViewModelTest { coEvery { repository.joinLobby("Alice", "12345") } returns response coEvery { repository.getLobbyInfo("12345") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Alice", "12345") - advanceTimeBy(1) + advanceTimeBy(1.milliseconds) viewModel.stopPollingForTest() assertEquals(listOf(ChatTab.GLOBAL), viewModel.getAvailableChatTabsForUser("Alice")) @@ -1068,7 +1071,6 @@ class LobbyViewModelTest { @Test fun getAvailableChatTabsForUser_spymasterWithTeam_returnsGlobalAndTeam() = runTest { - val repository = mockk() val response = LobbyResponse( lobbyCode = "12345", @@ -1078,11 +1080,12 @@ class LobbyViewModelTest { coEvery { repository.joinLobby("Alice", "12345") } returns response coEvery { repository.getLobbyInfo("12345") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Alice", "12345") - advanceTimeBy(1) + advanceTimeBy(1.milliseconds) viewModel.stopPollingForTest() assertEquals(listOf(ChatTab.GLOBAL, ChatTab.TEAM), viewModel.getAvailableChatTabsForUser("Alice")) @@ -1091,7 +1094,6 @@ class LobbyViewModelTest { @Test fun getAvailableChatTabsForUser_singleOperative_returnsGlobalAndTeam() = runTest { - val repository = mockk() val response = LobbyResponse( lobbyCode = "12345", @@ -1105,11 +1107,12 @@ class LobbyViewModelTest { coEvery { repository.joinLobby("Alice", "12345") } returns response coEvery { repository.getLobbyInfo("12345") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Alice", "12345") - advanceTimeBy(1) + advanceTimeBy(1.milliseconds) viewModel.stopPollingForTest() assertEquals( @@ -1121,7 +1124,6 @@ class LobbyViewModelTest { @Test fun getAvailableChatTabsForUser_multipleSameTeamOperatives_returnsAllTabs() = runTest { - val repository = mockk() val response = LobbyResponse( lobbyCode = "12345", @@ -1135,11 +1137,12 @@ class LobbyViewModelTest { coEvery { repository.joinLobby("Alice", "12345") } returns response coEvery { repository.getLobbyInfo("12345") } returns response + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.joinLobby("Alice", "12345") - advanceTimeBy(1) + advanceTimeBy(1.milliseconds) viewModel.stopPollingForTest() assertEquals( @@ -1151,11 +1154,11 @@ class LobbyViewModelTest { @Test fun clearError_resetsError() = runTest { - val repository = mockk() coEvery { repository.createLobby(any()) } throws RuntimeException("Network error") + coEvery { sessionRepository.clearUserId() } just Runs - val viewModel = LobbyViewModel(repository) + val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.createLobby("Max") diff --git a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt index 28f7837..f6e6c06 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/SessionViewModelTest.kt @@ -79,6 +79,8 @@ class SessionViewModelTest { runTest { val uuid = UUID.randomUUID() + viewModel.persistUserId(uuid) + viewModel.setUsername("Max") viewModel.persistUserState() From c1df7b7b2fef20c19e8817faaa3f89a41de87ecd Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 17:57:12 +0200 Subject: [PATCH 116/126] style: formatting --- .idea/androidTestResultsUserPreferences.xml | 4 +++ .../data/repository/LobbyRepository.kt | 3 +- .../frontend/network/dto/LobbyResponse.kt | 2 +- .../frontend/network/dto/PlayerDto.kt | 2 +- .../network/dto/WebSocketJoinMessage.kt | 2 +- .../frontend/ui/screens/UserNameScreen.kt | 1 - .../frontend/viewmodel/LobbyViewModel.kt | 21 ++++++++------ .../frontend/viewmodel/SessionViewModel.kt | 12 ++++---- .../frontend/viewmodel/LobbyViewModelTest.kt | 29 +++++-------------- 9 files changed, 33 insertions(+), 43 deletions(-) diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 36c3f52..63f1f35 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -58,6 +58,10 @@ + + + + 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 bb2e182..7a6f504 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 @@ -1,6 +1,5 @@ package com.codenames.frontend.data.repository -import android.util.Log import com.codenames.frontend.data.model.enums.Role import com.codenames.frontend.data.model.enums.Team import com.codenames.frontend.network.api.LobbyApi @@ -25,7 +24,7 @@ class LobbyRepository username: String, lobbyCode: String, ): LobbyResponse { - val msg = api.joinLobby(lobbyCode, username) + val msg = api.joinLobby(lobbyCode, username) return msg } 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 d92eb54..98f1439 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 @@ -8,5 +8,5 @@ data class LobbyResponse( val playerList: List? = emptyList(), val isStarted: Boolean = false, val message: String = "", - val uuid: String? = null + val uuid: String? = null, ) 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 970faf5..602a23d 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 @@ -10,5 +10,5 @@ data class PlayerDto( val role: Role? = null, val team: Team? = null, val isHost: Boolean, - val uuid: String? = null + val uuid: String? = null, ) 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 dbf447c..abef119 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 @@ -9,5 +9,5 @@ data class WebSocketJoinMessage( val username: String, @SerialName("code") val lobbyCode: String, - val uuid: String + val uuid: String, ) 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 index 60f3e44..a2bd72d 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/UserNameScreen.kt @@ -38,7 +38,6 @@ import com.codenames.frontend.ui.theme.blueGradient import com.codenames.frontend.viewmodel.GameViewModel import com.codenames.frontend.viewmodel.LobbyViewModel import com.codenames.frontend.viewmodel.SessionViewModel -import java.util.UUID @Suppress("ktlint:standard:function-naming") @Composable 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 139e1fa..b6e667b 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -32,7 +32,7 @@ class LobbyViewModel @Inject constructor( private val repository: LobbyRepository, - private val sessionRepository: SessionRepository + private val sessionRepository: SessionRepository, ) : ViewModel() { private val _state = MutableStateFlow(LobbyUiState()) val state: StateFlow = _state @@ -54,7 +54,7 @@ class LobbyViewModel _state.update { response.toLobbyState() } - if(response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) + if (response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) startPolling(response.lobbyCode) } catch (e: Exception) { setError(e) @@ -85,7 +85,7 @@ class LobbyViewModel _state.update { response.toLobbyState() } - if(response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) + if (response.uuid != null) sessionRepository.saveUser(username, UUID.fromString(response.uuid)) updateUiState(_state.value.players) startPolling(response.lobbyCode) } catch (e: Exception) { @@ -108,7 +108,7 @@ class LobbyViewModel onResult(successful) return } - if(userId == null) { + if (userId == null) { setError(ID_NOT_FOUND) return } @@ -139,14 +139,14 @@ class LobbyViewModel role: Role, team: Team, username: String, - userId: UUID? + userId: UUID?, ) { val lobbyCode = _state.value.lobbyCode if (lobbyCode.isNullOrBlank()) { setError("Not in a Lobby") return } - if(userId == null) { + if (userId == null) { setError(ID_NOT_FOUND) return } @@ -172,7 +172,7 @@ class LobbyViewModel fun changeRole( role: PlayerRoles, username: String, - userId: UUID? + userId: UUID?, ) { when (role) { PlayerRoles.BLUE_SPYMASTER -> changeRole(role = Role.SPYMASTER, team = Team.BLUE, username = username, userId) @@ -220,9 +220,12 @@ class LobbyViewModel return player.isHost } - fun sendStartGame(username: String, userId: UUID?) { + fun sendStartGame( + username: String, + userId: UUID?, + ) { val lobbyCode = _state.value.lobbyCode.orEmpty() - if(userId == null) { + if (userId == null) { setError(ID_NOT_FOUND) return } diff --git a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt index de73761..4e3a923 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/SessionViewModel.kt @@ -41,16 +41,16 @@ class SessionViewModel } fun persistLobbyState( - lobbyCode: String, - role: Role, - team: Team, - ) { + lobbyCode: String, + role: Role, + team: Team, + ) { viewModelScope.launch { sessionRepository.saveLobby( lobbyCode, role, team, - ) + ) } } @@ -67,7 +67,7 @@ class SessionViewModel viewModelScope.launch { sessionRepository.saveUser( _userState.value.username, - userId + userId, ) } } 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 2f4da2b..1ab9fb4 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -45,7 +45,6 @@ class LobbyViewModelTest { @Before fun setup() { Dispatchers.setMain(dispatcher) - } @After @@ -105,7 +104,6 @@ class LobbyViewModelTest { @Test fun testJoinLobby_success() = runTest { - val response = LobbyResponse( lobbyCode = "1234", @@ -141,7 +139,6 @@ class LobbyViewModelTest { @Test fun testJoinLobby_error() = runTest { - coEvery { repository.joinLobby(any(), any()) } throws RuntimeException("Network error") coEvery { sessionRepository.clearUserId() } just Runs @@ -162,7 +159,6 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_success() = runTest { - val response = LobbyResponse( lobbyCode = "1234", @@ -188,7 +184,7 @@ class LobbyViewModelTest { viewModel.joinLobby("User", "1234") advanceTimeBy(2000.milliseconds) - viewModel.leaveLobby(userId = userId , onResult = {}) + viewModel.leaveLobby(userId = userId, onResult = {}) advanceTimeBy(2000.milliseconds) @@ -204,7 +200,6 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_error() = runTest { - val response = LobbyResponse( lobbyCode = "1234", @@ -238,7 +233,6 @@ class LobbyViewModelTest { @Test fun testChangeRole_success() = runTest { - val newRole = Role.OPERATIVE val newTeam = Team.RED val username = "User" @@ -286,7 +280,6 @@ class LobbyViewModelTest { @Test fun testChangeRole_error() = runTest { - val response = LobbyResponse( lobbyCode = "1234", @@ -301,7 +294,7 @@ class LobbyViewModelTest { any(), any(), any(), - any() + any(), ) } throws RuntimeException("Network error") @@ -328,7 +321,6 @@ class LobbyViewModelTest { @Test fun testSetError_WithEmptyValue() = runTest { - coEvery { repository.createLobby(any()) } throws RuntimeException("") coEvery { sessionRepository.clearUserId() } just Runs @@ -349,7 +341,6 @@ class LobbyViewModelTest { @Test fun testSetError_WithNullValue() = runTest { - coEvery { repository.createLobby(any()) } throws RuntimeException() coEvery { sessionRepository.clearUserId() } just Runs @@ -371,7 +362,6 @@ class LobbyViewModelTest { @Test fun testPolling_callsRepositoryRepeatedly() = runTest { - coEvery { repository.getLobbyInfo(any()) } returns LobbyResponse( lobbyCode = "1234", @@ -415,7 +405,6 @@ class LobbyViewModelTest { @Test fun testPolling_doesNotStartTwice() = runTest { - coEvery { repository.getLobbyInfo("1234") } returns LobbyResponse( lobbyCode = "1234", @@ -444,7 +433,6 @@ class LobbyViewModelTest { @Test fun testCreateLobby_alreadyInLobby() = runTest { - val response = LobbyResponse( lobbyCode = "1234", @@ -505,7 +493,6 @@ class LobbyViewModelTest { @Test fun testLeaveLobby_notInLobby() = runTest { - val viewModel = LobbyViewModel(repository, sessionRepository) viewModel.leaveLobby(UUID.randomUUID(), onResult = {}) @@ -549,7 +536,7 @@ class LobbyViewModelTest { fun changeRole_DelegatesCorrectly2() { val viewModel = spyk(LobbyViewModel(repository, sessionRepository), recordPrivateCalls = true) - viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob", UUID.randomUUID()) + viewModel.changeRole(PlayerRoles.RED_OPERATIVE, "Bob", UUID.randomUUID()) verify { viewModel.changeRole(Role.OPERATIVE, Team.RED, "Bob", any()) @@ -642,7 +629,7 @@ class LobbyViewModelTest { false, ) - val userId = UUID.randomUUID() + val userId = UUID.randomUUID() coEvery { repository.joinLobby("Max", "ABCD") @@ -666,7 +653,7 @@ class LobbyViewModelTest { role = Role.SPYMASTER, team = Team.RED, username = "Max", - userId = userId + userId = userId, ) advanceUntilIdle() @@ -695,7 +682,7 @@ class LobbyViewModelTest { role = Role.SPYMASTER, team = Team.RED, username = "Max", - userId = UUID.randomUUID() + userId = UUID.randomUUID(), ) advanceUntilIdle() @@ -733,7 +720,7 @@ class LobbyViewModelTest { runTest { val viewModel = LobbyViewModel(repository, sessionRepository) - viewModel.changeRole(PlayerRoles.NONE, "User", UUID.randomUUID()) + viewModel.changeRole(PlayerRoles.NONE, "User", UUID.randomUUID()) advanceUntilIdle() @@ -909,7 +896,6 @@ class LobbyViewModelTest { @Test fun getIsHost_returnsFalseForNonHost() = runTest { - val response = LobbyResponse( lobbyCode = "12345", @@ -1154,7 +1140,6 @@ class LobbyViewModelTest { @Test fun clearError_resetsError() = runTest { - coEvery { repository.createLobby(any()) } throws RuntimeException("Network error") coEvery { sessionRepository.clearUserId() } just Runs From d10e29ff18aaea8f14dd1a481707bab8144c5026 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 18:30:52 +0200 Subject: [PATCH 117/126] test: added tests --- .idea/androidTestResultsUserPreferences.xml | 17 +++++++++++++ .../frontend/UserPreferencesDataStoreTest.kt | 15 ++++++++++++ .../data/repository/GameRepositoryTest.kt | 16 +++++++++++++ .../data/repository/SessionRepositoryTest.kt | 10 ++++++++ .../frontend/viewmodel/LobbyViewModelTest.kt | 24 +++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 63f1f35..6f6dfc5 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -38,6 +38,23 @@ + + + + + + + diff --git a/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt index a6308b5..76736b8 100644 --- a/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/UserPreferencesDataStoreTest.kt @@ -87,4 +87,19 @@ class UserPreferencesDataStoreTest { assertNull(result.lobbyRole) assertNull(result.lobbyTeam) } + + @Test + fun removeUserId_removesUserId() = + runBlocking { + dataStore.saveUserData( + username = "User", + userId = UUID.randomUUID(), + ) + + dataStore.removeUserId() + + val result = dataStore.userData.first() + + assertNull(result.userId) + } } 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 index 018eace..06afd6d 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/GameRepositoryTest.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test +import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class GameRepositoryTest { @@ -96,4 +97,19 @@ class GameRepositoryTest { coVerify { webSocketHandler.passTurn(any()) } } + + @Test + fun testSendRejoin() = + runTest { + val username = "User" + val lobbyCode = "ABCDE" + val userId = UUID.randomUUID() + coEvery { webSocketHandler.sendReconnectMessage(any()) } just Runs + + gameRepository.sendRejoin(username, userId, lobbyCode) + + coVerify { + webSocketHandler.sendReconnectMessage(any()) + } + } } diff --git a/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt index 60d11d4..400d218 100644 --- a/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt +++ b/app/src/test/java/com/codenames/frontend/data/repository/SessionRepositoryTest.kt @@ -64,4 +64,14 @@ class SessionRepositoryTest { dataStore.clearSessionData() } } + + @Test + fun `clearUserId delegates to datastore`() = + runTest { + repository.clearUserId() + + coVerify { + dataStore.removeUserId() + } + } } 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 1ab9fb4..2aa99bb 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -9,6 +9,7 @@ import com.codenames.frontend.data.repository.SessionRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto import com.codenames.frontend.ui.roles.PlayerRoles +import io.mockk.Awaits import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -34,6 +35,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import java.util.UUID +import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalCoroutinesApi::class) @@ -506,6 +508,28 @@ class LobbyViewModelTest { assertEquals("Not in a lobby, leaving not possible", state.error) } + @Test + fun testLeaveLobby_nullIdSetsError() = + runTest { + val viewModel = LobbyViewModel(repository, sessionRepository) + val username = "User" + val lobbyCode = "ABCDE" + val onResult = { bool: Boolean -> } + + coEvery { + repository.joinLobby(username, lobbyCode) + } just Awaits + coEvery { sessionRepository.clearUserId() } just Runs + + viewModel.joinLobby(username, lobbyCode) + + advanceUntilIdle() + + viewModel.leaveLobby(null, onResult) + + assertNotNull(viewModel.state.value.error) + } + @Test fun testChangeRoles_notInLobby() = runTest { From 0ba2e93b24b08efd71eb86160dc22d0fc0bf4883 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 19:14:10 +0200 Subject: [PATCH 118/126] test: add tests --- .../frontend/viewmodel/LobbyViewModelTest.kt | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) 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 2aa99bb..7743809 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -9,7 +9,6 @@ import com.codenames.frontend.data.repository.SessionRepository import com.codenames.frontend.network.dto.LobbyResponse import com.codenames.frontend.network.dto.PlayerDto import com.codenames.frontend.ui.roles.PlayerRoles -import io.mockk.Awaits import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify @@ -509,16 +508,23 @@ class LobbyViewModelTest { } @Test - fun testLeaveLobby_nullIdSetsError() = + fun testLeaveLobby_nullIdDoesNothing() = runTest { val viewModel = LobbyViewModel(repository, sessionRepository) val username = "User" val lobbyCode = "ABCDE" val onResult = { bool: Boolean -> } + val response = + LobbyResponse( + lobbyCode = "ABCDE", + playerList = listOf(PlayerDto("User", null, Team.RED, true)), + false, + ) + coEvery { repository.joinLobby(username, lobbyCode) - } just Awaits + } returns response coEvery { sessionRepository.clearUserId() } just Runs viewModel.joinLobby(username, lobbyCode) @@ -777,6 +783,18 @@ class LobbyViewModelTest { } } + @Test + fun sendStartGame_NullId_doesNothing() = + runTest { + val viewModel = LobbyViewModel(repository, sessionRepository) + + viewModel.sendStartGame("User", null) + advanceUntilIdle() + coVerify(exactly = 0) { + repository.sendStartGame("User", any()) + } + } + @Test fun getRoleForUser_blueOperative_returnsCorrectRole() = runTest { From b280f12ff5d1eb146c1927da495f18d32f33216a Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 19:36:56 +0200 Subject: [PATCH 119/126] ui: removed useless timer button, replaced with paywall --- .../frontend/ui/screens/GameboardScreen.kt | 7 +++---- .../frontend/ui/screens/LobbyScreen.kt | 21 ++++--------------- 2 files changed, 7 insertions(+), 21 deletions(-) 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 1ddabdb..2bc9306 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 @@ -157,7 +157,6 @@ fun GameboardScreen( userRole == PlayerRoles.BLUE_SPYMASTER || userRole == PlayerRoles.RED_SPYMASTER val isActiveSpymaster = userRole == currentTurn && isSpymaster val canEndTurn = userRole == currentTurn && !isSpymaster && remainingGuesses > 0 - val canSelectCards = canEndTurn val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current @@ -172,7 +171,7 @@ fun GameboardScreen( val onInputChange: (String) -> Unit = { hintInput = it } - val currentCanSelectCards by rememberUpdatedState(canSelectCards) + val currentCanSelectCards by rememberUpdatedState(canEndTurn) val currentSelectedCardPositions by rememberUpdatedState(selectedCardPositions) val shakeDetector = @@ -257,7 +256,7 @@ fun GameboardScreen( ), selectionState = BoardSelectionState( - canSelectCards = canSelectCards, + canSelectCards = canEndTurn, selectedCardPositions = selectedCardPositions, remainingGuesses = remainingGuesses, ), @@ -315,7 +314,7 @@ fun GameboardScreen( ) DeselectAllButton( - isVisible = canSelectCards && selectedCardPositions.isNotEmpty(), + isVisible = canEndTurn && selectedCardPositions.isNotEmpty(), onClick = { selectedCardPositions = emptyList() }, modifier = Modifier 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 e5118cf..ca537a9 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 @@ -40,7 +40,6 @@ import com.codenames.frontend.ui.roles.PlayerRoles import com.codenames.frontend.ui.theme.AppBackground import com.codenames.frontend.ui.theme.AppBlack import com.codenames.frontend.ui.theme.AppBlueLight -import com.codenames.frontend.ui.theme.AppMutedDark import com.codenames.frontend.ui.theme.AppRedLight import com.codenames.frontend.ui.theme.AppWhite import com.codenames.frontend.ui.theme.LocalResponsiveDimensions @@ -351,25 +350,13 @@ fun GameSettingsColumn( text = "GAME SETTINGS", color = AppWhite, fontWeight = FontWeight.Bold, - fontSize = dimensions.smallFontSize, + fontSize = dimensions.buttonFontSize, modifier = Modifier.padding(bottom = dimensions.itemSpacing), ) - AppButton( - text = "TIMER: OFF", - onClick = { /* TODO: Timer Logik */ }, - modifier = - Modifier - .fillMaxWidth() - .height(dimensions.secondaryButtonHeight) - .padding(bottom = dimensions.smallSpacing), - style = - AppButtonStyle( - containerColor = AppMutedDark, - contentColor = AppWhite, - fontSize = dimensions.smallFontSize, - lineHeight = dimensions.bodyFontSize, - ), + Text( + text = "Upgrade to pro to see all features!", + color = AppWhite, ) } From f661bde24c3d3318a747c4710be9ca2056e86c0f Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 20:14:35 +0200 Subject: [PATCH 120/126] ui: add return to home button + state resets --- .../websocket/GameWebSocketController.kt | 4 ++++ .../frontend/ui/screens/GameScreenWrapper.kt | 5 +++++ .../frontend/ui/screens/GameboardScreen.kt | 19 +++++++++++++++++++ .../frontend/viewmodel/GameViewModel.kt | 11 +++++++++++ .../frontend/viewmodel/LobbyViewModel.kt | 4 +++- 5 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt index f131a2d..5682616 100644 --- a/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt +++ b/app/src/main/java/com/codenames/frontend/network/websocket/GameWebSocketController.kt @@ -23,6 +23,10 @@ class GameWebSocketController webSocketSessionManager.connectStomp() } + suspend fun disconnect() { + webSocketSessionManager.disconnect() + } + suspend fun startGame(msg: StartGameMessage) { webSocketSessionManager.getSession().convertAndSend("/app/start-game", msg, StartGameMessage.serializer()) } 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 index a9c6d17..aa69918 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -75,5 +75,10 @@ fun GameScreenWrapper( onSettingsClick = { navController.navigate(Screen.Settings.route) }, + onReturnToHome = { + navController.navigate(Screen.Start.route) + lobbyViewModel.cleanup() + gameViewModel.resetGameState() + } ) } 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 2bc9306..1b237b6 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 @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -132,6 +133,7 @@ fun GameboardScreen( onPassTurn: () -> Unit = {}, onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, + onReturnToHome: (() -> Unit) ) { val dimensions = LocalResponsiveDimensions.current @@ -174,6 +176,8 @@ fun GameboardScreen( val currentCanSelectCards by rememberUpdatedState(canEndTurn) val currentSelectedCardPositions by rememberUpdatedState(selectedCardPositions) + val gameOver = gameState.winner != null + val shakeDetector = remember { ShakeDetector { @@ -326,6 +330,21 @@ fun GameboardScreen( ), ) + if(gameOver) { + AppButton( + onClick = onReturnToHome, + text = "Return to home screen", + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding( + start = dimensions.screenPadding, + end = dimensions.screenPadding, + bottom = dimensions.screenPadding, + ), + ) + } + onSettingsClick?.let { openSettings -> SettingsCornerButton( onClick = openSettings, 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 9fdea96..53a1268 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -232,6 +232,17 @@ class GameViewModel } } + fun resetGameState() { + viewModelScope.launch { + _uiState.update { + GameState() + } + } + viewModelScope.launch { + handler.disconnect() + } + } + private fun setConnectionError(error: Throwable) { _connectionState.value = ConnectionState.Error(ErrorMessageMapper.toUserMessage(error)) } 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 b6e667b..643b186 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/LobbyViewModel.kt @@ -258,7 +258,7 @@ class LobbyViewModel } } - private fun cleanup() { + fun cleanup() { _state.update { it.copy( lobbyCode = null, @@ -269,6 +269,8 @@ class LobbyViewModel redSpymasters = emptyList(), ) } + stopPolling() + clearError() } private fun updateUiState(players: List) { From 7df740bac5962c6ec6bd773a7a8a5e8f99cba899 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 20:38:47 +0200 Subject: [PATCH 121/126] hide hint section after game end + adjust background color --- .../frontend/ui/screens/GameScreenWrapper.kt | 2 +- .../frontend/ui/screens/GameboardScreen.kt | 41 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) 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 index aa69918..6464f29 100644 --- a/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt +++ b/app/src/main/java/com/codenames/frontend/ui/screens/GameScreenWrapper.kt @@ -79,6 +79,6 @@ fun GameScreenWrapper( navController.navigate(Screen.Start.route) lobbyViewModel.cleanup() gameViewModel.resetGameState() - } + }, ) } 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 1b237b6..4767696 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 @@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -133,7 +132,7 @@ fun GameboardScreen( onPassTurn: () -> Unit = {}, onSendChatMessage: (ChatTab, String) -> Unit = { _, _ -> }, onSettingsClick: (() -> Unit)? = null, - onReturnToHome: (() -> Unit) + onReturnToHome: (() -> Unit), ) { val dimensions = LocalResponsiveDimensions.current @@ -178,6 +177,8 @@ fun GameboardScreen( val gameOver = gameState.winner != null + val backgroundTeam = if (winner != null) getPlayerRoleFromTeam(winner) else currentTurn + val shakeDetector = remember { ShakeDetector { @@ -209,7 +210,7 @@ fun GameboardScreen( modifier = modifier .fillMaxSize() - .background(getTeamBackgroundColor(currentTurn)), + .background(getTeamBackgroundColor(backgroundTeam)), ) { Column( modifier = @@ -280,18 +281,19 @@ fun GameboardScreen( .weight(1f) .fillMaxWidth(), ) - - HintSection( - isActiveSpymaster, - currentHint, - hintInput, - countInput, - onHintChange = onHintChange, - onInputChange, - onCountChange = { countInput = it }, - keyboardController, - focusManager, - ) + if (!gameOver) { + HintSection( + isActiveSpymaster, + currentHint, + hintInput, + countInput, + onHintChange = onHintChange, + onInputChange, + onCountChange = { countInput = it }, + keyboardController, + focusManager, + ) + } } GameChatOverlay( @@ -330,7 +332,7 @@ fun GameboardScreen( ), ) - if(gameOver) { + if (gameOver) { AppButton( onClick = onReturnToHome, text = "Return to home screen", @@ -342,6 +344,7 @@ fun GameboardScreen( end = dimensions.screenPadding, bottom = dimensions.screenPadding, ), + style = AppButtonStyle(backgroundBrush = greenGradient), ) } @@ -1113,3 +1116,9 @@ fun getTeamBackgroundColor(currentTurn: PlayerRoles): Color = PlayerRoles.RED_OPERATIVE, PlayerRoles.RED_SPYMASTER -> AppRedTeamBackground PlayerRoles.NONE -> AppBackground } + +fun getPlayerRoleFromTeam(color: Team): PlayerRoles = + when (color) { + Team.RED -> PlayerRoles.RED_SPYMASTER + Team.BLUE -> PlayerRoles.BLUE_SPYMASTER + } From 007a0a77a344679b5d06a68f8aa7fd2c27b10159 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 20:49:34 +0200 Subject: [PATCH 122/126] text: fix game board sceen tests --- .../com/codenames/frontend/GameboardScreenTest.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index f4b5bf9..5c6ad37 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -1,5 +1,7 @@ package com.codenames.frontend +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -48,6 +50,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -71,6 +74,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -91,6 +95,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -122,6 +127,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -156,6 +162,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -179,6 +186,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -205,6 +213,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -236,6 +245,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -258,6 +268,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -279,6 +290,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -300,6 +312,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -324,6 +337,7 @@ class GameboardScreenTest { onReveal = {}, onPassTurn = { passTurnClicks++ }, onCheatRequest = {}, + onReturnToHome = {} ) } @@ -355,6 +369,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = { positions -> revealedPositions = positions }, onCheatRequest = {}, + onReturnToHome = {} ) } From e848852fad0721fc8e6407391716f976066b1761 Mon Sep 17 00:00:00 2001 From: XtophB Date: Wed, 17 Jun 2026 20:52:28 +0200 Subject: [PATCH 123/126] chore: change server URL to that of uni server --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 724550d..acd9eb4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,7 @@ if (localPropertiesFile.exists()) { val serverUrl = localProperties.getProperty( "SERVER_URL", - "http://10.0.2.2:8080", + "se2-demo.aau.at:53213", ) plugins { From c39844a56560edd44ef010792e7dd1ef3c877bac Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 21:06:34 +0200 Subject: [PATCH 124/126] style: formatting --- .idea/androidTestResultsUserPreferences.xml | 1 + .../codenames/frontend/GameboardScreenTest.kt | 28 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index 6f6dfc5..2c92f6d 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -80,6 +80,7 @@ + diff --git a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt index 5c6ad37..e8d3ed9 100644 --- a/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt +++ b/app/src/androidTest/java/com/codenames/frontend/GameboardScreenTest.kt @@ -1,7 +1,5 @@ package com.codenames.frontend -import androidx.compose.foundation.layout.offset -import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -50,7 +48,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -74,7 +72,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -95,7 +93,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -127,7 +125,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -162,7 +160,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -186,7 +184,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -213,7 +211,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -245,7 +243,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -268,7 +266,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -290,7 +288,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -312,7 +310,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = {}, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -337,7 +335,7 @@ class GameboardScreenTest { onReveal = {}, onPassTurn = { passTurnClicks++ }, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } @@ -369,7 +367,7 @@ class GameboardScreenTest { onHintChange = { _, _ -> }, onReveal = { positions -> revealedPositions = positions }, onCheatRequest = {}, - onReturnToHome = {} + onReturnToHome = {}, ) } From a5666e18a1e62dd0919862cf632ecbf539f103ad Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 21:36:36 +0200 Subject: [PATCH 125/126] test: added tests --- .../frontend/viewmodel/GameViewModel.kt | 2 -- .../frontend/viewmodel/GameViewModelTest.kt | 23 +++++++++++++++++++ .../frontend/viewmodel/LobbyViewModelTest.kt | 10 ++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) 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 53a1268..e390fc2 100644 --- a/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt +++ b/app/src/main/java/com/codenames/frontend/viewmodel/GameViewModel.kt @@ -237,8 +237,6 @@ class GameViewModel _uiState.update { GameState() } - } - viewModelScope.launch { handler.disconnect() } } 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 48b1e0c..b94cb4f 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/GameViewModelTest.kt @@ -655,6 +655,29 @@ class GameViewModelTest { assertEquals(ConnectionState.IDLE, viewModel.connectionState.value) } + @Test + fun resetGameState_resetsGameState() = + runTest { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + + coEvery { client.disconnect() } just Runs + + viewModel.handleMessage(testMessage) + + advanceUntilIdle() + + viewModel.resetGameState() + + advanceUntilIdle() + + val compareMessage = GameState() + + assertEquals(compareMessage.cards, viewModel.uiState.value.cards) + assertEquals(compareMessage.winner, viewModel.uiState.value.winner) + assertEquals(compareMessage.currentTurn, viewModel.uiState.value.currentTurn) + } + @Test fun requestCheat_callsRepository() = runTest { 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 7743809..52daa2f 100644 --- a/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt +++ b/app/src/test/java/com/codenames/frontend/viewmodel/LobbyViewModelTest.kt @@ -573,6 +573,16 @@ class LobbyViewModelTest { } } + @Test + fun changeRole_DoesNothingWithoutId() = + runTest { + val viewModel = LobbyViewModel(repository, sessionRepository) + + viewModel.changeRole(PlayerRoles.RED_SPYMASTER, "User", null) + + assertNotNull(viewModel.state.value.error) + } + @Test fun `getRoleForUser returns BLUE_OPERATIVE`() = runTest { From ef2e5f4ba359b987c274a2ec863ab2030df9bef0 Mon Sep 17 00:00:00 2001 From: Anna Pschernig Date: Wed, 17 Jun 2026 21:58:03 +0200 Subject: [PATCH 126/126] refactor: make url building more robust (does not need ending / anymore) --- .idea/misc.xml | 2 +- app/build.gradle.kts | 2 +- .../com/codenames/frontend/network/provider/IpProvider.kt | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index 3b0be22..24c1037 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acd9eb4..339e2c8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,7 +6,7 @@ if (localPropertiesFile.exists()) { localProperties.load(localPropertiesFile.inputStream()) } -val serverUrl = +val serverUrl: String? = localProperties.getProperty( "SERVER_URL", "se2-demo.aau.at:53213", diff --git a/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt index c467fcf..c44012d 100644 --- a/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt +++ b/app/src/main/java/com/codenames/frontend/network/provider/IpProvider.kt @@ -2,6 +2,6 @@ package com.codenames.frontend.network.provider import com.codenames.frontend.BuildConfig -fun getHttpUrl() = "http://" + BuildConfig.SERVER_URL +fun getHttpUrl() = "http://${BuildConfig.SERVER_URL}/" -fun getWsUrl() = "ws://" + BuildConfig.SERVER_URL + "ws-fallback" +fun getWsUrl() = "ws://${BuildConfig.SERVER_URL}/ws-fallback"